跳转至
大模型原理手册 推理原理 下载 notebook

KV Cache 与两阶段推理

KV Cache 是大模型推理中最重要的一个概念:它把生成的计算量从平方级降到线性级,同时带来了推理系统里几乎所有的显存管理问题。理解了它,也就理解了推理为什么分成 prefill 和 decode 两个性质完全不同的阶段,以及批处理为什么能大幅提升吞吐。

自测:能答上来就可以跳过本章

  1. 为什么历史 token 的 K、V 可以缓存复用?Q 为什么不需要缓存?
  2. 不用 KV Cache 生成 n 个 token,总共要处理多少个 token?用了之后呢?
  3. prefill 和 decode 的算术强度分别是多少?各自是什么瓶颈?
  4. 为什么把 8 个请求放在一起 decode,耗时远小于单独 decode 8 次?
  5. PagedAttention 解决的是 KV Cache 的什么问题?
自测参考答案(先自己答,再展开对照)
  1. 因果注意力里,历史 token 的 K、V 只依赖它自己和更早的 token,后面加入新 token 不会改变它们;Q 只在计算当前 token 的输出时用一次,之后不再需要。
  2. 不用缓存时,生成第 t 个 token 要重新处理 t 个 token,n 个 token 共约 \(n^2/2\) 个;用了缓存每步只处理 1 个新 token,共约 n 个(加上一次提示词的 prefill)。
  3. prefill 一次处理成百上千个 token,算术强度约等于 token 数,是计算瓶颈;decode 每个请求每步 1 个 token,强度约等于 batch 大小,远低于屋脊点,是访存瓶颈。
  4. decode 的时间主要花在读权重上,8 个请求一起 decode 时权重只读一遍、同时算 8 个 token,耗时增加很少(本章实测只多 60%),吞吐约为原来的 5 倍。
  5. KV Cache 的显存碎片和浪费:按最大长度连续预留会有大量内部和外部碎片;分页后按块按需分配,还能在请求之间按块共享前缀。

先看一个六格小剧场,再读正文:

漫画:为什么要有 KV Cache

为什么 K、V 可以缓存

生成第 t+1 个 token 时,模型要对前 t 个 token 做完整的前向计算吗?看因果注意力的结构:位置 i 的输出只依赖位置 ≤ i 的输入。所以追加一个新 token 时,所有历史位置在每一层的隐藏状态都不会改变,它们的 K、V 自然也不变。新 token 只需要:

  1. 计算自己的 Q、K、V;
  2. 把自己的 K、V 追加到缓存里;
  3. 用自己的 Q 和缓存中全部的 K、V 做注意力。

历史 token 的 Q 以后再也用不到(只有最新 token 的 query 参与计算),所以只缓存 K 和 V。这就是 KV Cache。

不用缓存时,生成第 t 个 token 要重新处理 t 个 token,生成 n 个 token 共处理约 \(n^2/2\) 个;用了缓存,每步只处理 1 个新 token。

验证:带缓存的逐步计算等于完整重算

这是检验推理实现正确性最常用的方法:

import time
import torch
from transformers import AutoTokenizer
from mini_llm import KVCache, Transformer

path = "models/Qwen3-0.6B"
tok = AutoTokenizer.from_pretrained(path)
model = Transformer.from_pretrained(path)
ids = tok("KV Cache 是大模型推理中最重要的概念之一,因为", return_tensors="pt").input_ids

with torch.no_grad():
    full = model(ids)                                   # 一次性处理整个序列
    cache = KVCache(model.cfg.num_hidden_layers)
    step_logits = [model(ids[:, :5], cache)]            # 先 prefill 前 5 个 token
    for t in range(5, ids.shape[1]):                    # 再逐个 decode 剩下的 token
        step_logits.append(model(ids[:, t:t + 1], cache))
    incremental = torch.cat(step_logits, dim=1)

diff = (full - incremental).abs().max().item()
print(f"逐步计算 vs 完整重算,logits 最大差异 {diff:.1e};缓存长度 {cache.length},K 的形状 {tuple(cache.k[0].shape)}")
assert diff < 1e-3 and cache.length == ids.shape[1]

缓存中每一层的 K 形状为 [B, n_kv, S, d_h] = [1, 8, S, 128]。

prefill 与 decode

有了 KV Cache,一次生成请求分成两个阶段:

prefill:提示词的 T 个 token 一次算完 t1 t2 t3 t4 t5 t6 Q、K、V:T 行,矩阵乘(算力受限) K,V K,V K,V K,V K,V K,V 全部 K、V 写入 KV Cache decode:每步只有 1 个新 token t7 q 只有 1 行:矩阵 × 向量(访存受限) K,V K,V K,V K,V K,V K,V 新 读出全部历史 K、V,追加 1 行 每个 token 的 KV = 2 × 层数 × KV 头数 × 头维 × 字节数;随上下文和并发线性增长,决定能同时服务多少请求
图:prefill 与 decode
prefill decode
输入 整个提示词(T 个 token) 每步 1 个新 token
做什么 计算所有提示词 token 的 K、V 写入缓存,输出第一个新 token 读取缓存,生成下一个 token,追加它的 K、V
每读一次权重参与计算的 token 数 T batch 中的请求数
瓶颈 计算(T 较大时) 显存带宽(读权重、读 KV Cache)
对应的延迟指标 TTFT(首 token 延迟) TPOT / ITL(每个输出 token 的延迟)

