Мультиагентная система без LangChain: почему абстракции ломаются и как строить production на чистом Python. chromadb.. chromadb. fastapi.. chromadb. fastapi. langchain.. chromadb. fastapi. langchain. llm.. chromadb. fastapi. langchain. llm. Natural Language Processing.. chromadb. fastapi. langchain. llm. Natural Language Processing. openai.. chromadb. fastapi. langchain. llm. Natural Language Processing. openai. Production.. chromadb. fastapi. langchain. llm. Natural Language Processing. openai. Production. python.. chromadb. fastapi. langchain. llm. Natural Language Processing. openai. Production. python. rag.. chromadb. fastapi. langchain. llm. Natural Language Processing. openai. Production. python. rag. yandexgpt.. chromadb. fastapi. langchain. llm. Natural Language Processing. openai. Production. python. rag. yandexgpt. искусственный интеллект.. chromadb. fastapi. langchain. llm. Natural Language Processing. openai. Production. python. rag. yandexgpt. искусственный интеллект. Мультиагентная система.. chromadb. fastapi. langchain. llm. Natural Language Processing. openai. Production. python. rag. yandexgpt. искусственный интеллект. Мультиагентная система. Проектирование API.

Введение

LangChain обещает красивую жизнь: переключите модель одной строкой, подключите RAG за две, дайте агенту инструменты за три. На лендинге всё выглядит как конструктор LEGO — берёшь кубики, соединяешь, работает. На хакатоне это действительно так. В production — не совсем.

Тезис «LangChain — overhead для production» не нов. Его обсуждают в каждом втором треде на Reddit и в комментариях на Хабре. Компания Octomind использовала LangChain в production больше года — и убрала, заменив на модульные компоненты. Ниже — мой опыт с конкретной системой: где именно абстракции сломались, что я построил вместо них и во что это обошлось.

У меня в продакшене мультиагентная система — AI-ассистент, который обрабатывает обращения клиентов через Telegram, WhatsApp и Max (мессенджер VK). Получает сообщение, классифицирует (вопрос по услуге? техническая проблема? нужен живой менеджер?), маршрутизирует к нужному агенту, тот ищет ответ в базе знаний через RAG, подтягивает данные клиента из CRM и отвечает. Если не справляется — эскалирует на человека.

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

Систему я построил без LangChain. И сейчас объясню, почему.

Сразу оговорюсь: LangChain — не плохой инструмент. Для прототипа, PoC, демо инвестору — он прекрасен. Эта статья не про «фреймворки — зло». Она про то, что происходит, когда маркетинговые обещания фреймворка встречаются с реальностью production-системы.

«Просто возьми модель получше»

Знаю, о чём вы думаете. «Автор, ты просто используешь слабые модели. Возьми Claude Opus, GPT-5.4 — и промпты подстраивать не надо, tool calling работает идеально, structured output из коробки».

Справедливо. GPT-5.4 или Claude Opus действительно понимают с полуслова. Давайте посчитаем, во что это обойдётся.

Одно обращение клиента — это не один вызов LLM. Это цепочка: классификация → маршрутизация → RAG-запрос (embedding + генерация) → формирование ответа. Минимум 3–5 вызовов, в сложных кейсах — до 10. Средний запрос — ~2K токенов на входе, ~500 на выходе.

Примерные цены на момент написания (март 2026, актуальные уточняйте в документации):

Модель

Вход ($/1M)

Выход ($/1M)

~Стоимость одного обращения (5 вызовов)

~50 обращений/день × 30 дней

GPT-5.4

~10

~30

~$0.20

~$300/мес

GPT-5.4 mini

0.75

4.50

~$0.02

~$30/мес

GPT-5.4 nano

0.20

1.25

~$0.005

~$7.5/мес

$300 в месяц — ещё терпимо. Но это при скромных 50 обращениях. Масштабируйте до 500 — и вы на $3000/мес за один LLM-сервис. За эти деньги можно нанять менеджера, который ещё и кофе сам себе сделает.

