Генерация и KV-кэш
Почему наивная генерация тратит работу впустую, как KV-кэш делает каждый новый токен дешёвым, сколько этот кэш весит у реальных моделей и почему именно с него начинаются MQA, GQA и MLA.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Модель обучена, теперь ей надо говорить. GPT генерирует авторегрессионноавторегрессионная модель (авторегрессия)EN autoregressive modelГенерирует последовательность по одному элементу, подавая каждый сгенерированный токен обратно на вход. Так работают GPT, Qwen, DeepSeek.:
предсказала токен, приписала его к тексту, снова предсказала. Метод generate из
прошлых уроков на каждом шаге прогоняет через модель всю последовательность заново.
Чтобы написать тысячный токен ответа, модель снова обрабатывает предыдущие 999, хотя для 998 из них уже всё
посчитала на прошлом шаге.
Решение — KV-кэшKV-кэшEN KV cache: хранить ключи и значения прошлых токенов и на каждом шаге считать только новый токен. Без него не работает ни один чат-бот: ни ChatGPT, ни DeepSeek, ни Qwen. Но у кэша есть цена — память. На длинном контексте кэш одного запроса весит столько же, сколько вся модель, а сервер обслуживает сотни запросов. С этой проблемы начинается целая ветка инноваций: MQA, GQA и MLA от DeepSeek (модуль 5.2).
Что пересчитывается зря
Заголовок раздела «Что пересчитывается зря»Вспомним причинное внимание (урок 4.4.2). Токен на позиции смотрит только на позиции . Значит, его векторы на всех слоях зависят только от него и от прошлого. Когда мы приписываем к тексту новый токен, ничего из уже посчитанного не меняется.
Возьмём строку «Мороз и солнце». Наивная генерация делает так:
| Шаг | Что подаём в модель | Что нужно на самом деле |
|---|---|---|
| 1 | «Мороз и солнце» (14 токенов) | всё — это промпт |
| 2 | «Мороз и солнце;» (15) | только «;» |
| 3 | «Мороз и солнце; » (16) | только пробел |
| 100 | 113 токенов | только последний |
Что именно нужно новому токену от прошлых? В формуле внимания запрос — только у нового токена, а от прошлых нужны ключи и значения на каждом слое. Их и кэшируем. Запросы прошлых токенов не нужны никогда, а их выходы уже использованы. Поэтому кэш называется KV, а не QKV.
Сколько стоит генерация с кэшем и без
Заголовок раздела «Сколько стоит генерация с кэшем и без»Посчитаем FLOPs для модели с слоями ширины (параметров в блоках , каждый параметр — 2 FLOPs на токен: умножение и сложение; внимание по позициям — на слой: на и на взвешенную сумму ). LM-голову и эмбеддинги для простоты не считаем.
Без кэша, шаг : прогоняем все токенов.
С кэшем, шаг : только новый токен, но внимание по всем ключам.
Что здесь написано: без кэша стоимость одного нового токена растёт квадратично по длине (из-за внимания) и линейно из-за проекций. С кэшем — линейно, а проекции и MLP вообще не зависят от длины. Сложите по шагам — получите против для всего ответа.
Для GPT-2 small (, ; строка про 4096 токенов условная — настоящий контекст GPT-2 всего 1024):
| Сгенерировать токенов | Без кэша | С кэшем | Во сколько раз |
|---|---|---|---|
| 128 | 1.43 TFLOP | 0.022 TFLOP | 65 |
| 1024 | 102 TFLOP | 0.19 TFLOP | 530 |
| 4096 | 2270 TFLOP | 1.0 TFLOP | 2259 |
Тысяча токенов без кэша — сто триллионов операций. Видеокарта, которая в идеальных условиях выполняет десятки–сотни триллионов операций в секунду, потратила бы на одну арифметику около секунды, а на 4096 токенов — от десятков секунд до минуты. С кэшем та же тысяча токенов — 0.2 TFLOP, доли секунды.
Две фазы генерации: prefill и decode
Заголовок раздела «Две фазы генерации: prefill и decode»С кэшем генерация распадается на две фазы, которые по-разному нагружают железо (подробно — в уроке 7.1.1):
flowchart LR
P["Промпт: 14 токенов"] --> A["Prefill: все 14 позиций одним проходом<br/>с причинной маской"]
A --> C[("KV-кэш: K и V<br/>14 позиций × L слоёв")]
A --> T1["Токен 15"]
T1 --> D["Decode: 1 токен за проход<br/>q нового токена × все K из кэша"]
C --> D
D -->|"дописать k, v в кэш"| C
D --> T2["Токен 16, 17, …"]- prefillprefill (предзаполнение)EN prefillПервая фаза генерации: весь промпт проходит через модель одним параллельным проходом, заполняется KV-кэш и получается первый токен. Упирается в вычисления. — промпт обрабатывается целиком, как при обучении: матрицы, причинная маска, все позиции параллельно. На каждый прочитанный вес приходится много арифметики (тем больше, чем длиннее промпт), и на длинных промптах GPU упирается в вычисления (ограничено вычислениямиограничено вычислениями (compute-bound)EN compute-boundОперация, в которой вычислитель загружен полностью, а память успевает подвозить данные. Ускоряется только более быстрым железом или меньшим числом операций.).
- Decodeдекодирование (фаза decode)EN decode phaseВторая фаза: один токен за один проход модели. На каждый токен заново читаются все (активные) веса и KV-кэш, поэтому фаза упирается в пропускную способность памяти. — по одному токену. Арифметики мало, а прочитать нужно все веса модели и весь кэш. Эта фаза упирается в скорость памяти (ограничено памятьюограничено памятью (memory-bound)EN memory-boundОперация, которая дольше ждёт данные из памяти, чем считает. Ускоряется, если читать меньше байт (квантование, GQA, MLA) или делать больше работы на каждый прочитанный байт (батчинг).), а не в FLOPs.
Такая генерация называется инкрементальным декодированиеминкрементальное декодированиеEN incremental decodingГенерация, при которой на каждом шаге модель обрабатывает только новый токен, а ключи и значения прошлых берёт из KV-кэша..
KV-кэш для одной головы на NumPy
Заголовок раздела «KV-кэш для одной головы на NumPy»Начнём с сути, без PyTorch. Функция получает один новый токен, дописывает его ключ и значение в кэш и считает внимание по всему кэшу. Маска не нужна: в кэше только прошлое и сам токен. Ровно так работала микро-GPT из урока 4.6.1, только там кэш был списком списков.
import numpy as np
def decode_step(x_t, W_q, W_k, W_v, cache): """Один новый токен x_t (вектор длины d): дописать его k, v в кэш и посчитать внимание по всему кэшу.""" q, k, v = x_t @ W_q, x_t @ W_k, x_t @ W_v if "K" in cache: # дописываем в конец по оси времени cache["K"] = np.vstack([cache["K"], k]) # vstack — приставить строку снизу: (t, 4) → (t+1, 4) cache["V"] = np.vstack([cache["V"], v]) else: cache["K"], cache["V"] = k[None, :], v[None, :] # первый токен: вектор → матрица из одной строки (1, 4) scores = cache["K"] @ q / np.sqrt(q.shape[0]) # (t,) — оценки нового запроса со всеми ключами w = np.exp(scores - scores.max()) # устойчивый softmax (числитель) return (w / w.sum()) @ cache["V"] # веса × значения
def full_causal(X, W_q, W_k, W_v): # эталон из урока 4.4: все позиции сразу Q, K, V = X @ W_q, X @ W_k, X @ W_v s = Q @ K.T / np.sqrt(Q.shape[1]) s = np.where(np.tril(np.ones((len(X), len(X)), dtype=bool)), s, -np.inf) w = np.exp(s - s.max(axis=1, keepdims=True)) return (w / w.sum(axis=1, keepdims=True)) @ V
rng = np.random.default_rng(0)X = rng.normal(size=(6, 8)) # 6 токенов, d = 8W_q, W_k, W_v = (rng.normal(size=(8, 4)) for _ in range(3))cache = {}step_by_step = np.stack([decode_step(x, W_q, W_k, W_v, cache) for x in X])print("кэш K:", cache["K"].shape, " совпадает с полным пересчётом:", np.allclose(step_by_step, full_causal(X, W_q, W_k, W_v)))кэш K: (6, 4) совпадает с полным пересчётом: TrueШесть вызовов по одному токену дали ровно те же выходы, что одно полное причинное внимание. Это главный тест любой реализации кэша: с кэшем и без кэша результат одинаковый, отличается только объём работы.
KV-кэш в нашей GPT на PyTorch
Заголовок раздела «KV-кэш в нашей GPT на PyTorch»Добавим кэш к модели из model.py (урок 4.6.2), не меняя её: напишем отдельные функции,
которые используют те же слои. Кэш — список словарей, по одному на блок; в словаре тензоры k и v формы
.
"""kv_generate.py — генерация с KV-кэшем для GPT из model.py и сравнение с наивной генерацией."""import time
import torchimport torch.nn.functional as F
from model import GPT, GPTConfig
def attn_with_cache(attn, x, cache): """CausalSelfAttention из model.py, но с кэшем: cache — словарь слоя ({} в начале).""" B, T, C = x.shape H = attn.n_head q, k, v = attn.c_attn(x).split(C, dim=2) q, k, v = (t.view(B, T, H, C // H).transpose(1, 2) for t in (q, k, v)) # (B, H, T, d_h) if cache: # дописываем новые K, V в конец по оси времени k = torch.cat([cache["k"], k], dim=2) v = torch.cat([cache["v"], v], dim=2) cache["k"], cache["v"] = k, v # prefill (кэш был пуст, T > 1): обычная причинная маска; decode (T == 1): маска не нужна y = F.scaled_dot_product_attention(q, k, v, is_causal=T > 1) return attn.c_proj(y.transpose(1, 2).reshape(B, T, C))
@torch.no_grad()def forward_with_cache(model, idx, caches): past = caches[0]["k"].size(2) if caches[0] else 0 # сколько токенов уже в кэше T = idx.size(1) pos = torch.arange(past, past + T) # позиции продолжают счёт! x = model.wte(idx) + model.wpe(pos) for block, cache in zip(model.blocks, caches): x = x + attn_with_cache(block.attn, block.ln_1(x), cache) x = x + block.mlp(block.ln_2(x)) return model.lm_head(model.ln_f(x))
@torch.no_grad()def generate_cached(model, idx, max_new_tokens): """Жадная генерация с KV-кэшем (не длиннее block_size: дальше позиций нет).""" caches = [{} for _ in model.blocks] logits = forward_with_cache(model, idx, caches) # prefill: весь промпт одним проходом for _ in range(max_new_tokens): next_id = logits[:, -1, :].argmax(dim=-1, keepdim=True) idx = torch.cat([idx, next_id], dim=1) logits = forward_with_cache(model, next_id, caches) # decode: только новый токен return idx
@torch.no_grad()def generate_naive(model, idx, max_new_tokens): """Жадная генерация без кэша: на каждом шаге вся последовательность заново.""" for _ in range(max_new_tokens): logits, _ = model(idx) next_id = logits[:, -1, :].argmax(dim=-1, keepdim=True) idx = torch.cat([idx, next_id], dim=1) return idx
torch.manual_seed(0)model = GPT(GPTConfig(vocab_size=137, block_size=1024, n_layer=4, n_head=4, n_embd=128)).eval()prompt = torch.randint(137, (1, 16))for n_new in (128, 512, 1000): t0 = time.perf_counter() a = generate_naive(model, prompt, n_new) t1 = time.perf_counter() b = generate_cached(model, prompt, n_new) t2 = time.perf_counter() print(f"{n_new:5d} новых токенов: без кэша {t1 - t0:6.2f} с, с кэшем {t2 - t1:5.2f} с, " f"ускорение ×{(t1 - t0) / (t2 - t1):4.1f}, токены совпали: {torch.equal(a, b)}")Наш запуск на одном потоке процессора (добавьте в начало torch.set_num_threads(1), чтобы повторить; модель
случайная: для замера скорости обучать её не нужно, а жадная генерация детерминирована):
128 новых токенов: без кэша 1.45 с, с кэшем 0.46 с, ускорение × 3.1, токены совпали: True 512 новых токенов: без кэша 18.80 с, с кэшем 4.00 с, ускорение × 4.7, токены совпали: True 1000 новых токенов: без кэша 87.99 с, с кэшем 8.89 с, ускорение × 9.9, токены совпали: TrueТокены совпали все до одного, а ускорение растёт с длиной, как и обещала формула. Но по FLOPs для нашей модели
(, , промпт 16 токенов) выигрыш должен быть ×74, ×287 и ×576. Почему на деле всего ×3–10? Модель
крошечная, и время шага с кэшем уходит не на арифметику, а на накладные расходы: вызовы Python, запуск десятков
маленьких операций, чтение весов, torch.cat растущего кэша. Чем больше модель и длиннее текст, тем ближе реальный
выигрыш к теоретическому.
Проверить кэш можно и на обученной модели из урока 4.6.2. Замените в kv_generate.py
блок замера (всё, что идёт после функций) на такой:
ckpt = torch.load("gpt_ru.pt", weights_only=False) # в чекпоинте лежит GPTConfig, поэтому weights_only=Falsemodel = GPT(ckpt["cfg"])model.load_state_dict(ckpt["model"])model.eval()stoi = {ch: i for i, ch in enumerate(ckpt["chars"])}itos = dict(enumerate(ckpt["chars"]))
prompt = torch.tensor([[stoi[c] for c in "Модель "]])n_new = model.cfg.block_size - prompt.size(1) # промпт + новые токены ≤ block_size = 128a = generate_naive(model, prompt, n_new)b = generate_cached(model, prompt, n_new)print("совпали:", torch.equal(a, b))print("".join(itos[i] for i in b[0].tolist()))совпали: TrueМодель по словарь — по словарь — по словами — обучения в словаре — одна по словами в словаре — после нет.После с обучении — посБез weights_only=False свежие версии PyTorch откажутся загружать файл: по умолчанию torch.load читает только
тензоры, а у нас в чекпоинте ещё и объект GPTConfig. Заодно видно знакомое по
уроку 4.1.3 поведение жадной генерации: модель зацикливается на
«по словарь — по словарь». Кэш тут ни при чём, он лишь ускоряет вычисления.
Три ловушки, на которых спотыкаются все
Заголовок раздела «Три ловушки, на которых спотыкаются все»1. Позиции должны продолжать счёт. Новый токен стоит на позиции past, а не 0. Если забыть сдвиг, модель
решит, что каждый новый токен — первый в тексте. Код при этом не падает. У моделей с RoPE то же самое: угол
поворота считается от абсолютной позиции, и без сдвига кэшированные ключи и новый запрос «повёрнуты» несогласованно.
2. is_causal=True при декодировании. Маска в F.scaled_dot_product_attention выравнивается по левому
верхнему углу. При одном запросе и пяти ключах она разрешает смотреть только на первый ключ:
import torchimport torch.nn.functional as F
torch.manual_seed(0)q = torch.randn(1, 1, 1, 8) # один новый токенk, v = torch.randn(1, 1, 5, 8), torch.randn(1, 1, 5, 8) # 5 токенов в кэшеy = F.scaled_dot_product_attention(q, k, v, is_causal=True)print("видит только первый токен:", torch.allclose(y, v[:, :, :1]))видит только первый токен: TrueПоэтому в attn_with_cache стоит is_causal=T > 1: при декодировании одного токена маска не нужна, а prefill мы
делаем только с пустым кэшем. Если промпт приходится дописывать кусками в непустой кэш, маску строят вручную
по правому нижнему углу, как в MiniMind (см. китайский угол ниже).
3. Склеивать по оси времени. У тензора время — ось 2. torch.cat(..., dim=1) молча склеит
головы, и формы перестанут сходиться только на следующем слое — или, хуже, сойдутся.
Сколько весит KV-кэш
Заголовок раздела «Сколько весит KV-кэш»На каждом слое для каждого токена храним ключ и значение каждой головы:
- — ключи и значения;
- — число слоёв;
- — число KV-голов (в классическом многоголовое вниманиемногоголовое вниманиеEN multi-head attention (MHA) равно числу голов );
- — размер головы;
- — длина контекста, — число одновременных запросов (батч);
- байт на число: 2 в FP16/BF16, 1 в FP8.
Пример, GPT-2 small: байт = 36 КиБ на токен. На полном контексте 1024 токена — 36 МиБ. Немного. Но посмотрите, что происходит с ростом модели:
| Модель | Кэш на токен (BF16) | Пример | ||
|---|---|---|---|---|
| GPT-2 small (2019) | 12 | 12 × 64 | 36 КиБ | 1024 токена — 36 МиБ |
| GPT-3 175B (2020) | 96 | 96 × 128 | 4.5 МиБ | 2048 токенов — 9 ГиБ |
| Llama 2 7B (MHA) | 32 | 32 × 128 | 512 КиБ | 4096 токенов × 32 запроса — 64 ГиБ |
| Llama 3 8B (GQA) | 32 | 8 × 128 | 128 КиБ | 8192 токена — ровно 1 ГиБ |
| Qwen2.5-7B (GQA) | 28 | 4 × 128 | 56 КиБ | 32K токенов — 1.75 ГиБ |
| DeepSeek-V3 (MLA) | 61 | латент 512 + 64 | ≈ 69 КиБ | при 128 головах и 671B параметров |
Строка про Llama 2 7B — главный урок. Веса модели в FP16 занимают около 13.5 ГБ. А сервер, который держит 32 разговора по 4096 токенов, тратит на кэш 64 ГиБ (≈ 69 ГБ) — примерно в пять раз больше, чем на саму модель. Поэтому при обслуживании LLM узкое место — не веса, а кэш: он определяет, сколько пользователей влезет на одну видеокарту и какой длины контекст им можно дать.
Поиграйте с калькулятором: выберите модель, переключите тип внимания и длину контекста.
Как уменьшить кэш
Заголовок раздела «Как уменьшить кэш»Каждый множитель в формуле — рычаг. Индустрия потянула за все:
| Рычаг | Идея | Где | Подробно |
|---|---|---|---|
| меньше KV-голов: одна на все головы (MQAмногозапросное внимание (MQA)EN multi-query attention (MQA)Все головы запросов делят одну голову ключей и одну голову значений. KV-кэш меньше в H раз, но качество заметно падает (Shazeer, 2019).) или одна на группу (GQAвнимание с группами запросов (GQA)EN grouped-query attention (GQA)Головы запросов делятся на G групп, у каждой группы своя пара K/V. G = H — это MHA, G = 1 — MQA. Стандарт Llama 3 и Qwen (Ainslie и др., 2023).) | Llama 3, Qwen2.5/3 | урок 5.2.1 | |
| сжать K и V всех голов в один латентный вектор (MLAмногоголовое латентное внимание (MLA)EN multi-head latent attention (MLA)Предложено DeepSeek в DeepSeek-V2 (2024).) | DeepSeek-V2/V3, Kimi K2 | урок 5.2.2 | |
| байт | хранить кэш в FP8 вместо BF16 | vLLM, SGLang | модуль 7.2 |
| смотреть не на все токены: скользящее окно хранит только последние токенов, разреженное внимание читает только выбранные | MiMo-V2-Flash (окно 128 в пяти слоях из шести), DeepSeek-V3.2 | урок 5.5.2 | |
| фрагментация | выделять память под кэш страницами, как ОС (PagedAttentionPagedAttention (страничный KV-кэш)EN PagedAttentionKV-кэш хранится блоками фиксированного размера (например, по 16 токенов), а таблица блоков связывает логические позиции с физическими — как страницы виртуальной памяти в ОС. Основа vLLM.) | vLLM | урок 7.1.2 |
У DeepSeek-V3 128 голов по 128 измерений. В классическом MHA кэш на токен был бы байт ≈ 3.8 МиБ, а MLA хранит только латент на 512 чисел и общий RoPE-ключ на 64: байта, в 57 раз меньше. Как это сделано без потери качества — центральная тема модуля 5.2.
Практика
Заголовок раздела «Практика»Задание в браузере
Заголовок раздела «Задание в браузере»Реализуйте шаг декодирования с кэшем и три функции для расчёта памяти: размер кэша, байты на токен по
config.json реальной модели и максимальный контекст, который влезет в свободную память. Тесты сверят пошаговую
генерацию с полным причинным вниманием и посчитают кэш GPT-2, Llama 3 8B, Qwen2.5-7B, Qwen3-8B и Qwen3-0.6B.
import numpy as np
# ── Часть 1. Шаг декодирования с KV-кэшем (одна голова) ─────────────────────────
def decode_step(x_t, W_q, W_k, W_v, cache):
"""Внимание для ОДНОГО нового токена с KV-кэшем.
x_t — вектор нового токена формы (d,); W_q, W_k, W_v — матрицы (d, d_h).
cache — словарь; пустой {} в начале, затем cache["K"] и cache["V"] формы (t, d_h).
1) q, k, v = x_t @ W_q, x_t @ W_k, x_t @ W_v
2) допишите k и v в конец cache["K"] и cache["V"] (np.vstack); для первого токена — k[None, :]
3) оценки = cache["K"] @ q / sqrt(d_h); устойчивый softmax; верните веса @ cache["V"] → форма (d_h,)
Маска не нужна: в кэше только прошлые токены и текущий.
"""
# TODO: ваш код
raise NotImplementedError("decode_step")
# ── Часть 2. Сколько весит KV-кэш ───────────────────────────────────────────────
def kv_cache_bytes(n_layers, n_kv_heads, head_dim, seq_len, batch=1, bytes_per_elem=2):
"""Размер KV-кэша в байтах: 2 (K и V) · слои · KV-головы · размер головы · длина · батч · байт на число."""
# TODO: ваш код
raise NotImplementedError("kv_cache_bytes")
def kv_bytes_per_token(config, bytes_per_elem=2):
"""Байт KV-кэша на один токен по конфигу в стиле Hugging Face (config.json).
Обязательные ключи: num_hidden_layers, num_attention_heads, hidden_size.
Необязательные: num_key_value_heads (если нет — это MHA: столько же, сколько голов запросов),
head_dim (если нет — hidden_size // num_attention_heads).
"""
# TODO: ваш код (используйте kv_cache_bytes с seq_len=1, batch=1)
raise NotImplementedError("kv_bytes_per_token")
def max_context(free_bytes, config, batch=1, bytes_per_elem=2):
"""Сколько токенов на КАЖДЫЙ из batch запросов поместится в free_bytes байт свободной памяти.
Целое число (округление вниз, //).
"""
# TODO: ваш код
raise NotImplementedError("max_context")
# ── Проверка: кэш против полного пересчёта и размеры кэша реальных моделей ─────────
try:
rng = np.random.default_rng(0)
d, d_h, T = 8, 4, 5
X = rng.normal(size=(T, d))
W_q, W_k, W_v = (rng.normal(size=(d, d_h)) for _ in range(3))
cache = {}
outs = np.stack([decode_step(X[t], W_q, W_k, W_v, cache) for t in range(T)])
print("выход по шагам:", outs.shape, " кэш K:", cache["K"].shape)
MODELS = {
"GPT-2 small": {"num_hidden_layers": 12, "num_attention_heads": 12, "hidden_size": 768},
"Llama 2 7B (MHA)": {"num_hidden_layers": 32, "num_attention_heads": 32, "hidden_size": 4096},
"Llama 3 8B (GQA)": {"num_hidden_layers": 32, "num_attention_heads": 32, "num_key_value_heads": 8,
"hidden_size": 4096},
"Qwen2.5-7B (GQA)": {"num_hidden_layers": 28, "num_attention_heads": 28, "num_key_value_heads": 4,
"hidden_size": 3584},
"Qwen3-0.6B (GQA)": {"num_hidden_layers": 28, "num_attention_heads": 16, "num_key_value_heads": 8,
"hidden_size": 1024, "head_dim": 128}, # head_dim ≠ 1024 // 16!
}
for name, cfg in MODELS.items():
per_tok = kv_bytes_per_token(cfg)
print(f"{name:17s} {per_tok / 1024:4.0f} КиБ на токен, {per_tok * 1024 / 2**20:4.0f} МиБ на 1024 токена")
except NotImplementedError as e:
print("Сначала допишите функцию:", e)
Обратите внимание на конфиги Qwen3 в тестах: в них есть явный head_dim. У Qwen3-8B он совпадает
с , а у Qwen3-0.6B — нет: , но head_dim равен 128.
Посчитаете по формуле «ширина / число голов» — ошибётесь с кэшем вдвое. Читая чужой config.json, всегда
проверяйте, нет ли там head_dim и num_key_value_heads.
Заодно сравните ответы: крошечная Qwen3-0.6B хранит 112 КиБ на токен — вдвое больше, чем Qwen2.5-7B (56 КиБ).
Размер кэша зависит не от числа параметров, а от слоёв, KV-голов и head_dim.
Проверь себя
Заголовок раздела «Проверь себя»- Из-за причинной маски ключи и значения прошлых токенов не меняются. KV-кэш хранит их, и новый токен стоит на внимание вместо полного пересчёта.
- Генерация делится на prefill (весь промпт одним проходом) и decode (по токену, упирается в память).
- Правильный кэш даёт точно те же токены, что и наивная генерация. Ловушки: позиции продолжают счёт,
is_causalпри декодировании, склейка по оси времени. - Кэш весит байт. На длинном контексте и большом батче он больше самой модели.
- MQA, GQA, MLA, FP8-кэш, скользящее окно и PagedAttention — рычаги этой формулы.
Дальше: перед ступенью 5 соберём в одну карту инструменты, которыми пользуются практики «На вашей машине»: Hugging Face, accelerate и torchrun, серверы инференса, терминальные помощники и типичные ошибки — Инструменты LLM-инженера.