Привет, Хабр! Меня зовут Сергей Нотевский, я AI Platform Lead в Битрикс24.

Это третья статья серии про prefix caching: первая - про экономику кэширования и особенности разных провайдеров, вторая - про антипаттерны в простых сценариях. А здесь про то, как та же механика работает против вас в агентном цикле.

TL;DR

  • Если на каждом шаге менять tools, system prompt или ранние блоки context, prefix cache будет часто начинаться заново.

  • Поэтому большой, но стабильный список tools иногда дешевле, чем маленький список, который постоянно пересобирается.

  • Для своего инференса часто работает схема «стабильные tools в промпте + masking / constrained decoding».

  • Для API-провайдеров стоит смотреть на allowed_tools, tool_search, defer_loading или фиксированные наборы tools по маршрутам.

  • Минимальный guardrail: логировать cached_tokens / cache_read_input_tokens, prefix_hash, tools_count и алертить при падении cache hit rate.

Если у агента 30+ tools, первое желание - не тащить их все в каждый запрос. Отобрать 5-7 релевантных на текущем шаге, убрать лишний шум, сделать промпт короче. На уровне одного запроса это выглядит как очевидный выигрыш по стоимости и скорости. Я бы сам так сделал.

Проблема начинается там, где запрос перестает быть одиночным. В агентском цикле оптимизация работает уже на цепочке из 10, 20, 50 вызовов. И там внезапно оказывается, что короткий промпт может быть дороже длинного. Под эффективной стоимостью дальше я имею в виду стоимость после учета cache hits и cache misses, а не просто сырое число входных токенов.

Упрощённо симптом в логах выглядит так. Системный промпт не меняется, история растет append-only, но cached_tokens почти каждый раз начинаются заново:

Шаг

Tools

Prefix hash

Cached

1

32

a91f…

0

2

32

a91f…

41 800

3

7

c03b…

1 200

4

9

8aa1…

900

5

6

f17c…

1 100

На бумаге все стало лучше: tools меньше, промпт короче.

В логах - наоборот: модель получала новый префикс почти на каждом шаге. Для человека это тот же агент с другим набором доступных действий. Для prefix cache - новый промпт.

А вот как выглядит та же сессия, если список tools оставить стабильным и ограничивать доступность отдельно:

Шаг

Tools

Стратегия

Prefix hash

Cached

1

32

все tools

a91f…

0

2

32

все tools

a91f…

41 800

3

32

+ masking

a91f…

45 200

4

32

+ masking

a91f…

48 900

5

32

+ masking

a91f…

52 100

Tools стало больше. Эффективная стоимость - ниже.

Почему короткий промпт проиграл

В обычном одиночном запросе к модели все довольно просто: системный промпт, сообщение пользователя, ответ. Если вы уменьшили промпт на 1000 токенов, вы действительно почти наверняка сэкономили 1000 входных токенов.

С агентом это перестает быть локальной оптимизацией. Агентный цикл выглядит так:

системный промпт
-> tool definitions
-> сообщение пользователя
-> assistant tool_call
-> tool result
-> assistant tool_call
-> tool result
-> ...

К 20-му шагу контекст легко уходит за 50K токенов. Соотношение входа и выхода в таких сценариях может доходить до 100:1. Вы платите за новый вопрос и за повторную обработку всей растущей истории.

Для масштаба: при цене входа $5/MTok и 50K входных токенов на позднем шаге один cache miss стоит $0.25. Если cached input дешевле в 10 раз, cache hit при тех же 50K стоит $0.025. Разница за один шаг небольшая. За 20 шагов агента это уже порядка $4–5 на сессию. При тысячах сессий в день это превращается в отдельную строку в бюджете.

Исследование Don’t Break the Cache на 500+ агентских сессиях показывает тот же порядок эффекта: в итоговой таблице лучшего cache mode стоимость снижается на 41–80%, а TTFT - на 6–31%. Кстати, очень рекомендую исследование - оно непосредственно про кэширование в задачах агентов, подразумевающих большие контексты.

Но тут конечно важен здравый смысл. Если агент ходит 3–5 раз, модель дешевая, трафик небольшой, динамический отбор tools может быть нормальным решением. Экономия от стабильного префикса будет копеечной, а выигрыш в точности выбора tool может оказаться важнее.

