Transformer 解剖:从 Attention 到推理系统

第 15 章 KV Cache 工程学:PagedAttention 解决了什么

作者 杨艺韬 · 4,698 字

第 15 章 KV Cache 工程学:PagedAttention 解决了什么

第 14 章我们看到 Decode 阶段被 HBM 带宽锁死、每生成一个 token 都要把整个模型权重读一遍。但还有一个问题没讲清楚:K 和 V 是从哪儿读的?每次 decode 要不要重算前面 N-1 个 token 的 K 和 V?

答案是:不要重算,缓存起来反复用——这就是 KV Cache。

KV Cache 是 LLM 推理的核心数据结构。没有它,自回归生成的总复杂度从 O(N²) 退化为 O(N³);有它,第 i 步 decode 只算 1 个新 token 的 K、V 和 1×i 的 attention。

这一章我们把 KV Cache 从概念到工程一路讲透:为什么需要、长什么样、为什么是显存杀手、PagedAttention 怎么救它、Prefix Caching 怎么省钱、MLA 怎么把它压到 1/10

读完这章你能:

15.1 没有 KV Cache 会怎样

先看一个反事实:如果不缓存 KV,每次生成新 token 时全部重算,会怎样?

设当前已经生成了 i-1 个 token,准备生成第 i 个。完整的 forward 需要:

  1. 把所有 i 个 token 都过一遍 embedding
  2. 计算它们的 Q、K、V
  3. 算 i × i 的 attention
  4. FFN 处理 i 个 token
  5. 取最后一个位置的 logits 采样

计算量:每次 decode 都做一次完整的 prefill 工作量(O(i² · d))。生成 N 个 token 的总计算量:

i=1Ni2d=O(N3d)\sum_{i=1}^{N} i^2 \cdot d = O(N^3 \cdot d)

——cubic in N。对一个生成 1000 token 的请求来说,没 KV Cache 时计算量是有 KV Cache 时的 1000 倍。完全不可接受。

KV Cache 的核心想法非常朴素:第 i 步 decode 时,前 i-1 个 token 的 K、V 早在前面已经算过——把它们存起来,每次只算第 i 个新 token 的 K、V,再做一次 1×i 的 attention

flowchart LR
  subgraph "无 KV Cache 第 i 步"
    DM_X["输入: i 个 token"] --> DM_QKV["重算所有 K, V"]
    DM_QKV --> DM_ATT["i x i attention"]
    NOTE1["O(i²) FLOPs"]
  end
  subgraph "有 KV Cache 第 i 步"
    DK_X["输入: 1 个新 token"] --> DK_QKV["只算新 K, V"]
    DK_QKV --> DK_CONCAT[拼接到 KV Cache]
    DK_CONCAT --> DK_ATT["1 x i attention"]
    NOTE2["O(i) FLOPs"]
  end

KV Cache 让 decode 第 i 步的计算量从 O(i²) 降到 O(i)——总复杂度 iO(i)=O(N2)\sum_i O(i) = O(N^2),比无 KV Cache 时的 O(N3)O(N^3) 少 N 倍。这就是为什么所有 LLM 推理引擎都必须用 KV Cache。

15.2 KV Cache 长什么样

KV Cache 是按层组织的——每一层 Transformer Block 都有自己的 K Cache 和 V Cache。

形状(每层):

KcacheRB×hkv×T×dhK_{\text{cache}} \in \mathbb{R}^{B \times h_{\text{kv}} \times T \times d_h} VcacheRB×hkv×T×dhV_{\text{cache}} \in \mathbb{R}^{B \times h_{\text{kv}} \times T \times d_h}

其中:

对一个 LL 层模型,整体 KV Cache 是 LL 个这样的 (K, V) 对。

单 token 单层 KV Cache 大小

size per token per layer=2×hkv×dh×bytes\text{size per token per layer} = 2 \times h_{\text{kv}} \times d_h \times \text{bytes}

「2」是 K 和 V 各一份。bytes 是 dtype 的字节数(FP16/BF16 是 2,INT8 是 1,INT4 是 0.5)。

整模型 KV Cache 大小(每个 token)

size per token=L×2×hkv×dh×bytes\text{size per token} = L \times 2 \times h_{\text{kv}} \times d_h \times \text{bytes}

举例:

模型 L h_kv d_h dtype size/token
Llama-2 7B 32 32 (MHA) 128 FP16 512 KiB
Llama-3 70B 80 8 (GQA) 128 FP16 320 KiB
Llama-3 8B 32 8 (GQA) 128 FP16 128 KiB
Mistral 7B v0.3 32 8 (GQA) 128 FP16 128 KiB
GPT-3 175B 96 96 (MHA) 128 FP16 4.5 MiB
DeepSeek-V3 61 128 (MLA, kv_lora=512) FP16 约 69 KiB

