awesome-everything EN
↑ Обратно к восхождению

AI / LLM

Сборка production LLM-приложения: баг живёт в шве

Суть Кэширование, RAG, стриминг, тулзы, агенты и evals проходят каждый свой тест, а вместе падают. Трассируй один запрос насквозь: баг живёт в шве между двумя корректными слоями, а не внутри одного из них.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 17 min

Ассистент прошёл все юнит-тесты. Кэш: 90% попаданий. RAG: top-5 recall отличный. Стриминг: токены текут. Тулзы: функция вызывается. Агент: задачу доводит. Потом ты выкатываешь RAG-агентного ассистента — и счёт утраивается, а ответы кажутся медленнее. Ни один компонент не сломан. Cache read rate в проде упал почти до нуля, потому что RAG-контекст теперь вшит в кэшируемый префикс — а он меняется на каждом запросе. Шесть зелёных компонентов, один красный счёт.

Один запрос, все слои

Запрос-капстоун выглядит безобидно: пользователь спрашивает «какое у нас окно возврата для заказов из EU?», и ассистент достаёт документы политики, рассуждает, может вызвать тулзу и стримит ответ. Под капотом этот один ход пересекает шесть слоёв из прошлых юнитов, каждый протестирован отдельно:

  1. Prompt cache — длинный статический префикс (системные правила, схемы тулзов) с пометкой cache_control, чтобы повторные вызовы не перекодировали его заново.
  2. RAG — достать top-k чанков политики под этот запрос и вшить их в контекст.
  3. Tool calls — модель может вызвать lookup_order или get_policy посреди хода.
  4. Streaming — токены идут пользователю по мере генерации, по SSE.
  5. Agent loop — если первому ответу нужен ещё retrieval или тулза, цикл повторяется.
  6. 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 → retrievalEvals генерации проходят на фиксированном контекстеНабор не варьирует 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. 1 Трассируй ОДИН реальный продовый запрос через все слои (кэш, RAG, тулзы, стрим, цикл)
  2. 2 На каждой границе спроси, что следующий слой считал, а этот слой только что изменил
  3. 3 Найди шов: попроцессный RAG-контекст внутри кэшируемого префикса → hit rate ≈ 0
  4. 4 Вынеси RAG-контекст за брейкпойнт cache_control; статические правила оставь до него
  5. 5 Добавь end-to-end eval, который варьирует retrieval, чтобы шов не регрессировал снова тихо
Вспомните перед уходом
  1. 01
    Объясни, почему RAG-ассистент с длинным статическим системным промптом всё равно может видеть почти нулевой cache hit rate в проде, и как это починить.
  2. 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. Ход сеньора — не лучшие компоненты; это трассировать один реальный запрос через все слои и на каждой границе спросить, что следующий слой считал, а предыдущий только что изменил. Моделируй угрозы и стоимость всего потока.

Продолжить восхождение ↑Сборка LLM-приложений: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.