Инференс LLM · Память

KV-кэш: почему генерация текста упирается в память, а не только в вычисления

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

Рост кэша с длиной контекста · модель 7B, MHA, fp16, batch=1 (оценка)
~2 ГБ
4K токенов
~16 ГБ
32K токенов
~64 ГБ
128K токенов
~512 ГБ
1M токенов
Рост линейный по числу токенов, но абсолютные числа делают его субъективно «взрывным»: увеличение окна контекста в 256 раз (4K → 1M) даёт ровно такой же рост объёма кэша — просто линейная зависимость с большим множителем ощущается как экспонента.
Что кэшируется и зачем

При авторегрессивной генерации модель предсказывает токены по одному, и на каждом шаге attention каждого слоя обращается ко всем предыдущим токенам. Ключи и значения (K и V) для уже сгенерированных токенов не меняются от шага к шагу — они зависят только от самих токенов, а не от того, что будет сгенерировано дальше. KV-кэш — это сохранение этих тензоров, чтобы не пересчитывать линейные проекции K/V для всего префикса на каждом новом шаге.

# без кэша: на каждом шаге t пересчитываем K,V для ВСЕХ t токенов
for t in range(n):
    K, V = project(tokens[:t])      # O(t) работы, всего O(n²)
    attn = softmax(Q[t] @ K.T) @ V

# с кэшем: пересчитываем K,V только для нового токена
for t in range(n):
    k_t, v_t = project(tokens[t])   # O(1) работы на шаг, всего O(n)
    cache.append(k_t, v_t)
    attn = softmax(Q[t] @ cache.K.T) @ cache.V
Сам механизм внимания (умножение Q на кэш) всё равно стоит O(n) на шаг генерации — это неизбежно. Кэш убирает именно повторный пересчёт проекций K/V, который без него доминировал бы по стоимости при больших скрытых размерностях.
Из чего складывается размер кэша

Размер KV-кэша в байтах считается по простой формуле, и каждый множитель — это отдельный рычаг, которым управляют на практике:

Size = 2 × L × H_kv × d_head × S × B × bytes_per_elem
МножительЧто этоКак влияет
2отдельно K и Vконстанта, не оптимизируется
Lчисло слоёв трансформерабольше модель → больше слоёв → линейный рост
H_kvчисло KV-голов вниманияMHA: H_kv = H (все головы); GQA/MQA: H_kv ≪ H — главный рычаг сжатия
d_headразмерность одной головыобычно 64–128, зависит от архитектуры
Sдлина последовательности (контекст + сгенерированное)растёт с каждым токеном генерации — главный источник роста во времени
Bbatch — число одновременных запросов/лучейлинейный множитель на весь остальной кэш
bytes_per_elemточность хранения (fp16=2, int8=1, int4=0.5)прямое сжатие в 2–4 раза без изменения архитектуры
Когда кэш растёт стремительно
Длинный контекст
Каждый добавленный токен контекста — это ещё один полный «слой» K/V по всем L слоям и H_kv головам. Переход с 4K на 128K токенов — это умножение кэша в 32 раза при прочих равных, независимо от того, вырос ли размер модели.
Параллельные запросы (batch)
Продакшн-сервер держит отдельный кэш на каждый активный диалог. 32 одновременных пользователя с контекстом 4K — это те же ~64 ГБ, что один пользователь с 128K, просто множитель приходит не из S, а из B.
Beam search и спекулятивные кандидаты
Beam search с шириной луча 4 фактически ведёт 4 параллельные гипотезы — 4 независимых кэша вместо одного. Спекулятивная генерация с черновой моделью временно держит кэш и черновой, и целевой модели одновременно.
Большая модель без сжатия голов
Больше слоёв и голов внимания напрямую умножают L и H_kv в формуле. Модель с классическим Multi-Head Attention (все головы — KV-головы) при том же числе параметров даст кэш в разы больше, чем модель того же размера с GQA.
Повторяющийся системный промпт без переиспользования кэша
Если у сервиса длинный системный промпт (инструкции, few-shot примеры, RAG-документы) и он не кэшируется между запросами, каждый новый запрос заново «отстраивает» одинаковый по объёму кэш для одинакового префикса — рост происходит не по архитектурным причинам, а по недоработке инфраструктуры обслуживания.
Когда рост замедляется или ограничивается
GQA / MQA — меньше KV-голов
Grouped-Query и Multi-Query Attention оставляют много query-голов, но всего несколько (или одну) KV-голову, которые делятся между группами query-голов. Это прямое уменьшение H_kv в формуле — сжатие кэша в 4–8 раз почти без потери качества, поэтому это стандарт для всех современных моделей с длинным контекстом.
Квантизация кэша
Хранение K/V в int8 вместо fp16 сразу даёт двукратное сжатие, int4 — четырёхкратное. Это влияет только на bytes_per_elem в формуле и не требует переобучения модели, поэтому применяется как «бесплатная» оптимизация на этапе обслуживания.
Скользящее окно и attention sinks
Sliding window attention (например, у Mistral) ограничивает внимание последними W токенами — кэш перестаёт расти после заполнения окна, независимо от общей длины диалога. StreamingLLM идёт дальше: держит несколько первых «sink»-токенов плюс скользящее окно, что даёт стабильный кэш даже при бесконечном потоковом диалоге.
Prefix caching и PagedAttention
Если много запросов начинаются с одинакового префикса (системный промпт, общий контекст), сервер вычисляет и хранит его кэш один раз и переиспользует между запросами. PagedAttention (vLLM) не уменьшает общий объём данных, но устраняет фрагментацию памяти, позволяя эффективно разместить намного больше кэшей на том же железе.
Зависимость от предыдущих этапов работы с текстом