Автоматизация имеет экономический смысл только когда стоимость inference ниже стоимости ручного труда. А значит — в production мы используем дешёвые модели: GPT-5.4 nano, YandexGPT 5 Lite. И вот именно с ними начинается всё то, о чём эта статья.


1. «Переключите модель одной строкой»

Главное обещание LangChain (и любого фреймворка-обёртки над LLM): «Мы абстрагируем провайдера. Хотите OpenAI — пожалуйста. Хотите Anthropic — одна строка. Хотите YandexGPT — ещё одна строка. Ваш код не меняется».

Формально это правда. Вы действительно можете поменять строку в конфиге. А потом наблюдать, как система начинает вести себя непредсказуемо.

Один провайдер, разные модели — уже проблема

Возьмём OpenAI. GPT-5.4 и GPT-5.4 nano — модели одного производителя, один API, один формат. Казалось бы, переключение должно быть безболезненным. На практике:

У меня есть классификатор обращений. Простая задача: получи текст сообщения, верни JSON с категорией и уверенностью. Три категории, чёткий системный промпт, few-shot примеры. На GPT-5.4 работает стабильно — месяцами одинаковый формат ответа, правильные категории, парсинг не ломается.

Переключаю на GPT-5.4 nano (потому что в разы дешевле). И начинается:

  • Температура 0.3, которая на флагманской модели давала стабильный structured output, на nano начинает выдавать «креативные» вариации. То ключ category с большой буквы, то дополнительное поле explanation, о котором его никто не просил.

  • При длинном контексте nano начинает «забывать» инструкции из системного промпта. Не всегда — в этом и подлость. В 90% случаев всё хорошо. В 10% — сюрприз.

  • Few-shot примеры, которые на GPT-5.4 задавали формат ответа, на nano иногда воспринимаются как «ещё один пример для классификации». Модель классифицирует сам пример, а не запрос пользователя.

И это две модели одного провайдера с одним API.

Разные провайдеры — другая вселенная

Теперь представьте: вы переключаете с OpenAI на YandexGPT. Тот же промпт, та же задача — классификация обращений. Вот что происходит:

JSON? Какой JSON? YandexGPT может решить, что вам будет удобнее получить ответ в свободной форме. «Я считаю, что данное обращение относится к категории “вопрос по курсу”, поскольку клиент спрашивает о расписании занятий». Спасибо, но мой парсер ожидает {"category": "course_question", "confidence": 0.95}.

Четвёртая категория из воздуха. Промпт чётко говорит: три категории — course_question, technical_issue, escalation. YandexGPT иногда придумывает general_inquiry или complaint. Откуда? Модель «помогает», расширяя классификацию. В production это значит, что downstream-логика падает с ошибкой валидации.

Few-shot — не для всех. Примеры, которые на OpenAI задают формат ответа, YandexGPT может интерпретировать иначе. Контекст промпта воспринимается по-другому, и ответ отличается не только по формату, но и по содержанию.

Что это значит для «переключения одной строкой»

Переключение модели — это не инфраструктурная задача. Это задача качества продукта. За каждым «просто поменяй строку» стоит проверка промптов, адаптация few-shot примеров, обновление валидации, прогон тестового набора и решение о том, при каком проценте деградации переключение допустимо.

LangChain прячет разницу между моделями за единым интерфейсом. Но разница никуда не девается — она всплывает в поведении системы. И чем позже вы её обнаружите, тем дороже будет починить.

Абстракция полезна, когда она упрощает работу с одинаковыми вещами. Абстракция вредна, когда она делает вид, что разные вещи одинаковы.


2. Фоллбек на YandexGPT: инженерия, а не конфиг

OpenAI прилёг на 20 минут. Или вам нужен провайдер с серверами в РФ. Или просто хотите подстраховку. Логичное решение — фоллбек на YandexGPT.

