Введение
Последние полгода моя работа сильно изменилась. Раньше я писала код руками, а теперь все задачи я решаю через LLM, и разбираюсь с тем, как вообще правильно организовать такую работу: как писать инструкции, как раскладывать контекст, как измерять, стало ли от этого лучше.
При этом привычка разбираться в том, как вещи устроены внутри, никуда не ушла. И вот тут возникло странное ощущение. В материалах про работу с LLM я периодически натыкаюсь на советы без какой-то теоретической базы, а основанные исключительно на личном опыте. Часть таких советов наверняка работает. Но непонятно, какая именно и почему, а это значит, что применять их приходится наугад. Мне захотелось разобраться, что же из этого имеет под собой документированную основу.
Опираться я буду на три типа источников. Первое — документация провайдеров. Это описание контракта, например Anthropic или OpenAI: что гарантировано, какие есть пороги и ограничения, что приходит в ответе API. Второе — открытые движки. Провайдеры свои внутренности не раскрывают, но есть открытые проекты, которые решают ровно те же задачи, и их код можно изучать, например vLLM и SGLang. И третье — исследовательские статьи, из которых эти движки выросли.
Начать я решила с кэширования промптов, потому что эта тема хорошо документирована, кроме того, из неё следуют практические выводы.
Как работает кэш
Между запросами у модели нет состояния. Когда вы пишете второе сообщение в чате, клиент отправляет заново весь диалог целиком: инструкции, ваш первый вопрос, ответ модели и новый вопрос. Иллюзию непрерывного разговора создаёт клиент, а не модель.
Каждый такой запрос обрабатывается в две фазы. Сначала модель читает присланный промпт целиком, за один проход. Потом генерирует ответ по одному токену за раз. Чтение стоит времени и денег пропорционально длине промпта, и чем длиннее разговор, тем дороже обходится каждый следующий ход.
Кэширование промптов экономит именно фазу чтения. Если начало присланного промпта совпадает с тем, что уже читали в предыдущем запросе, читать его заново не нужно. На генерацию при этом такой кэш никак не влияет, ведь её всё равно придётся делать токен за токеном.
Разбирая промпт, модель вычисляет для каждого токена внутреннее представление, и представление токена под номером N зависит от всех токенов до него, но не зависит ни от одного токена после. Модель читает строго в одну сторону. Значит, результат обработки первых N токенов детерминирован: одни и те же N токенов в одном и том же порядке всегда дают одно и то же, и это можно сохранить и в следующий раз не считать. А вот совпадающий кусок в середине не поможет, так как он зависит от всего, что было раньше.
Вот как это выглядит в чате из трёх ходов:
Ход 1: [--инструменты--][--инструкции--][--вопрос 1--]
└──── всё читается заново, пишется в кэш ─────┘
дальше модель генерирует ответ 1
Ход 2: [инструменты][инструкции][вопрос 1][--ответ 1--][--вопрос 2--]
└──── прочитано из кэша ──────────┘└──── новое, пишется ─────┘
дальше модель генерирует ответ 2
Ход 3: [инструменты][инструкции][вопрос 1][ответ 1][вопрос 2][--ответ 2--][--вопрос 3--]
└─────────── прочитано из кэша ──────────────────────┘└───── новое, пишется ────┘
Ответ модели с хода 2 на ходу 3 уже лежит в кэшированном префиксе. Собственные ответы модели попадают в кэш, потому что на следующем ходу они просто часть присланного промпта. При этом прирост на каждом ходу маленький, а накопленная история большая. Без кэша на пятидесятом шаге агентской сессии пришлось бы перечитывать всё, что накопилось за предыдущие сорок девять, и суммарная работа получалась бы квадратичной. С кэшем каждый шаг читает только свой прирост.
Отсюда главное правило раскладки: стабильное (инструкции, описания инструментов и документация) в начало, изменчивое (запрос пользователя) в конец. Совпадение при этом требуется абсолютно точное: буквально те же токены в том же порядке.
Можно предположить, что сервер запоминает результат после каждого прочитанного токена, а при следующем запросе просто ищет, до какого места два промпта совпадают, и продолжает оттуда. Однако у кэша есть зернистость, и записи существуют не в любой точке префикса, а только в некоторых.
Где именно эти точки, зависит от провайдера. Иногда их расставляете вы: у Anthropic за это отвечает пометка cache_control, и запись появится только там, где она стоит, а для более ранних позиций её не будет, даже если текст там совершенно стабильный. А иногда точки выбирает сам сервер, и тогда на их расположение вы не влияете. В обоих случаях следствие одно: переиспользуется не всё до последнего совпавшего токена, а всё до ближайшей такой точки перед ним.
Что такое KV-кэш
Чтобы углубиться в детали реализации, нам потребуются некоторые дополнительные определения.
KV-кэш — это рабочая память модели. Без неё модель не может нормально генерировать текст, и работает она даже когда запрос один и переиспользовать нечего. Кэширование промптов — уже оптимизация поверх этого: переиспользование той же рабочей памяти между разными запросами. Поэтому устройство второго — прямое следствие устройства первого.
Провайдеры свой код не публикуют, но открытые движки решают ту же задачу, и по их коду и дизайн-документам видно, как такая память может быть организована. Разберём это на примере устройства vLLM.
Возьмём промпт «Кот сидит на» — три токена. На фазе чтения модель считает для каждого пару векторов. Их называют ключом и значением, отсюда K и V в названии.
позиция: 1 2 3
токен: Кот сидит на
память: [KV] [KV] [KV]
Теперь модель генерирует. Чтобы выбрать следующее слово — пусть это будет «окне» — она смотрит на ключи и значения всех предыдущих позиций. Выбрала, посчитала KV для «окне», дописала в хвост:
позиция: 1 2 3 4
токен: Кот сидит на окне
память: [KV] [KV] [KV] [KV]
Следующий шаг смотрит на позиции с первой по четвёртую, дописывает пятую. И так до конца ответа.
Главное здесь, что память растёт только вправо, и ничего ранее посчитанное никогда не меняется. Если бы KV не сохранялись, их пришлось бы пересчитывать для всей последовательности на каждом новом токене, и генерация выродилась бы в квадратичную по длине работу. Заодно видно, что в этой памяти вперемешку лежат и токены промпта, и сгенерированные: для неё это просто позиции с первой по N-ю.
Объём этой памяти — главное ограничение. Он заметно больше, чем можно подумать: пара векторов хранится не одна на токен, потому что модель состоит из десятков слоёв и каждый держит свои данные. И растёт он линейно с длиной контекста: вдвое длиннее промпт, вдвое больше памяти.
Отсюда проблема, которая и породила всю дальнейшую архитектуру кэшей. Модель не знает заранее, сколько токенов сгенерирует: может 10, может 2 тысячи. Наивное решение — выделить каждому запросу непрерывный кусок памяти сразу под максимально возможную длину. Тогда короткий запрос держал бы полноразмерный кусок до самого конца, а большая часть этого куска так и осталась бы пустой.
Блоки памяти
Решение, предложенное в vLLM, называется PagedAttention, и идея в нём заимствована у операционных систем. Вместо непрерывного куска память нарезается на блоки фиксированного размера и выдаётся по мере роста последовательности. В vLLM размер блока по умолчанию, например, 16 токенов.
Блоки лежат в общем пуле, там, где нашлось свободное место, поэтому каждому запросу нужна табличка соответствия:
Запрос A, таблица блоков:
токены 1-16 → блок 847
токены 17-32 → блок 12
токены 33-48 → блок 503
Таким образом, блоки понадобились, чтобы не терять память. Но раз память уже нарезана, а результат обработки первых N токенов детерминирован, возникает следующая идея. Если два запроса начинаются одинаково, их первые блоки содержат ровно одно и то же — зачем считать дважды? Поэтому нужен способ быстро спросить, есть ли блок с таким содержимым. Отсюда появились хеши блоков.
Наивно было бы хешировать токены, содержащиеся внутри блока, но это не сработало бы. Блок с токенами «на зелёном» в запросе «Кот сидит на зелёном» и в запросе «Вы настаиваете на зелёном» содержит одни и те же токены, а KV у них разные, потому что KV зависят от всего, что было раньше. Поэтому хеш блока считается из хеша предыдущего блока плюс токенов этого блока:
хеш блока 1 = h( —, токены 1-16 )
хеш блока 2 = h( хеш блока 1, токены 17-32 )
хеш блока 3 = h( хеш блока 2, токены 33-48 )
Каждое звено зависит от всех предыдущих, поэтому идентичность блока включает в себя всю историю до него, а не только собственное содержимое. Кроме этого в хеш подмешиваются и дополнительные значения, например, соль для изоляции между клиентами.
Дальше блоки живут как самый обычный кэш в операционной системе. Есть хеш-таблица, по которой движок при новом запросе быстро проверяет, какие из его блоков уже посчитаны. Есть счётчик ссылок: когда блок переиспользуется, счётчик растёт, и блок убирается из очереди на вытеснение, чтобы его не отобрали из-под работающего запроса. Когда запрос заканчивается, счётчики его блоков уменьшаются, и блоки с нулём уходят в хвост очереди свободных, чтобы в конце концов быть вытесненными.
Движок SGLang решает ту же задачу кэширования иначе — через дерево, в котором хранятся уже посчитанные куски, и поиск идёт по самому длинному совпадающему префиксу. Общие начала у разных запросов тогда физически хранятся один раз, а расходящиеся хвосты становятся ветками.
Из блочного устройства вытекает одно следствие. Кэшируются только полные блоки: частично заполненный блок получает хеш и попадает в кэш лишь после того, как заполнится до конца. И совпадение тоже считается целыми блоками: если расхождение произошло посередине блока, переиспользовать его нельзя, даже если часть токенов в нём совпала.
Пример из документации vLLM, блоки по четыре токена:
Промпт А: [1 2 3 4][5 6 7 8][9 10 11 12]
Промпт Б: [1 2 3 4][5 6 7 8][9 10 13 14]
↑ разошлись
Совпало токенов: 10
Переиспользовано: 8 (два блока)
Отсюда понятно, почему попадание в кэш — это величина, которая может оказаться меньше ожидаемой, и почему удлинение стабильной части иногда помогает сильнее, чем кажется, ведь в таком случае вы добираете блок до полного.
Второе следствие — кэш вытесняется. Он не живёт вечно и не гарантирован: ваши блоки могут уйти под нагрузкой от других запросов. Поэтому попадание — это вероятность, которая тем выше, чем стабильнее префикс и чем чаще вы к нему обращаетесь.
Если вы работаете через API
Когда запросы формирует ваш код, появляется прямой контроль. Однако сначала стоит разобраться, чем именно вы управляете, потому что у провайдеров это устроено по-разному. У Anthropic кэширование по умолчанию выключено, и включает его та самая пометка cache_control. У OpenAI наоборот, кэш работает сам, без всяких пометок, а возможность расставлять их вручную появилась только в свежих моделях.
Если пометки ставите вы, правило одно: пометка должна идти на последний блок, который не меняется между запросами. Система не ищет стабильный контент сама — она проверяет, есть ли готовая запись для префикса, который заканчивается ровно в отмеченной точке.
Всё остальное стоит смотреть в документации того провайдера, которым вы пользуетесь, потому что расходится почти всё: ставите вы пометку сами или кэш включается автоматически, какой минимальной длины должен быть промпт, сколько живёт запись и от какого момента считается срок, что именно её обнуляет помимо правки текста и по каким полям в ответе проверить, что кэш вообще сработал.
Насколько сильно расходятся детали, хорошо видно как раз на времени жизни. У Anthropic запись по умолчанию живёт пять минут, бесплатно продлевается при каждом обращении, а за доплату можно взять час; отсчёт при этом идёт от начала запроса, а не от конца ответа, так что если ответ стримился четыре минуты, на следующий заход остаётся около минуты. У OpenAI в новых моделях это тридцать минут, в более старых — порядка пяти-десяти минут простоя с возможностью явно включить сутки. Ни одна из этих цифр не универсальна.
Проверить, работает ли всё это, можно прямо из кода. У Anthropic в ответе приходят три числа: сколько токенов записано в кэш, сколько прочитано из него и сколько обработано вне кэша. Если оба поля про кэш нулевые, кэширование не случилось: скорее всего, промпт не добрал до минимальной длины, которая зависит от модели и составляет от 500 до нескольких тысяч токенов. У OpenAI доступны свои поля данных про кэш.
Кроме всего вышеперечисленного, в документации Anthropic есть отдельное предупреждение: в некоторых языках, например в Swift и Go, порядок ключей при сериализации в JSON не гарантирован. Если аргументы вызовов инструментов уезжают в запрос с плавающим порядком ключей, префикс каждый раз получается новый.
Если вы пользуетесь Claude Code или Codex
Здесь промпт собирает сам инструмент, и точки останова расставлены внутри него, поэтому напрямую вы на это не влияете, но косвенно можете.
Самое дорогое действие — перезапуск агента. Правка файла с правилами или описания скилла на диске сама по себе может вообще ничего не поменять. Клиент читает такие файлы в какой-то момент своей жизни и подставляет в запрос то, что прочитал; если он не перечитывает их на каждом ходу, то и присланный промпт не меняется, а значит, префикс остаётся тем же.
Именно это и имеет в виду агент, когда говорит «у меня нет твоего нового MCP, перезапусти меня»: нового инструмента физически нет в присланном промпте, потому что клиент отправляет тот набор, который прочитал раньше. А вот при перезапуске он перечитает конфигурацию, положит в запрос другой набор определений, и тогда кэш обнулится, потому что определения инструментов лежат в самом начале префикса. Так что если вам нужны разные наборы инструментов под разные задачи, дешевле развести их по отдельным сессиям, чем переключать на ходу внутри одной.
Есть и обратная, уже положительная сторона. Когда агент читает файл инструментом посреди разговора, содержимое приезжает как результат вызова, то есть в конец запроса. Это самое дешёвое место: префикс не меняется, кэш цел, дописывается только прирост. Поэтому «загляни вот в этот файл» посреди сессии стоит дёшево, а перезапуск с новым набором инструментов — дорого.
Длинные паузы тоже стоят кэша. Запись живёт от нескольких минут до нескольких десятков минут (у Claude Code и Codex сроки разные), так что если вы вернулись к разговору сильно позже, префикс может посчитаться заново, даже если вы ничего не меняли. Это объясняет, почему продолжить старый разговор иногда ощутимо медленнее, чем начать новый: длинная история уже не в кэше, и её приходится обрабатывать целиком.
От агента к агенту
Механизм кэширования универсален, потому что он следует не из политики провайдера, а из того, как модель читает текст. Представление каждого токена зависит только от того, что было до него, поэтому переиспользовать можно исключительно начало, и это верно для любой модели такой архитектуры, независимо от того, кто её обслуживает.
А вот всё остальное — продуктовое решение, и оно бывает разное. Есть ли кэширование вообще, включается ли оно само или по пометке, с какой длины начинает работать, сколько живёт запись, что её обнуляет и видно ли это в ответе — всё это надо смотреть по конкретной модели конкретного провайдера. У Anthropic, OpenAI и Google кэширование есть, но устроено по-разному, а у отдельной модели его может не быть совсем.
Если вы работаете через API, цифры проверять обязательно: на них строится расчёт расходов, и ошибка здесь стоит денег. Если вы просто сидите в агенте, глубоко проверять вам незачем, так как на эти настройки вы не влияете, и здесь остаются общие принципы, которые работают сами по себе.
Общий вывод, который следует из всего механизма: стабильность начала важнее краткости контекста. Экономия приходит не только от того, что контекст короткий, но и от того, что его начало не меняется между запросами.