Объём KV-кэша в любой момент времени — это не изолированное число, а прямое следствие всего, что произошло с текстом раньше: как он был токенизирован и что уже было сгенерировано или загружено в контекст до текущего шага.

Схема токенизации задаёт S ещё до генерации
Число токенов S в формуле напрямую определяется тем, каким токенизатором был размечен текст. Один и тот же абзац при word-level или byte-level разбиении даст в разы больше токенов, чем при BPE с крупным словарём — а значит и кэш для него будет в разы больше при одинаковом содержании. Выбор токенизатора на этапе предобработки (тот же шаг, что и в пайплайне razdel/spaCy) буквально масштабирует будущий кэш ещё до того, как модель сгенерировала хоть один токен.
Промпт — это уже накопленные «этапы» до генерации
Системная инструкция, найденные RAG-документы и история диалога — это последовательные стадии сборки контекста, и каждая добавляет свою часть в S ещё до prefill. Модель проходит весь этот текст один раз (prefill), сразу заполняя кэш целиком для всего накопленного контекста, а уже потом начинает генерацию токен за токеном.
Кэш на шаге t — функция кэша на шаге t−1
Это ключевая структурная особенность: кэш не пересчитывается заново на каждом шаге, а наращивается поверх предыдущего состояния. Каждый следующий шаг зависит от всей цепочки предыдущих — это невозможно распараллелить по времени, в отличие от обучения, где все позиции последовательности обрабатываются одним параллельным проходом.
cache₀ (промпт)→ cache₁ = cache₀ + K,V(token₁)→ cache₂ = cache₁ + K,V(token₂)→ …
В диалоговых системах это же правило действует и между репликами: если сессия хранит состояние, кэш следующего хода пользователя начинается не с нуля, а с кэша, накопленного за все предыдущие ходы разговора — рост кэша за сессию отражает всю историю обработки текста в этой сессии, а не только последнее сообщение.
Модели на практике: во сколько раз отличается кэш на токен

Ниже — приблизительные оценки объёма кэша на один токен последовательности (fp16, без учёта batch), которые показывают, насколько сильно архитектурные решения меняют картину при одинаковой длине контекста.

Модель (архитектура)СлоёвKV-головКэш / токен128K токенов
Llama-2-7B MHA3232≈ 512 КБ≈ 64 ГБ
Mistral-7B GQA, 8 групп328≈ 128 КБ≈ 16 ГБ
Llama-3-70B GQA, 8 групп808≈ 320 КБ≈ 40 ГБ
Числа — оценка по опубликованным параметрам архитектур для иллюстрации порядка величин, а не точный бенчмарк конкретной реализации или квантизации.

Итоги

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