Разницу в поведении моделей мы уже обсудили. Теперь — как с ней жить инженерно. Вот как выглядит реальный поток фоллбека (не две строки кода):

graph TD
    IN[Входящее обращение] --> OAI[OpenAI Provider]
    OAI --> CHECK1{Ответ получен?}
    CHECK1 -- да --> VAL1[Pydantic-валидация<br/>единая схема]
    CHECK1 -- нет / таймаут --> YGP[YandexGPT Provider<br/>адаптированный промпт]
    VAL1 --> VALID1{Валидация пройдена?}
    VALID1 -- да --> OUT[Ответ клиенту]
    VALID1 -. нет → retry .-> CHECK1
    YGP --> VAL2[Pydantic-валидация<br/>та же схема]
    VAL2 --> VALID2{Валидация пройдена?}
    VALID2 -- да --> OUT
    VALID2 -- нет --> ESC[Эскалация<br/>→ живой менеджер]

Что нужно для честного фоллбека

Для каждого агента в системе фоллбек означает:

Адаптированный промпт. Не копия промпта для OpenAI, а отдельная версия. Другие формулировки, другие few-shot примеры, иногда — другая структура инструкций. И не забывайте про разницу в контекстных окнах: промпт с RAG-контекстом, который влезает в 1M-окно GPT-5.4, может не влезть в окно YandexGPT 5 Lite.

Вот конкретный пример — промпт классификатора для двух провайдеров:

# OpenAI — лаконичный, модель хорошо следует формату
OPENAI_CLASSIFY_PROMPT = """Classify the message into one of: course_question, technical_issue, escalation.
Return JSON: {"category": "...", "confidence": 0.0-1.0}
No explanations."""

# YandexGPT — более явный, с повторением формата и негативными инструкциями
YANDEX_CLASSIFY_PROMPT = """Ты — классификатор обращений. Твоя задача — определить категорию.

Допустимые категории (ТОЛЬКО эти три, никаких других):
- course_question — вопрос про курс, расписание, материалы
- technical_issue — проблема с платформой, ошибки, доступ
- escalation — жалоба, требование связаться с менеджером

Ответ — ТОЛЬКО JSON, без пояснений, без маркдауна:
{"category": "course_question", "confidence": 0.95}

НЕ добавляй комментарии. НЕ придумывай новые категории. НЕ оборачивай в ```json."""

Один промпт на два провайдера — гарантия того, что хотя бы один из них будет работать плохо.

Единая валидация на выходе. Какая бы модель ни отвечала, результат проходит через одну и ту же Pydantic-схему:

class ClassificationResult(BaseModel):
    category: Literal["course_question", "technical_issue", "escalation"]
    confidence: float = Field(ge=0.0, le=1.0)

Модель вернула невалидный JSON, добавила лишние поля, придумала четвёртую категорию — Pydantic это поймает. До того, как ответ уйдёт пользователю.

Тестовый набор и порог допуска. 50–100 реальных обращений. Прогоняете через обоих провайдеров, сравниваете. Мой порог — 85%. Ниже — фоллбек не включается. Лучше «сервис временно недоступен», чем неправильный ответ с уверенным лицом.

Protocol-based абстракция

Каждый провайдер реализует один и тот же Protocol (structural subtyping — не наследование от ABC, а утиная типизация на уровне типов; это позволяет подменять реализации без общего базового класса, что упрощает тестирование):

class LLMProvider(Protocol):
    async def classify(self, message: str) -> ClassificationResult: ...
    async def generate_response(self, context: RAGContext) -> str: ...

Внутри OpenAIProvider и YandexGPTProvider — разные промпты, разный формат запросов, разная авторизация (IAM-токены с ротацией у Яндекса). Общее — только контракт на входе и выходе.

LangChain предлагает абстракцию, которая прячет эту разницу. Я предлагаю абстракцию, которая делает эту разницу явной — и даёт контролировать каждый аспект.


