Пока LLM-фича — это один вызов модели, хватает обычного SDK провайдера: собрал промпт, отправил, разобрал ответ. Потом фича растёт. Сначала надо найти в базе знаний нужный абзац, потом положить его в промпт, потом спросить модель, потом вызвать внешний сервис, потом спросить ещё раз. Каждый такой шаг сам по себе прост, а клей между ними — сборка промпта, передача истории, разбор ответа, повтор при сбое — разрастается быстрее самой фичи, и через месяц в сервисе живёт свой маленький фреймворк, который никто не проектировал.
LangChain — самый известный готовый ответ на эту проблему: библиотека, в которой такие шаги уже оформлены как кирпичики с общими интерфейсами. После статьи вы будете знать, из каких блоков она собрана, какую цену за неё платят, почему цепочка из готовых блоков умеет ломаться молча и когда лучше остаться на голом SDK.
Из чего собирается LLM-приложение
Возьмём чат по внутренней документации. Пользователь спрашивает: «сколько дней на возврат?» Приложение должно найти в документах абзац про возврат, подставить его в шаблон вопроса, отправить модели и вытащить из ответа число. Это четыре разных действия, и у каждого свой код. LangChain называет их так.
Цепочка — это последовательность шагов, где выход одного становится входом следующего. Самая частая: шаблон промпта, потом модель, потом разбор ответа. В коде это выглядит буквально как конвейер, шаги соединяют вертикальной чертой:
chain = prompt | model | parser
answer = chain.invoke({"question": "сколько дней на возврат?", "facts": facts})
Такую запись называют LCEL. Важно знать про историю: раньше под это были отдельные классы (Chain, LLMChain), и в старых примерах из интернета вы увидите именно их; начиная с первой версии их убрали, и в документации разделы про цепочки надо искать под LCEL.
Состояние диалога. Модель не помнит прошлых сообщений: каждый вызов независим, и всю историю вы отправляете заново. Пока диалог короткий, это не проблема; на сотом сообщении история перестаёт влезать в окно контекста, а каждый вызов стоит как весь разговор целиком. Решать, что помнить целиком, а что сжать, — отдельная работа, и её делают по-разному. Три стратегии, между которыми выбирают на практике:
- окно из последних N сообщений — просто и предсказуемо, но бот забывает, о чём договорились в начале;
- сводка старого плюс свежий хвост — старые сообщения периодически сжимают в короткий пересказ той же моделью, свежие держат дословно; помнит долго, но за каждую сводку платят вызовом модели, и детали в ней теряются;
- факты о пользователе отдельно — имя, тариф, выбранный город выносят в обычное хранилище и подставляют в системный промпт; это единственная из трёх, где факт не «размывается» по мере роста диалога.
Раньше эту работу делало семейство классов Memory (ConversationBufferMemory и родня); их объявили устаревшими и убирают. Сейчас состояние держит LangGraph — соседний проект той же команды: он запоминает шаги между вызовами и умеет складывать их в базу, так что разговор переживает перезапуск приложения. Провайдеры со своей стороны это смягчают: повторяющееся начало запроса они кэшируют и берут за него дешевле, а некоторые API умеют хранить нить разговора у себя.
Ретривер. Модель знает интернет до даты обучения и не знает вашей документации. Чтобы ответ опирался на ваши данные, нужный кусок надо найти и положить в промпт до вызова модели, и делать это на каждый вопрос заново. Компонент, который по тексту вопроса достаёт подходящие фрагменты из векторной базы или другого источника, и называется ретривером. Это основа RAG: найти нужное, дать модели, получить ответ по фактам.
Инструменты и агенты. Когда ответ требует действия — посмотреть остаток на складе, создать заявку, — модели дают описание функций, а она решает, какую вызвать и с какими аргументами. LangChain оборачивает такой вызов инструментов и даёт готовую сборку агента: модель в цикле «подумала, вызвала инструмент, посмотрела на результат, подумала снова». Крутит этот цикл и хранит его состояние опять же LangGraph. Пока поток — простой цикл, хватает готовой сборки; когда появляются ветвления, несколько агентов или остановка на подтверждение человека, поток рисуют графом прямо в LangGraph.
Фреймворк даёт кирпичики и стрелки между ними. Ваше остаётся внутри кирпичиков: документы и индекс под ретривером, функции за инструментами, текст промпта и правило, что делать, если поиск ничего не нашёл.
Про язык — сразу. Сам LangChain живёт в двух языках: Python и JavaScript. На остальных его просто нет, и это не беда: блоки и идеи от языка не зависят, меняются только имена библиотек. Java-разработчикам те же кирпичики дают LangChain4j и Spring AI, в Go есть сообществный порт langchaingo. Дальше в статье речь о блоках, а искать их в своём коде вы будете под местными именами.
Когда шаг успешен, а результат нет
Вернёмся к чату про возврат. Он работает неделю, и вдруг пользователь получает ответ «возврат — 30 дней», хотя в документе написано 14. В логах при этом всё чисто: ретривер отработал, промпт собрался, модель ответила, парсер достал число. Ни одной ошибки.
Причина в том, что ретривер ничего не нашёл. Для него это не ошибка, а нормальный результат: пустой список. Шаблон промпта подставил пустой список на место фактов и тоже отчитался успехом. Модель получила вопрос без фактов и сделала то, что умеет лучше всего: написала связный уверенный текст, взяв срок «из головы». Парсер разобрал ответ. Каждый шаг сделал своё дело, а результат — выдумка.
Для цепочки оба прогона одинаково успешны: каждый шаг получил вход и отдал выход. Разница только в данных, поэтому проверку «ретривер что-то нашёл» ставят до вызова модели явно.
Это главное свойство цепочек из готовых блоков, которое надо знать до того, как их собирать: успех каждого шага не равен успеху результата. Блоки соединены только данными, и пустые данные проходят через них так же свободно, как полные. Отсюда два правила.
Первое: между поиском и моделью ставят явную проверку. Ретривер вернул пусто или все найденные куски слишком далеки от вопроса — модель не зовут вовсе, а отвечают «в документации этого нет» и пишут вопрос в журнал. Это несколько строк, и они важнее любого промпта «отвечай строго по фактам»:
def answer(question):
facts = retriever.invoke(question)
if not facts:
return "В документации ответа нет, передаю вопрос оператору."
return chain.invoke({"question": question, "facts": facts})
Второе: раз шаги не падают, за ними надо уметь подсмотреть. Об этом отдельный раздел про наблюдаемость ниже: трасса прогона показывает, что именно ушло в промпт на каждом шаге, и без неё вопрос «почему бот сказал 30» решается печатью в консоль.
SDK или фреймворк
Всё, что описано выше, можно написать и на голом SDK провайдера. Поэтому первое решение — не «какой фреймворк», а «нужен ли он».
Решение одно и принимается по форме потока, а не по моде: один вызов на голом SDK читается целиком на одном экране, поток из пяти шагов на SDK через месяц превращается в свой фреймворк, только без документации.
Что даёт фреймворк, когда поток сложный:
- единый интерфейс к разным моделям. Модель подставляется настройкой, а не отдельной веткой кода, и держать запасного провайдера становится дёшево. Только не думайте, что на этом всё: у разных провайдеров по-своему устроен вызов инструментов, по-разному поддержаны строгие схемы ответа, разные лимиты, а на одном и том же промпте ответы выходят разные. Строку поменять просто, а качество после этого надо мерить заново.
- готовые паттерны. Стратегии памяти, ретриверы, разбиение документов на куски, парсеры вывода уже написаны и обкатаны на тысячах проектов.
- быстрый прототип. Собрать работающий RAG или агента из готовых блоков — вопрос десятков строк, и это честный способ за день проверить, стоит ли фича того.
Что вы за это платите, и это та часть, о которой в документации фреймворка не пишут:
- слой в горячем пути. Каждый вызов модели проходит через несколько уровней обёрток. На фоне секундного ответа модели их миллисекунды не видны, но в отладке видны очень хорошо: чтобы понять, какой именно промпт ушёл модели, приходится продираться сквозь эти уровни.
- зависимость, которая тянет зависимости. Ядро библиотеки небольшое, а интеграции с провайдерами, базами и загрузчиками документов живут отдельными пакетами, и каждый несёт свои транзитивные зависимости с их уязвимостями и конфликтами версий. В сервисе, где обновление библиотек идёт через проверку безопасности, это заметная статья расходов.
- скорость перемен. Экосистема молодая: за пару лет цепочки переехали с классов на LCEL, память — из LangChain в LangGraph, и старый код перестаёт собираться при обновлении минорной версии. Для сервиса в проде это значит, что версию закрепляют, а обновление планируют как работу, а не как смену цифры.
Практическое правило: один-два вызова модели — берите SDK; поток из поиска, памяти, инструментов и ветвлений — фреймворк экономит недели. И даже с фреймворком полезно понимать, что он делает под капотом, иначе отладка превращается в гадание. LangChain не единственный: в питоновском мире есть LlamaIndex и собственный тонкий слой поверх SDK, за его пределами — те самые LangChain4j, Spring AI и langchaingo.
Глубже: те же блоки на своём языкерасширенное
Эта фаза стоит в программе для Java, а LangChain есть только для Python и JavaScript. Те же блоки на Java существуют, и их два набора.
Spring AI живёт в экосистеме Spring: ChatClient как единый интерфейс к моделям провайдеров (подставляется настройкой, как источник данных), структурированный вывод в Java-объект по схеме, советники (advisors), которые оборачивают вызов памятью диалога или поиском по документам, интерфейс VectorStore с реализациями для pgvector и выделенных баз, инструменты как методы бина с аннотацией, и наблюдаемость через Micrometer, ту же, что у остального сервиса. Для команды на Spring это естественный выбор: конфигурация в application.yml, бины, тесты с заглушкой модели.
LangChain4j ближе к оригинальному LangChain: декларативные сервисы (AiServices), где интерфейс с аннотированными методами превращается в вызовы модели, инструменты, память, ретриверы и готовые интеграции с моделями и базами; есть и модуль для Spring Boot. Его берут, когда нужно повторить архитектуру из Python-примеров или нужен провайдер, которого в Spring AI ещё нет.
Вектор в PostgreSQL через jOOQ. В проекте, где jOOQ объявлен единственным слоем доступа, pgvector ложится так: колонка типа vector(1536) в миграции, индекс HNSW по косинусному оператору, а в jOOQ тип vector объявляют через привязку (Binding) к float[], после чего запрос ближайших пишется обычным построителем с полем-выражением для оператора:
Field<Double> distance = DSL.field("{0} <=> {1}", Double.class, CHUNK.EMBEDDING, DSL.val(query, EMBEDDING_TYPE));
dsl.select(CHUNK.ID, CHUNK.TEXT, distance)
.from(CHUNK)
.where(CHUNK.TENANT_ID.eq(tenantId))
.orderBy(distance)
.limit(20)
.fetch();
Фильтр по арендатору стоит в WHERE рядом с векторным поиском, и это главный аргумент за pgvector в сервисе с данными в PostgreSQL: права, транзакции и индексы по метаданным те же, что у остальной базы, о чём статья про векторные базы.
Правило выбора то же, что и в Python: один-два вызова это клиент провайдера напрямую или ChatClient; поток из поиска, памяти и инструментов это Spring AI или LangChain4j; и в обоих случаях понимать, какой промпт ушёл модели. Проверка «ретривер вернул пусто» на Java выглядит так же: список документов пуст — модель не зовём.
На Go LangChain нет, и это фаза общая для программ на четырёх языках, поэтому ответ короткий: те же блоки дают либо SDK провайдера напрямую (официальные клиенты OpenAI и Anthropic для Go), либо сообществный порт langchaingo.
langchaingo повторяет устройство оригинала: llms.Model как единый интерфейс к провайдерам (llms/openai, llms/anthropic, llms/ollama), цепочки, память, агенты с инструментами, ретриверы и хранилище vectorstores/pgvector поверх pgx. Минимальный вызов:
llm, err := openai.New(openai.WithModel("gpt-4o-mini"))
if err != nil {
return err
}
answer, err := llms.GenerateFromSinglePrompt(ctx, llm, prompt)
Порт живёт силами сообщества, за официальными провайдерами отстаёт на месяцы, и часть блоков (структурированный вывод, свежие форматы инструментов) удобнее собрать руками поверх SDK: цикл агента из статьи про инструменты укладывается в сотню строк на Go, а context.Context даёт тайм-ауты и отмену бесплатно.
Вектор в PostgreSQL. Колонка vector(1536) в миграции, индекс HNSW по косинусному оператору, тип зарегистрирован у pgx через pgvector-go, запрос ближайших с фильтром по арендатору в WHERE рядом с векторным поиском; разбор с кодом в статье про векторные базы. Это главный аргумент за pgvector в сервисе с данными в PostgreSQL: права, транзакции и индексы по метаданным те же, что у остальной базы.
Правило выбора то же, что и в Python: один-два вызова это SDK провайдера; поток из поиска, памяти и инструментов это langchaingo или свой цикл; и в обоих случаях понимать, какой промпт ушёл модели. Проверка «ретривер вернул пусто» выглядит так же: срез документов пуст — модель не зовём.
На Node LangChain родной: это второй язык проекта после Python, и всё описанное выше существует под теми же именами. Пакеты: @langchain/core (базовые типы, Runnable, шаблоны промптов), провайдеры отдельными пакетами (@langchain/openai, @langchain/anthropic), интеграции с базами в @langchain/community (в том числе PGVectorStore для pgvector), а поток агента со состоянием и ветвлениями в LangGraph.js.
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
const answer = await model.invoke(prompt);
Отличий от Python два. Первое: язык типизирован, и withStructuredOutput(schema) со схемой zod даёт объект нужной формы без ручного разбора JSON, а выдуманное поле не пройдёт проверку типов. Второе: у Node нет глобального интерпретатора, и параллельные вызовы модели пишутся Promise.all, а ограничение параллелизма делают явно, иначе провайдер ответит 429.
Вектор в PostgreSQL. Колонка vector(1536) в миграции, индекс HNSW по косинусному оператору, разбор типа через пакет pgvector для pg, запрос ближайших с фильтром по арендатору в WHERE; разбор с кодом в статье про векторные базы. Права, транзакции и индексы по метаданным те же, что у остальной базы.
Правило выбора то же: один-два вызова это SDK провайдера; поток из поиска, памяти и инструментов это LangChain или LangGraph; и в обоих случаях понимать, какой промпт ушёл модели. Проверка «ретривер вернул пусто» выглядит так же: массив документов пуст — модель не зовём.
На Python LangChain родной, и вся статья выше написана в его терминах, так что здесь только карта пакетов. langchain-core держит базовые типы и протокол Runnable, провайдеры вынесены в отдельные пакеты (langchain-openai, langchain-anthropic), интеграции с базами в свои (langchain-postgres с PGVector для pgvector), а граф агента со состоянием и ветвлениями в LangGraph.
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o-mini")
answer = model.invoke(prompt)
Две вещи, о которых узнают после первой аварии. with_structured_output(Model) с классом Pydantic возвращает объект нужной формы, и это единственный надёжный способ получить из модели JSON: разбор json.loads от ответа «с пояснением сверху» падает на первой же нестандартной фразе. И зависимости: пакеты LangChain выходят часто и ломают друг друга при несовпадении версий, поэтому их закрепляют в lock-файле и обновляют вместе, а не по одному.
Вектор в PostgreSQL. Колонка vector(1536) в миграции, индекс HNSW по косинусному оператору, адаптер типа pgvector.psycopg или тип Vector для SQLAlchemy, запрос ближайших с фильтром по арендатору в WHERE; разбор с кодом в статье про векторные базы. Права, транзакции и индексы по метаданным те же, что у остальной базы.
Правило выбора то же: один-два вызова это SDK провайдера; поток из поиска, памяти и инструментов это LangChain или LangGraph; и в обоих случаях понимать, какой промпт ушёл модели. Проверка «ретривер вернул пусто» выглядит так же: список документов пуст — модель не зовём.
Глубже: версия модели и версия промптарасширенное
Провайдер выпускает новую версию модели и через несколько месяцев снимает старую; на тех же промптах поведение меняется: другой формат, другая строгость, другие ошибки. Фича, которая «просто работала», ломается без единого коммита. Отсюда правила, которые в обычном коде называются управлением зависимостями.
Закреплять версию модели. В вызове указывают не «модель такая-то», а её датированный снимок, и обновляют его осознанно, прогнав оценку из статьи про основы; провайдеры объявляют снятие версий заранее, и это событие попадает в тот же трекер, что и обновления библиотек. Псевдоним «последняя версия» в проде это выкат, который сделает провайдер без вас.
Промпт как код. Промпт лежит в репозитории рядом с кодом, с версией, историей и ревью, а не в базе или в конфигурации, которую правят руками; каждая правка проходит оценку в CI. В ответе фичи и в журнале записывают версию промпта и версию модели, иначе разбор «почему ответ вчера был другим» невозможен.
Постепенный выкат. Новый промпт или новую модель включают на долю трафика или для внутренних пользователей, сравнивают долю приемлемых ответов и жалобы с контрольной группой, и только потом переключают всех; откат это смена версии в настройке, а не откат кода. Так же выкатывают смену набора документов для поиска, которая меняет ответы не меньше промпта.
Всё это удваивается для судьи-модели в оценке: её версию тоже закрепляют, иначе оценка «поплыла» вместе с судьёй, а не с фичей.
Глубже: наблюдаемость: трасса прогона и журнал вопросоврасширенное
Отладка названа выше главной ценой фреймворка, а лечится она тем же, чем у любого сервиса: трассой, метриками и журналом, только с полями, которых у обычного сервиса нет.
Трасса прогона. Один запрос пользователя это цепочка шагов: поиск документов, вызов модели, вызов инструмента, ещё вызов модели. Каждый шаг это спан с атрибутами: версия промпта и модели, входные и выходные токены, время, стоимость, что вернул инструмент, был ли ответ из кэша; для вызова модели сам промпт и ответ пишут в спан целиком или в связанное хранилище с ограниченным доступом (там персональные данные). Для этого есть соглашения OpenTelemetry для генеративных моделей, которые Spring AI и LangChain4j умеют заполнять, и специализированные хранилища трасс LLM-приложений с просмотром промптов. История про «30 дней» в такой трассе читается за минуту: в спане ретривера ноль документов, в спане промпта пустое поле фактов.
Метрики. Доля отказов модели (не ответила, ответила отказом, не уложилась в схему), доля запасных ответов, доля пустых результатов поиска, задержка до первого токена и полная, токены и стоимость по фиче и по пользователю, доля ответов из кэша. Доля выдумок напрямую не измеряется; её оценивают на выборке судьёй-моделью или людьми и ведут как метрику по неделям. И оценка пользователя (полезно или нет под ответом), самая дешёвая метрика качества.
Журнал вопросов. Все вопросы и ответы с версиями, оценкой пользователя и результатом судьи хранятся с ограниченным сроком и доступом: из него растёт набор для оценки, в нём ищут инъекции и в нём же видно, что спрашивают на самом деле, а не что предполагали при проектировании. Об общей цене наблюдаемости и о персональных данных в трассах говорит раздел про наблюдаемость.
Коротко
- LangChain — фреймворк оркестрации: готовые блоки, чтобы собирать LLM-приложения из вызовов модели, поиска и инструментов; сам ИИ по-прежнему снаружи.
- Четыре блока: цепочки (
prompt | model | parser, запись зовут LCEL), состояние диалога (окно из N сообщений, сводка плюс хвост или факты отдельно; держит его LangGraph, старые классыMemoryустарели), ретриверы (найти ваши данные для RAG), инструменты и агенты (цикл агента тоже крутит LangGraph). - Успех шага не равен успеху результата: пустой ответ ретривера проходит через цепочку как обычные данные, и модель дописывает недостающее сама. Между поиском и моделью ставят явную проверку «нашлось ли что-то».
- LangChain есть только для Python и JavaScript; на других языках те же блоки под своими именами: LangChain4j и Spring AI, langchaingo.
- Цена фреймворка: слой обёрток в горячем пути и в отладке, пакеты интеграций с транзитивными зависимостями, быстрая смена API, из-за которой версию закрепляют.
- Правило: один-два вызова — голый SDK; поток из поиска, памяти, инструментов и ветвлений — фреймворк. В любом случае понимать, какой промпт ушёл модели.
- На Java те же блоки это Spring AI (
ChatClient, советники,VectorStore, наблюдаемость Micrometer) и LangChain4j; на Go langchaingo или SDK провайдера со своим циклом; вектор в PostgreSQL читают через свой слой доступа (jOOQ с привязкой типа, pgx сpgvector-go,pgи psycopg с пакетомpgvector). - Версию модели закрепляют датированным снимком и обновляют через оценку, промпт живёт в репозитории с версией, обе версии пишут в журнал, новый промпт выкатывают на долю трафика.
- Наблюдаемость LLM-фичи это трасса прогона со спанами на каждый вызов (промпт, токены, время, инструмент), метрики отказов, пустых поисков и цены, оценка выдумок на выборке и журнал вопросов как источник набора для оценки.
Что почитать дальше
- RAG и эмбеддинги — как устроен поиск, который стоит перед моделью, и почему он возвращает пусто или не то.
- Векторные базы данных — pgvector и выделенные базы под ретриверами.
- Агенты и инструменты — что происходит, когда цепочка превращается в цикл с решениями модели.
- Наблюдаемость — трассы и метрики, к которым добавляются поля модели.