СодержаниеПредыстория и мотивация — коротко о том, зачем я написал этот пост, и краткий обзор территории, куда мы сейчас полезем.Советы, как стабильнее попадатьСодержаниеПредыстория и мотивация — коротко о том, зачем я написал этот пост, и краткий обзор территории, куда мы сейчас полезем.Советы, как стабильнее попадать

[Перевод] Как работает кэширование промптов — PagedAttention и автоматическое кэширование префикса плюс практические советы

Содержание
  1. Предыстория и мотивация — коротко о том, зачем я написал этот пост, и краткий обзор территории, куда мы сейчас полезем.

  2. Советы, как стабильнее попадать в кэш промпта — зачем вообще нужно кэширование промптов и как повысить долю попаданий в кэш.

  3. Основы инференса LLM — основы префилл, декодирования и KV-кэширования.

  4. Проблема памяти — почему традиционное выделение KV-кэша не масштабируется.

  5. PagedAttention — вдохновлённое ОС решение vLLM с блоками и таблицами блоков.

  6. Кэширование префикса — хеширование блоков, самое длинное попадание в кэш и общая картина.

Предварительно условие: начиная с раздела 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 к декодированию будет большим — это ключевые понятия, которые я разберу в следующих разделах).

Codex показывает кэшированные токены в конце сессии
Codex показывает кэшированные токены в конце сессии

Вот где кэширование промптов вас спасает. До 10× экономии на входных токенах при попадании в кэш. И ответы приходят быстрее. Как видно на картинке ниже, у Sonnet 4.5 входные токены при кэш-попаданиях стоят 1/10 от обычной цены.

Цены OpenAI на кэширование промптов — обратите внимание: доплаты за хранение токенов в кэше нет
Цены OpenAI на кэширование промптов — обратите внимание: доплаты за хранение токенов в кэше нет

Я называл 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, где кэширование может быть полезным.

Примеры шаринга KV-кэша. Синие блоки — части промпта, которыми можно делиться, зелёные — части, которыми делиться нельзя, жёлтые — выходы модели, которыми тоже делиться нельзя. Источник: блог SGLang
Примеры шаринга KV-кэша. Синие блоки — части промпта, которыми можно делиться, зелёные — части, которыми делиться нельзя, жёлтые — выходы модели, которыми тоже делиться нельзя. Источник: блог SGLang

Рекомендации, как повысить долю попаданий в кэш

В документации OpenAI и Anthropic есть несколько советов. Основная идея — держать максимально длинный стабильный префикс.

  • Структурируйте промпты так, чтобы статичный или повторяющийся контент был в начале, а динамичный, зависящий от пользователя — в конце.

  • Используйте параметр prompt_cache_key последовательно в запросах с общими префиксами. Выберите такую гранулярность, чтобы каждая уникальная комбинация «префикс–prompt_cache_key» оставалась ниже 15 запросов в минуту, чтобы избежать переполнения кэша.

  • Отслеживайте метрики производительности кэша, включая долю попаданий в кэш (cache hit rate), задержку (latency) и долю закэшированных токенов, чтобы улучшать стратегию.

  • Поддерживайте ровный поток запросов с одинаковыми префиксами промптов, чтобы уменьшить вытеснение из кэша и получить максимум пользы от кэширования.

Скриншот из документации

Мне показалось, что эти советы недостаточно продуманы: в них много места для ошибок. Я нашёл более удачный гайд в очень полезном блоге Manus — особенно в первом разделе.

Советы Manus по контекст-инжинирингу:

С точки зрения контекст-инжиниринга, повышение доли попаданий (hit rate) KV-кэша опирается на несколько ключевых практик:

  1. Держите префикс промпта стабильным. Из-за авторегрессионной природы LLM даже отличие в один токен может сделать кэш недействительным, начиная с этого токена и дальше. Типичная ошибка — добавлять таймстемп (особенно с точностью до секунды) в начало системного промпта. Да, модель сможет сказать текущее время, но вы при этом убьёте hit rate кэша.

  2. Делайте контекст “только на добавление” (append-only). Не изменяйте предыдущие действия или наблюдения. Убедитесь, что сериализация детерминирована. Многие языки программирования и библиотеки не гарантируют стабильный порядок ключей при сериализации JSON-объектов — и это может незаметно ломать кэш.

  3. Явно помечайте точки разрыва кэша, когда это нужно. Некоторые провайдеры моделей или фреймворки инференса не поддерживают автоматическое инкрементальное кэширование префикса и требуют вручную вставлять “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