3. RAG: подключить за две строки, поменять — за две недели

RAG в LangChain выглядит магически:

retriever = Chroma.from_documents(docs, embedding).as_retriever()
chain = RetrievalQA.from_chain_type(llm, retriever=retriever)
answer = chain.run("Какие документы нужны для возврата?")

Три строки. Работает. Можно идти пить кофе.

А потом вы решаете что-нибудь поменять.

Ловушка embedding-модели

Вы начали с text-embedding-3-small. Через несколько месяцев OpenAI выпускает следующее поколение — точнее, дешевле, лучше. Меняете одну строку в конфиге (LangChain же обещал). Запускаете. Всё работает.

Только поиск теперь возвращает ерунду.

Потому что ваша база знаний проиндексирована старой embedding-моделью. Новая модель генерирует векторы в другом пространстве. Косинусное сходство между вектором запроса (новая модель) и вектором документа (старая модель) — математически бессмысленно.

LangChain молча это проглотит. Никакого предупреждения. Поиск формально работает — просто находит не то. Вы узнаете об этом, когда клиенты начнут жаловаться.

Решение — переиндексация всей базы знаний. Не «одна строка», а операционная задача.

Ловушка chunk_size

Вы начали с чанков по 500 токенов. Потом поняли, что для ваших документов лучше 1000 — контекст теряется при мелкой нарезке. Поменяли параметр, загрузили новые документы. Старые остались с чанками по 500.

В одной коллекции лежат чанки разного размера, нарезанные по разным правилам. Поиск работает, но качество — лотерея.

Когда нужно больше, чем get_relevant_documents()

В production вам нужно понимать, почему вернулся конкретный чанк. С каким score? Какие ещё кандидаты были? Нужно фильтровать по метаданным — у разных продуктов разная документация. Нужно обновлять базу без даунтайма.

Всё это можно сделать через ChromaDB напрямую:

results = collection.query(
    query_embeddings=[embedding],
    n_results=5,
    where={"product": client.product},
    include=["documents", "distances", "metadatas"]
)

# Видим каждый чанк, его score, метаданные
# Можем отфильтровать, отсортировать, залогировать
# Собираем промпт руками — с полным контролем

Кода больше? Да, строк на десять. Но когда клиент получает неправильный ответ, вы открываете логи и видите всю цепочку: запрос → чанки → scores → промпт → ответ модели. Не чёрный ящик, а прозрачный конвейер.


4. Tool calling: пошли ловить медведя, а она берёт удочку

Tool calling — это когда вы даёте модели набор инструментов и говорите: «Сама разберись, какой когда использовать». В теории — мощная штука. На практике — самое хрупкое место агентных систем.

Классические поломки

Не тот инструмент. У вас есть search_knowledge_base (поиск по FAQ) и search_crm (поиск данных клиента). Клиент спрашивает: «Когда у меня следующее занятие?» Это вопрос про данные клиента → нужен CRM. Но модель решает, что «занятие» — это про курс → идёт в базу знаний → возвращает общее расписание вместо персонального.

Правильный инструмент, неправильные параметры. Модель вызывает search_crm, но передаёт имя клиента в поле phone. Или придумывает значение enum, которого нет.

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

Зависимость от модели. Всё вышеперечисленное меняется при смене модели. Флагманская стабильна, nano путается, YandexGPT может проигнорировать tool calling целиком.

Медведь, удочка и тихая охота

Вы обнаружили, что модель путает инструменты. Логичное решение — подправить промпт, добавить примеры. «Если клиент спрашивает про своё расписание — используй search_crm». Починили. На медведя теперь берём ружьё. Работает.

Через неделю приходит обращение: «Хочу записаться на тихую охоту» (курс по сбору грибов). Модель видит «охоту» → вспоминает ваш пример → вызывает search_crm вместо search_knowledge_base. За грибами с ружьём.