可以看到 GQA 把 Llama-3 70B 的单 token KV Cache 从 MHA 时代的几 MB 压到 320 KiB——8× 压缩。MLA 进一步压到 70 KB——60× 压缩

15.3 KV Cache 是显存杀手

把 size/token 乘以 context 长度:

Total KV Cache=T×L×2×hkv×dh×bytes\text{Total KV Cache} = T \times L \times 2 \times h_{\text{kv}} \times d_h \times \text{bytes}

举例 Llama-3 70B:

Context KV Cache(单序列) 单 H100 80GB 能装多少并发用户
4K 1.25 GiB 80/1.25×α5080/1.25 \times \alpha \approx 50
8K 2.5 GiB 25 个
32K 10 GiB 6 个
128K 40 GiB 1 个

实际还要扣模型权重(70B BF16 = 140 GB,单卡放不下需要 TP 拆到 2 卡才能装下,KV Cache 跟着分摊)。再扣激活值、临时 buffer——可用于 KV Cache 的显存只有总显存的 30-40%。

KV Cache 决定了一个推理集群的并发能力上限——不是算力,是显存。

flowchart LR
  GPU[GPU 80 GB] --> W[模型权重 70 GB] 
  GPU --> KV[KV Cache 池]
  GPU --> ACT[激活 / 中间 buffer]
  W --> R1[剩余 ~30 GB]
  R1 --> KV
  R1 --> ACT
  KV --> CAP[KV Cache 容量决定并发用户数]

要装下更多用户,要么减小单用户 KV Cache(GQA、MLA、量化),要么扩集群(更多卡 + 更多显存)。这是大模型推理工程最关键的一笔账。

15.4 第一个工程问题:碎片化

理论上每用户「自己的 KV Cache 占多少显存」是清楚的。但实际工程里有个微妙问题:每个用户不知道自己会生成多少 token

假设你有一个 80 GB H100,模型权重占 60 GB,剩 20 GB 给 KV Cache。每用户 1 GB(4K context),理论上能装 20 个用户。但工程实现里:

做法 1:预分配最大长度

每个用户进来就分配它「可能用到的最大长度」(比如 4K)的 KV Cache。

实测平均利用率只有 20-40%,剩下 60-80% 显存浪费在「预分配但用不上」的空间里。

做法 2:动态扩展

每用户生成时按需扩展 KV Cache(比如每次按 256 token 加一段)。

更糟的是:不同用户的 KV Cache 在显存里散落——有些早结束、有些还在跑、有些刚开始——显存被切得稀碎,新用户进来时找不到一段连续的足够大的空间。

flowchart LR
  subgraph "预分配最大长度<br/>50% 利用率"
    A1[U1: 4K 分配<br/>但只用了 100 token]
    A2[U2: 4K 分配<br/>用了 500 token]
    A3[U3: 4K 分配<br/>用了 2K token]
    A4[剩余: 不够装新用户]
  end
  subgraph "动态分配<br/>显存碎片化"
    B1[U1: 100 token 已结束]
    B2[U2: 500 token 还在跑]
    B3[空洞 50 KB]
    B4[U3: 2K 还在跑]
    B5[空洞 200 KB]
    B6[U4: 待加入<br/>找不到连续空间]
  end

这是 LLM 推理服务化的真实痛点——显存的「物理碎片」让实际可用容量远低于纸面容量

15.5 PagedAttention:从操作系统借的智慧

2023 年 Kwon 等人在 Efficient Memory Management for Large Language Model Serving with PagedAttention (SOSP 2023) 论文里提出了 PagedAttention——这是 vLLM 的核心创新,也是 2023 之后所有主流推理引擎的标配。

它的核心想法借鉴了操作系统的虚拟内存(virtual memory)

不要让一个用户的 KV Cache 占用一段连续显存。把 KV Cache 切成固定大小的小块(page),每个用户维护一个『虚拟到物理的页表』,需要时按页分配,不需要时按页释放

具体地:

  1. 物理 KV Cache 被切成固定大小的 block(典型 16 token 一块)。
  2. 每个序列有自己的 block table——一个数组,第 i 项指向「序列的第 i 块对应的物理 block 地址」。
  3. 生成新 token 时:当前 block 没填满就直接 append;填满了就分配一个新的物理 block,更新 block table。
  4. 序列结束:释放它所有的物理 block 回到 free pool。