Используйте 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 блоков.


Основы инференса LLM

Теперь, когда с практикой закончили, давайте посмотрим, что происходит «под капотом». Можно спросить, есть ли смысл тратить время на внутренности. Мне кажется, когда вы оптимизируете что-то на любом уровне стека (особенно на уровне приложения/инженерии), спуск на уровень абстракций ниже может очень сильно помочь. Иногда другого выбора просто нет: приходится смотреть на «кирпичики» и уже из них собирать решения.

У меня так бывало и раньше, а после блога Manus я снова об этом вспомнил. Эти ребята смогли оптимизировать именно потому, что понимали, как всё устроено внутри.

Prefill и декодирование

У инференса LLM есть два этапа (точнее, два режима/типа запросов): prefill (обработка входа, чтобы получить первый токен) и decode (декодирование, генерация выхода).

Время до первого токена (TTFT). Источник: документация NVIDIA NIM
Время до первого токена (TTFT). Источник: документация NVIDIA NIM

Возьмём входной промпт: «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-тензоры пересчитываются заново.

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 к декодированию большое — поэтому мы выигрываем по задержке и стоимости.

Необязательный разбор кода KV-кэша

Изменения в коде в основном сводятся к трём вещам:

  • выделение памяти на GPU;

  • конкатенация новых тензоров k/v;

  • изменения, связанные с каузальной маской: когда у вас всего один запрос / один токен на декодирование, каузальная маска не нужна, потому что это последний токен, и ему разрешено «видеть» всё, что было до него.

Хорошая точка входа в код KV-кэша — nanochat от «сенсея» Карпати. Мой минимальный разбор кода nanochat можно посмотреть здесь.

Более простой разбор кода есть у Sebastian Raschka.


Проблема памяти

Традиционный KV-кэш — непрерывное выделение памяти под каждый запрос
Традиционный KV-кэш — непрерывное выделение памяти под каждый запрос

Проблема в том, что 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-кэш

Классические проблемы памяти

Это классические проблемы выделения памяти в ОС:

  1. Внутренняя фрагментация: неиспользуемое место внутри выделенного блока. Возникает, когда выделенной памяти больше, чем реально нужно, а «лишнее» не может быть использовано другими процессами.

  2. Внешняя фрагментация: неиспользуемое место между выделенными блоками. Возникает, когда свободная память разбита на маленькие несмежные куски, из-за чего невозможно выделить большой непрерывный блок, даже если суммарно свободной памяти хватает.

Внутренняя и внешняя фрагментация при выделении памяти в ОС
Внутренняя и внешняя фрагментация при выделении памяти в ОС

Для KV-кэша эти проблемы проявляются так:

  • Внутренняя: мы заранее выделяем память под максимальную длину последовательности. Если запрос использует 100 токенов, а мы выделили место под 1024, то память под оставшиеся 924 токена просто простаивает.

  • Внешняя: запросы завершаются в разное время, оставляя «разбросанные» дырки в памяти GPU. Новый запрос, которому нужен непрерывный блок на 512 токенов, может не поместиться, даже если суммарно в GPU есть эквивалент 512 токенов свободной памяти, но она распилена на мелкие фрагменты.

Внутренняя и внешняя фрагментация в кэш-памяти типа "ключ-значение". Источник: статья vLLM.
Внутренняя и внешняя фрагментация в кэш-памяти типа «ключ‑значение». Источник: статья vLLM.

Избыточность

Помимо проблем с памятью, одинаковые префиксы будут храниться многократно. 100 запросов с одним и тем же системным промптом = 100 копий одного и того же KV-кэша. Эх, если бы у нас были блоки и указатели… как операционные системы решили ровно эту задачу десятилетия назад.

Постраничная организация памяти в ОС — отображение виртуальных адресов в физические через таблицу страниц
Постраничная организация памяти в ОС — отображение виртуальных адресов в физические через таблицу страниц