算术强度的差异是理解一切的关键。一个 BF16 线性层,每个参数 2 字节,每个 token 用它做 2 次运算:

  • decode(batch = 1):每读 2 字节做 2 次运算,算术强度约 1 FLOP/Byte;
  • prefill(T 个 token):每读 2 字节做 2T 次运算,算术强度约 T FLOP/Byte。

H100 的脊点约为 989 TFLOPS / 3.35 TB/s ≈ 295 FLOP/Byte。所以 prefill 几百个 token 就能把 GPU 算满,而 decode 离算满差了两个数量级:GPU 的大部分算力在 decode 时是闲置的,时间都花在从显存读权重和 KV Cache 上。

在 CPU 上也能观察到同样的规律(CPU 同样有"内存带宽 vs 算力"的平衡问题):

torch.set_num_threads(16)
prompt = tok("介绍一下大模型推理中的 prefill 和 decode 两个阶段。" * 8, return_tensors="pt").input_ids

@torch.no_grad()
def timed_generate(input_ids, n_new=20):
    cache = KVCache(model.cfg.num_hidden_layers)
    t0 = time.perf_counter()
    logits = model(input_ids, cache)
    t_prefill = time.perf_counter() - t0
    nxt = logits[:, -1].argmax(-1)
    t0 = time.perf_counter()
    for _ in range(n_new):
        nxt = model(nxt[:, None], cache)[:, -1].argmax(-1)
    return t_prefill, (time.perf_counter() - t0) / n_new

timed_generate(prompt[:, :8], n_new=2)                   # 预热
t_pre, t_dec = timed_generate(prompt)
T = prompt.shape[1]
print(f"prefill {T} 个 token: {t_pre * 1000:.0f} ms(每个 token {t_pre / T * 1000:.1f} ms)")
print(f"decode: 每个 token {t_dec * 1000:.1f} ms")

在本手册的环境里运行得到(数值因机器而异,重要的是比例):

prefill 104 个 token: 179 ms(每个 token 1.7 ms)
decode: 每个 token 45.5 ms

prefill 平均到每个 token 只要 1.7 ms,decode 生成一个 token 却要 45.5 ms,相差约 26 倍:同样读一遍权重,prefill 让一百多个 token 分摊了这次读取,decode 只有一个。

批处理:让多个请求分摊权重读取

既然 decode 的瓶颈在于"读一次权重只算一个 token",那就让多个请求一起 decode:权重读一次,同时为 B 个请求各算一个 token。只要还没到计算瓶颈,耗时几乎不随 B 增长,吞吐量却提高了 B 倍:

@torch.no_grad()
def decode_step_time(batch, ctx_len=64, steps=10):
    ids = torch.randint(0, 150000, (batch, ctx_len))
    cache = KVCache(model.cfg.num_hidden_layers)
    model(ids, cache)
    nxt = torch.randint(0, 150000, (batch,))
    t0 = time.perf_counter()
    for _ in range(steps):
        nxt = model(nxt[:, None], cache)[:, -1].argmax(-1)
    return (time.perf_counter() - t0) / steps

decode_step_time(2, steps=2)                              # 预热
t1, t8 = decode_step_time(1), decode_step_time(8)
print(f"batch=1: 每步 {t1 * 1000:.1f} ms;batch=8: 每步 {t8 * 1000:.1f} ms({t8 / t1:.1f} 倍的时间,8 倍的 token)")

在本手册的环境里:

batch=1: 每步 45.4 ms;batch=8: 每步 74.8 ms(1.6 倍的时间,8 倍的 token)

8 个请求一起 decode,每一步只多花了 60% 的时间,吞吐却提高到约 5 倍。这就是推理服务追求大 batch 的根本原因。但 batch 不能无限增大:每个请求都有自己的 KV Cache,显存会先耗尽;batch 越大,每个请求的 decode 延迟也会上升。推理服务的调度,本质上是在吞吐和延迟之间找平衡,见推理服务。

推理视角

decode 时,除了权重,KV Cache 也要每步完整地读一遍。权重是所有请求共享的,批处理可以分摊;KV Cache 却是每个请求私有的,无法分摊。上下文越长、batch 越大,读 KV Cache 的时间占比越高,最终可能超过读权重的时间。这就是长上下文推理慢的原因,也是 GQA、MLA、KV Cache 量化如此重要的原因(见注意力变体)。

把这笔账放到具体的模型和卡上算一算:选一个模型、一张卡和平均上下文长度,看 KV 能放下多少个请求,满载时 decode 一步至少要多久。