flowchart LR
  subgraph "物理 KV Cache 池"
    PB1[block 1]
    PB2[block 2]
    PB3[block 3 free]
    PB4[block 4]
    PB5[block 5 free]
    PB6[block 6]
    PB7[block 7]
    PB8[block 8 free]
  end
  subgraph "用户 1 序列"
    U1L["block table:<br/>[1, 4, 6]"]
  end
  subgraph "用户 2 序列"
    U2L["block table:<br/>[2, 7]"]
  end
  U1L -.指向.-> PB1
  U1L -.指向.-> PB4
  U1L -.指向.-> PB6
  U2L -.指向.-> PB2
  U2L -.指向.-> PB7

这相当于操作系统中的 page table——CPU 看的是逻辑地址,page table 翻译到物理地址。LLM 推理引擎看的是「序列的虚拟 KV Cache」,PagedAttention 翻译到「散落在物理显存里的 block」

PagedAttention 的好处

1. 内存利用率从 ~30% 提升到 ~95%

每个序列只占用「实际生成 token 数 / block size」上取整这么多 block,没有「预分配但没用上」的空间。vLLM 论文报告显存利用率从 PyTorch 朴素实现的 20-40% 提升到 96%。

2. 没有外部碎片

所有 block 大小相同,只要有空闲 block 就能给任何用户用。不会出现「显存有 1 GB 空闲但分散在 100 个小空洞里、谁都用不上」的情况。

3. 支持复杂的内存共享模式

不同序列的 block table 可以指向同一个物理 block——这是 prefix caching 的基础(下一节)。

PagedAttention 的代价

PagedAttention 不是免费的。它有一些工程代价:

1. 块大小是 trade-off

太小(比如 4 token)→ block table 太长、查表开销大、内存利用率高 太大(比如 64 token)→ block table 短、查表快、但回到「最后一块半空」的浪费

vLLM 默认 16 token,是综合考虑下的甜点。

2. Attention kernel 必须改写

朴素 attention 假设 K、V 在显存中连续。PagedAttention 下 K、V 散落在不同 block 里,attention 计算时要按 block table 跳着读。需要专门的 paged attention kernel——vLLM、SGLang、TensorRT-LLM 都有自己的实现。

3. 调度复杂

每生成一个 token 要查 block table;block 用完要分配新的;序列结束要释放——所有这些事比朴素的「预分配大块连续显存」复杂得多。

但这些代价远小于内存利用率提升带来的收益。今天 vLLM、SGLang、TensorRT-LLM 默认开启 PagedAttention 几乎是绝对默认。

15.6 Prefix Caching:跨用户的 KV 共享

PagedAttention 有一个非常有价值的副产品:当不同序列共享前缀时,它们的前缀 block 可以指向同一个物理 block

flowchart LR
  subgraph "用户 1 prompt 'You are a helpful assistant. Answer: 2+2='"
    U1["block 1: 'You are'<br/>block 2: 'a helpful'<br/>block 3: 'assistant. Answer: 2+2='"]
  end
  subgraph "用户 2 prompt 'You are a helpful assistant. Answer: 3+3='"
    U2["block 1: 'You are'<br/>block 2: 'a helpful'<br/>block 3: 'assistant. Answer: 3+3='"]
  end
  PB12["共享物理 block:<br/>'You are'"]
  PB22["共享物理 block:<br/>'a helpful'"]
  U1 -.前两块.-> PB12
  U1 -.前两块.-> PB22
  U2 -.前两块.-> PB12
  U2 -.前两块.-> PB22

如果两个用户的 prompt 前 32 token 一样(共享同一个 system prompt + 文档前缀),他们的前 2 个 block(16 token × 2 = 32 token)可以共享 K 和 V——省一半 KV Cache

更重要的是 省 Prefill 计算。前缀的 KV 在第一个用户来时已经算过、缓存住了,第二个用户来时直接复用——Prefill 跳过共享前缀部分,只算 diverging 之后的 token

这就是 Prefix Caching(前缀缓存),是今天 LLM API 的关键优化。

实际场景:

  1. System prompt:同一个应用所有请求的 system prompt(描述 AI 角色、规则)一致——通常 500-2000 token,每次请求都能命中。
  2. Few-shot examples:在 prompt 里给的例子是固定的——可以复用。
  3. 长文档 QA:用户对同一份 100 页文档问不同问题——文档部分可复用。
  4. Agent 多轮:Agent 多步对同一个上下文做不同操作——历史可复用。

OpenAI 在 2024 年正式推出 Prompt Caching API,对命中前缀的部分输入按 50% 价格收费——是实打实的省钱。Anthropic Claude API 也有类似机制。