И, наконец, традиционный KV-кэш привязан к запросу: его выбрасывают после завершения генерации. Никакого шаринга между разными запросами нет.

К более удачным решениям выделения памяти для переиспользования KV-кэша, то есть кэширования промптов

Разные движки реализуют автоматическое кэширование префикса по-разному.

PagedAttention (инференс-движок vLLM)

Чтобы решить эту проблему, исследователи из Беркли предложили PagedAttention и построили инференс-движок vLLM v0.

В постраничной организации памяти ОС мы разбиваем один большой непрерывный кусок RAM на страницы фиксированного размера и выдаём процессу таблицу страниц, которая отображает виртуальные адреса (CPU) в физические адреса (RAM). Страницы могут лежать где угодно в физической памяти. Идея — смоделировать KV-кэш так, чтобы он работал похоже на постраничную память в операционных системах.

Radix Attention (инференс-движок SGLang)

SGLang решает кэширование промптов через radix attention, который использует radix-дерево. Можно прочитать статью и посмотреть видео.


PagedAttention

Обзор инференс-движка

Инференс-движки должны обрабатывать конкурентные запросы пользователей асинхронно, в режиме реального времени. Они запускаются на распределённой GPU-инфраструктуре. Обычно в таких системах есть планировщик (scheduler), который распределяет запросы по этапам вроде префилл/декодирование и занимается другой оркестрацией.

Базовые техники оптимизации инференса, которые такие движки обычно поддерживают, включают переиспользование KV-кэша, непрерывный батчинг (continuous batching, он же in-flight batching) и чанковый prefill (chunked-prefill). Эти три техники рассчитаны на быструю асинхронную генерацию. Среди других распространённых оптимизаций — нативные оптимизации PyTorch (torch.ao, compile и т. п.), спекулятивное декодирование и квантование KV-кэша. Такие движки также поддерживают несколько вариантов реализации attention, чтобы можно было обслуживать модели с разной архитектурой.

Упрощённая схема движка vLLM
Упрощённая схема движка vLLM

Теперь пора перейти к 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)) )

В каждый хеш входят три компонента:

  1. parent_block_hash — хеш предыдущего блока (или «зерно» для блока 0)

  2. curr_block_token_ids — ID токенов в этом блоке

  3. 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.

Поток выделения (allocation flow)
Поток выделения (allocation flow)

Физический блок памяти (памяти GPU) реально содержит место, куда будут записаны KV-тензоры для соответствующих токенов. Сами KV-тензоры вычисляются и записываются во время прохода forward (prefill). Таблица блоков лишь говорит GPU-ядру, куда именно их писать.

Переиспользование блоков — несколько запросов делят между собой блоки KV-кэша. Сделано с помощью Nano Banana Pro
Переиспользование блоков — несколько запросов делят между собой блоки KV-кэша. Сделано с помощью Nano Banana Pro

На схеме видно, что запрос 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 и ему нужно вычислить только новые блоки.

Сухой прогон кэширования префикса — запрос 2 приходит в момент t=1 и переиспользует закэшированные блоки
Сухой прогон кэширования префикса — запрос 2 приходит в момент t=1 и переиспользует закэшированные блоки

Вот так и работает кэширование промптов. Один и тот же системный промпт = один и тот же хеш = одни и те же закэшированные 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.

Вступительный тест по курсу MLOps
Вступительный тест по курсу MLOps

Если после разбора 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». Записаться

Источник

Отказ от ответственности: Статьи, размещенные на этом веб-сайте, взяты из общедоступных источников и предоставляются исключительно в информационных целях. Они не обязательно отражают точку зрения MEXC. Все права принадлежат первоисточникам. Если вы считаете, что какой-либо контент нарушает права третьих лиц, пожалуйста, обратитесь по адресу service@support.mexc.com для его удаления. MEXC не дает никаких гарантий в отношении точности, полноты или своевременности контента и не несет ответственности за любые действия, предпринятые на основе предоставленной информации. Контент не является финансовой, юридической или иной профессиональной консультацией и не должен рассматриваться как рекомендация или одобрение со стороны MEXC.