Проблема начинается в другом режиме: 15–50 шагов, дорогая модель, длинная история, тысячи сессий. Или свой инференс, где cache miss - это не только деньги, но и лишний prefill на GPU, рост TTFT и узкое место при высоком RPS.

Главный тезис тут: сначала посчитайте стоимость cache miss на последних шагах. Если она незаметна - не усложняйте архитектуру. Если заметна - стабильность префикса быстро становится важнее локальной экономии токенов.

Где именно ломается кэш

Когда cached_tokens перестают расти, первое подозрение обычно падает на историю: может, compaction переписывает старые сообщения, может, фреймворк добавляет trace_id, может, где-то меняется системный промпт.

Но в системе ai-агента первым делом стоит проверить набор тулов(tools).

Tools почти всегда находятся очень рано в промпте. OpenAI в документации по function calling прямо пишет, что функции инжектятся в системное сообщение и учитываются как входные токены. У Anthropic в документации по prompt caching порядок инвалидации описан каскадом: tools -> system -> messages.

То есть если на шаге 5 вы поменяли список tools, вы поменяли не маленький хвост запроса. Вы поменяли начало, после которого стоит вся накопленная история агента.

На своем инференсе внутренняя механика другая, но эффект тот же: кэш работает по самому длинному общему префиксу. Первый несовпавший блок или токен обрезает переиспользование всего, что стоит дальше. Даже другой порядок tools в массиве - это другая JSON-сериализация и другой prefix hash.

Это не теоретическая придирка. В Claude Code был реальный баг: порядок перечисления sub-agent types в описании тулов агента оказался недетерминированным. tools[0] менялся между вызовами, и весь prefix hash после него летел. Фикс оказался скучным: стабилизировать сериализацию.

Здесь легко сделать неправильный вывод: «значит, много tools - плохо». Но как бы не хотелось - не все так просто.

Don’t Break the Cache уточняет картину: при длинных агентных сессиях стоимость кэша остается стабильной при росте числа tool calls в сессии от 3 до 50. Это про количество вызовов, а не про количество описаний tools в промпте. Но вывод для нашей темы важный: многошаговость сама по себе кэш не ломает. Ломает его изменение кэшируемого префикса между шагами.

Главный вывод такой:

  • большой стабильный список tools - неприятен, но кэшируем;

  • маленький плавающий список - красив в одном запросе, но дорог в агентском цикле.

Фикс, который выглядит неправильно

После такого диагноза хочется сделать странную вещь: вернуть tools обратно.

Не потому что «всегда надо пихать все в контекст». Это тоже плохая догма. А потому что фильтрация доступности и изменение кэшируемого префикса - разные задачи.

Весь вопрос в том, где вы ограничиваете список доступных действий.

// Динамический отбор: тело запроса меняется на каждом шаге
{
  "tools": [tool_A, tool_C, tool_E], // шаг 5: выбрали 3 из 5
  "messages": [...]
}
// -> префикс НЕ совпадает с шагом 4
// -> cache miss, full prefill
// Masking / allowed_tools: описания стабильны
{
  "tools": [tool_A, tool_B, tool_C, tool_D, tool_E], // всегда все 5
  "messages": [...]
}
// -> префикс совпадает
// -> cache hit

// Ограничения - на уровне декодирования или метаданных,
// не через переписывание tools.

В первом случае вы меняете входной текст. Во втором - тело запроса остается стабильным, а доступность tools контролируется уже после формирования кэшируемого префикса.

Динамический отбор tools реально помогает: пространство действий уже и модели проще не промахнуться. Поэтому это не «глупая идея». Ошибка начинается там, где вы сужаете это пространство через изменение тела запроса на каждом шаге.

Более безопасные варианты:

  • masking / constrained decoding на своем инференсе;

  • allowed_tools, если провайдер умеет отделять полный набор tools от разрешенного подмножества;

  • tool_search / defer_loading, если tools много и полные схемы не хочется держать в начале;

  • фиксированные наборы tools для определенных сценариев, если приложение их подразумевает.

