Transformer 解剖:从 Attention 到推理系统

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

作者 杨艺韬 · 5,745 字 · 发布于 · 更新于

第 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 怎么把它压到同规模 MHA 的约 1/70。

读完这章你能:

  • 推导出 KV Cache 显存的精确公式(含 GQA、MLA 影响);
  • 解释 PagedAttention 的核心抽象(page、block table、virtual memory analogy);
  • 估算给定 GPU 配置下能跑多少并发用户;
  • 解释 prefix caching 为什么能大幅削掉重复输入的成本(OpenAI 推出时命中部分按 50% 计价,Anthropic 的缓存读取折扣更大);
  • 对比 KV 量化、MLA、KV offloading 等不同压缩方案的取舍。

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=1Ni2⋅d=O(N3⋅d)\sum_{i=1}^{N} i^2 \cdot d = O(N^3 \cdot d)

——cubic in N。两者之比是 ∑i2∑i≈2N3\frac{\sum i^2}{\sum i} \approx \frac{2N}{3}:生成 1000 token 的请求,没 KV Cache 时的 attention 计算量是有 KV Cache 时的几百倍。完全不可接受。

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。

形状(每层):

Kcache∈RB×hkv×T×dhK_{\text{cache}} \in \mathbb{R}^{B \times h_{\text{kv}} \times T \times d_h} Vcache∈RB×hkv×T×dhV_{\text{cache}} \in \mathbb{R}^{B \times h_{\text{kv}} \times T \times d_h}

其中:

  • BB:batch size
  • hkvh_{\text{kv}}:KV head 数(GQA 下小于 query head 数)
  • TT:当前序列长度(持续累积)
  • dhd_h:每个 head 的维度

对一个 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 MLA:dcd_c=512 + RoPE key 64 – FP16 约 69 KiB

可以看到 GQA 把 Llama-3 70B 的单 token KV Cache 从「同架构 MHA」的 2.5 MiB 压到 320 KiB——8× 压缩。MLA 压到约 69 KiB:相对 GPT-3 那种 MHA(4.5 MiB/token)是 60 倍以上,相对 8 组 GQA 也还小约 5 倍。(跨模型比较只能看量级,因为层数和头维都不同。)注意 MLA 这一行不套上面带「2×」的公式:K、V 共用同一个 latent,每 token 每层只存 dc+dhR=512+64=576d_c + d_h^R = 512 + 64 = 576 个数,即 61×576×261 \times 576 \times 2 B ≈ 69 KiB。

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(单序列) 每 80 GiB KV Cache 池能装多少并发
4K 1.25 GiB 64 个
8K 2.5 GiB 32 个
32K 10 GiB 8 个
128K 40 GiB 2 个

注意右列是「纯 KV Cache 池」的容量,不是一张卡的并发数。70B BF16 权重就有 140 GB,单卡根本放不下(至少 2 卡,实践上 4 卡起),权重、激活、临时 workspace 都要先扣掉——一张卡上真正能划给 KV Cache 的比例取决于 TP 卡数:卡越少,权重占得越多,留给 KV 的越少。

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

