Динамический хвост в системном промпте отключает кеш провайдера целиком
Симптом
Счета LLM-провайдера за агентный сервис выглядели так, будто prompt caching не существует: каждый вызов оплачивался как холодный.
Внутри системы этому не было ни одного подтверждения — ни метрики, ни лога, ни поля в трейсе. Единственным сигналом был биллинг внешнего поставщика. Контур обратной связи занимал месяц.
Контекст
Агент работал на модели с implicit caching: порог кеширования — 4096 токенов, TTL порядка 3–5 минут, чтение из кеша в десять раз дешевле обычного входного токена.
Запрос состоял из системного сообщения, истории диалога из event log и массива объявлений инструментов — 132 схемы и около 92 тысяч токенов в каждом вызове.
Внутри одного хода агент крутил tool loop. На каждой итерации сообщения заново собирались из журнала событий, а весь префикс повторно уходил провайдеру. Поэтому кешируемость префикса была не оптимизацией на полпроцента, а основной статьёй расхода.
Системное сообщение собиралось конкатенацией:
- статичный промпт роли;
- ISO-таймстемп текущего времени с микросекундной точностью;
- пять блоков памяти: core-блоки, пользовательские предпочтения, векторная выдача по тексту запроса, недавний контекст и «уроки».
Проблема была невидима изнутри сразу в трёх местах:
- структура учёта токенов знала только входные и выходные токены — полей для кешированных токенов не существовало;
- адаптер провайдера читал из ответа только два счётчика из десятка доступных;
- в систему трассировки usage-разбивка уходила под нестандартными именами, которые не совпадали с названиями позиций в прайсе модели, поэтому стоимость не рассчитывалась.
Корневая причина
Кеш провайдера переиспользовал общий префикс запросов. Если один токен менялся, актуальный KV-cache для всех следующих токенов уже нельзя было использовать как продолжение того же префикса.
Таймстемп с микросекундами менял системное сообщение на каждой сборке. Следом за ним оказывались память, история и объявления инструментов, поэтому динамический фрагмент в начале обесценивал почти весь 92-тысячетокенный запрос независимо от своего собственного размера.
Ошибочное предположение
Мы считали: раз usage отправляется в систему трассировки, стоимость там видна. На самом деле ключи usage-разбивки сопоставлялись с названиями позиций прайса по точному совпадению. Данные приходили под нашими именами, но метрика стоимости не считалась.
Обнаружение
Нужная метрика — доля кешированных токенов среди входных на каждом вызове модели. Провайдер возвращал этот счётчик в ответе без дополнительных запросов. Ноль при повторных вызовах с префиксом выше порога кеширования — сигнал нарушенного инварианта.
Второй сигнал — детерминированный тест сборщика. Он собирает сообщения одного хода дважды с разным «сейчас» и разным результатом retrieval, после чего требует побайтового совпадения системного сообщения. Конкатенация времени делает такой тест красным без доступа к провайдеру.
Исправление
Системное сообщение стало побайтово статичным: в нём остался только промпт роли. Всё волатильное — время и пять блоков памяти — собирается в один блок пользовательской роли перед последней репликой пользователя, то есть в хвосте запроса.
Время и core-блоки памяти фиксируются один раз при входе в ход и передаются вниз. Сборщик сообщений вызывается на каждой итерации tool loop; если брать «сейчас» во время каждой сборки, волатильный блок снова будет меняться внутри одного хода.
Между ходами общий префикс заканчивается на волатильном блоке прошлого хода. Поэтому tool-трафик предыдущего хода в следующем ходу снова оплачивается как обычный ввод. Убрать это можно только переносом retrieval в инструмент, чтобы его результаты становились обычными сообщениями истории. Это отдельная задача.
Есть ещё один компромисс. Пользовательские предпочтения и «уроки» агента — то есть поведенческие инструкции — переместились в пользовательскую роль, где их приоритет ниже системной. Блок явно обрамлён, а статичный системный промпт говорит, что это доверенный контекст текущего запроса. Но это не возвращает ему уровень authority системного сообщения.
Проверка
Добавлены два guardrail-теста без сети:
- Сборка сообщений дважды с разным временем и разной retrieval-выдачей — системное сообщение обязано совпасть побайтово.
- Append-only внутри хода: список сообщений итерации N обязан быть точным префиксом списка итерации N+1. Тест краснеет, если динамический блок снова вставят в середину.
Отдельный тест закрепляет учёт токенов: кешированные токены — подмножество входных, и ни в одном месте они не складываются с ними.
Общий вывод
В кеше префикса позиция динамического фрагмента важнее его размера. Любая динамика, дописанная к статичному блоку, стоит не только собственных токенов, но и всей оставшейся части запроса. Волатильное живёт в хвосте — это инвариант, а не оптимизация.
Инвариант должен быть закреплён тестом. Вернуть таймстемп в системный промпт может кто угодно: изменение выглядит безобидно, а счёт снова становится 92-тысячетокенным.
И прежде чем чинить стоимость, надо завести на неё метрику. Иначе и дефект, и его исправление замечают через месяц.