Введение
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:
Сколько провайдеров LLM вам нужно поддерживать? Один — фреймворк ок. Два и больше — фреймворк создаст иллюзию простоты, а реальную работу по адаптации промптов и валидации всё равно придётся делать самому.
Как часто вы будете менять компоненты RAG? Embedding-модель, chunk strategy, reranking — если это будет меняться (а оно будет), вам нужен контроль над каждым стыком. Фреймворк его спрячет.
Что произойдёт, когда модель ответит неправильно? Если это учебный проект — ничего страшного. Если это ответ реальному клиенту — вам нужна полная прозрачность: от входящего сообщения до ответа модели, с логами каждого промежуточного шага. Чёрные ящики в этой цепочке недопустимы.
Если на все три вопроса ответ указывает на сложность — пишите своё. Не потому что так модно, а потому что production LLM-инженерия — это управление неопределённостью. И управлять ей можно только тем, что видишь и контролируешь.
Код проекта (open-source версия) — на GitHub.
Если у вас другой опыт — строите production на LangChain и всё хорошо — мне искренне интересно услышать. Возможно, у нас просто разные задачи. А возможно — вы знаете приём, который я упустил.