技术上,prefix caching 需要:

vLLM、SGLang 都内置 prefix caching。

15.7 主流 KV Cache 压缩方案

KV Cache 是显存杀手——即使有了 PagedAttention 和 Prefix Caching,要在长上下文 + 高并发场景下放下足够的 KV,还需要把单 token 的 KV Cache 进一步压缩。主流方案:

1. GQA / MQA(架构层)

第 3.6 节已经讲过——把 KV head 数从 query head 数减少 8× 或更多。今天主流模型(Llama-3、Mistral、Qwen)都用 GQA。

2. MLA(Multi-Head Latent Attention,DeepSeek-V2/V3)

更激进的方案:把 K、V 通过一个低秩压缩投影到低维 latent 空间,KV Cache 只存这个 latent 向量

ctKV=WDKVxtRdcc^{KV}_t = W_{DKV} \cdot x_t \in \mathbb{R}^{d_c}

其中 dcdmodeld_c \ll d_{\text{model}}(DeepSeek-V3 用 dc=512d_c = 512,相比 dmodel=7168d_{\text{model}} = 7168 是 14× 压缩)。

推理时按需从 ctKVc^{KV}_t 解压出 K 和 V:

kt(i)=WUK(i)ctKVk_t^{(i)} = W_{UK}^{(i)} \cdot c^{KV}_t vt(i)=WUV(i)ctKVv_t^{(i)} = W_{UV}^{(i)} \cdot c^{KV}_t

这样 KV Cache 只存一个 dcd_c 维向量(每 token、每层),不存所有 head 的 K 和 V。

flowchart LR
  X["输入 x_t"] --> CTX["低秩压缩<br/>c_KV (d_c=512)"]
  CTX --> KV["KV Cache 只存 c_KV<br/>每 token 1 KB"]
  KV -.推理时按需解压.-> K1["k_t^(1), k_t^(2), ..."]
  KV -.推理时按需解压.-> V1["v_t^(1), v_t^(2), ..."]

DeepSeek-V3 的 KV Cache 比 GQA 还小 5 倍。但代价是需要额外的解压计算——MLA 比 GQA / MHA 计算量略大。这是「显存省 → 算力略增」的 trade-off。

不过推理时算力本来就用不满(memory-bound),花一点算力换大量显存是非常合算的——这是 MLA 在 DeepSeek-V3 这种长上下文 / 高并发场景下的关键优势。

3. KV Cache 量化

把 KV Cache 用低精度存储——FP16 → INT8 / INT4。

FP16 → INT8:每元素从 2 byte 降到 1 byte,显存减半。质量几乎无损(< 0.5% 精度损失)。

FP16 → INT4:每元素从 2 byte 降到 0.5 byte,显存减 4 倍。质量略有损失(1-2% 精度),但在长上下文下损失可接受。

具体方法:

这些方案都把 KV Cache 显存进一步压 2-4 倍。代价是额外的 dequant 算子,但因为 KV 的访问已经是 memory-bound 主力,量化后访存量减少更多——实际推理还能加速。

4. KV Cache Offloading

把暂时不需要的 KV Cache(比如序列前部、不太可能马上被访问到的)从 HBM 卸载到 CPU 内存或 NVMe SSD,需要时再加载。

适合场景:超长上下文(128K+)、对延迟不敏感的离线推理。

代价:HBM ↔ CPU 内存的 PCIe 带宽(~64 GB/s)远低于 HBM 带宽(3.35 TB/s),加载时延迟巨大。

综合对比

方法 显存压缩比 计算开销 质量损失 适用
GQA 4-8× 几乎无 几乎无 主流模型默认
MLA 5-10× 略增 几乎无 DeepSeek 系
INT8 量化 略增 < 0.5% 通用
INT4 量化 略增 1-2% 长上下文
Offload 5-10× 大(PCIe) 离线 / 超长

实际工业部署常常是叠加使用:GQA + INT8 KV 量化 + PagedAttention + Prefix Caching——四重组合让一张 H100 能服务的并发数翻 50-100 倍。

15.8 一个完整的并发能力估算

把所有内容串起来。我们估算一下:用 8×H100 80GB 服务 Llama-3 70B(FP16),能撑多少并发用户?

Step 1:模型权重占用

70B × 2 byte = 140 GB。8 卡 TP 平均每卡 17.5 GB。

Step 2:剩余显存

每卡 80 - 17.5 ≈ 62 GB,扣激活、buffer、临时 workspace ≈ 50 GB 给 KV Cache。8 卡共 400 GB。

Step 3:单 token 单层 KV Cache

