Оригинал: Prompt Caching In Agents (Earendil Engineering).
Большие языковые модели часто представляют как чистые функции: отправил текст — получил текст. Абстракция удобная, но она упускает фундаментальную деталь работы coding-агентов: бóльшая часть входных данных на каждом шаге повторяет предыдущий. Иными словами, контекст почти всегда только дописывается в конец.
Coding-агент отправляет модели системный промпт, описания инструментов, инструкции проекта, историю диалога, вызовы инструментов и их результаты. На следующем ходу он передаёт практически то же самое плюс небольшую порцию новых данных. Когда сессия разрастается до десятков или сотен тысяч токенов, пересчитывать весь промпт заново на каждом шаге становится медленно и накладно.
Кэширование промптов (prompt caching) делает такую работу экономически осмысленной, но у него высокая хрупкость. Изменилось описание инструмента, переключилась модель или роутер провайдера перенаправил запрос на другой узел — и то, что должно было стать дешёвым инкрементальным запросом, превращается в полный пересчёт всего контекста с нуля.
Поэтому для coding-агентов поведение кэша — не просто деталь реализации или техническая оптимизация. Оно напрямую определяет задержку (latency), расходы, дизайн инструментов, архитектуру сессий и даже то, какие продуктовые фичи вообще имеет смысл делать.
Что лежит в KV-кэше
Трансформер обрабатывает промпт в две фазы. На этапе prefill он считывает входные токены и вычисляет для них состояние механизма внимания (attention state). На этапе decode — генерирует новые токены по одному за раз.
На каждом слое внимания каждый обработанный токен порождает ключ (key) и значение (value). Это не совсем ключи и значения как в хеш-таблице: оба представляют собой массивы чисел — обычно float’ы или квантованные значения пониженной точности. Когда модель обрабатывает новый токен, она сравнивает его запрос (query) с предыдущими ключами, чтобы оценить релевантность каждого предшествующего токена. Затем по этим оценкам релевантности формируется взвешенная сумма соответствующих значений. В этом смысле ключ — это то, с чем модель сопоставляет запрос, а значение — извлекаемая информация (только поиск здесь не точный по словарю, а нечёткий, с «размазыванием» весов).
Ключи и значения сохраняются, чтобы при генерации следующего токена модель могла обращаться ко всему предыдущему контексту без повторного вычисления прошлых токенов. Это сохранённое состояние и называется KV-кэшем.
Концептуально запросы выглядят так:
запрос 1:
[system][tools][user][assistant][tool result][user]
<-------------------- prefill -------------------->
|
тензоры K и V для каждого токена и слоя
запрос 2:
[system][tools][user][assistant][tool result][user][new]
<----------- переиспользуемый префикс ------------><--->
|
новая работаВ реальности внутреннее представление устроено сложнее, зависит от архитектуры модели и весит прилично. Главное его свойство: оно строго привязано к конкретному префиксу токенов. Два промпта с одинаковым смыслом, но разной токенизацией, не смогут разделить KV-кэш. Если же изменить хотя бы один токен в середине, всё, что идёт после него, считается принципиально другим продолжением.
Prompt caching продлевает жизнь этому состоянию за пределы одного ответа. Когда следующий API-запрос агента начинается с тех же токенов, инференс-система переиспользует уже вычисленный префикс и выполняет prefill только для нового суффикса. В теории всё просто.
Где живёт кэш
Чтобы кэш работал, его нужно где-то хранить и уметь адресовать. В инференс-системах доступ к KV-кэшу для последующих запросов организуют двумя принципиальными путями.
Более простой подход — session affinity (привязка сессии). KV-кэш оставляют прямо на GPU (или рядом с ним), где он был вычислен, а следующий запрос направляют на тот же воркер. Идентификатор сессии или ключ кэша становится тривиальной подсказкой для маршрутизации, поэтому распределять запросы можно даже на уровне обычного HTTP-балансировщика, не заглядывая внутрь тела запроса.
запрос(session-42) --> роутер --> воркер 7 --> KV-кэш на GPU 7
следующий(session-42) --> роутер --> воркер 7 --> KV-кэш на GPU 7Такой подход избавляет от передачи тяжёлого кэша по сети. Пока всё работает, это быстро, но жёстко связывает руки планировщику. Выбранный воркер может оказаться перегружен, перезагрузиться или вытеснить запись из памяти. Балансировщик также может решить, что выравнивание нагрузки по кластеру важнее, чем сохранение кэша одной конкретной сессии. Тем не менее подход крайне популярен: он практически не требует дополнительной инфраструктуры.
Второй подход — распределённый кэш (distributed cache). Блоки KV-кэша выносят на отдельный ярус памяти или делают доступными между разными воркерами, разрывая жёсткую привязку запроса к конкретному GPU.
+---------------------+
запрос --> шедулер ------>| воркер 3 / GPU 3 |
| +---------------------+
|
+----------> распределённые блоки KV
|
+----------> воркер 9 / GPU 9Это даёт гибкость в планировании и восстановлении после сбоев, но перемещение, индексация и хранение блоков KV сами по себе превращаются в сложную инженерную задачу. Реализации комбинируют память GPU, оперативную память хоста, локальные SSD, сетевые хранилища, маршрутизацию с учётом префиксов (prefix-aware routing) и разные политики вытеснения (eviction policies).
Для понимания масштаба: KV-кэши массивны, но часто занимают меньше места, чем кажется. За счёт различных оптимизаций объём KV-кэша даже для длинных диалогов удаётся ужать до нескольких гигабайт.
Кэши и префиксы
Сессии в pi — это деревья, а не плоские списки. Команда /tree позволяет откатить активный диалог к любой более ранней точке и продолжить по другой ветке. Откат (rewind) отбрасывает активный суффикс, не удаляя его из файла сессии. Новая ветка может делить со старой почти весь контекст, малую его часть или вообще ничего. Такое устройство характерно не только для pi — схожие концепции встречаются во многих coding-агентах, ведь возможность откатиться назад нужна практически везде.
+-- E -- F другая ветка
|
сессия S: root -- A -- B -- C -- D текущая ветка
|
+-- Z ветка в самом началеУ всех трёх веток может быть один и тот же идентификатор сессии pi. С точки зрения роутера это одна сессия. С точки зрения prompt-кэша — три разные последовательности токенов с лишь частичным пересечением префиксов.
Если кэш хранит переиспользуемые блоки префикса, переключение с ветки D на F всё ещё сможет задействовать отрезок root -> C. Но если кэшируется только самая «горячая» ветка, если общие блоки успели вытесниться или запрос прилетел на другой воркер — попадание в кэш окажется гораздо скромнее. Переход в ветку Z сохранит разве что системный промпт и базовые описания инструментов, хотя формально ветвление произошло от узла A. Поведение здесь сильно зависит от конкретного провайдера.
Бывает и обратная ситуация. Команда /fork или создание новой сессии порождают новый session ID, при том что передают гигантский кусок идентичного контекста. Система маршрутизации, изолирующая кэши строго по ключу сессии, просто не заметит этого полезного пересечения.
Что реально можно переиспользовать — определяет общий префикс токенов. Идентификатор сессии лишь помогает инфраструктуре быстрее найти нужные данные. В одних системах ключ маршрутизации критичен для управления кэшем, в других — служит лишь вспомогательной подсказкой.
Явное и автоматическое кэширование префиксов
API провайдеров предоставляют кэширование в двух основных стилях.
Классический интерфейс Anthropic опирается на явные точки разметки — cache_control. Клиент явно расставляет границы после стабильных частей запроса: системного промпта, описаний инструментов или последней контрольной точки диалога. Сервер сохраняет или ищет префикс, заканчивающийся на этих метках. Хотя границы задаются явно, для попадания в кэш весь предшествующий контент всё равно обязан совпадать бит в бит. Явными здесь выступают не только точки кэширования, но и тарификация: запись в кэш оплачивается отдельно, причём можно выбирать срок удержания (TTL), что отражается на цене.
Другие API используют автоматическое кэширование префиксов. Клиент отправляет стандартный запрос без спецразметки, а провайдер сам находит максимальный общий префикс. Ключ кэша или заголовок сессии могут упростить группировку и роутинг, но они не способны сделать разные префиксы одинаковыми.
Почему наборы инструментов ломают кэш
Описания инструментов обычно передаются перед историей сообщений и внутри «вклеиваются» прямо в системный промпт. Названия инструментов, их описания и JSON-схемы — точно такой же текст на входе модели, как и всё остальное. Добавление или удаление инструмента, правка схемы или даже банальное изменение порядка сериализации сдвигает точку первого несовпадения в самое начало промпта.
ход 1: [system][read][write][bash][диалог..................]
ход 2: [system][read][write][bash][deploy][диалог..........]
|
старый диалог теперь идёт
после несовпаденияЭто частая неожиданность при работе с плагинами и каталогами инструментов в стиле MCP. Идея подгружать инструмент только тогда, когда он реально понадобился, кажется логичной: изначально передаётся меньше схем. Однако на большинстве моделей расширение набора инструментов инвалидирует весь последующий кэш диалога. Экономия сотни токенов на схеме инструмента оборачивается повторным prefill’ом десятков тысяч токенов переписки.
В некоторых свежих API появилась поддержка аддитивной загрузки инструментов (additive tool loading). Инструмент становится доступен в конкретной точке диалога — внутри результата вызова инструмента — вместо того чтобы встраиваться в изначальный список в начале промпта. Старый префикс остаётся нетронутым:
[system][базовые инструменты][диалог][новый инструмент][след. ход]
<------ кэшированный префикс ------->В pi реализована поддержка такого механизма для моделей с нативными отложенными инструментами. Когда расширение вносит чисто аддитивное изменение через setActiveTools(), pi привязывает добавленные имена к результату выполнения инструмента. Для поддерживаемых моделей Anthropic используются deferred-определения и tool_reference, для моделей OpenAI — соответствующие элементы tool-search. Для остальных моделей действует безопасный fallback: pi отправляет полный список активных инструментов со следующим запросом. Функционально это работает, но может полностью сбросить prompt-кэш.
Слово аддитивный здесь ключевое. Удаление инструментов, замена одного набора на другой или динамическая правка сниппетов в промпте по-прежнему меняют ранний вход. Расширение, которое на каждом шаге пересобирает системный промпт, тасует порядок инструментов, вставляет текущее время или меняет набор утилит, способно намертво сломать кэширование на всю сессию.
Расширяемость означает, что pi не может гарантировать сохранность кэша за спиной сторонних расширений. Агент предоставляет дружественные к кэшу примитивы, но расширения должны их грамотно использовать. На практике для авторов расширений эффективность кэша часто остаётся на десятом месте — отчасти потому, что на фиксированной подписке цена промахов не так бросается в глаза.
Паузы и TTL
У многих провайдеров время жизни кэша по умолчанию невелико. Дефолтные 5 минут у Anthropic особенно коварны, поскольку это меньше типичной паузы в разработке. Отошёл налить кофе — вернулся через 10 минут, и банальное «привет» обходится в копеечку.
Человек воспринимает сессию с агентом как непрерывный процесс, но для провайдера инференса это лишь цепочка изолированных вызовов:
запрос к модели --> 7 минут крутятся тесты --> запрос к модели
нет обращений к кэшуДолгая сборка, запуск тестов, обед, митинг или банальная пауза на вычитку diff’а легко переживают TTL кэша. Следующий запрос отправляет ровно тот же промпт, но сохранённого KV-состояния уже нет — и весь префикс тарифицируется по полной ставке входных токенов.
Поскольку pi пока не входит в список разрешённых клиентов по подписке Anthropic, в нём действует рекомендованный для API пятиминутный интервал. При этом из исходников Claude Code известно, что для своих подписчиков они увеличивают таймаут кэша до одного часа. Впрочем, при оплате токенов поштучно через API такая щедрость часто себя не окупает.
Тем не менее более долгий срок можно запросить явно. Некоторые провайдеры (включая Anthropic) позволяют управлять временем удержания. В прямых подключениях пользователи pi могут задать PI_CACHE_RETENTION=long. Но это лишь пожелание: pi не может заставить шлюз сохранить запись, защитить её от вытеснения при нехватке памяти или удержать кэш живым, пока к модели не идут запросы.
Цена промаха
Провайдеры обычно отдельно тарифицируют некэшированный вход, запись в кэш и чтение из кэша. Чтение из кэша стоит заметно дешевле, потому что тяжёлый этап prefill уже пройден. Запись же в кэш может стоить чуть дороже обычного входа, так как провайдер берёт на себя обязательство удерживать состояние в памяти.
Представьте сессию на 100 000 токенов контекста и короткую команду вроде continue. Когда кэш срабатывает, почти вся эта история оплачивается по минимальному тарифу чтения из кэша. По полной ставке обсчитывается лишь крошечная порция новых данных (плюс возможная запись в кэш).
При промахе провайдер вынужден прогнать через prefill все 100 000 токенов по полной стоимости обычного входа, а затем ещё и взять плату за повторную запись в кэш. Именно поэтому короткое «продолжай» после паузы способно стоить неожиданно дорого. В длинных сессиях повторное считывание старого контекста обходится несоизмеримо дороже генерации самого ответа.
Кроме того, кэширование создаёт неочевидный конфликт интересов.
Пользователь заинтересован в высоком hit rate: это снижает и задержку, и счёт. Владелец GPU-инфраструктуры заинтересован в том же: меньше вычислений на prefill — больше запросов обслуживает то же железо. Продуманная скидка на кэшированные токены согласовывает интересы обеих сторон и оставляет оператору хорошую маржинальность.
А вот у шлюза-посредника или реселлера стимулы могут быть иными. Если его выручка завязана на токены по полному входящему тарифу, промах кэша приносит больше денег, чем попадание. Превращается ли это в дополнительную прибыль — зависит от условий контракта с апстримом и того, кто оплачивает хранение кэша. В криво выстроенной цепочке сторона, отвечающая за маршрутизацию, может не нести издержек от промахов, зато сторона, выставляющая счёт, зарабатывает на них больше.
Это не значит, что провайдеры намеренно сбрасывают кэши, но поведение кэша обязано быть прозрачным. Пользователь не должен узнавать об аномалиях постфактум из внезапно раздутого счёта.
Жёсткая привязка к кэшу также ограничивает манёвр для шлюза: сложнее перенаправить запрос на более выгодную или менее загруженную модель между ходами диалога.
Почему pi не чистит контекст агрессивно
Теперь понятно, почему pi не стремится агрессивно вырезать старые вызовы инструментов. Велик соблазн экономить, вычищая старые ответы команд или переписывая историю, и порой это неизбежно — особенно на границе контекстного окна. Но у чистки контекста (pruning) есть собственная цена с точки зрения кэша.
Удаление фрагмента из середины меняет префикс ровно в месте вырезания. Весь последующий диалог придётся рассчитывать заново. Разовая стоимость пересчёта длинного кэшированного хвоста часто на порядки перекрывает будущую копеечную экономию от пары удалённых сообщений, которые и так читались бы из кэша со скидкой.
Грубая формула точки безубыточности:
разовая цена пересчёта
~= оставшиеся токены после правки * (цена входа без кэша - цена чтения из кэша)
будущая экономия на каждом ходе
~= вырезанные токены * цена чтения из кэшаИ это не только финансовый вопрос: старые результаты инструментов содержат факты, на которых модель строила свои выводы. Их удаление способно ухудшить поведение агента, даже если краткая выжимка сохраняет общий смысл.
Поэтому pi предпочитает стабильный журнал, работающий только на дозапись (append-only), и не считает каждый старый токен мусором. Сжатие контекста (compaction) применяется только тогда, когда переполнение окна делает потерю деталей неизбежной. Поскольку сжатие осознанно создаёт новый контекст, а не случайно инвалидирует неизменённый промпт, статистика pi учитывает его как запланированный сброс кэша (cache reset), а не как сбой.
Цель — не минимальный по размеру промпт, а оптимальный баланс между полнотой контекста, утилизацией кэша, задержкой ответа и расходами.
Впрочем, аргументы в пользу вырезания тоже бывают. Если провайдер не даёт скидок на кэш или конфигурация не позволяет добиться стабильного hit rate, чистка контекста может быть оправдана. Кроме того, она развязывает руки балансировщику: без привязки к кэшу запросы проще распределять по разным бэкендам.
Что pi может и чего не может
Задача pi — сохранять стабильное стабильным. Агент передаёт постоянный session ID и специфичные для провайдеров подсказки маршрутизации, расставляет явные точки кэширования там, где это требует API, ведёт строгий учёт прочитанных и записанных кэш-токенов и поддерживает аддитивную подгрузку инструментов. Базовое поведение транскрипта исключает лишние переписывания старого контекста.
Чего pi сделать не может — так это контролировать то, что происходит с запросом за пределами машины. Агент не выбирает политику вытеснения на стороне провайдера, не может продлить жизнь кэшу сверх лимитов API, удержать нужный GPU на плаву или заставить чужой шлюз уважать session affinity. И точно так же он бессилен, если префикс ломает какое-нибудь расширение.
Но что pi сделать в состоянии — так это сделать состояние кэша прозрачным.
В интерактивном футере суммарные операции чтения и записи кэша отображаются как R и W, а CH показывает долю попадания в кэш на последнем запросе. Команда /session выводит полную картину: общий объём кэшированных и «холодных» токенов, совокупный hit rate, общую стоимость и оценку повторно оплаченных токенов из-за значительных промахов кэша.
Messages
Total: 178
User: 6
Assistant: 58
Tools: 114 calls, 114 results
Tokens
Input: 7,129,883
Cached: 6,776,832 (95.0%)
Uncached: 353,051
Output: 30,013
Total: 7,159,896
Cost
Total: $6.054
Cache Re-billed: $0.728 (161,744 tokens, 2 misses)Для тех, кто хочет видеть промахи сразу в процессе диалога, в настройках есть опция Show cache miss notices (showCacheMissNotices в settings.json). В этом режиме pi выводит предупреждение после каждого крупного промаха с оценкой лишних токенов и расходов. Если промах вызван сменой модели или очевидным простоем дольше TTL, агент прямо об этом скажет. В остальных случаях — зафиксирует факт, не пытаясь гадать о скрытых процессах на стороне бэкенда.
Типичные причины деградации кэша
Если hit rate в сессии неожиданно упал, типичные виновники обычно одни и те же:
- Простой (idling). Пауза на выполнение команды, код-ревью или кофе превысила окно удержания кэша у провайдера.
- Смена модели или провайдера. Состояние KV привязано к конкретной архитектуре весов и между провайдерами не переносится.
- Навигация по веткам. Команда
/tree, откаты, форки и альтернативные ветки меняют активную цепочку токенов, даже если session ID остался прежним. - Сжатие (compaction) или ручная правка истории. Они намеренно заменяют часть диалога, создавая принципиально новый префикс.
- Изменение состава инструментов или уровня reasoning. Добавление, удаление, смена порядка или редактирование схем инструментов меняют начало промпта (если только модель не поддерживает аддитивную загрузку). Переключение уровня reasoning обычно приводит к тому же эффекту.
- Динамические системные промпты. Временные метки (timestamp), случайные значения, постоянно меняющийся контекст проекта и сниппеты от расширений инвалидируют всё, что следует за ними.
- Трансформация контекста расширениями. Если расширение на лету модифицирует прошлые сообщения или payload запроса, внешне стабильная история pi превращается в нестабильную в сетевом потоке.
- Маршрутизация и вытеснение у провайдера. Промпт может совпадать до символа, но запрос всё равно попадёт мимо кэша, если нужные блоки KV вытеснены из памяти или запрос прилетел на другой узел кластера.