Динамический хвост в системном промпте отключает кеш провайдера целиком

Симптом

Счета LLM-провайдера за агентный сервис выглядели так, будто prompt caching не существует: каждый вызов оплачивался как холодный.

Внутри системы этому не было ни одного подтверждения — ни метрики, ни лога, ни поля в трейсе. Единственным сигналом был биллинг внешнего поставщика. Контур обратной связи занимал месяц.

Контекст

Агент работал на модели с implicit caching: порог кеширования — 4096 токенов, TTL порядка 3–5 минут, чтение из кеша в десять раз дешевле обычного входного токена.

Запрос состоял из системного сообщения, истории диалога из event log и массива объявлений инструментов — 132 схемы и около 92 тысяч токенов в каждом вызове.

Внутри одного хода агент крутил tool loop. На каждой итерации сообщения заново собирались из журнала событий, а весь префикс повторно уходил провайдеру. Поэтому кешируемость префикса была не оптимизацией на полпроцента, а основной статьёй расхода.

Системное сообщение собиралось конкатенацией:

  1. статичный промпт роли;
  2. ISO-таймстемп текущего времени с микросекундной точностью;
  3. пять блоков памяти: core-блоки, пользовательские предпочтения, векторная выдача по тексту запроса, недавний контекст и «уроки».

Проблема была невидима изнутри сразу в трёх местах:

Корневая причина

Кеш провайдера переиспользовал общий префикс запросов. Если один токен менялся, актуальный KV-cache для всех следующих токенов уже нельзя было использовать как продолжение того же префикса.

Таймстемп с микросекундами менял системное сообщение на каждой сборке. Следом за ним оказывались память, история и объявления инструментов, поэтому динамический фрагмент в начале обесценивал почти весь 92-тысячетокенный запрос независимо от своего собственного размера.

Ошибочное предположение

Мы считали: раз usage отправляется в систему трассировки, стоимость там видна. На самом деле ключи usage-разбивки сопоставлялись с названиями позиций прайса по точному совпадению. Данные приходили под нашими именами, но метрика стоимости не считалась.

Обнаружение

Нужная метрика — доля кешированных токенов среди входных на каждом вызове модели. Провайдер возвращал этот счётчик в ответе без дополнительных запросов. Ноль при повторных вызовах с префиксом выше порога кеширования — сигнал нарушенного инварианта.

Второй сигнал — детерминированный тест сборщика. Он собирает сообщения одного хода дважды с разным «сейчас» и разным результатом retrieval, после чего требует побайтового совпадения системного сообщения. Конкатенация времени делает такой тест красным без доступа к провайдеру.

Исправление

Системное сообщение стало побайтово статичным: в нём остался только промпт роли. Всё волатильное — время и пять блоков памяти — собирается в один блок пользовательской роли перед последней репликой пользователя, то есть в хвосте запроса.

Время и core-блоки памяти фиксируются один раз при входе в ход и передаются вниз. Сборщик сообщений вызывается на каждой итерации tool loop; если брать «сейчас» во время каждой сборки, волатильный блок снова будет меняться внутри одного хода.

Между ходами общий префикс заканчивается на волатильном блоке прошлого хода. Поэтому tool-трафик предыдущего хода в следующем ходу снова оплачивается как обычный ввод. Убрать это можно только переносом retrieval в инструмент, чтобы его результаты становились обычными сообщениями истории. Это отдельная задача.

Есть ещё один компромисс. Пользовательские предпочтения и «уроки» агента — то есть поведенческие инструкции — переместились в пользовательскую роль, где их приоритет ниже системной. Блок явно обрамлён, а статичный системный промпт говорит, что это доверенный контекст текущего запроса. Но это не возвращает ему уровень authority системного сообщения.

Проверка

Добавлены два guardrail-теста без сети:

  1. Сборка сообщений дважды с разным временем и разной retrieval-выдачей — системное сообщение обязано совпасть побайтово.
  2. Append-only внутри хода: список сообщений итерации N обязан быть точным префиксом списка итерации N+1. Тест краснеет, если динамический блок снова вставят в середину.

Отдельный тест закрепляет учёт токенов: кешированные токены — подмножество входных, и ни в одном месте они не складываются с ними.

Общий вывод

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

Инвариант должен быть закреплён тестом. Вернуть таймстемп в системный промпт может кто угодно: изменение выглядит безобидно, а счёт снова становится 92-тысячетокенным.

И прежде чем чинить стоимость, надо завести на неё метрику. Иначе и дефект, и его исправление замечают через месяц.