Агентные фреймворки (LangChain): Боль абстракций и возврат к ванильному коду

18.06.2026 09:00

Когда индустрия только начинала строить системы поверх больших языковых моделей, разработчики массово внедряли специализированные фреймворки. LangChain, LlamaIndex и десяток подобных решений обещали решить все проблемы интеграции. Идея казалась безупречной: связывание промптов, вызовов моделей, парсеров и инструментов в элегантные декларативные цепочки. Инженеры тратили месяцы на изучение внутреннего устройства этих библиотек, разбирались в многослойных классах, механизмах сериализации графов выполнения и концепции Runnable.

Но суровая реальность продакшена быстро разрушила иллюзию магии. Когда система выходит из тепличных условий туториалов, агентные фреймворки превращаются в тяжелую гирю, тянущую проект на дно. Отказ от них и возврат к ванильному коду — это не регресс, а необходимое условие выживания сложного продукта.

Анатомия боли: как абстракции прячут суть

Любое взаимодействие с LLM сводится к примитивному текстовому протоколу. Мы отправляем JSON с массивом сообщений, получаем JSON с ответом. Все. В этой схеме нет места для сложных архитектурных паттернов.

Что делает фреймворк? Он оборачивает этот массив сообщений в класс ChatPromptTemplate. Затем пропускает его через LLMChain. Результат прокидывается в OutputParser. Если мы добавляем инструменты (tools) для агента, фреймворк прячет JSON-схему вызовов функций за тяжеловесными декораторами. Управление историей переписки абстрагируется в ConversationBufferMemory, который жестко диктует, как должны называться переменные. Назвали ключ user_input вместо ожидаемого input? Цепочка падает с неочевидной ошибкой.

Когда агент начинает галлюцинировать, зависать или отдавать некорректный ответ, инженер открывает логи и видит гигабайты трейсов. Ошибка падает где-то на пятнадцатом уровне стека, внутри site-packages/langchain_core/runnables/base.py. Чтобы понять, какой именно сырой текст улетел в API провайдера, приходится встраивать кастомные коллбеки, перехватывать события on_llm_start или переопределять базовые классы библиотеки.

Мы регулярно сталкивались с ситуацией, когда добавление одного нового параметра в запрос (например, temperature, frequency_penalty или seed) требовало переписывания нескольких слоев абстракций. Авторы фреймворка жестко зашили список допустимых аргументов в своих Pydantic-моделях. Команда тратила часы на борьбу с архитектурой стороннего пакета вместо того, чтобы решать бизнес-задачи.

Сломанные обещания переносимости

Главный аргумент сторонников фреймворков звучит так: "Вы можете переключаться между разными LLM-провайдерами одной строчкой кода. Меняете ChatOpenAI на ChatAnthropic, и все продолжает работать".

На практике это утверждение не выдерживает критики. Разные модели обладают радикально разными характеристиками. Они по-разному реагируют на структуру системных промптов. У них отличается синтаксис вызова инструментов. Фреймворк пытается сгладить эти шероховатости, принудительно приводя ответы к единому формату. И здесь рождаются самые сложные баги.

Если модель возвращает вызов функции, но забывает передать обязательный аргумент, парсер фреймворка падает с сухой ошибкой OutputParserException. Он поглощает оригинальный ответ модели, лишая разработчика возможности написать фоллбек-логику или понять причину сбоя. Хуже того, когда провайдер выкатывает новый функционал — поддержку структурированного вывода, новые режимы работы с контекстом или нативную мультимодальность — разработчики оказываются заблокированы. Приходится ждать неделями, пока контрибьюторы добавят поддержку этих параметров. Толстый слой чужого кода отрезает проект от инноваций.

Инженерия против магии: возврат к ванильному коду

Осознание архитектурного тупика пришло к нам во время инцидента. Пайплайн обработки юридических документов начал стабильно падать на специфических контрактах. Анализ логов фреймворка показывал лишь ошибку валидации ответа.

Мы переписали проблемный модуль с нуля, используя стандартную библиотеку Python. Без агентов, AgentExecutor и цепочек. Только чистый requests.post. Отправив ровно тот же текст напрямую в API, мы мгновенно увидели корень проблемы: модель возвращала валидный JSON, но добавляла в конце маркдаун-блок с извинениями. Парсер фреймворка не мог это переварить, а логи скрывали исходный текст.

Для создания полноценного агента не нужен фреймворк. Весь хваленый AgentExecutor — это цикл while, список словарей с историей переписки и функция маршрутизации. Ванильный код читается сверху вниз. Вы контролируете каждый байт, уходящий по сети. Если что-то ломается, вы используете обычный дебаггер и видите сырые данные. Никаких черных ящиков.