Llama-3 70B GQA: hkv=8h_{kv} = 8, dh=128d_h = 128, L=80L = 80, FP16:

size/token=80281282=327,680 bytes=320 KiB\text{size/token} = 80 \cdot 2 \cdot 8 \cdot 128 \cdot 2 = 327{,}680 \text{ bytes} = 320 \text{ KiB}

Step 4:每用户 KV Cache(假设平均 8K context)

8192×320 KiB=2.5 GiB8192 \times 320 \text{ KiB} = 2.5 \text{ GiB}

Step 5:并发用户数

400 GiB/2.5 GiB160400 \text{ GiB} / 2.5 \text{ GiB} \approx 160 个并发

但这是「平均每用户 8K context」的假设。如果 context 更长(比如 32K 平均),并发数减到 ~40;如果更短(4K 平均),能撑到 ~320。

Step 6:开启 INT8 KV 量化后

每 token 减半到 160 KiB,每用户 8K 只占 1.25 GiB——并发能到 ~320。

Step 7:开启 prefix caching 命中 50%

如果 50% 的用户共享同一个前缀(system prompt):

总并发能力大概再翻 1.5-2 倍——综合到 ~500。

Step 8:实际工业(DeepSeek-V3 + MLA + INT8 + prefix caching)

671B 模型 + MLA + INT8 KV + prefix caching 综合下来,DeepSeek 公开报告 H100 集群能跑到几千 QPS——这个量级已经接近 OpenAI / Anthropic 的产品水平。

flowchart TB
  N1["朴素 PyTorch<br/>10-20 个并发"] --> N2["+ PagedAttention<br/>30-50 个"]
  N2 --> N3["+ GQA 架构<br/>~150 个"]
  N3 --> N4["+ INT8 KV<br/>~300 个"]
  N4 --> N5["+ Prefix Caching<br/>~500 个"]
  N5 --> N6["+ MLA + FP8<br/>~1000+ 个"]

15.9 KV Cache 的工程实战 tips

最后一些非常实操的经验:

Tip 1:监控 KV Cache 占用率

服务化推理引擎要实时监控 kv_cache_used / kv_cache_total。如果 > 80% 就要触发拒绝新请求或降级(比如缩短 context)——避免 OOM。

Tip 2:设置合理的 max_model_len

vLLM / SGLang 启动时可配置 max_model_len——单序列允许的最大 token 数。设太大浪费 KV Cache 池的预留;设太小用户长 prompt 失败。需要根据实际请求分布定。

Tip 3:Prompt 长度直接决定 throughput

如果你的应用平均 prompt 是 32K(长文档场景)vs 平均 1K(chat 场景),同样硬件吞吐能差 30 倍。设计 RAG / Agent 时尽量把不必要的上下文拿掉。

Tip 4:长尾用户拖累集群

一个用户生成 4K token 占用 KV Cache 4K × 320KB = 1.3GB 持续 30 秒——同期可能阻塞其他几十个短用户。如果服务质量敏感,需要做 timeout / preemption。

Tip 5:跨用户复用 prefix 设计 system prompt

把 system prompt、文档摘要、few-shot 这些「用户间共通」的内容写在一起、放在 prompt 最前——这样 prefix caching 命中率最高。同一个应用所有请求共享相同的 system prompt 是最理想的。

本章小结

  1. KV Cache 把自回归生成的复杂度从 O(N³) 降到 O(N²)——是 LLM 推理可行的核心数据结构。
  2. 单 token KV Cache = L × 2 × h_kv × d_h × bytes——这个公式决定了一切显存账。
  3. KV Cache 是显存杀手——长上下文 + 多并发让它远超模型权重大小。
  4. 碎片化是工程痛点:预分配浪费、动态分配碎片化。
  5. PagedAttention 借鉴 OS 虚拟内存——把 KV Cache 切成 block + 用 block table,显存利用率从 30% 提升到 95%。
  6. Prefix Caching 复用相同前缀的 KV——OpenAI / Anthropic API 50% 折扣的来源。
  7. GQA / MLA 在架构层减小 KV head 数——Llama-3 用 GQA 减 8 倍,DeepSeek-V3 用 MLA 再减 5-10 倍。
  8. 量化(INT8/INT4)和 offloading 是显存的进一步压缩手段。
  9. 综合优化能让一台 H100 集群从「20 并发」提升到「数百到数千并发」——这就是过去 2 年 LLM 推理工程的核心成就。

下一章我们看推理优化的另一条主线——量化。从 FP16 到 INT8、INT4、FP8 的演化,每一刀都在「质量损失 vs 显存计算节省」之间找最优点。

延伸阅读