Предыстория и мотивация — коротко о том, зачем я написал этот пост, и краткий обзор территории, куда мы сейчас полезем.
Советы, как стабильнее попадать в кэш промпта — зачем вообще нужно кэширование промптов и как повысить долю попаданий в кэш.
Основы инференса LLM — основы префилл, декодирования и KV-кэширования.
Проблема памяти — почему традиционное выделение KV-кэша не масштабируется.
PagedAttention — вдохновлённое ОС решение vLLM с блоками и таблицами блоков.
Кэширование префикса — хеширование блоков, самое длинное попадание в кэш и общая картина.
Предварительно условие: начиная с раздела 2 предполагается знакомство с самовниманием (self-attention) в декодерных трансформерах. См. nanoGPT или 3blue1brown.
Недавно на работе мне пришлось пилить фичу в жёсткие сроки. Там был чат плюс компоненты для вызова инструментов (tool calling). Я особо не думал про кэширование промптов — просто пытался как можно быстрее выпустить v0.
На следующей неделе я начал оптимизировать и понял, что под давлением наделал глупостей. В итоге я добавил длинные пользовательские данные в конец системного промпта, думая, что мне достаточно держать максимально длинный префикс стабильным в рамках одного диалога / массива сообщений.
Массив сообщений выглядел бы так
0. [системный промпт + определения инструментов] 1. пользователь: как дела. пожалуйста, собери для меня эту фичу 2. ассистент: можешь сказать, где искать? кодовая база большая 3. пользователь: посмотри папку kv_caching 4. ассистент: ты абсолютно прав! я посмотрю там 5. tool output: grep чтение файлов 6. ассистент: LLM получает вывод как observation 7. пользователь: ... 8. ассистент: ...
Я ожидал попасть в кэш на пункте 4 в рамках этой сессии — и это верно, потому что пункты 0–3 повторяются. Но я упустил полную картину: попадания в кэш могут начинаться с пункта 0 между разными пользователями. Ваш системный промпт может быть общим для всех диалогов внутри организации, привязанной к вашему API-ключу.
Моя когнитивная модель была неправильной. Я представлял инференс как синхронный движок — один блокирующий процесс на одного пользователя, как при локальном хостинге модели. Первый промпт → модель делает prefill → генерирует KV-кэш → отвечает. Второй промпт → попадаем в кэш → быстрый ответ.
Но именно так модели не разворачиваются в масштабе у провайдеров вроде OpenAI и Anthropic. Им нужно обрабатывать конкурентные запросы пользователей. Для этого они используют асинхронные распределённые системы (несколько GPU, несколько узлов). Когда слышите слово async, в голове должны всплывать планировщики и очереди сообщений.
Такие движки включают несколько техник оптимизации инференса LLM: повторное использование KV-кэша, непрерывный батчинг, чанковый prefill (chunked prefill), спекулятивное декодирование и многое другое. Именно повторное использование KV-кэша и делает возможным кэширование промптов.
Чтобы понять, как работает кэширование промптов, нам также нужно разобрать основы инференс-движка вроде vLLM и дальше — как именно реализуется повторное использование KV-кэша.
Я нашел массу отличных советов по кэшированию промптов, но не нашёл цельного материала, в котором бы подробно объяснялось, как кэширование промптов устроено «под капотом». Вот я и взвалил на себя эту ответственность и теперь пишу эту статью. Следуя принципу «стань тем изменением, которое хочешь видеть в окружающем» и всё такое. Когда кто-то наберет в поиске «как на самом деле работает кэширование промптов», я надеюсь, что эта статья всплывет в выдаче и даст понятную картину — с бонусом в виде понимания того, как выглядит инференс в масштабе.
Я потратил кучу времени, чтобы разобраться в движке vLLM и техниках инференса и написать этот текст. Вот это был я пару дней назад.
Долгое время я думал, что кэширование промптов работает за счёт KV-кэширования — и это было частично верно. Но по факту оно работает потому, что KV-кэш реально переиспользуется с помощью разных техник, например PagedAttention (страничное внимание) и radix attention (внимание на префиксном дереве). В этом посте я фокусируюсь на PagedAttention. Для этого нам придётся посмотреть, как устроен движок vLLM. Цель статьи — «въехать» в prompt caching, поэтому я буду разбирать только те части vLLM, которые максимально релевантны PagedAttention и кэшированию префикса.
Прежде чем лезть во внутренности, начну с советов, как стабильнее попадать в кэш промпта. Именно они и зажгли во мне достаточно любопытства, чтобы докопаться до того, как всё устроено внутри.
Кэширование промптов — это когда LLM провайдеры переиспользуют ранее вычисленные тензоры ключей и значений (key-value) для одинаковых префиксов промпта, пропуская лишние вычисления. Если вы попали в кэш, вы платите меньше и получаете ответы быстрее.
Если вы пользуетесь Codex/Claude Code/Cursor и смотрите статистику использования API, вы заметите, что значительная часть токенов помечена как «cached». К счастью, код — штука структурированная: разные запросы могут опираться на один и тот же контекст/префиксы, чтобы отвечать на вопросы, поэтому попаданий в кэш много. Именно это помогает держать счета под контролем.
Агенты для кодогенерации — отличный пример: у них контекст растёт очень быстро, и соотношение входных токенов к выходным может быть очень большим (а значит, и соотношение prefill к декодированию будет большим — это ключевые понятия, которые я разберу в следующих разделах).
Вот где кэширование промптов вас спасает. До 10× экономии на входных токенах при попадании в кэш. И ответы приходят быстрее. Как видно на картинке ниже, у Sonnet 4.5 входные токены при кэш-попаданиях стоят 1/10 от обычной цены.
Я называл Anthropic жадными, потому что у них запись в кэш стоит дороже (а Sonnet/Opus и так недешёвые). Для сравнения, OpenAI за это отдельно не берёт. Это взгляд со стороны потребителя. Но если смотреть глазами инженера, хранение тензоров key-value в GPU VRAM или в локальном хранилище рядом с GPU тоже стоит денег — что и объясняет доплату; дальше по тексту мы к этому ещё вернёмся.
Косвенно связанный момент: OpenAI также недавно ввели политику удержания кэша на 24 часа для линейки GPT-5.1 и модели GPT-4.1. По умолчанию кэшированные префиксы остаются в GPU VRAM 5–10 минут простоя. Расширенное удержание на 24 часа выгружает KV-тензоры в локальное GPU-хранилище (SSD, подключённые к узлам с GPU) во время простоя и подгружает их обратно в VRAM при попадании в кэш.
Ниже — несколько разных паттернов вызовов LLM, где кэширование может быть полезным.
В документации OpenAI и Anthropic есть несколько советов. Основная идея — держать максимально длинный стабильный префикс.
Структурируйте промпты так, чтобы статичный или повторяющийся контент был в начале, а динамичный, зависящий от пользователя — в конце.
Используйте параметр prompt_cache_key последовательно в запросах с общими префиксами. Выберите такую гранулярность, чтобы каждая уникальная комбинация «префикс–prompt_cache_key» оставалась ниже 15 запросов в минуту, чтобы избежать переполнения кэша.
Отслеживайте метрики производительности кэша, включая долю попаданий в кэш (cache hit rate), задержку (latency) и долю закэшированных токенов, чтобы улучшать стратегию.
Поддерживайте ровный поток запросов с одинаковыми префиксами промптов, чтобы уменьшить вытеснение из кэша и получить максимум пользы от кэширования.
Мне показалось, что эти советы недостаточно продуманы: в них много места для ошибок. Я нашёл более удачный гайд в очень полезном блоге Manus — особенно в первом разделе.
Советы Manus по контекст-инжинирингу:
С точки зрения контекст-инжиниринга, повышение доли попаданий (hit rate) KV-кэша опирается на несколько ключевых практик:
Держите префикс промпта стабильным. Из-за авторегрессионной природы LLM даже отличие в один токен может сделать кэш недействительным, начиная с этого токена и дальше. Типичная ошибка — добавлять таймстемп (особенно с точностью до секунды) в начало системного промпта. Да, модель сможет сказать текущее время, но вы при этом убьёте hit rate кэша.
Делайте контекст “только на добавление” (append-only). Не изменяйте предыдущие действия или наблюдения. Убедитесь, что сериализация детерминирована. Многие языки программирования и библиотеки не гарантируют стабильный порядок ключей при сериализации JSON-объектов — и это может незаметно ломать кэш.
Явно помечайте точки разрыва кэша, когда это нужно. Некоторые провайдеры моделей или фреймворки инференса не поддерживают автоматическое инкрементальное кэширование префикса и требуют вручную вставлять “breakpoints” кэша в контекст. Назначая их, учитывайте возможное истечение кэша и как минимум следите, чтобы точка разрыва включала конец системного промпта.
Дополнительно: если вы хостите модели у себя и используете фреймворки вроде vLLM, убедитесь, что включено prefix/prompt caching, и что вы применяете техники вроде session IDs, чтобы маршрутизировать запросы согласованно между распределёнными воркерами.
Я прочитал этот пост и ещё пару материалов — и в итоге внёс изменения в рабочую задачу, про которую говорил в начале.
Сделайте префикс стабильным
В итоге я убрал из системного промпта всё пользовательское и вообще любой динамический контент. Благодаря этому другие пользователи смогли попадать в кэш промпта даже на уровне системного сообщения — потому что оно становится общим префиксом в «блоках KV-кэша» (подробнее об этом позже).
Держите контекст в режиме «только на добавление» append-only
В фиче, которую я делал, могло быть несколько вызовов инструментов, и их умеренно длинные результаты сохранялись в массиве сообщений. Я думал, что на длинном диалоге это может ухудшить качество из-за «протухания контекста» (context rot), поэтому по мере роста диалога я обрезал в массиве сообщений только результаты вызовов инструментов.
На деле я этим ломал префикс, поэтому решил перестать обрезать — выгода по стоимости и задержке для меня оказалась важнее. Теперь мой контекст стал append-only.
Полагаю, что «уплотнение» (compaction) в Claude Code, скорее всего, тоже реализовано как append-only процесс.
Используйте детерминированную сериализацию
В блоге Manus упоминается детерминированная сериализация. Я в итоге стал использовать sort_keys=True при сериализации JSON в результатах вызовов инструментов. Даже если два объекта семантически одинаковы, разный порядок ключей даёт разные строки — а значит, разные ключи кэша и промахи по кэшу. Про первые два пункта я знал, а вот про этот момент не подумал.
sort_keys=True в json.dumps(), чтобы порядок ключей был стабильным. В этом посте бенчмаркают разницу в стоимости при попадании в кэш промпта. Источник: блог Ankit
Не меняйте определения инструментов динамически
Manus упомянул ещё одну важную деталь: определения инструментов (tool call definitions) обычно располагаются до или после системного промпта. См. здесь. Это означает, что изменение или удаление каких-то определений инструментов «сломает» весь префикс после точки изменения.
Недавно Anthropic запустили Tool Search Tool, который ищет инструменты по запросу. То есть не нужно перечислять все инструменты заранее. Я задумался, не будет ли это ломать кэширование, потому что инструменты обычно находятся в начале или в конце системного промпта (внутренне). Позже я увидел в документации, что эти определения инструментов «добавляются» (appended) в контекст — то есть контекст остаётся только на добавление.
prompt_cache_key и cache_control
Для OpenAI ваш запрос к API должен быть направлен на ту же машину, чтобы попасть в кэш. OpenAI маршрутизируют запросы на основе хеша начального префикса (примерно ~256 токенов). Можно передать параметр prompt_cache_key, который объединяется с хешем этого префикса и помогает вам влиять на маршрутизацию, когда у многих запросов есть длинные общие префиксы. Важно: это не параметр «точки разрыва кэша» — это подсказка для роутинга. С этим мне ещё надо поэкспериментировать.
Для Anthropic, насколько я понимаю, автоматического кэширования префикса нет (но я не уверен на 100%), поэтому нужно явно отмечать точки cache_control breakpoints, чтобы указать, где кэшировать (как упоминалось в пункте 3 у Manus). От каждой такой точки Anthropic проверяет назад, чтобы найти самый длинный кэшированный префикс; при этом на каждую точку есть окно просмотра назад на 20 блоков.
Теперь, когда с практикой закончили, давайте посмотрим, что происходит «под капотом». Можно спросить, есть ли смысл тратить время на внутренности. Мне кажется, когда вы оптимизируете что-то на любом уровне стека (особенно на уровне приложения/инженерии), спуск на уровень абстракций ниже может очень сильно помочь. Иногда другого выбора просто нет: приходится смотреть на «кирпичики» и уже из них собирать решения.
У меня так бывало и раньше, а после блога Manus я снова об этом вспомнил. Эти ребята смогли оптимизировать именно потому, что понимали, как всё устроено внутри.
У инференса LLM есть два этапа (точнее, два режима/типа запросов): prefill (обработка входа, чтобы получить первый токен) и decode (декодирование, генерация выхода).
Возьмём входной промпт: «The capital of France is» — то есть режим «prefill».
Во время prefill модель обрабатывает весь промпт целиком. Каждый токен «смотрит» на предыдущие через каузальное самовнимание, вычисляя тензоры Query, Key и Value во всех слоях трансформера, чтобы получить первый выходной токен. Это сильно параллелизуемый шаг (спасибо матричным умножениям) и он в основном упирается в вычисления / GPU FLOPs. Здесь гораздо больше «шагов вычислений», чем «перетаскивания памяти». Больше арифметики здесь.
В отличие от этого, декодирование упирается в память: на каждом шаге обрабатывается всего один токен, но при этом нужно загрузить весь KV-кэш из памяти GPU. Разница «вычисления vs память» важна для планирования: vLLM отдаёт приоритет очереди выполняющихся запросов (decode) над очередью ожидающих (prefill), чтобы чувствительные к задержкам шаги декодирования не «голодали» из-за тяжёлых по вычислениям prefills. Chunked prefill расширяет это поведение: он ограничивает число токенов prefill в одном батче, позволяя decode-запросам продолжать работу без ожидания.
# источник: [nanoGPT](https://github.com/karpathy/nanoGPT/blob/3adf61e154c3fe3fca428ad6bc3818b27a3b8291/model.py#L29) def forward(self, x): B, T, C = x.size() # размер батча, длина последовательности, размерность эмбеддинга (n_embd) # prefill - вычисляем query, key, values для всех голов в батче и переставляем размерности так, чтобы размерность головы стала размерностью батча q, k, v = self.c_attn(x).split(self.n_embd, dim=2) k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
Когда prefill завершён, начинается декодирование. Мы берём выходной токен, дописываем его к входной последовательности и прогоняем через LLM (авторегрессионная генерация). Процесс повторяется, пока не получим токен конца последовательности.
Сухой прогон:
Prefill [The capital of France is] → Paris
Итерация decode 1 [The Capital of France is Paris] → which
Итерация decode 2 [The Capital of France is Paris which] → has
Итерация decode 3 [The Capital of France is Paris which has] → the
Итерация decode 4 [The Capital of France is Paris which has the] → Eiffel
Наблюдение 1: на каждой итерации декодирования мы заново пересчитываем KV-тензоры для всех предыдущих токенов — это расточительно:
[The]→K₁V₁ [Capital]→K₂V₂ [of]→K₃V₃ [France]→K₄V₄ [is]→K₅V₅ [Paris]→K₆V₆ [which]→K₇V₇ [has]→K₈V₈ → [the] WASTE WASTE WASTE WASTE WASTE WASTE WASTE NEW
Наблюдение 2: x в q, k, v = self.c_attn(x).split(self.n_embd, dim=2) — это эмбеддинг входного промпта. Для простоты я буду писать просто английскую версию. На итерации 1, x было бы «The Capital of France is Paris». На итерации 2, x было бы «The Capital of France is Paris which». Мы снова и снова обрабатываем одно и то же входное предложение, и KV-тензоры пересчитываются заново.
Это происходит потому, что LLM не хранит состояние (stateless). Но, к счастью, можно добавить состояние вручную.
Можно сохранить KV-тензоры для входного промпта в памяти GPU и переиспользовать их. Тогда итерации меняются вот так.
Prefill (with cache) Model view: x = [The capital of France is] Output: Paris We compute and store K/V for: [The], [capital], [of], [France], [is] → KV cache now has entries for the whole prefix.
Дальше — decode. На каждом шаге мы прогоняем через проекции только новый токен и добавляем его K/V в кэш. Полный контекст восстанавливается как «префикс из кэша + новый токен».
Итерация decode 1 Вид модели: x = [Paris] (только новый токен) Кэш: K/V для [The capital of France is] + [Paris] Выход: which
Мы вычисляем K/V только для [Paris] и добавляем в уже существующий кэш.
Итерация decode 2 Вид модели: x = [which] Кэш: K/V для [The capital of France is Paris] + [which] Выход: has
Итерация decode 3 Вид модели: x = [has] Кэш: K/V для [The capital of France is Paris which] + [has] Выход: the
Итерация decode 4 Вид модели: x = [the] Кэш: K/V для [The capital of France is Paris which has] + [the] Выход: Eiffel
Теперь в процессе декодирования на каждом шаге обрабатывается только один токен, а остальное берётся из KV-кэша. Добавление в KV-кэш — операция O(1). В большинстве сценариев, например с длинными документами и кодом, входной контекст/промпт обычно намного больше по объёму, чем число выходных токенов. Иными словами, соотношение prefill к декодированию большое — поэтому мы выигрываем по задержке и стоимости.
Изменения в коде в основном сводятся к трём вещам:
выделение памяти на GPU;
конкатенация новых тензоров k/v;
изменения, связанные с каузальной маской: когда у вас всего один запрос / один токен на декодирование, каузальная маска не нужна, потому что это последний токен, и ему разрешено «видеть» всё, что было до него.
Хорошая точка входа в код KV-кэша — nanochat от «сенсея» Карпати. Мой минимальный разбор кода nanochat можно посмотреть здесь.
Более простой разбор кода есть у Sebastian Raschka.
Проблема в том, что KV-кэш нужно где-то хранить — в памяти GPU. Самый простой подход — выделять под каждый запрос один большой непрерывный кусок памяти GPU, но для масштабного обслуживания это плохо и приводит к нескольким проблемам.
Размер KV-кэша растёт линейно с длиной последовательности / контекста:
kv_size = 2 (K+V) × layers × kv_heads × head_dim × seq_len × precision
Для модели 7B (32 слоя, 32 KV-головы, head_dim 128, float16 = 2 байта):
На токен: 2 × 32 × 32 × 128 × 2 байта ≈ 0,5 МБ
Контекст 1K: ~512 МБ на запрос
100 одновременных запросов: ~50 ГБ только под KV-кэш
Это классические проблемы выделения памяти в ОС:
Внутренняя фрагментация: неиспользуемое место внутри выделенного блока. Возникает, когда выделенной памяти больше, чем реально нужно, а «лишнее» не может быть использовано другими процессами.
Внешняя фрагментация: неиспользуемое место между выделенными блоками. Возникает, когда свободная память разбита на маленькие несмежные куски, из-за чего невозможно выделить большой непрерывный блок, даже если суммарно свободной памяти хватает.
Для KV-кэша эти проблемы проявляются так:
Внутренняя: мы заранее выделяем память под максимальную длину последовательности. Если запрос использует 100 токенов, а мы выделили место под 1024, то память под оставшиеся 924 токена просто простаивает.
Внешняя: запросы завершаются в разное время, оставляя «разбросанные» дырки в памяти GPU. Новый запрос, которому нужен непрерывный блок на 512 токенов, может не поместиться, даже если суммарно в GPU есть эквивалент 512 токенов свободной памяти, но она распилена на мелкие фрагменты.
Помимо проблем с памятью, одинаковые префиксы будут храниться многократно. 100 запросов с одним и тем же системным промптом = 100 копий одного и того же KV-кэша. Эх, если бы у нас были блоки и указатели… как операционные системы решили ровно эту задачу десятилетия назад.
И, наконец, традиционный KV-кэш привязан к запросу: его выбрасывают после завершения генерации. Никакого шаринга между разными запросами нет.
Разные движки реализуют автоматическое кэширование префикса по-разному.
PagedAttention (инференс-движок vLLM)
Чтобы решить эту проблему, исследователи из Беркли предложили PagedAttention и построили инференс-движок vLLM v0.
В постраничной организации памяти ОС мы разбиваем один большой непрерывный кусок RAM на страницы фиксированного размера и выдаём процессу таблицу страниц, которая отображает виртуальные адреса (CPU) в физические адреса (RAM). Страницы могут лежать где угодно в физической памяти. Идея — смоделировать KV-кэш так, чтобы он работал похоже на постраничную память в операционных системах.
Radix Attention (инференс-движок SGLang)
SGLang решает кэширование промптов через radix attention, который использует radix-дерево. Можно прочитать статью и посмотреть видео.
Инференс-движки должны обрабатывать конкурентные запросы пользователей асинхронно, в режиме реального времени. Они запускаются на распределённой GPU-инфраструктуре. Обычно в таких системах есть планировщик (scheduler), который распределяет запросы по этапам вроде префилл/декодирование и занимается другой оркестрацией.
Базовые техники оптимизации инференса, которые такие движки обычно поддерживают, включают переиспользование KV-кэша, непрерывный батчинг (continuous batching, он же in-flight batching) и чанковый prefill (chunked-prefill). Эти три техники рассчитаны на быструю асинхронную генерацию. Среди других распространённых оптимизаций — нативные оптимизации PyTorch (torch.ao, compile и т. п.), спекулятивное декодирование и квантование KV-кэша. Такие движки также поддерживают несколько вариантов реализации attention, чтобы можно было обслуживать модели с разной архитектурой.
Теперь пора перейти к PagedAttention.
Вместо того чтобы выделять под KV-кэш один большой кусок памяти, vLLM при старте заранее выделяет пул блоков фиксированного размера (фиксированный объём памяти GPU). Все эти блоки лежат в FreeKVCacheBlockQueue#free_block_queue. По умолчанию в каждом блоке есть место под 16 токенов. Это ровно та же идея, что и постраничная организация памяти в ОС: страницы фиксированного размера, разбросанные по физической памяти, которыми управляет таблица страниц.
Каждый блок представлен структурой KVCacheBlock:
@dataclass class KVCacheBlock: block_id: int ref_cnt: int = 0 _block_hash: BlockHashWithGroupId | None = None
block_id — какой физический блок памяти GPU
ref_cnt — сколько запросов сейчас используют этот блок
block_hash — хеш содержимого для кэширования префикса (об этом позже)
Когда приходит запрос, токены сначала отображаются на логические позиции блоков:
block_index = token_position // block_size # какой блок offset = token_position % block_size # позиция внутри блока
Логически мы группируем по 16 токенов в один блок. Промпту на 50 токенов нужно ceil(50/16) = 4 блока.
Запрос: "The capital of France is Paris which is known for..." (50 токенов) Позиции токенов: [0-15] [16-31] [32-47] [48-49] ↓ ↓ ↓ ↓ Логические блоки: Block 0 Block 1 Block 2 Block 3 (full) (full) (full) (partial)
Пока что это просто математика в том смысле, что реальная память GPU под эти блоки ещё не назначена. Дальше нужно решить, какие физические блоки использовать.
Чтобы это сделать, vLLM использует хеширование блоков. Идея в том, чтобы вычислять контент-адресуемые хеши блоков на основе ID токенов. Когда приходит запрос, для блока вычисляется хеш и проверяется в кэше. Если такой хеш уже есть, мы переиспользуем закэшированный блок. Если нет — берём блок из очереди свободных и выделяем его. Эти хеши также сохраняются в таблице поиска (в следующем разделе).
Хеширование даёт O(1) поиск на блок, тогда как прямое сравнение последовательностей токенов со всеми закэшированными префиксами было бы куда дороже.
Функция хеширования:
def hash_block_tokens( parent_block_hash: BlockHash | None, curr_block_token_ids: Sequence[int], extra_keys: tuple[Any, ...] | None = None, ) -> BlockHash: if not parent_block_hash: parent_block_hash = NONE_HASH # seed for first block return BlockHash( sha256((parent_block_hash, tuple(curr_block_token_ids), extra_keys)) )
В каждый хеш входят три компонента:
parent_block_hash — хеш предыдущего блока (или «зерно» для блока 0)
curr_block_token_ids — ID токенов в этом блоке
extra_keys — опциональные метаданные (соль кэша, LoRA-адаптер, мультимодальные входы)
hash(block 0) = sha256(NONE_HASH, tokens[0:16], extras) hash(block 1) = sha256(hash(block 0), tokens[16:32], extras) hash(block 2) = sha256(hash(block 1), tokens[32:48], extras)
Хеш родительского блока включается в вычисление так, чтобы хеш блока N «кодировал» блоки 0…N−1. Если хеш блока 5 совпал, то блоки 0–4 гарантированно идентичны — то есть одним обращением к кэшу мы валидируем весь префикс. Это и есть основа того, как работает кэширование префикса.
Можно спросить: почему бы не хешировать каждый блок независимо? Проблема в каузальном внимании. KV-значения токена 32 зависят от токенов 0–31. Если мы переиспользуем закэшированные KV-тензоры блока 2, мы тем самым неявно предполагаем, что блоки 0 и 1 тоже идентичны. Независимые хеши этого гарантировать не могут. Поэтому и нужна «цепочка» через родительский хеш.
Примечание про изоляцию кэша: по умолчанию никакой изоляции нет — кэш чисто контент-адресуемый. Если нужна изоляция по арендаторам (tenant isolation), vLLM поддерживает параметр cache_salt, который включается в хеш блока и тем самым создаёт отдельные пространства имён кэша для каждого пользователя/тенанта.
Вычисленные хеши хранятся в словаре BlockHashToBlockMap:
class BlockHashToBlockMap: def __init__(self): self._cache: dict[BlockHashWithGroupId, KVCacheBlock] = {} def get_one_block(self, key: BlockHashWithGroupId) -> KVCacheBlock | None: return self._cache.get(key) # O(1) lookup
Эта хеш-таблица по сути отвечает на вопрос: «есть ли уже физический блок с KV-тензорами, соответствующими данному хешу?»
После поиска по хешу таблица блоков (block table) строится в отдельном рабочем процессе: она отображает логические позиции в физические блоки памяти GPU.
Физический блок памяти (памяти GPU) реально содержит место, куда будут записаны KV-тензоры для соответствующих токенов. Сами KV-тензоры вычисляются и записываются во время прохода forward (prefill). Таблица блоков лишь говорит GPU-ядру, куда именно их писать.
На схеме видно, что запрос 0 и запрос 2 переиспользуют блоки 0, 1, 2 из запроса 0. Это означает, что у них одинаковые KV-тензоры, потому что совпадает префикс. Если оба запроса (0 и 2) активны одновременно, ref_cnt будет равен 2. Когда один завершается, ref_cnt = 1. Когда завершаются оба, ref_cnt = 0, и блок возвращается в очередь свободных блоков, где применяется политика вытеснения LRU.
Наконец, у нас есть все «кирпичики», чтобы объяснить, как работает кэширование префикса.
Ключевая мысль: закэшированные блоки позволяют пропустить вычисления на этапе prefill. Если мы можем найти самый длинный префикс закэшированных блоков среди нескольких запросов, мы можем полностью пропустить prefill для этой части.
Почему именно «префикс»? Повторю ещё раз: причина снова в каузальном внимании. Каждый токен может «смотреть» только на токены, которые были до него. Если вы меняете что-то перед ним, значения KV-тензоров на позиции N будут другими.
KV-тензоры токена 50 зависят от токенов 0–49. Значит, KV-значения корректны только если весь префикс идентичен. Нельзя переиспользовать KV-тензоры блока 3, если блоки 0, 1 или 2 отличаются — потому что тогда KV-тензоры на позиции 3 тоже будут другими. Это ещё одна причина, почему используется родительская цепочка хешей.
Каждый хеш кодирует всю свою историю. Если хеш блока 2 совпал, то блоки 0 и 1 гарантированно совпадают. Одним поиском мы валидируем весь префикс.
Остаётся только найти самый длинный префикс. Когда приходит запрос, vLLM заранее вычисляет хеши блоков для всех полностью заполненных блоков и сохраняет их в объекте запроса.
Хеш родительского блока включается в вычисление так, чтобы хеш блока N «кодировал» блоки 0…N−1. Если хеш блока 5 совпал, то блоки 0–4 гарантированно идентичны — то есть одним обращением к кэшу мы валидируем весь префикс. Это и есть основа того, как работает кэширование префикса.
def find_longest_cache_hit(block_hashes, block_pool): computed_blocks = [] for block_hash in block_hashes: if cached_block := block_pool.get_cached_block(block_hash): computed_blocks.append(cached_block) else: break # остановка при первом промахе return computed_blocks
Непрерывная серия попаданий, начиная с блока 0, и есть закэшированный префикс. Поскольку хеши «цепляются» через родителей, если совпал хеш блока 2, то блоки 0 и 1 тоже гарантированно совпадают.
Дальше во время prefill мы вычисляем KV-тензоры только там, где кэш промахнулся:
Request: "The capital of France is Paris which..." [block 0] [block 1] [block 2] [block 3] ↓ ↓ ↓ ↓ Lookup: HIT HIT MISS MISS ↓ ↓ ↓ ↓ Prefill: [skip] [skip] [compute] [compute]
Блоки 0 и 1 уже содержат KV-тензоры в памяти GPU от предыдущего запроса. Мы их не пересчитываем — мы просто указываем на них в таблице блоков. Prefill выполняется только для блоков 2 и 3.
И, по сути, это всё. Если вы поняли до этого места — вы ухватили суть PagedAttention.
Один «сухой прогон»
Приходит запрос 1, вычисляет все блоки и начинает декодирование. Пока запрос 1 всё ещё генерирует токены, приходит запрос 2 — совершенно другой запрос от другого пользователя. Поскольку у них один и тот же системный промпт, запрос 2 получает попадания в кэш по блокам 0–2 и ему нужно вычислить только новые блоки.
Вот так и работает кэширование промптов. Один и тот же системный промпт = один и тот же хеш = одни и те же закэшированные KV-блоки. Блоки KV-кэша можно использовать между разными запросами. Пользователь B получает выгоду от блоков, которые уже закэшировал пользователь A.
Получается, моя первоначальная когнитивная модель была неправильной. Я думал, что кэширование работает «на диалог», но на самом деле оно работает «на содержимое». Кэширование префикса работает на уровне токенов, а не на уровне запроса — именно поэтому оно работает между запросами.
Надеюсь, теперь понятно, почему провайдерам нужен статичный префикс: любое изменение в префиксе ломает всю цепочку хешей.
Если хочется копнуть глубже, стоит изучить continuous batching и chunked-prefill. Они не были обязательными для понимания здесь, но делают инференс в целом более асинхронным и быстрым. Это довольно стандартные вещи для инференс-движков.
Спасибо за внимание!
Референс кодаЭта статья опирается на vLLM v1.
vllm/ ├── utils/ │ └── hashing.py │ └── sha256() # Хеш-функция для содержимого блока │ └── v1/core/ ├── kv_cache_utils.py │ ├── KVCacheBlock # Метаданные блока (block_id, ref_cnt, hash) │ ├── hash_block_tokens() # Вычисляет хеш блока с «цепочкой» через родителя │ └── BlockHash # Псевдоним типа для 32-байтного хеша │ ├── block_pool.py │ ├── BlockHashToBlockMap # Словарь поиска: хеш → KVCacheBlock │ └── BlockPool # Управляет очередью свободных блоков и закэшированными блоками │ ├── kv_cache_manager.py │ ├── get_computed_blocks() # Точка входа для поиска попаданий в кэш префикса │ └── allocate_slots() # Выделяет блоки для промахов по кэшу │ ├── single_type_kv_cache_manager.py │ └── find_longest_cache_hit() # Проходит по хешам до первого промаха │ └── sched/ └── scheduler.py # Оркестрирует поток выделения блоков
Статьи
Efficient Memory Management for Large Language Model Serving with PagedAttention — оригинальная статья про vLLM от исследователей из Беркли.
Видео
Julien Simon's LLM Inference Optimisation — очень рекомендую как базу по оптимизациям инференса.
Andrej Karpathy's nanoGPT — чтобы понять внутренности трансформера.
3Blue1Brown Attention Mechanism — визуальное объяснение механизма attention.
SGLang Radix Attention — альтернативный подход к кэшированию префикса (prefix caching).
Код
Karpathy's nanochat — аккуратная реализация KV-кэша.
vLLM GitHub — исходники, которые я «прочитал за выходные».
Прочее
NVIDIA NIM Metrics — справочник по метрикам инференса LLM.
SGLang Blog — объяснение Radix Attention.
Если после разбора prompt caching захотелось смотреть шире — на весь путь модели до production, пригодится курс MLOps. Разбираем деплой и обновление моделей, упаковку в сервисы, CI/CD, Kubernetes и наблюдаемость (Prometheus/Grafana), плюс MLflow/DVC, Airflow и Kafka. Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа курса.
А для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:
14 января 18:00. «Локальное окружение для начинающего ML-инженера». Записаться
28 января 20:00. «Как GPT понимает язык и формулирует ответы». Записаться
29 января 20:00. «Какие ИИ-инструменты реально нужны Data Engineer». Записаться
Источник