Добавляете уточнение в промпт. Работает. До следующего edge case, который вы не предусмотрели. И так — до бесконечности.

Это не баг промпта. Это фундаментальное свойство: модель принимает решения на основе вероятностей, а не логики. Каждый новый пример сдвигает распределение, и вы не знаете, какой edge case вылезет завтра. Классическая игра в whack-a-mole.

Архитектурный ответ: не доверяй модели критичные решения

В моей системе модель не выбирает инструменты. За маршрутизацию отвечает rule-based классификатор:

class MessageClassifier:
    def classify(self, message: str, client: Client) -> Route:
        # Сначала — детерминированные правила
        if self._is_escalation_trigger(message):
            return Route.ESCALATION
        if self._has_crm_keywords(message) and client.has_active_order:
            return Route.CRM_AGENT
        
        # LLM — только как fallback для неоднозначных случаев
        return self._llm_classify(message)

Это не костыль для слабого промпта. Это осознанное архитектурное решение: детерминированная логика там, где нужна предсказуемость, LLM — там, где нужна гибкость и понимание естественного языка.

Rule-based классификатор не перепутает CRM с базой знаний из-за слова «охота». А LLM подключается для случаев, когда правила не сработали — и даже тогда его ответ проходит валидацию.


5. Security: меньше зависимостей — меньше attack surface

Это может показаться паранойей, пока не посмотришь на список CVE за последний год.

CVE-2025-68664 (CVSS 9.3). Уязвимость сериализации в LangChain Core. Через prompt injection можно извлечь API-ключи из переменных окружения. Девять и три из десяти — «всё очень плохо».

CVE-2026-34070 (CVSS 7.5). Path traversal в загрузке промптов. Специально сформированный шаблон даёт доступ к произвольным файлам на сервере.

CVE-2025-67644 (CVSS 7.3). SQL-инъекция в LangGraph через metadata filter keys в SQLite checkpoint.

Три критические уязвимости за полгода. И это только те, которые нашли и опубликовали.

Дело не в том, что LangChain плохо написан. Дело в принципе: чем больше кода и транзитивных зависимостей, тем больше attack surface. Мой стек зависимостей для LLM-интеграции: httpx, chromadb, pydantic. Три зрелых, хорошо проаудированных библиотеки. Input sanitization, prepared statements, PII-маскирование — всё моё, и я точно знаю, что оно покрывает.


6. Что я построил вместо LangChain

Хватит ломать — давайте строить. Вот архитектура:

graph TD
    TG[Telegram] --> WH[Webhook Handler<br/>FastAPI]
    WA[WhatsApp] --> WH
    MX[Max VK] --> WH
    WH --> OR[Orchestrator]
    OR --> CL[Classifier<br/>rule-based + LLM fallback]
    CL --> CA[CourseAgent<br/>RAG + LLM]
    CL --> PA[PlatformAgent<br/>RAG + LLM]
    CL --> EA[EscalationAgent<br/>→ менеджер]
    CA --> CH[(ChromaDB)]
    CA --> CRM[(Bitrix24 CRM)]
    PA --> CH
    PA --> CRM

Никакого фреймворка. FastAPI, нативные SDK провайдеров, ChromaDB напрямую, Bitrix24 API через httpx. Всё на async Python.

Каждый агент — явный контракт

class CourseAgent:
    def __init__(self, llm: LLMProvider, kb: KnowledgeBase, crm: CRMClient):
        self.llm = llm
        self.kb = kb
        self.crm = crm
    
    async def handle(self, message: str, client: ClientData) -> AgentResponse:
        # 1. Ищем в базе знаний
        chunks = await self.kb.search(message, {"product": client.product})
        
        # 2. Собираем промпт (явно, не в чёрном ящике)
        prompt = self._build_prompt(message, chunks, client)
        
        # 3. Генерируем ответ
        raw_response = await self.llm.generate(prompt)
        
        # 4. Валидируем
        return self._validate_response(raw_response)

