AI / LLM
Сборка production LLM-приложения: баг живёт в шве
Ассистент прошёл все юнит-тесты. Кэш: 90% попаданий. RAG: top-5 recall отличный. Стриминг: токены текут. Тулзы: функция вызывается. Агент: задачу доводит. Потом ты выкатываешь RAG-агентного ассистента — и счёт утраивается, а ответы кажутся медленнее. Ни один компонент не сломан. Cache read rate в проде упал почти до нуля, потому что RAG-контекст теперь вшит в кэшируемый префикс — а он меняется на каждом запросе. Шесть зелёных компонентов, один красный счёт.
Один запрос, все слои
Запрос-капстоун выглядит безобидно: пользователь спрашивает «какое у нас окно возврата для заказов из EU?», и ассистент достаёт документы политики, рассуждает, может вызвать тулзу и стримит ответ. Под капотом этот один ход пересекает шесть слоёв из прошлых юнитов, каждый протестирован отдельно:
- Prompt cache — длинный статический префикс (системные правила, схемы тулзов) с пометкой
cache_control, чтобы повторные вызовы не перекодировали его заново. - RAG — достать top-k чанков политики под этот запрос и вшить их в контекст.
- Tool calls — модель может вызвать
lookup_orderилиget_policyпосреди хода. - Streaming — токены идут пользователю по мере генерации, по SSE.
- Agent loop — если первому ответу нужен ещё retrieval или тулза, цикл повторяется.
- Evals — офлайн-набор, который гейтит деплои.
Каждый слой корректен в изоляции. Сбой в проде всегда на шве — там, где выходной контракт одного слоя тихо нарушает входное допущение следующего. Эти баги не найти, тестируя куски. Их находят, трассируя один реальный запрос через все слои и спрашивая на каждой границе: «что следующий слой считает само собой, а предыдущий это только что изменил?»
Шов 1: RAG отравляет кэш-префикс
Prompt caching в Anthropic — это prefix match: запрос раскладывается как tools → system → messages, и кэш переиспользует самый длинный байт-в-байт идентичный префикс до твоего последнего брейкпойнта cache_control. TTL по умолчанию — 5 минут (или 1 час). Наивная сборка — собрать один большой system-блок: правила + схемы тулзов + «Вот релевантные документы:» + полученные чанки. На демо работает идеально. В проде полученные чанки разные на каждом запросе, поэтому префикс отличается с первого байта, cache read rate схлопывается к нулю, и ты платишь полную цену input на каждом вызове — ровно там, где кэш был важнее всего.
Фикс — раскладка с учётом шва: ставь статический префикс (системные правила, схемы тулзов) до брейкпойнта, а попроцессный RAG-контекст — после него, в messages. Статический префикс остаётся в кэше; перекодируется только дешёвый динамический хвост. В этом весь смысл того, что cache_control позволяет ставить брейкпойнты на границах, а не кэшировать весь блоб запроса.
| Шов | Каждая сторона корректна | Сбой в сборке | Фикс на границе |
|---|---|---|---|
| RAG → кэш | Retrieval ранжирует хорошо; кэш попадает в юнит-тесте | Попроцессные чанки внутри кэшируемого префикса → hit rate ≈ 0, полная цена input | Статический префикс до брейкпойнта; RAG после него |
| Тулзы → стриминг | Тулза вызывается; стрим отдаёт токены | stop_reason: tool_use посреди стрима; UI крутит спиннер, ждёт прозу | Стрим как стейт-машина: пауза рендера, запуск тулзы, продолжение |
| Agent loop → бюджет | Цикл сходится на happy path | Плохой вход ретраит вечно; нет потолка по шагам/$ → разгон трат | Жёсткие лимиты: step ≤ MAX, spent ≤ BUDGET, дедуп повторов |
| Evals → retrieval | Evals генерации проходят на фиксированном контексте | Набор не варьирует retrieval → регрессии retrieval уходят зелёными | End-to-end evals с живым путём retrieval |
Шов 2: вызов тулзы ломает стрим
Стриминг и tool use работают каждый сам по себе, но делят один провод. Стримящийся ход не всегда кончается прозой: модель может выдать stop_reason: tool_use посреди генерации. Если фронтенд считает стрим «токенами до конца», он отрендерит частичный текст, а потом зависнет — спиннер не разрешится, потому что настоящее продолжение это другой запрос, который ты ещё не отправил (результат тулзы, скормленный обратно). Хуже из практики: сетевой сбой обрывает стрим со stop reason tool_use, но с нулём блоков tool-call, поэтому агент не находит, что выполнять, и тихо уходит в простой; или краш процесса посреди исполнения тулзы оставляет осиротевший результат, и следующий запрос отклоняется с unexpected tool_use_id found in tool_result block, оставляя сессию невосстановимой без ручной хирургии.
Правило сборки: стрим — это стейт-машина, а не труба токенов. Состояния: text, tool_use_requested, awaiting_tool_result, resumed. Стоп по tool-use — это переход, а не конец. И каждый id tool_use должен быть сопоставлен ровно одному tool_result в следующем запросе — отслеживай их, иначе API отклонит весь ход.
Почему это работает
Почему это кусает только в проде? На демо ты задаёшь один чистый вопрос, и модель отвечает прозой — ветка tool_use не срабатывает, поэтому шов «стриминг + тулзы» вообще не задействуется. Первый реальный пользователь, который дёрнет тулзу посреди ответа, и есть первый трафик, который вообще пересекает этот шов. «Работает у меня» здесь значит «я ни разу не попал в ветку, которая ломается».
Шов 3: agent loop без бюджета
Agent loop — это «вызови модель, запусти тулзы, скорми результаты обратно, повторяй до готовности». Юнит-тест кончается, потому что задача удаётся. Продовый вход не сотрудничает: тулза возвращает кривой результат, модель переформулирует и ретраит, результат всё ещё кривой, она ретраит снова — и поскольку каждая итерация шлёт обратно весь растущий транскрипт, стоимость растёт сверхлинейно. Часто цитируемый постмортем: четыре агента без потолка по шагам ушли в цикл, проработали 11 дней и сожгли $47 000, прежде чем кто-то заметил. Урок там резкий — алерты по бюджету токенов это не enforcement. Алерты срабатывают после траты; enforcement отказывает в следующем вызове.
Фикс сборки — три assert перед каждым вызовом модели: step ≤ MAX_STEPS, spent ≤ BUDGET_USD и hash(tool_name, args) not in seen, чтобы убить циклы «повтори тот же вызов». Бюджет-осознанный gateway возвращает ошибку вместо форварда запроса, когда потолок достигнут. Команды, которые это добавляют, обычно срезают стоимость агента на 55–75%.
У твоего RAG-ассистента cache hit rate в проде около 0%, хотя системный промпт длинный и статический. Выбери фикс.
Тезис сеньора: моделируй поток, а не части
Сквозная линия каждого шва выше: слой, корректный по своему контракту, меняет то, на что следующий слой тихо рассчитывал. RAG поменял префикс, который кэш считал стабильным. Вызов тулзы поменял стрим, который рендерер считал прозой. Реальный вход поменял цикл, который бюджет считал завершимым. Путь retrieval поменялся под набором evals, который считал контекст фиксированным. Ни один из них не баг внутри компонента — каждый баг между компонентами. Поэтому навык сеньора на капстоуне не в том, чтобы строить лучшие куски; он в том, чтобы моделировать угрозы и стоимость всего пути запроса: что считает каждая граница и какой вышестоящий слой может это нарушить? Трассируй один реальный запрос насквозь — и швы загораются.
Стримящийся ход кончается на stop_reason: tool_use посреди генерации, и UI зависает на спиннере. Какая правильная ментальная модель?
Твой офлайн-набор evals зелёный на каждом деплое, но пользователи жалуются на ухудшение ответов после смены ретривера. Почему evals это пропустили?
Расставь, как дебажить собранное LLM-приложение, у которого стоимость утроилась, а ответы кажутся медленнее:
- 1 Трассируй ОДИН реальный продовый запрос через все слои (кэш, RAG, тулзы, стрим, цикл)
- 2 На каждой границе спроси, что следующий слой считал, а этот слой только что изменил
- 3 Найди шов: попроцессный RAG-контекст внутри кэшируемого префикса → hit rate ≈ 0
- 4 Вынеси RAG-контекст за брейкпойнт cache_control; статические правила оставь до него
- 5 Добавь end-to-end eval, который варьирует retrieval, чтобы шов не регрессировал снова тихо
- 01Объясни, почему RAG-ассистент с длинным статическим системным промптом всё равно может видеть почти нулевой cache hit rate в проде, и как это починить.
- 02В чём тезис «баг живёт в шве» и как он меняет дебаг собранного LLM-приложения в сравнении с одним компонентом?
Production LLM-приложение — это сборка слоёв: prompt caching, RAG, tool calls, стриминг, agent loop и evals, — и каждый из них может пройти свой юнит-тест, пока система падает. Сбои живут в швах. Попроцессный RAG-контекст, вшитый в кэшируемый префикс, схлопывает cache hit rate почти до нуля, потому что кэш — это байт-в-байт prefix match, а чанки меняются на каждом запросе; фикс — держать статический префикс до брейкпойнта cache_control, а динамический контекст после него. Стоп по tool-use посреди стрима вешает рендерер, который ждёт прозу, поэтому моделируй стрим как стейт-машину и сопоставляй каждый id tool_use с tool_result. Agent loop без потолка по шагам или долларам ретраит вечно на плохом входе — знаменитый случай шёл 11 дней на $47 000 — поэтому ставь лимиты, а не только алерты. А evals генерации на замороженном контексте отгружают регрессии retrieval зелёными, поэтому добавь end-to-end evals, которые варьируют живой путь retrieval. Ход сеньора — не лучшие компоненты; это трассировать один реальный запрос через все слои и на каждой границе спросить, что следующий слой считал, а предыдущий только что изменил. Моделируй угрозы и стоимость всего потока.