1. 为什么每个搞大模型的人都绕不开 KV Cache
如果你部署过任何一个像样的语言模型,不管是 7B 还是 70B,你迟早会撞上同一个问题:显存不够用,吞吐上不去,延迟下不来。很多人第一反应是去优化模型权重、量化、剪枝,折腾一圈发现效果有限。真正卡脖子的地方,往往不在权重本身,而在推理过程中那个被反复读写、疯狂吃显存的东西——KV Cache。
我最早接触这个概念是在做本地部署的时候。当时用一张消费级显卡跑一个 13B 的模型,单条对话还行,一旦并发上来,显存直接爆掉,服务直接挂。查了半天日志,发现显存占用跟上下文长度几乎成正比增长,而且增长得非常夸张。后来才明白,这就是 KV Cache 在作祟。它既是让自回归生成变得高效的关键机制,也是吃掉显存、限制并发和上下文长度的头号元凶。
这篇文章我想把 KV Cache 这件事从头到尾讲透。不是那种只告诉你"缓存 Key 和 Value 可以加速"的泛泛之谈,而是从它为什么存在、它到底占多少显存、怎么算、怎么优化、工程上怎么落地这几个角度,把我在实际部署和调优中踩过的坑、算过的账、用过的方案都摊开来讲。适合正在做推理优化、模型部署、或者单纯想把大模型原理搞明白的读者。哪怕你只是用 Ollama 或者 vLLM 跑过模型,看完也能明白那些显存数字背后到底发生了什么。
2. KV Cache 的本质:用空间换时间的经典交易
2.1 自回归生成到底在重复算什么
要理解 KV Cache,得先理解 Transformer 解码器是怎么生成文本的。大模型生成是自回归的,也就是一个 token 一个 token 往外蹦。每生成一个新 token,模型都要把"到目前为止的所有 token"重新过一遍注意力计算。
问题就出在这里。假设你要生成第 100 个 token,按照最朴素的做法,模型需要把前面 99 个 token 加上当前这个,一共 100 个 token 全部送进网络,重新计算一遍注意力。而生成第 101 个 token 时,又要重新算这 101 个。这种重复计算是巨大的浪费,因为前面那些 token 的 Key 和 Value 投影结果,其实根本没有变。
注意力的计算公式是:
Attention(Q, K, V) = softmax(QK^T / sqrt(d)) V其中 Q 是当前 token 的查询向量,K 和 V 是序列中所有 token 的键和值。对于已经处理过的历史 token,它们的 K 和 V 是固定的,不会因为后面来了新 token 而改变。既然不变,为什么每次都要重算?答案就是——缓存起来。
2.2 KV Cache 的核心思想
KV Cache 做的事情非常直白:把每一层、每个历史 token 计算出来的 Key 和 Value 张量存下来,生成新 token 时直接复用,只计算当前这个新 token 的 Q、K、V。
这样一来,每生成一个 token,注意力计算从"对全序列重算"变成了"当前 token 的 Q 去和缓存的 K、V 做一次注意力"。计算复杂度从每步 O(n²) 降到 O(n),整个生成过程的复杂度从 O(n³) 降到 O(n²)。这个提升是数量级的,没有 KV Cache,现在的大模型推理根本不可能实用。
用一个生活化的类比:你在写一份长报告,每写一句都要回头把前面所有句子重新读一遍才能保证连贯。KV Cache 相当于你给前面每句话都做了摘要卡片,写新句子时只需要翻卡片,不用重读全文。卡片就是缓存,翻卡片就是复用。
2.3 为什么是 Key 和 Value,不是 Query
这里有个很多人会疑惑的点:为什么只缓存 K 和 V,不缓存 Q?
原因在于 Q 的角色。Query 是"当前这个 token 想查询什么",它只跟当前 token 有关,每个新 token 都会产生一个新的 Q,历史 token 的 Q 在后续计算中根本用不到。而 K 和 V 是"历史 token 提供的信息",它们会被后续所有 token 的注意力反复查询,所以必须缓存。
换句话说,Q 是一次性的,K 和 V 是长期资产。缓存长期资产,丢弃一次性消耗品,这就是 KV Cache 的经济学。
3. 算一笔账:KV Cache 到底吃掉多少显存
3.1 显存占用的计算公式
很多人对 KV Cache 的显存占用没有概念,觉得"缓存嘛,能有多大"。实际算一下你就知道为什么它这么可怕。
KV Cache 的显存占用公式是:
KV Cache 显存 = 2 × batch_size × seq_len × num_layers × num_kv_heads × head_dim × dtype_bytes逐个解释这些参数:
- 2:因为要存 Key 和 Value 两份
- batch_size:并发处理的序列数
- seq_len:序列长度,也就是上下文长度
- num_layers:Transformer 层数
- num_kv_heads:KV 注意力头数量(注意不是 Q 的头数,后面讲 GQA 会展开)
- head_dim:每个注意力头的维度
- dtype_bytes:数据类型字节数,FP16 是 2,FP8 是 1,INT8 是 1
3.2 一个真实模型的显存账
拿一个典型的 13B 模型举例,假设配置是:40 层,hidden size 5120,32 个注意力头,head_dim 128,FP16 精度。
在标准的多头注意力(MHA)下,num_kv_heads 等于 num_heads,也就是 32。那么单个 token 的 KV Cache 占用是:
2 × 1 × 1 × 40 × 32 × 128 × 2 = 655,360 字节 ≈ 0.625 MB看起来不多?但这是单个 token。如果上下文长度是 4096,单个序列就是:
0.625 MB × 4096 ≈ 2.56 GB如果并发 8 条序列,就是 20.5 GB。而模型权重本身 FP16 下大约 26 GB。也就是说,KV Cache 的显存占用几乎和模型权重一个量级,甚至在高并发长上下文场景下会超过权重。
这就是为什么很多人发现:模型能加载进去,但一跑长对话就 OOM。权重是固定的,KV Cache 是随上下文和并发动态增长的,它才是那个不可控的变量。
3.3 不同配置下的显存对比
我把几种常见配置的 KV Cache 占用整理成表格,方便你对照自己的场景估算:
| 模型规模 | 层数 | KV头数 | 上下文 | 并发 | FP16 KV Cache |
|---|---|---|---|---|---|
| 7B | 32 | 32 | 2048 | 1 | 约 0.5 GB |
| 7B | 32 | 32 | 8192 | 8 | 约 8 GB |
| 13B | 40 | 32 | 4096 | 8 | 约 20 GB |
| 70B | 80 | 8 (GQA) | 8192 | 16 | 约 21 GB |
| 70B | 80 | 64 (MHA) | 8192 | 16 | 约 168 GB |
最后一行那个 168 GB 不是笔误。70B 模型如果用标准 MHA,KV Cache 会大到离谱,这也是为什么现在几乎所有大模型都改用 GQA 的原因。这个表你可以直接拿去估算自己的部署方案,把并发和上下文代进去,心里就有数了。
提示:估算时一定要把 batch_size 和 seq_len 都考虑进去,很多人只算单条序列,结果上线一并发就崩。
4. 从 MHA 到 GQA:KV Cache 优化的主战场
4.1 多头注意力为什么这么费缓存
标准的多头注意力(MHA,Multi-Head Attention)里,每个注意力头都有自己独立的 Q、K、V 投影。32 个头就是 32 组 K 和 V,全部要缓存。头越多,缓存越大。
但研究者后来发现,KV 头其实没必要和 Q 头一样多。Q 需要多个头来捕捉不同的查询视角,但 K 和 V 提供的信息可以在多个 Q 头之间共享。这个洞察直接催生了 MQA 和 GQA。
4.2 MQA:激进但有效的共享
MQA(Multi-Query Attention)的做法是:所有 Q 头共享同一组K 和 V。也就是说 num_kv_heads = 1。
这样 KV Cache 直接缩小到原来的 1/num_heads。32 个头的话,缓存变成原来的 1/32,效果立竿见影。但代价是模型质量会下降,因为 K 和 V 的表达能力被压缩得太狠,注意力机制失去了部分区分度。
MQA 在推理速度上很香,但质量损失让很多团队不敢直接用。
4.3 GQA:质量和效率的平衡点
GQA(Grouped-Query Attention,分组查询注意力)是目前的主流方案。它的思路是折中:把 Q 头分成若干组,每组共享一组 K 和 V。
比如 32 个 Q 头,分成 8 组,每组 4 个 Q 头共享一组 KV,那么 num_kv_heads = 8。KV Cache 缩小到原来的 8/32 = 1/4,质量损失却比 MQA 小得多。
这也是为什么 Llama 2 70B、Llama 3、Qwen 等主流模型都用 GQA。它把 KV Cache 这个显存大户压到了一个可接受的范围,同时保住了模型效果。
| 注意力类型 | KV头数 | 相对缓存大小 | 质量 | 典型使用者 |
|---|---|---|---|---|
| MHA | = Q头数 | 100% | 最好 | 早期模型 |
| GQA | Q头数/组数 | 25% 左右 | 接近 MHA | Llama 3、Qwen |
| MQA | 1 | 3% 左右 | 有损失 | 部分推理优化场景 |
4.4 实操心得:选模型时先看 KV 头数
我在选型的时候有个习惯:拿到一个模型,先看它的 config 里 num_key_value_heads 是多少。如果这个数等于 num_attention_heads,说明是标准 MHA,部署时显存压力会很大,并发上不去。如果是 GQA,心里就踏实一半。
这个细节很多人忽略,但它直接决定了你的部署成本和并发能力。同样参数量的两个模型,GQA 的那个可能能多扛 3 到 4 倍的并发。
5. PagedAttention:把操作系统内存管理搬进显存
5.1 传统 KV Cache 的浪费在哪
即便用了 GQA,KV Cache 的显存管理还是有问题。传统做法是为每条序列预分配一块连续的显存,大小按最大可能长度算。
这就带来两个浪费:
第一,预留浪费。你按 4096 长度预分配,但实际对话可能只用了 500 个 token,剩下 3596 个位置的显存白白占着,别的请求用不了。
第二,碎片浪费。不同序列长度不一,连续分配会导致显存碎片,最后明明总量够,却找不到一块连续空间。
实测下来,传统方案下 KV Cache 的显存利用率经常只有 20% 到 40%,大部分显存都在空转。
5.2 PagedAttention 的核心机制
vLLM 提出的 PagedAttention 借用了操作系统的虚拟内存分页思想。它把 KV Cache 切成固定大小的块(block),比如每块存 16 个 token 的 KV。序列的 KV 不再要求连续存放,而是通过一张块表(block table)把逻辑上的连续序列映射到物理上分散的块。
这样做的好处:
- 按需分配:用到多少分配多少,不再预留
- 消除碎片:块大小固定,任何空闲块都能用
- 共享前缀:多条序列如果开头相同(比如相同的 system prompt),可以共享同一批块,省显存
vLLM 的实测数据显示,PagedAttention 能把 KV Cache 的显存利用率从不到 40% 提升到 90% 以上,吞吐量提升 2 到 4 倍。这个提升在长上下文和高并发场景下尤其明显。
5.3 前缀共享:一个被低估的省钱技巧
PagedAttention 带来的前缀共享能力,在实际业务里价值巨大。很多应用场景下,大量请求共享同一段很长的 system prompt 或者 few-shot 示例。传统方案下,每个请求都要为这段前缀单独存一份 KV,浪费惊人。
用 PagedAttention,这段前缀的块只存一份,所有请求的块表都指向它。我做过一个测试,在一个 system prompt 长达 2000 token 的场景下,开启前缀共享后显存占用直接降了 60% 以上,并发能力翻倍。
注意:前缀共享要求前缀完全一致,包括每一个 token。差一个字符都会导致无法共享,所以设计 prompt 模板时要尽量标准化。
6. 工程落地:KV Cache 优化的实操清单
6.1 量化 KV Cache
除了结构优化,最直接的省显存手段是量化 KV Cache。把 KV Cache 从 FP16 降到 FP8 或 INT8,显存直接减半。
现在 vLLM、TensorRT-LLM 等框架都支持 KV Cache 量化。FP8 量化对质量影响很小,基本可以无脑开。INT8 稍微激进一点,但在大多数场景下也够用。
我的一般建议是:显存紧张就上 FP8,还紧张再考虑 INT8,同时监控输出质量有没有明显退化。
6.2 合理设置 max_model_len 和并发
很多人部署时把 max_model_len 设成模型支持的最大值,比如 128K,觉得越大越好。但实际上,max_model_len 直接决定了 KV Cache 的峰值占用。设成 128K,框架就要为可能的 128K 长度准备资源。
我的做法是根据实际业务需求设置。如果业务里 99% 的请求都在 8K 以内,那就设 8K 或 16K,别为了那 1% 的极端情况牺牲整体并发。
6.3 调度策略:抢占和交换
当显存真的不够时,框架需要决定牺牲谁。常见策略有两种:
- 交换(Swap):把暂时不活跃序列的 KV Cache 换出到 CPU 内存,需要时再换回来
- 重计算(Recompute):直接丢弃某些序列的 KV Cache,需要时重新算一遍
交换省算力但费带宽,重计算省显存但费算力。vLLM 会根据情况自动选择。理解这个机制有助于你判断为什么某些请求会突然变慢——很可能是它的 KV Cache 被换出或丢弃了。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 加载成功但一对话就 OOM | KV Cache 峰值超显存 | 降 max_model_len、开量化、减并发 |
| 并发上不去 | KV Cache 占用过高 | 换 GQA 模型、开 PagedAttention |
| 长对话越来越慢 | KV Cache 换出/重计算 | 检查显存余量、调调度参数 |
| 显存利用率低 | 未用分页或碎片严重 | 启用 PagedAttention、开前缀共享 |
| 输出质量下降 | KV 量化过度 | 从 INT8 退回 FP8 或 FP16 |
7. 我在实际调优中的几点体会
KV Cache 这个东西,理论看着简单,真到工程里全是细节。我踩过最深的坑是早期用连续分配方案,显存明明显示还有 10G,但新请求就是进不来,最后发现是碎片问题,换成 PagedAttention 后立刻顺畅。
另一个体会是,不要迷信参数。同样标称 32K 上下文的模型,实际能稳定跑多长,取决于你的显存、并发和框架实现。我一般会做压力测试,从短到长逐步加,找到那个"再长就崩"的临界点,然后留 20% 余量作为生产配置。
还有一点,KV Cache 的优化不是孤立的。它和批处理策略、调度算法、量化方案是联动的。单独调一个参数往往效果有限,要整体看。比如你开了 PagedAttention,就可以更激进地提高并发,因为显存利用率上去了;反过来如果还用连续分配,提高并发只会加速 OOM。
最后分享一个小技巧:监控 KV Cache 的实际占用率。vLLM 会暴露 gpu_cache_usage 这类指标,盯着它看,你就能知道自己的显存到底是被浪费了还是真的用满了。这个数字比总显存占用有用得多,它直接告诉你还有多少优化空间。