Что на входе — понятно. Что на выходе — понятно. Где может сломаться — понятно. Каждый шаг логируется через structlog с маскированием персональных данных.

Для тестов — MockLLMProvider, MockKnowledgeBase, MockCRMClient. Подменяются через dependency injection, потому что все зависимости — Protocol, а не конкретные классы. Никакого monkey-patching.

Обработка ошибок: иерархия, а не try/except

class RetryableError(Exception):
    """Временная ошибка — повторить"""

class PipelineError(Exception):
    """Критическая ошибка — в DLQ"""

Таймаут LLM-провайдера — RetryableError, повторяем с экспоненциальным backoff. Невалидный ответ модели после трёх попыток — PipelineError, логируем и эскалируем на человека. Неизвестная ошибка — в dead letter queue, разбираем потом.

Результат

170 тестов: unit, integration, e2e, security. 84% покрытие по строкам на ~4500 строк кода (без тестов). CI/CD с security-сканированием (bandit, safety).

Кода больше, чем было бы с LangChain? Безусловно. Но каждая строка — моя, понятная, тестируемая, дебажимая. Когда клиент получает неправильный ответ — я открываю логи и вижу всю цепочку, а не стектрейс из недр RunnableSequence.


7. Когда LangChain всё-таки стоит использовать

Было бы нечестно написать шесть глав критики и не сказать, когда фреймворк — правильный выбор.

Прототип, PoC, демо. Вам нужно за день показать инвестору работающего чат-бота с RAG. LangChain — идеален. Серьёзно. Три строки — и у вас есть рабочая демонстрация. Проблемы, описанные в этой статье, для прототипа не существуют.

Одна модель, один провайдер, простой RAG. Если вам не нужен фоллбек на другого провайдера, не нужна мультипровайдерность, базу знаний обновляете раз в месяц руками — LangChain справится. Overhead абстракции не будет вам мешать, потому что вы не столкнётесь с ситуациями, где он проявляется.

Обучение. Для человека, который только начинает работать с LLM, LangChain — отличная точка входа. Он показывает паттерны: цепочки, агенты, RAG. Потом, когда поймёте, как устроено под капотом — решите сами, нужен ли вам фреймворк.

Production с мультипровайдерностью, кастомной логикой, требованиями к безопасности — пишите своё. Не потому что это героически, а потому что вам всё равно придётся. LangChain в этом случае не сэкономит время — он его съест на борьбу с абстракциями.


Заключение: три вопроса перед выбором

Три вопроса, которые стоит задать себе перед тем, как тащить LangChain (или любой фреймворк-обёртку) в production:

1. Сколько провайдеров LLM вам нужно поддерживать? Один — фреймворк ок. Два и больше — фреймворк создаст иллюзию простоты, а реальную работу по адаптации промптов и валидации всё равно придётся делать самому.

2. Как часто вы будете менять компоненты RAG? Embedding-модель, chunk strategy, reranking — если это будет меняться (а оно будет), вам нужен контроль над каждым стыком. Фреймворк его спрячет.

3. Что произойдёт, когда модель ответит неправильно? Если это учебный проект — ничего страшного. Если это ответ реальному клиенту — вам нужна полная прозрачность: от входящего сообщения до ответа модели, с логами каждого промежуточного шага. Чёрные ящики в этой цепочке недопустимы.

Если на все три вопроса ответ указывает на сложность — пишите своё. Не потому что так модно, а потому что production LLM-инженерия — это управление неопределённостью. И управлять ей можно только тем, что видишь и контролируешь.

Код проекта (open-source версия) — на GitHub.

Если у вас другой опыт — строите production на LangChain и всё хорошо — мне искренне интересно услышать. Возможно, у нас просто разные задачи. А возможно — вы знаете приём, который я упустил.

Автор: alex2061

Источник