flowchart LR
  GPU[GPU 80 GB] --> W[模型权重 TP=4 分片后 ~35 GB]
  GPU --> KV[KV Cache 池]
  GPU --> ACT[激活 / 中间 buffer]
  W --> R1[剩余 ~45 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。

  • 优点:分配简单,每用户独立的连续内存
  • 缺点:如果用户实际只生成了 100 token(远小于 4K),剩下的 3900 token 显存白浪费——大量碎片

PagedAttention 论文对这类系统的实测是只有 20.4%~38.2% 的 KV 显存真正存了 token 状态,其余 60% 以上浪费在预留、内部碎片和外部碎片里(见 15.5)。

做法 2:动态扩展

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

  • 优点:不浪费显存
  • 缺点:扩展时需要 reallocation——把旧 KV 拷到新缓冲区,开销大、还可能 fragmented

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

flowchart LR
  subgraph "预分配最大长度<br/>实测利用率仅 20-40%"
    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. 消除预分配带来的浪费

每个序列只占用「实际生成 token 数 / block size」上取整这么多 block,没有「预分配但没用上」的空间。PagedAttention 论文(arXiv:2309.06180)图 2 对既有系统(按预留策略分成 Orca (Max)、Orca (Pow2)、Orca (Oracle) 三种配置,不是朴素的 PyTorch 实现)测得的数字是:只有 20.4% ~ 38.2% 的 KV cache 显存真正用来存 token 状态,其余都被预留和碎片吃掉;同一张图里 vLLM 的这一比例是 96.3%,摘要的表述是浪费「接近于零」——因为每个请求的浪费被限制在最后一个 block 之内,也就是至多 block_size - 1 个 token 的空间。这个上界比任何百分比都更能说明问题:它与序列长度无关。

2. 没有外部碎片

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

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

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

PagedAttention 的代价

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

1. 块大小是 trade-off

块越小 → block table 越长、查表开销越大,但「最后一块半空」的浪费越小 块越大 → block table 短、查表快,但回到「最后一块半空」的浪费

vLLM 在 CUDA 平台上把 block_size 兜底为 16 token(vllm-0.8.5/vllm/platforms/cuda.py:142),FlashAttention / Triton 后端要求它是 16 的倍数,MLA + FlashMLA 路径则强制 64。16 是这条路径上的默认值,不是「对所有部署都最优」的甜点——vLLM 自己也没有把它写成静态默认。

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 都以分页式 KV Cache 为默认做法。

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——这段前缀在显存里只需要存一份,共享它的用户越多、省得越多。

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

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

实际场景:

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

OpenAI 在 2024 年正式推出 Prompt Caching,推出时对命中前缀的部分输入按 50% 价格收费(此后的新模型折扣有调整,以官方价目表为准)——是实打实的省钱。Anthropic Claude API 也有类似机制。

技术上,prefix caching 需要:

  • 基于哈希的 block 标识:每个 block 的 key 是它的内容哈希。注意这里有个容易想当然的地方——哈希是把父 block 的哈希一起算进去的,见 vllm-0.8.5/vllm/v1/core/kv_cache_utils.py:392 的 hash_block_tokens(入参就有 parent_block_hash)。所以能命中的只有「从第 0 个 token 起一路相同」的前缀;两段内容一模一样但前文不同的 block 不会、也不能互相复用——它们的 K、V 本来就不一样。
  • LRU 淘汰策略:缓存满时淘汰最久不用的 prefix。vLLM 里对应 vllm-0.8.5/vllm/v1/core/block_pool.py:40 的 free_block_queue(按淘汰顺序排的空闲块队列)加 cached_block_hash_to_block 这张反查表
  • 持久化层(可选):把热门 prefix 写到磁盘 / 共享缓存,跨节点共享

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 数的 1/4~1/8(MQA 更极端,只留 1 个)。今天主流模型(Llama-3、Mistral、Qwen)都用 GQA。

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

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

ctKV=WDKV⋅xt∈Rdcc^{KV}_t = W_{DKV} \cdot x_t \in \mathbb{R}^{d_c}

其中 dc≪dmodeld_c \ll d_{\text{model}}(DeepSeek-V3 用 dc=512d_c = 512,相比 dmodel=7168d_{\text{model}} = 7168 是 14× 压缩;实际缓存还要再加一份所有 head 共享的 64 维 RoPE key)。

推理时按需从 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 KiB(BF16,另加 RoPE key)"]
  KV -.推理时按需解压.-> K1["k_t^(1), k_t^(2), ..."]
  KV -.推理时按需解压.-> V1["v_t^(1), v_t^(2), ..."]

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

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

3. KV Cache 量化

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

FP16 → INT8 / FP8:每元素从 2 byte 降到 1 byte,显存减半。这一档的质量影响通常较小,但仍要在自己的任务上验证;引擎支持也有前提——vLLM v0.8.5 的 V1 引擎里,FP8 KV cache 只在 Hopper + FlashAttention 3 上可用(《vLLM》专栏第 13 章)。

FP16 → INT4:每元素从 2 byte 降到 0.5 byte,显存减 4 倍。质量有可测量的损失,要配合更细的分组量化(比如 KIVI 那种 K 按 channel、V 按 token 的非对称做法)才压得住,上线前务必自己测。

具体方法:

  • KIVI(Liu et al., 2024):把 K 沿 channel、V 沿 token 分别量化,2-bit 量化下精度损失很小
  • FP8 KV cache(vLLM --kv-cache-dtype fp8):每层 K、V 各一个 per-tensor 缩放因子 k_scale / v_scale,优先从 checkpoint 加载(vllm-0.8.5/vllm/model_executor/layers/quantization/kv_cache.py)

按位宽算,这些方案把 KV Cache 数据本体再压 2 倍(8 bit)到 8 倍(2 bit),缩放因子等元数据另计。代价是额外的 dequant 算子,但因为 KV 的访问已经是 memory-bound 主力,量化后访存量减少更多——实际推理往往还能加速。

4. KV Cache Offloading

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

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

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

综合对比

方法 显存压缩比 计算开销 质量损失 适用
GQA 4-8× 几乎无 几乎无 主流模型默认
MLA 相对自身 MHA 约 70×(比 8 组 GQA 再小约 5×) 略增 几乎无 DeepSeek 系
INT8 / FP8 量化 2× 略增 通常较小,需验证 通用(vLLM V1 的 FP8 KV 须 Hopper + FA3)
INT4 量化 4× 略增 可测量,需分组量化压制 长上下文
Offload 不压缩,只腾挪(取决于卸载比例) 大(PCIe) 无 离线 / 超长

实际工业部署常常是叠加使用:GQA + INT8 KV 量化 + PagedAttention + Prefix Caching——按 15.8 节的量级估算,四重组合下来,同一张卡能扛的并发比朴素实现高一个数量级以上。

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 GiB 给 KV Cache。8 卡共 400 GiB。

Step 3:单 token 单层 KV Cache

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

size/token=80⋅2⋅8⋅128⋅2=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 GiB≈160400 \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),且这段前缀占到上下文的大头:

  • 前缀 KV Cache 只占一份,省下重复存储
  • 推理省下重复 Prefill 计算