Это не отменяет проблемы размывания контекста. Большой список tools - это шум, засорение внимания и риск ошибки выбора tool. У OpenAI в документации есть мягкая рекомендация держать меньше 20 функций доступными на старте turn, а дальше - измерять на своем сценарии. Моя рабочая эвристика: 20–30 компактных tools часто еще терпимо, при 50+ я бы уже не тащил все полные схемы в начало. Лучше отложенная загрузка, tool_search или несколько специализированных агентов с фиксированными наборами tools.

Смысл в том, чтобы блок инструментов и системных инструкции в начале промпта оставался монолитным. Если вы измените хотя бы один символ в начале, весь накопленный кэш огромной истории сообщений под ним просто “сгорит”.

Это не только наша проблема

Когда понимаешь, что ломается именно префикс, публичные кейсы начинают читаться иначе. Все они упираются в одну инженерную дисциплину: статичное - в начало, динамичное - в конец.

Manus сформулировал это почти буквально: mask, don’t remove. Команда пробовала динамически менять пространство действий, но отказалась: описания tools находятся в начале контекста, а их изменение инвалидирует KV-cache для всех последующих действий. Плюс модель начинает путаться, если в истории уже есть вызовы tools, которые потом «исчезли».

Их решение: все описания tools остаются в запросе, а запрещенные tools блокируются на уровне декодирования. Второй механизм - предзаполнение ответа: например, <tool_call>{"name": "browser_ заставляет модель продолжить именем из группы browser_*. Формат имен превращается в простой способ групповой маскировки.

Для своего инференса это можно реализовать через свой logit processor. Упрощенный пример:

from vllm import SamplingParams


def make_tool_logit_processor(tokenizer, blocked_tools: list[str]):
    blocked_ids = set()

    for name in blocked_tools:
        blocked_ids.update(tokenizer.encode(name, add_special_tokens=False))

    def processor(token_ids, logits):
        # Псевдокод: в продакшене маску нужно включать только
        # в позиции выбора имени tool, а не во всем ответе.
        for tid in blocked_ids:
            logits[tid] = float("-inf")
        return logits

    return processor


params = SamplingParams(
    logits_processors=[make_tool_logit_processor(tokenizer, blocked_tools)]
)

В продакшене нельзя банить эти токены во всем тексте. Маска должна включаться только в позиции выбора имени tool. Это делается через проверку текущего state генерации (например, если мы внутри тега <tool_call>). Но идея та же: промпт стабилен, контроль уходит на уровень декодирования.

Claude Code - хороший публичный пример того же паттерна. В разборе от LMCache получилось 92 вызова, около 2 миллионов входных токенов и переиспользование префикса на уровне 92%. В опубликованной трассе у основного агента было 18 tools, а субагенты получали подмножество 10/18. Цифра вторична. Важна форма: список tools фиксируется заранее, прогревается warm-up вызовами и дальше не плавает от шага к шагу.

Показательный кейс - Plan mode. Наивная реализация: заменить tools на read-only подмножество. Реальная: оставить все tools, добавить EnterPlanMode / ExitPlanMode, а переключение состояния делать через сообщения пользователя. Префикс не трогается.

Еще одна деталь из Claude Code: часть динамики вроде cwd, platform, date и git status можно выносить из системного промпта в первое сообщение пользователя через excludeDynamicSections / exclude_dynamic_sections. Это делает системный промпт стабильнее и улучшает переиспользование кэша между пользователями. В разборе Claude Code Camp со ссылкой на инженера Claude Code эта мысль сформулирована жестко: prompt caching - архитектурное ограничение продукта, а просадка cache hit rate может становиться SEV.

У API-провайдеров появляются похожие механизмы. OpenAI в документации по tool_search прямо пишет, что tool_search спроектирован так, чтобы сохранять кэш модели: новые tools добавляются в конец контекста, а не переписывают префикс. Для gpt-5.4 и новее можно откладывать схему параметров или детали namespace/MCP-сервера.

allowed_tools у OpenAI - промежуточный вариант: полный набор tools передается стабильно, а на текущем шаге разрешается только подмножество через tool_choice.allowed_tools. В документации GPT-5.4 и Prompt Caching 201 это описывается как способ ограничивать доступные tools, не меняя массив tools.

У Anthropic похожая идея реализована через Tool Search и defer_loading: отложенные tools не попадают в префикс system prompt до вычисления ключа кэша, а найденный tool раскрывается позже в теле диалога как tool_reference.

История тоже может ломать префикс

Даже если tools стабилизировали, агент все равно живет в растущей истории. Через 20 шагов контекст становится большим, tool results занимают много места, и появляется желание «почистить» середину.

Здесь важно не сломать начало.

Наивный вариант - кэшировать весь контекст целиком, включая результаты tools. Don’t Break the Cache показывает, что это проигрывает стратегии Exclude Tool Results: результаты tools обычно специфичны для конкретной сессии и почти не помогают будущим запросам.

Для себя я держу простую шкалу: raw -> compaction -> summarization. В разборе Phil Schmid эта идея хорошо сформулирована: сначала сохраняем структурные данные - пути файлов, URL, ID — и вырезаем объемные тела ответов. Summarization уже lossy, ее лучше оставлять на крайний случай.

Практический паттерн выглядит так:

anchor: system + tools + первые сообщения
middle: compacted tool results
tail: свежие шаги без изменений

Если вы заменяете только последний tool_result компактной версией, предыдущие сообщения не менялись, их префикс остается в кэше. Потеря кэша ограничена последним блоком. Если вы суммаризируете раннюю часть истории, вы ломаете префикс почти с самого начала.

Отдельная ловушка - фреймворки. Иногда run_id, trace_id, timestamp или недетерминированная сериализация попадают слишком рано в промпт. Ошибки в логах может не быть: модель отвечает нормально, промпт даже выглядит разумно, но доля попаданий в кэш падает до нуля.

Минимальная диагностика выглядит так:

import hashlib
import json


def log_prefix_hash(messages, tools, step: int):
    """Хэш первых N токенов — должен быть стабильным между шагами."""
    prefix = json.dumps(
        {"tools": tools, "system": messages[0]},
        sort_keys=True,
        ensure_ascii=False,
    )
    h = hashlib.sha256(prefix.encode()).hexdigest()[:12]
    print(f"Step {step}: prefix_hash={h}")
    return h


# Если хэш меняется между шагами — ищите динамический ID
# или плавающий список tools.

Такой хэш - не точная замена токенизации на стороне провайдера, но хороший guardrail. Истина все равно в usage-метаданных: cached_tokens, cache_read_input_tokens, доля попаданий в кэш.

Что выбрать у себя

Я бы выбирал стратегию по длине агентского цикла, числу tools, стоимости модели и уровню контроля над инференсом.

Стратегия

Доля попаданий в кэш

Сложность

Когда подходит

Динамический отбор tools

Низкая

Низкая

Прототипы, короткие агенты, дешевые модели

Все tools + masking / constrained decoding

Высокая

Высокая

Свой инференс, контроль над декодированием

allowed_tools

Средняя / высокая

Средняя

Набор tools стабилен, но права или режимы отличаются по шагам

tool_search / defer_loading

Высокая

Низкая на стороне приложения

API-провайдеры, большие наборы tools, тяжелые схемы

Фиксированные наборы tools на маршрут

Высокая

Средняя

Мультидоменные приложения

Ориентиры грубые, но полезные:

  • До 10 компактных tools - держите все в промпте, сортируйте по имени, мониторьте долю попаданий в кэш. Сложные схемы обычно не нужны.

  • 10–50 tools, свой инференс - держите tools стабильно и ограничивайте доступность через masking / constrained decoding. Схемы внутри цикла не меняйте.

  • 30–100+ tools, API-провайдер - смотрите в сторону tool_search, defer_loading или allowed_tools. Стабильная верхняя часть промпта важнее, чем агрессивная фильтрация на каждом шаге.

  • Несколько независимых сценариев - используйте семантический роутер до агентского цикла. Каждый маршрут получает фиксированный набор tools и свой стабильный префикс. Запросы на пересечении доменов требуют fallback или оркестрации поверх роутера.

  • Динамический отбор tools - нормален для прототипа, короткого агента или дешевой модели. В продакшене оставляйте его только после явного расчета стоимости cache misses.

Короткие вопросы

«Кэш считается по всему сообщению целиком?»

Нет. Prefix cache работает по самому длинному общему префиксу. Первый несовпавший участок обрезает кэш на все, что дальше, но все до него остается. Отсюда правило: статика в начало, динамика в конец.

«А если tools положить в сообщение пользователя, системный префикс сохранится?»

Технически да. Практически - анти-паттерн. Параметр tools в API существует как раз для того, чтобы описания попадали в правильную зону. В сообщении пользователя они будут обрабатываться заново и дублироваться в истории.

«У нас 5 tools. Нужен ли masking?»

Скорее нет. При 5 стабильных tools префикс маленький, а выигрыш от masking обычно не окупает сложность. Он становится интересен, когда tools много и на них приходится заметная часть префикса.

«30 tools в каждом запросе - модель не начнет путаться?»

Может. Это реальный компромисс. Поэтому при 50+ tools лучше уходить от выбора между «все всегда» и «плавающий список» к отложенной загрузке, tool_search, allowed_tools или фиксированным маршрутам.

«Можно ли обойти строгое совпадение префикса совсем?»

Начинают появляться альтернативы вроде CacheBlend, который переиспользует ранее вычисленные связные блоки вне границы общего префикса и выборочно пересчитывает часть токенов. Это выглядит многообещающе, но пока не отменяет базовую дисциплину: если можно не ломать префикс - не ломайте.

Аудит агента за 30 минут

Минимальные guardrails

  • Логируйте cached_tokens / cache_read_input_tokens на каждом шаге агента.

  • Хэшируйте каноническое представление начала запроса: tools + системный промпт.

  • Логируйте prefix_hash рядом с tools_count.

  • Смотрите корреляцию: меняется ли hash каждый раз, когда меняется список tools.

  • Введите алерт, если доля попаданий в кэш падает ниже ожидаемого порога.

Tools

  • Описания tools фиксируются при старте сессии.

  • Порядок tools в массиве детерминированный: sorted by name.

  • JSON-ключи в схемах сериализуются с sort_keys=True.

  • В описаниях нет timestamp, run_id, версий или других динамических полей.

  • Если нужна динамическая доступность - используются masking, allowed_tools или отложенная загрузка, а не фильтрация массива tools.

История

  • Предыдущие сообщения не модифицируются без необходимости.

  • Tool results компактируются: пути, ID и URL оставить, большие тела ответов убрать.

  • Compaction не трогает начало истории: system + tools + первые сообщения остаются якорем.

  • Summarization применяется только когда compaction уже недостаточно.

Фреймворк

  • Фреймворк не инжектит run_id, trace_id, timestamp в начало промпта.

  • Сериализация tools стабильна между вызовами.

  • В CI/smoke-тесте есть синтетическая сессия на 3–5 шагов.

  • Если cache_read_input_tokens или cached_tokens падают ниже порога - тест падает.

Вывод

В агенте нельзя оптимизировать один запрос в отрыве от всей траектории.

Динамический отбор tools выглядит разумно:меньше токенов, меньше шума. Но если список tools меняется на каждом шаге, вы покупаете короткий промпт ценой постоянных cache misses.

Для обычного одиночного запроса к модели это может быть терпимо. Для агента на 20–50 шагов - это уже архитектурная ошибка.

Главное правило после такого инцидента простое: не меняйте то, что стоит раньше растущей истории.

Сначала стабилизируйте префикс. Потом уменьшайте промпт.


Бонус

Да, информации, нюансов и особенностей провайдеров для поддержания хорошей метрики попадания в кэш - достаточно много. И держать все это в голове или документации - вариант немного странный для 2026 года, да?

Поэтому я разложил весь свой опыт и найденные материалы по полочкам и сформировал Skill для агента, который позволяет Codex, Claude Code и тд:

  • Сделать аудит вашего AI-проекта на анти-паттерны кэширования

  • Посоветует что из обсервабилити добавить

  • Какие из особенностей кэширования провайдеров учесть

Проект открытый, предложения приветствуются!

Если материал показался вам полезным - в канале @sergeinotevskii я делюсь опытом построения AI-платформы. Метрики, guardrails, семантические роутеры, движки для сервинга LLM и другие аспекты.

Комментарии (0)