Привет, Хабр! Меня зовут Сергей Нотевский, я AI Platform Lead в Битрикс24.
В процессе подготовки статьи про экономику кэширования, собрал несколько анти-паттернов, способных все сломать. Сначала были мысли о том, что это будет короткая врезка в конце, но подняв заметки и сделав пару ресерчей - стало понятно, что это вообще отдельная тема.
Таких поломок с prefix_cache много, но механика у них схожая. В этой статье попытался свести все к трем причинам: у запросов перестаёт совпадать начало, одинаковые запросы попадают на разные машины, или прогретый кэш не доживает до следующего обращения.
В прошлый раз я писал про деньги. Здесь - про то, почему кэширование вроде бы включено, а повторного использования почти нет: cached_tokens не растут, задержка увеличивается, и система снова платит полную цену за prefill.
TL;DR
Самые дорогие промахи часто создаёт не пользовательский текст, а обвязка вокруг API:
tools,response_format,json_schema, сериализация, переключение режимов.В кластере с несколькими репликами стабильный prompt сам по себе не спасает. Если у вас round-robin без привязки к прогретому кэшу, одинаковые запросы начинают прогреваться независимо на разных машинах.
В своей инфраструктуре всё ещё веселее: даже хороший общий кусок запроса можно потерять из-за вытеснения, особенностей хранения блоков и слишком маленького KV budget.
Быстро про механику: три условия, без которых кэш не сработает
Для того чтобы кэш вообще сработал, мало “похожего” запроса. Должны одновременно выполниться три условия.
Во-первых, у запросов должно буквально совпадать начало. Не “почти такое же”, а тот же поток токенов до точки, где система может использовать уже посчитанный префикс.
Во-вторых, запрос должен попасть туда, где это начало уже успело прогреться. В single-node setup это почти не заметно. В multi-replica это уже отдельная инженерная проблема.
В-третьих, сам KV-cache должен дожить до следующего запроса. Если его уже вытеснили, точное совпадение начала вам не поможет.
Всё остальное - детали реализации.
Синтетический кейс, на котором дальше будем всё ломать
Возьмём корпоративного AI-ассистента. В каждый запрос он отправляет длинный system prompt, 14 tool definitions и короткий хвост истории чата. Это как раз тот режим, где кэш обычно должен работать хорошо: общая начальная часть длинная, повторов много, цена промаха заметная.
До релиза всё выглядит нормально:
Метрика | До |
|---|---|
| ~2.5k |
| ~2.4k |
TTFT p50 | ~0.5s |
Реплики | 2 |
Роутинг | sticky / prefix-aware |
Потом команда выкатывает три “безобидных” изменения:
В
system promptдобавляет текущее время иuser.company.toolsначинает собирать из динамической структуры без жёсткой фиксации порядка.После масштабирования на 4 реплики с моделью оставляет обычный round-robin.
После релиза модель та же, токенов почти столько же, а вот картина уже другая:
Метрика | После |
|---|---|
| ~2.6k |
| ~0 |
TTFT p50 | ~1.8s |
Реплики | 4 |
Роутинг | round-robin |
Ниже синтетический, но реалистичный кейс: цифры здесь иллюстративные. Это просто модель поломки с примерным порядком величин.
В этом кейсе ломаются сразу все три условия: у запросов перестаёт совпадать начало, одинаковые запросы попадают на разные машины, и прогретый кэш не доживает до следующего обращения.
1. Волатильные данные в начале запроса
Это самый частый убийца hit rate. И самый банальный.
Проблема выглядит так:
system = f""" Сегодня: {datetime.now().isoformat()} Ты корпоративный ассистент для {user.company}. {BASE_SYSTEM_PROMPT} """
Кажется, что это нормальная персонализация. На практике вы делаете каждый запрос уникальным с самых первых токенов. Один timestamp в начале - и весь хвост запроса уже другой. То же самое происходит с user.name, session_id, request_id и любыми другими полями, которые меняются чаще, чем живёт кэш.
Правило тут простое: всё, что живёт долго и повторяется между запросами, должно быть максимально близко к началу. Всё, что меняется от запроса к запросу, - как можно дальше от него.
system = BASE_SYSTEM_PROMPT user_ctx = { "company": user.company, "now": datetime.now().isoformat(), } messages = [ {"role": "system", "content": system}, { "role": "user", "content": f"[ctx={json.dumps(user_ctx, sort_keys=True)}] {query}", }, ]
Это не делает весь запрос одинаковым. Но сохраняет общей ту часть, что самая дорогая в prefill.
2. Невидимый дрейф шаблона: когда один и тот же запрос в системе собирается по-разному
Сам по себе лишний пробел в шаблоне - не катастрофа. Если вы один раз глобально поменяли prompt, кэш просто один раз остынет, а потом прогреется заново уже на новой версии.
Проблема начинается в другом месте: когда в системе одновременно живут несколько почти одинаковых вариантов одного и того же запроса. Один сервис добавляет один перевод строки, другой - два. Один путь отправляет изображение с detail="low", другой - с detail="high". Один SDK сериализует сообщение так, другой чуть иначе.
Глазами это “тот же prompt”. Для кэша - уже нет.
# web-chat system = BASE_SYSTEM + "\\n" # api-agent system = BASE_SYSTEM + "\\n\\n" или так: ```python content = [ {"type": "input_text", "text": user_query}, {"type": "input_image", "image_url": img_url, "detail": "low"}, ]
А в соседнем код-пути уже detail=“high” или тот же URL, но с другой query-строкой.
Вот это уже постоянное дробление кэша между несколькими почти одинаковыми версиями начала запроса.
Самая неприятная часть этой поломки в том, что её часто ищут не там. Начинают дебажить сам кэш, хотя проблема в шаблонизаторе, рендеринге prompt’а, SDK или сериализации мультимодального ввода.
Поэтому нормализовать нужно не только user input, но и сам способ сборки запроса: пробелы, переводы строк, markdown-шаблоны, параметры изображений и сериализацию ссылок.
3. Tools, schema и response_format - это тоже начало запроса
Очень часто кэш ломает не сам prompt, а обычная обвязка вокруг API.
tools = list(tool_registry.values()) # порядок зависит от сборки/регистрации response_format = { "type": "json_schema", "json_schema": { "name": "answer", "schema": { "type": "object", "properties": { "requestId": {"type": "string", "const": request_id}, "answer": {"type": "string"}, }, "required": ["requestId", "answer"] } } }
С инженерной точки зрения всё валидно. С точки зрения prefix caching вы сделали каждый запрос уникальным ещё до user message.
Фикс здесь скучный. И поэтому рабочий:
tools = canonicalize_tools(STABLE_TOOLS) # фиксированный порядок response_format = { "type": "json_schema", "json_schema": { "name": "answer", "schema": { "type": "object", "properties": { "answer": {"type": "string"}, }, "required": ["answer"] } } }
А request_id, tenant context, trace id и прочую живую телеметрию лучше передавать отдельно, не внутрь кэшируемой схемы.
Это дорогая поломка, потому что schema и tools часто весят сотни и тысячи токенов. Один случайный requestId в json_schema - и общий тяжёлый кусок в начале запроса больше не общий.
4. Переписывание истории вместо добавления в конец (append-only)
Кэш любит диалог, который растёт в хвост. Он плохо переносит списки запросов, котором вы переписываете начало.
Проблема обычно появляется, когда команда начинает “оптимизировать” длинный контекст. Суммаризация ранних ходов. Агрессивное сжатие истории. Наивное обрезание. Переключение режимов через mutation system prompt.
messages = [ {"role": "system", "content": DEBUG_SYSTEM_PROMPT}, {"role": "assistant", "content": summary_of_previous_turns}, {"role": "user", "content": new_query}, ]
Если хочется сохранить повторное использование, лучше расти в хвост:
messages = original_history + [ {"role": "assistant", "content": summary_for_future_reference}, {"role": "user", "content": new_query}, ]
Есть и более прямой способ на это смотреть: если вы действительно пересобираете контекст заново, считайте это новой сессией и не ждите старого hit rate.
Summarization не плохая идея сама по себе. Иногда она обязательна. Но это не бесплатное ускорение. Это обмен: вы уменьшаете длину контекста ценой того, что кэш с точки переписывания больше не совпадает.
5. Обычный балансировщик размазывает тёплый кэш по репликам
Даже стабильное начало запроса - это только половина задачи. Вторая половина - попасть туда, где этот запрос уже успели прогреть.
На одной только сборке prompt’а всё не заканчивается. Можно стабилизировать system prompt, канонизировать tools, зафиксировать schema - и всё равно не получить повторного использования, если одинаковые запросы разъезжаются по разным репликам.
router = RoundRobinRouter(replicas=4) resp = router.send(request)
Практически здесь нужен либо prompt_cache_key, либо роутинг с учётом прогретого кэша, либо хотя бы sticky routing на уровне gateway:
resp = client.responses.create( model=MODEL, input=messages, tools=tools, prompt_cache_key=f"assistant:v3:tenant:{tenant_id}", )
У этого есть цена: маршрутизация с привязкой к прогретому кэшу улучшает повторное использование, но может создавать hot spots на популярных префиксах. То есть вы выигрываете в reuse, но можете проиграть в равномерности нагрузки, если не следите за балансом.
Это не теоретическая придирка к архитектуре. В production-стеке vLLM переход на prefix-aware routing поднимал hit rate до 87.4% и давал около 70% экономии вычислений. То есть правильный роутинг здесь влияет не меньше, чем аккуратная сборка запроса.
6. Параллельный запуск по ещё не прогретому началу запроса
Это очень контринтуитивный баг.
Инженер думает так: “сейчас я отправлю 10 одинаковых длинных запросов, первый прогреет кэш, остальные выиграют”. На практике легко получить обратное.
Проблема не в стабильности prompt’а. Prompt может быть одинаковым. Порядок запуска - нет.
tasks = [call_llm(shared_prefix, q) for q in batch] results = await asyncio.gather(*tasks)
Рабочий сценарий - сначала прогреть общий кусок в начале запроса, и только потом веерить похожие вызовы:
# 1. прогреть общий prefix await call_llm(shared_prefix, warmup_query) # 2. только потом веерить похожие запросы tasks = [call_llm(shared_prefix, q) for q in batch] results = await asyncio.gather(*tasks)
Этот анти-паттерн неприятен тем, что его легко спутать с “почему-то низким hit rate у провайдера”, хотя проблема на самом деле в порядке и тайминге ваших же вызовов.
И здесь мы плавно переходим к следующей группе проблем: даже если запрос доехал туда, куда надо, и даже если порядок запуска хороший, кэш ещё должен дожить до следующего обращения.
7. Кэш не доживает до следующего обращения
Последняя большая группа поломок - время жизни кэша.
Здесь уже можно всё сделать правильно на уровне сборки запроса и роутинга, а hit rate всё равно будет плохим.
Первый анти-паттерн - редкий трафик. Если “один и тот же” тяжёлый запрос приходит редко, обычный кэш в памяти будет каждый раз почти холодным.
Второй - под KV-кэш просто не хватает памяти. Если в трафике одновременно живёт больше “тёплых” префиксов, чем помещается в памяти, они начинают вытеснять друг друга. Снаружи это выглядит как случайный hit rate: то есть, то нет. На деле система просто снова и снова прогревает одни и те же куски запроса.
То есть для managed API это история про короткое окно жизни кэша. Для своей инфраструктуры - ещё и про настройки движка инференса и реальный объём доступной памяти.
Что мониторить, чтобы заметить проблему раньше счёта
cached_tokens - полезная метрика, но она показывает проблему слишком поздно. Когда она заметно просела, задержка и стоимость уже обычно успели вырасти.
Я бы смотрел минимум на четыре вещи.
Во-первых, какую долю трафика вообще можно закэшировать. Если заметная часть запросов слишком короткая, уникальная или не имеет общего начала, никакая оптимизация кэша не даст большого эффекта.
Во-вторых, время до первого токена по группам однотипных запросов. Если у вас есть сценарии, где запросы должны хорошо переиспользовать общий префикс, их стоит смотреть отдельно. Иначе в среднем всё может выглядеть нормально, а один важный будет сломан.
В-третьих, поведение после изменений: деплоя, A/B-теста, изменения числа реплик, смены SDK или prompt-шаблона. Хороший дашборд должен отвечать на вопрос: после какого именно изменения кэш начал работать хуже.
В-четвёртых, сколько раз вы прогреваете одни и те же куски заново. В managed API это выглядит как скачки hit rate и latency на общих длинных запросах. В своей инфраструктуре - ещё и как заполненный KV-cache и вытеснение полезных блоков.
Чеклист перед деплоем
Если бы мне нужно было оставить один блок, который люди реально унесут в прод, я бы оставил этот.
Начало запроса
В начале только статичный и общий контент.
Вся персонализация, время, request id, tenant-specific мелочь - в конец или в metadata.
Один канонический рендеринг system prompt.
Обвязка вокруг API
toolsстабильны по содержимому и порядку.JSON/schema сериализуются детерминированно.
response_formatне содержит динамических полей вродеrequestId.Переключение режимов не мутирует базовый system prompt.
История
Диалог растёт append-only.
Summarization и compaction - это осознанный trade-off, а не “бесплатная оптимизация”.
Truncation у длинных диалогов не ломает самый дорогой общий кусок в начале запроса.
Роутинг
Для длинных общих префиксов есть stickiness:
prompt_cache_key, affinity, prefix-aware routing.Параллельный запуск не стартует раньше, чем успел прогреться первый запрос.
Время жизни кэша
KV budget соответствует рабочему набору, а не “максимальному контексту из паспорта модели”.
Есть наблюдаемость по вытеснению и повторному прогреву, а не только по среднему
cached_tokens.
Если сократить всё до одной фразы: сначала стабилизируй начало запроса, потом добейся, чтобы одинаковые запросы попадали на одну и ту же машину, потом убедись, что прогретый кэш вообще доживает до следующего обращения.
Вывод
Анти-паттернов много, но почти все они сводятся к трём поломкам: у запросов перестаёт совпадать начало, одинаковые запросы попадают на разные машины, или прогретый кэш слишком быстро исчезает.
И это главный практический вывод. Плохой prefix_cache_hit это очень конкретная инженерная поломка: один timestamp, один плавающий tools list, один round-robin, один слишком маленький KV budget.
Если тема откликается, в своем канале я обычно разбираю именно такие инженерные развилки - где проблема выглядит как “что-то подросла latency”, а корень на самом деле в одной детали архитектуры.