总并发能力粗估再翻 1.5-2 倍——综合到 ~500。前缀越短,这一步收益越小。

Step 8:实际工业(DeepSeek-V3 + MLA + 大规模 EP + 磁盘 KV 缓存)

DeepSeek 在 2025 年 2 月开源周的收尾文档里公开过 V3/R1 线上推理系统的数据:在 H800 节点上用大规模 EP + PD 分离,单节点 prefill 吞吐达到每秒数万输入 token、decode 达到每秒上万输出 token 的量级,输入 token 中过半命中磁盘 KV 缓存;MLA 核心计算用的是 BF16。MLA 把单 token KV 压到 69 KiB 是这套账能成立的前提之一。(具体数字请以 DeepSeek 官方那篇推理系统说明为准,各家硬件和 workload 差异极大。)

flowchart TB
  NOTE["下面是量级示意,不是实测基准"]
  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 启动时可配置 max_model_len(SGLang 对应的参数是 --context-length)——单序列允许的最大 token 数。设太大,KV 池可能连一条满长序列都放不下,vLLM 启动时直接报错(vllm-0.8.5/vllm/v1/core/kv_cache_utils.py:510 的 check_enough_kv_cache_memory);设太小用户长 prompt 失败。需要根据实际请求分布定。

Tip 3:Prompt 长度直接决定 throughput

如果你的应用平均 prompt 是 32K(长文档场景)vs 平均 1K(chat 场景),单请求 KV 占用差 32 倍,同样硬件能容纳的并发也随之差一个数量级以上。设计 RAG / Agent 时尽量把不必要的上下文拿掉。

Tip 4:长尾用户拖累集群

一个用户生成 4K token 占用 KV Cache 4K × 320 KiB = 1.25 GiB,持续整个生成过程(几十秒以上)——同期可能阻塞其他几十个短用户。如果服务质量敏感,需要做 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,把每个请求的显存浪费限制在「最后一个 block 之内」(至多 block_size − 1 个 token),与序列长度无关。
  6. Prefix Caching 复用相同前缀的 KV——OpenAI / Anthropic API 上「命中缓存的输入更便宜」就来自这里。
  7. GQA / MLA 在架构层压缩 KV——GQA 减少 KV head 数(Llama-3 70B 减 8 倍),MLA 改存低秩 latent(DeepSeek-V3 比 Llama-3 70B 的 GQA 还小约 5 倍)。
  8. 量化(INT8/INT4)和 offloading 是显存的进一步压缩手段。
  9. 按 15.8 节的量级示意,综合优化能让一台 H100 集群从「20 并发」提升到「数百到数千并发」——这就是过去 2 年 LLM 推理工程的核心成就。

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

延伸阅读