Даже обработка потоковых ответов (streaming), которая во фреймворках требует написания сложных асинхронных генераторов и подписки на StreamingStdOutCallbackHandler, в ванильном Python реализуется элегантно и просто: достаточно передать stream=True в параметрах запроса и итерироваться через iter_lines, аккуратно собирая дельты токенов.

Практика: Прямые запросы к RouterAPI

Рассмотрим реализацию агента без абстракций на примере интеграции с RouterAPI. Нам не нужно скачивать сотни мегабайт зависимостей. Требуется только пакет requests и базовое понимание HTTP-протокола.

import os
import requests
import json

def run_agent_loop(user_prompt: str):
 api_key = os.environ.get('ROUTERAPI_API_KEY')
 url = "https://routerapi.ru/api/v1/chat/completions"
 
 headers = {
 "Authorization": f"Bearer {api_key}",
 "Content-Type": "application/json",
 }
 
 messages = [
 {"role": "system", "content": "You are a database query assistant."},
 {"role": "user", "content": user_prompt}
 ]
 
 # Имитация агента: позволяем модели сделать до 5 шагов
 for _ in range(5):
 payload = {
 "model": "openai/gpt-4o",
 "messages": messages,
 "temperature": 0.1,
 "tools": [
 {
 "type": "function",
 "function": {
 "name": "query_db",
 "description": "Execute SQL query",
 "parameters": {
 "type": "object",
 "properties": {
 "query": {"type": "string"}
 },
 "required": ["query"]
 }
 }
 }
 ]
 }
 
 response = requests.post(url, headers=headers, json=payload, timeout=120)
 
 if response.status_code != 200:
 raise RuntimeError(f"API Error {response.status_code}: {response.text}")
 
 response_message = response.json['choices'][0]['message']
 messages.append(response_message)
 
 # Если модель не вызвала инструмент, возвращаем финальный ответ
 if not response_message.get("tool_calls"):
 return response_message.get("content")
 
 # Выполнение инструментов
 for tool_call in response_message["tool_calls"]:
 if tool_call["function"]["name"] == "query_db":
 args = json.loads(tool_call["function"]["arguments"])
 # Заглушка выполнения запроса (в реальности здесь обращение к БД)
 db_result = f"Result for {args['query']}" 
 
 messages.append({
 "role": "tool",
 "tool_call_id": tool_call["id"],
 "name": tool_call["function"]["name"],
 "content": db_result
 })

 return "Agent loop exhausted."

Взгляните на этот код. Он абсолютно прозрачен. Видно, куда идет запрос, как формируются заголовки, как обрабатывается история сообщений. Цикл вызова функций (tool calling) занимает пару десятков строк понятной логики. Любой разработчик, знающий Python, сможет прочитать, отладить и модифицировать этот скрипт.

Если RouterAPI начнет поддерживать новый параметр, мы просто добавим ключ в словарь payload. Если понадобится кастомная логика ретраев (retry), мы напишем обычный блок try-except или применим стандартную библиотеку tenacity. Нет необходимости изучать документацию стороннего фреймворка, чтобы понять, как встроить перехватчик запросов в чужой граф.

Архитектура здорового человека

Отказ от специализированных LLM-библиотек заставил нас переосмыслить архитектуру проектов. Мы вернулись к классическим паттернам проектирования бэкенда.

Управление историей диалогов перестало быть магическим черным ящиком и превратилось в работу с обычными реляционными базами данных или Redis. Сообщения извлекаются обычными SQL-запросами и маппятся в список словарей. Маршрутизация запросов реализуется не через монструозные графы выполнения, а через паттерн Strategy или простые условные операторы. Контроллеры остались тонкими, а бизнес-логика переехала в изолированные сервисные классы.

Мы пишем код работы с LLM так же, как писали его для любых других внешних API. Большая языковая модель — это не мистическая сущность, требующая особых ритуалов инициализации. Это стандартный удаленный сервер. Он принимает HTTP POST запрос с JSON-схемой и возвращает JSON в ответ.

Абстракции имеют смысл только тогда, когда они скрывают нерелевантную сложность и предоставляют удобный высокоуровневый интерфейс. Но в разработке AI-продуктов формирование промпта, управление историей и парсинг ответа — это не низкоуровневая рутина. Это сама суть продукта. Пряча ее за толстыми слоями объектно-ориентированного кода, проект теряет контроль над собственным поведением.

Переход на ванильный код и прямые HTTP-запросы возвращает этот контроль обратно в руки разработчиков. Вы получаете скорость отладки, абсолютную прозрачность данных и уверенность в том, что система выполняет ровно те действия, которые явно описаны в исходном коде. Инженерия перестает быть борьбой с фреймворками и снова становится инженерией.

Теги

Ещё по теме