2.1 KV Cache 与 PagedAttention 原理 本节摘要:大语言模型逐 token 生成时,每生成一个新 token 都要对"之前所有 token"算 attention。如果每次都重算历史 token 的 Key 和 Value 向量,计算量随序列长度平方增长,根本无法实时。KV Cache 把每层的 K、V 缓存下来,让 decode 阶段只算新 token 的贡献,这是流式 LLM 能跑起来的前提。但 KV Cache 带来了严重的显存碎片——不同请求长度不一,连续分配导致大量显存被浪费。PagedAttention 借鉴操作系统的虚拟内存分页机制,把 KV Cache 切成固定大小的块按需分配,把显存利用率从不到 40% 拉到 90% 以上。
本节摘要:大语言模型逐 token 生成时,每生成一个新 token 都要对"之前所有 token"算 attention。如果每次都重算历史 token 的 Key 和 Value 向量,计算量随序列长度平方增长,根本无法实时。KV Cache 把每层的 K、V 缓存下来,让 decode 阶段只算新 token 的贡献,这是流式 LLM 能跑起来的前提。但 KV Cache 带来了严重的显存碎片——不同请求长度不一,连续分配导致大量显存被浪费。PagedAttention 借鉴操作系统的虚拟内存分页机制,把 KV Cache 切成固定大小的块按需分配,把显存利用率从不到 40% 拉到 90% 以上。
阅读完本节,你应当能够:
想象你在生成一段 1000 token 的文字。生成第 1000 个 token 时,attention 机制要求新 token 和前面 999 个 token 都算一遍相关性。如果不做任何缓存,生成这 1000 个 token 的总计算量是 1+2+3+...+1000 ≈ 50 万次 attention 计算——其中绝大部分是重复的,因为前 999 个 token 的 Key 和 Value 向量在生成每个新 token 时都一样。
这就是 KV Cache 要解决的问题。它的直觉很简单:既然历史 token 的 K、V 向量在生成后续 token 时不变,那就把它们存起来,每生成新 token 只算新 token 的 K、V,再和缓存里所有的 K、V 做 attention。这样 decode 阶段每个 token 的计算量变成常数级,和序列长度无关。
但天下没有免费的午餐。缓存是要占显存的,而且占得不少。一个 70B 参数的模型,上下文长度 4096,batch 大小 32,KV Cache 能吃掉上百 GB 显存——比模型权重本身还大。更麻烦的是,不同请求的序列长度各不相同,有的生成 50 token 就结束,有的要生成 2000 token,你怎么给这些长短不一的缓存分配显存?这就是 PagedAttention 要解决的碎片化难题。
先快速过一下 attention 的基础。对于序列里的每个 token,模型会算出三个向量:Query(Q,我在找什么)、Key(K,我是什么)、Value(V,我携带的信息)。新 token 拿自己的 Q 去和所有历史 token 的 K 算相关度,再用相关度对历史 token 的 V 加权求和,得到新 token 的表示。
关键在于:历史 token 的 K、V 不依赖新 token。一旦某个 token 经过某一层算出了 K、V,这俩向量在整个生成过程中都不会变。所以可以缓存。而 Q 是新 token 独有的,每个新 token 都要重算自己的 Q。
这是做容量规划必须会的估算。对于单个请求,KV Cache 占的显存大致是:
KV显存 ≈ 2 × 层数 × 序列长度 × 隐藏维度 × batch × 每元素字节数
其中的"2"是因为要同时存 K 和 V。举例:一个 32 层、隐藏维度 4096 的模型,batch=8,序列长度 2048,用 FP16(每元素 2 字节):
2 × 32 × 2048 × 4096 × 8 × 2 字节 ≈ 8.6 GB
这只是 KV Cache,还没算模型权重。如果序列长度翻到 8192,KV Cache 就要 34 GB。所以长上下文场景下,KV Cache 往往比模型权重还吃显存,是真正的瓶颈。
| 模型规模 | 上下文长度 | batch | KV Cache 显存(FP16) |
|---|---|---|---|
| 7B(32层4096维) | 2048 | 8 | ~8.6 GB |
| 7B | 8192 | 8 | ~34 GB |
| 70B(80层8192维) | 4096 | 4 | ~108 GB |
| 70B | 8192 | 4 | ~216 GB |
💡 关键直觉:做大模型推理容量规划时,别只看模型权重。70B 模型权重约 140 GB(FP16),但加上 KV Cache 总显存轻松破 300 GB。这就是为什么长上下文场景必须做多卡张量并行——单卡根本装不下。
传统的做法是把一个请求的整个 KV Cache 连续分配在一块显存里。这看起来自然,但会带来严重的碎片化,主要两类:
内部碎片:你预分配了 max_length 的空间(比如 2048 token 的 KV Cache),但这个请求只生成了 100 token 就结束了,剩下 1948 token 的空间全浪费。统计显示这种方式下显存利用率经常低于 40%。
外部碎片:请求 A 占了 0–2000 地址,请求 B 占了 2000–4000,A 结束释放了 0–2000。现在来了个需要 2500 连续空间的请求 C,虽然总空闲有 2000+若干其他碎片,但没有一块连续的 2500,C 只能等。这和操作系统早期的内存分配问题一模一样。
后果是:明明显存还剩很多,但因为碎片化装不下新请求,batch 上不去,吞吐被严重压制。
PagedAttention 的思路直接来自操作系统的虚拟内存分页。物理显存被切成固定大小的块(block,比如每块存 16 个 token 的 KV),一个请求的 KV Cache 不需要连续,而是由若干块拼成,用一个页表(block table)记录逻辑位置到物理块的映射。
这样做的好处是:请求 A 的 KV Cache 散布在不连续的物理块里,但通过页表能正确访问;某个请求结束,它占的块立刻归还块池,能被新请求复用;请求生成的 token 数正好按块分配,几乎没有内部碎片(最多浪费不到一块)。显存利用率从 40% 提升到 90%+。
更重要的是,这种机制让连续批处理(下一节讲)成为可能——请求可以在 token 级别动态进出 batch,因为每个请求的 KV Cache 都独立分块管理,加一个新请求只是从块池领几块,移除一个请求只是归还它的块。
⚠️ 常见坑:PagedAttention 的块大小(block size)要选对。块太大(比如 256)内部碎片又回来了;块太小(比如 1)页表本身占显存,且 attention 访问时的随机访存增加,拖慢速度。实践中 16 左右是常见选择,具体要结合模型和硬件测。
有些场景下,多个请求共享相同的 prompt 前缀。最典型的是系统提示——成千上万个对话用同一段几千 token 的系统提示开头。如果每个请求都独立算并缓存这段前缀的 KV,既浪费算力又浪费显存。
PagedAttention 的分块机制天然支持前缀共享:相同的 prompt 前缀只算一次、占一份物理块,多个请求的页表都指向这些块(只读共享)。这能省下大量 prefill 计算和显存。vLLM 的 automatic prefix caching 就是做这个的。
# 概念性示意:前缀共享的逻辑 class PrefixCache: def __init__(self): self.cache = {} # 前缀hash -> 物理块列表 def get_or_compute(self, prompt_tokens, model): # 找最长可复用前缀 for split in range(len(prompt_tokens), 0, -1): prefix = tuple(prompt_tokens[:split]) h = hash_tokens(prefix) if h in self.cache: # 复用已有块,只算新增部分 new_tokens = prompt_tokens[split:] new_blocks = model.prefill(new_tokens, prev=self.cache[h]) blocks = self.cache[h] + new_blocks self.cache[hash_tokens(prompt_tokens)] = blocks return blocks # 完全miss,全算 blocks = model.prefill(prompt_tokens) self.cache[hash_tokens(prompt_tokens)] = blocks return blocks
一块 GPU 的显存要在三件事之间分配:模型权重、KV Cache 池、激活值和中间缓冲。权重是固定的,激活值随 batch 变,KV Cache 池是你能调的旋钮。
策略是:先预留权重的空间,再留出激活值的余量(通常是权重的 10–20%),剩下的全给 KV Cache 池。池子越大,能同时服务的请求越多,吞吐越高。但要注意别把池子撑到让激活值溢出——那样训练时会 OOM。
| 显存部分 | 占比(典型) | 说明 |
|---|---|---|
| 模型权重 | 40–60% | 固定,FP16 或量化后更小 |
| KV Cache 池 | 30–50% | 可调,越大吞吐越高 |
| 激活值与缓冲 | 5–15% | 随 batch 和序列长度变 |
KV Cache 太占显存时,可以量化压缩。把 FP16 的 K、V 量化到 INT8 甚至 INT4,显存直接砍半或砍到四分之一。代价是精度损失——attention 相关度计算会有误差,可能影响生成质量。实践中 INT8 量化对主流模型质量影响很小,INT4 要谨慎测试。
这个方向最近很热,因为长上下文(128K、1M token)场景下 KV Cache 是绝对的瓶颈,不压根本装不下。
💡 关键直觉:选量化精度别只看"平均质量下降多少"。要看长序列表现——很多量化方案在短序列没问题,到长序列因为误差累积质量骤降。测的时候一定要用你的真实长上下文用例。
下一节讲 PagedAttention 这个底座上怎么进一步提升吞吐和降低延迟——连续批处理让请求 token 级动态进出,推测解码用小模型猜大模型验。
PagedAttention 的论文标题里就带着" Efficient Memory Management",但真正值得咀嚼的是它的思想谱系。1961 年 Atlas 计算机首次实现虚拟内存分页时,面对的问题和 2023 年的 GPU 推理几乎同构:物理内存昂贵且容量有限,程序申请的地址空间大小不一,连续分配要么浪费(按最大需求预留)要么搁浅(剩余空间不连续)。操作系统的答案是切断"逻辑连续"与"物理连续"的绑定——地址空间切成页,物理内存切成帧,页表记录映射,逻辑上相邻的两页在物理上可以天各一方。GPU 推理的对应物是:请求的逻辑 KV 序列按块切分,显存按块分配,块表记录每个请求的逻辑块到物理块的映射,attention kernel 改造成按块遍历。
这次迁移里真正困难的不是数据结构,而是 kernel。传统 attention kernel 假设 K、V 在显存里连续存放,可以直接做大规模合并访存;分页之后地址不再连续,朴素实现会让访存退化为零散小块,性能反而下降。vLLM 的解法是重写 CUDA kernel,把块表加载进寄存器后手动重排访存模式,让分页带来的额外开销控制在几个百分点内——这也是为什么"分页"这个想法人人能想到,做出来快的引擎只有少数。
顺着这条线还能理解几个衍生设计。copy-on-write 对应多请求共享系统提示时的策略:两个请求共用前缀块,直到生成内容分叉才真正复制块。前缀换出(swap)对应内存压力下的页面交换:显存不够时把部分请求的 KV 块换到 CPU 内存,decode 暂停,显存空出来接新请求,压力缓解后再换回。这些机制没有一项是新发明,全部是操作系统教科书里的老朋友换了舞台。