几个值得试的组合:LLaMA-3-8B 和 Qwen2.5-7B 参数量差不多,但 KV 头数差一倍、层数也多 4 层,每个 token 的 KV 差了一倍多,能服务的请求数也差这么多;把 KV 换成 FP8,请求数翻倍;DeepSeek-V3 的 FP8 权重在 8 张 H100 上放不下、8 张 H200 可以,而且 MLA 的缓存每张卡都要存一份。

KV Cache 的显存管理

每个 token 的 KV Cache 大小是固定的(Qwen3-0.6B 为 2 × 28 × 8 × 128 × 2 字节 = 112 KB,BF16),但一个请求最终会生成多少 token 是事先不知道的。朴素的做法是按最大长度为每个请求预留一块连续的显存,问题很大:

  • 内部碎片:预留了 32K,实际只用了 500,其余全浪费;
  • 外部碎片:请求来来去去,显存被切成大小不一的空洞;
  • 无法共享:多个请求有相同的前缀(系统提示),却要各存一份。

vLLM 提出的 PagedAttention 借鉴了操作系统的虚拟内存:把 KV Cache 切成固定大小的块(比如每块 16 个 token),每个请求用一张块表记录它的逻辑块存放在哪些物理块中,按需分配、用完回收;相同的前缀可以映射到相同的物理块(写时复制)。显存浪费从 60%-80% 降到不足 4%(数据来自 vLLM 论文),同样的显存能容纳多得多的并发请求。代价是注意力 kernel 需要通过块表间接寻址,参见 CUDA 手册中的分页 decode attention。

在此基础上,还有这些常见的 KV Cache 管理手段:

手段 做什么
前缀缓存(prefix caching) 请求结束后不立即释放 KV,按内容哈希(vLLM)或基数树(SGLang 的 RadixAttention)索引,新请求命中相同前缀就直接复用
抢占(preemption) 显存不够时暂停一些请求,丢弃它们的 KV(之后重新计算)或换出到 CPU 内存
KV 卸载 把不常用的 KV 放到 CPU 内存或 SSD(LMCache、Mooncake 等),需要时再拉回来
KV 量化 FP8 或更低精度存储 KV,容量翻倍、读取量减半

怎么讲清楚

按"为什么能缓存 → 省了什么 → 带来什么问题"讲:因果注意力下历史 token 的 K、V 不会变,Q 只用当前的;缓存把生成 n 个 token 的计算从平方级降到线性级。代价是显存:每 token 的 KV = 2 × 层数 × KV 头数 × 头维 × 字节数(Qwen3-0.6B 为 112 KB),它决定并发和上下文上限。prefill 计算密集(决定 TTFT),decode 访存密集(决定 TPOT);批处理能分摊权重的读取(本章 8 个请求一起 decode 只多花 60% 的时间),却分摊不了各自的 KV。最后提 PagedAttention、前缀缓存、KV 量化。

练习

1. 计算题。 生成 1000 个 token(提示词 100 个 token),不用 KV Cache 和使用 KV Cache,分别需要处理多少个 token 的前向计算?

参考答案

不用缓存:第 i 步处理 100 + i − 1 个 token,总共 \(\sum_{i=1}^{1000}(99 + i) = 99 \times 1000 + 500500 = 599500\) 个。使用缓存:prefill 100 个,之后 999 步各 1 个(最后一个 token 生成后不需要再计算),共 1099 个,约为前者的 1/545。

assert sum(99 + i for i in range(1, 1001)) == 599_500

2. 估算题。 在 H100(3.35 TB/s)上用 BF16 部署 LLaMA-3-8B(权重约 16 GB,KV Cache 128 KB/token)。batch = 32、每个请求的上下文长度都是 4096 时,decode 每一步至少要读多少数据?延迟下限是多少?此时读 KV 占多大比例?

参考答案

KV Cache:32 × 4096 × 128 KB = 16 GB,和权重一样大。每步至少读 32 GB,延迟下限约 32 GB / 3.35 TB/s ≈ 10 ms,读 KV 占一半。如果上下文增加到 16K,KV 就是权重的 4 倍。这说明长上下文、大 batch 的场景下,KV Cache 的读取才是 decode 的主要开销。

kv = 32 * 4096 * 128 * 1024
weights = 16 * 2**30
print(f"{(kv + weights) / 3.35e12 * 1000:.1f} ms, KV 占比 {kv / (kv + weights):.0%}")

小结

  • 因果注意力保证历史 token 的 K、V 不变,所以可以缓存;Q 不需要缓存。
  • KV Cache 把生成的计算量从平方级降到线性级;用"逐步计算 = 完整重算"验证实现。
  • prefill 计算密集(TTFT),decode 访存密集(TPOT);decode 时 GPU 算力大量闲置。
  • 批处理让多个请求分摊权重读取,是提高吞吐的根本手段;KV Cache 无法分摊。
  • PagedAttention 用块表管理 KV Cache,消除碎片并支持前缀共享;另有前缀缓存、抢占、卸载、量化等手段。

本章练习题(2 道)

在浏览器里直接写代码、跑测试;也可以在本地用 python practice/judge.py 判题。

发现错误或有更好的写法?在 GitHub 上编辑此页提交问题