Введение

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 и всё хорошо — мне искренне интересно услышать. Возможно, у нас просто разные задачи. А возможно — вы знаете приём, который я упустил.