给一个可以直接套用的估算例子。70B 模型、FP16 权重约占 140 GB,需要多卡张量并行;每层 KV Cache 大小为 2(K 和 V)× 序列长度 × 隐藏维度 × kv 头数占隐藏头数的比例 × 2 字节。以 GQA(分组查询注意力)优化后的典型配置算,单请求 4K 上下文的 KV 约 0.5 GB,8K 约 1 GB。一块 80 GB 的 A100 减去权重分摊和激活,留给 KV 的空间往往只有几个 GB——这直接决定了你能同时服务多少并发请求,也是为什么长上下文服务的并发容量会断崖式下降。把这套数字代入你的容量规划,比"感觉够用"可靠得多。
三个高频问答。问:块大小设多少合适?答:默认 16 在多数负载下接近最优;请求普遍很短(几百 token)可以降到 8,减少尾部浪费;请求普遍超长再考虑加大,但访存局部性会变差。问:prefix caching 什么时候收益最大?答:系统提示长且重复率高的时候——客服机器人的 2000 token 固定提示被上千请求共享,命中时 prefill 几乎归零;反之提示各不相同的话,缓存索引本身反成开销。问:显存 OOM 该先动什么?答:先降最大序列长度上限(往往配得虚高),再开 GQA 或量化 KV(FP8),最后才考虑买卡——顺序反了就是烧钱。