1. 为什么大模型推理绕不开 KV Cache
第一次接触 Transformer 推理优化的人,十有八九会在某个时刻盯着显存占用曲线发呆:明明模型权重才几个 G,怎么一跑长文本生成,显存就蹭蹭往上涨,batch 稍微开大一点直接 OOM。这个问题背后最大的“元凶”之一,就是注意力机制里那个看似不起眼、实则决定生死的东西——KV Cache。
先把话说直白点:KV Cache 不是什么高深的新算法,它本质上是一种用显存换时间的工程手段。Transformer 在自回归生成时,每生成一个新 token,都要拿当前 token 的 Query 去和前面所有 token 的 Key、Value 做注意力计算。如果不做任何缓存,那每生成一步,前面所有 token 的 K 和 V 都要重新算一遍。生成第 N 个 token 时,计算量是 O(N),生成整个长度为 N 的序列,累计就是 O(N²)。而有了 KV Cache,前面算过的 K、V 直接存下来复用,每步只需要算当前这一个 token 的 K、V,单步计算量降到 O(N),整体复杂度从平方级压到线性级。
这个差别有多大?我拿一个实际场景举例。假设你在部署一个 7B 级别的对话模型,输出长度 512 个 token。不开 KV Cache,注意力部分的计算量大概是开了缓存的几十倍量级,首 token 之后的每个 token 延迟会肉眼可见地飙升,用户体感就是“打字机卡成 PPT”。所以但凡你看到某个推理框架号称“高性能”,它一定在 KV Cache 上做了大量文章。
这篇文章我打算把 KV Cache 从原理到落地讲透。包括它到底缓存了什么、显存怎么算、为什么会有 MQA/GQA 这些变体、PagedAttention 又是怎么解决碎片问题的、长上下文场景下有哪些坑。适合正在做推理部署、想优化显存和吞吐的工程师,也适合想搞明白“为什么我的显存不够用”的算法同学。看完你至少能做到两件事:第一,能自己估算一个模型在给定并发下的 KV Cache 显存开销;第二,知道在什么场景下该选哪种注意力变体和缓存策略。
2. KV Cache 的核心原理与显存账本
2.1 自回归生成到底重复算了什么
要理解 KV Cache,得先回到 Transformer 的注意力公式。标准的缩放点积注意力是这样的:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V在训练阶段,整个序列是并行喂进去的,Q、K、V 都是一次性算出来的,不存在重复计算的问题。但推理阶段是自回归的:你给一个 prompt,模型生成第一个 token,然后把这个 token 拼回输入,再生成第二个,如此循环。
问题就出在这个循环里。生成第 t 个 token 时,模型需要用到前 t-1 个 token 的 K 和 V 来计算注意力。如果每步都重新算,那第 t 步就要重新计算前 t-1 个位置的 K、V 投影。这些投影涉及的是同样的输入、同样的权重矩阵,结果完全一样,纯属重复劳动。
KV Cache 的思路非常朴素:既然算过一次,那就存起来。每生成一个新 token,只计算这个新 token 对应的 K、V,然后追加到缓存里,注意力计算时直接读取整个缓存。这样每步的 K、V 投影计算量从 O(t) 降到 O(1),注意力打分部分仍然是 O(t)(因为要和所有历史位置算相似度),但整体已经从平方级降下来了。
注意:KV Cache 只缓存 Key 和 Value,不缓存 Query。因为 Query 只跟当前 token 有关,历史位置的 Query 在后续步骤里根本用不到。这一点很多人第一次会搞混。
2.2 显存开销的精确计算公式
KV Cache 省了计算,代价是显存。这笔账必须算清楚,否则你永远不知道自己为什么 OOM。单个 token 的 KV Cache 大小可以用下面这个公式估算:
单 token KV 大小 = 2 × num_layers × num_kv_heads × head_dim × dtype_bytes这里的 2 是因为要存 K 和 V 两份。num_kv_heads是关键,它不一定等于注意力头数,后面讲 MQA/GQA 时会展开。dtype_bytes取决于你用 FP16 还是 INT8,FP16 就是 2 字节。
我拿一个具体的配置算一遍,方便你对量级有感觉。假设一个模型有 32 层,隐藏维度 4096,32 个注意力头,head_dim 是 128,用 FP16 推理:
- 单 token KV 大小 = 2 × 32 × 32 × 128 × 2 字节 = 524288 字节 ≈ 0.5 MB
看起来不大?但你要乘以序列长度和并发数。序列长度 4096、并发 16 的情况下:
- 总 KV Cache = 0.5 MB × 4096 × 16 ≈ 32 GB
32 GB 什么概念?一张 24G 的卡直接放不下,还没算模型权重本身。这就是为什么长上下文 + 高并发是显存杀手。如果你把num_kv_heads通过 GQA 降到 8,单 token 大小直接变成原来的四分之一,总占用降到 8 GB,一张卡就能扛住了。这个对比就是 GQA 存在的全部意义。
2.3 为什么说它是“推理加速的核心机制”
有人可能会问,KV Cache 省的是计算,跟“加速”到底什么关系。这里要区分两个指标:计算量和内存带宽。在大模型推理里,尤其是 decode 阶段,瓶颈往往不是算力,而是显存带宽。每生成一个 token,都要把整个 KV Cache 从显存读进计算单元,这个读取量正比于缓存大小。
所以 KV Cache 的设计直接决定了两个东西:一是你能开多大的 batch 和多长的上下文(显存容量),二是每个 token 的生成速度(显存带宽)。这也是为什么后来的优化几乎都围绕“如何让 KV Cache 更小、读取更快”展开——MQA、GQA 是压缩头数,PagedAttention 是减少碎片,量化是压缩精度。理解了这一点,你就理解了整个推理优化领域的主线。
3. 从 MHA 到 GQA:注意力变体怎么选
3.1 MHA、MQA、GQA 的本质区别
原始的 Multi-Head Attention(MHA)里,每个注意力头都有自己独立的 K、V 投影,头数等于 KV 头数。这样表达能力最强,但 KV Cache 也最大。为了压缩缓存,研究者提出了两条路线。
第一条是 Multi-Query Attention(MQA),所有注意力头共享同一组 K、V。也就是说num_kv_heads = 1。KV Cache 直接缩小到原来的 1/num_heads,压缩比非常夸张。但代价是表达能力下降,训练时容易不稳定,生成质量在某些任务上会掉点。
第二条是 Grouped-Query Attention(GQA),算是 MHA 和 MQA 的折中:把注意力头分成若干组,每组共享一组 K、V。比如 32 个注意力头分成 8 组,那num_kv_heads = 8,KV Cache 缩小到 1/4,质量损失比 MQA 小很多。现在主流的中大规模模型基本都默认用 GQA,因为它把压缩比和质量平衡得比较好。
| 变体 | KV 头数 | KV Cache 相对大小 | 表达能力 | 典型适用场景 |
|---|---|---|---|---|
| MHA | 等于注意力头数 | 1x | 最强 | 小模型、质量优先 |
| GQA | 注意力头数的 1/4 到 1/8 | 1/4 到 1/8 | 接近 MHA | 主流大模型默认 |
| MQA | 1 | 1/num_heads | 最弱 | 极致吞吐、边缘部署 |
3.2 选型时到底看什么指标
选哪种变体不是拍脑袋,得看你的部署目标。我一般按下面这个顺序判断。
先看质量容忍度。如果是面向用户的对话产品,生成质量掉一点用户马上能感知,那就优先 GQA,别为了省显存上 MQA。如果是内部批处理任务,比如批量摘要、分类,质量要求没那么苛刻,MQA 换来的吞吐提升很划算。
再看并发规模。并发越高,KV Cache 越吃显存,压缩收益越明显。单用户低并发场景,MHA 和 GQA 的差距可能感知不到;但你要做几十路并发,GQA 省下的显存就是能不能多塞几个请求的区别。
最后看硬件条件。显存小的卡上,GQA 几乎是必选项。我见过有人在 16G 卡上硬跑 MHA 的长上下文模型,结果上下文只能开到 2K,再长就 OOM,体验极差。换成 GQA 版本后上下文直接翻倍。
实操心得:模型选型阶段就要把 KV Cache 开销算进去,别等部署了才发现显存不够。一个简单的判断方法是,用 2.2 节的公式算出单 token 大小,乘以你预期的最大上下文和并发数,看是否超过显存的一半。超过一半就要警惕,因为还要留空间给权重和激活值。
3.3 一个容易忽略的细节:head_dim 的影响
很多人只关注头数,忽略了head_dim。实际上 KV Cache 大小和head_dim是线性关系。有些模型为了提升表达能力,会把head_dim设得比较大(比如 128 甚至 256),这时候即使头数不多,单 token 缓存也不小。
举个例子,同样是 32 层、FP16,配置 A 是 32 头 × head_dim 64,配置 B 是 16 头 × head_dim 128。两者隐藏维度都是 2048,但单 token KV 大小:
- 配置 A:2 × 32 × 32 × 64 × 2 = 262144 字节
- 配置 B:2 × 32 × 16 × 128 × 2 = 262144 字节
诶,居然一样。因为隐藏维度相同的情况下,头数 × head_dim 是定值,KV Cache 总量只跟隐藏维度有关。所以真正决定缓存大小的,是隐藏维度 × 层数,头数只是通过 GQA 的分组比例间接影响。这个认知能帮你在看模型配置时快速估算,不用被头数迷惑。
4. PagedAttention 与缓存管理的工程实践
4.1 传统 KV Cache 的显存碎片问题
早期推理框架给每个请求预分配一块连续的 KV Cache 空间,大小按最大可能长度算。这带来两个浪费:一是内部碎片,请求实际只生成了 100 个 token,但预分配了 2048 的空间,剩下 1948 全浪费;二是外部碎片,不同请求的块大小不一,显存里出现很多无法利用的小空洞。
在低并发时这个问题不明显,但一旦并发上来,碎片能吃掉 30% 到 60% 的显存。我实测过一个场景,理论能开 32 路并发,实际因为碎片只能开 18 路,吞吐直接腰斩。这就是 PagedAttention 要解决的核心痛点。
4.2 分页机制是怎么工作的
PagedAttention 的思路借鉴了操作系统的虚拟内存分页。它把 KV Cache 切成固定大小的块(block),比如每块存 16 个 token 的 KV。每个请求的缓存不再要求连续,而是由一组物理块通过块表(block table)映射起来,逻辑上连续、物理上可以离散。
这样做的好处很直接:块是固定大小的,分配和回收都以块为单位,不会产生外部碎片;请求按需申请块,生成到哪申请到哪,不会预分配浪费,内部碎片最多浪费最后一个块的部分空间。实测下来,显存利用率能从 60% 左右提到 90% 以上,同样的卡能多扛将近一半的并发。
块表的存在还带来一个额外好处:前缀共享。如果多个请求有相同的 system prompt,它们的开头 KV 块可以指向同一份物理块,不用各存一份。这在多轮对话和批量推理场景里省下的显存相当可观。
4.3 落地时的参数调优
用支持 PagedAttention 的框架时,有几个参数值得调。
block size是最关键的。块太小,块表本身占内存,管理开销大;块太大,最后一个块的浪费变多。常见取值是 16,我试过 8 和 32,16 在大多数场景下比较均衡。如果你的请求长度普遍很短,可以调到 8;如果都是长文本,32 也行。
gpu_memory_utilization决定了框架能用多少显存做 KV Cache。默认一般是 0.9,意思是留 10% 给其他开销。这个值别设太满,设到 0.95 以上容易在峰值时 OOM。我一般设 0.85 到 0.9,留点余量更稳。
max_num_seqs控制最大并发请求数。这个不是越大越好,得配合你的显存和上下文长度。设太大,框架会尝试调度更多请求,但 KV Cache 不够时反而触发频繁的换入换出,吞吐不升反降。建议先用小值压测,逐步往上加,找到吞吐拐点。
注意:开启前缀共享后,如果请求的 prompt 前缀差异很大,共享收益会很低,甚至因为块表查找带来额外开销。这种场景下可以考虑关掉,或者把 system prompt 统一化来提高共享率。
5. 长上下文与量化场景下的实战避坑
5.1 长上下文为什么让 KV Cache 失控
上下文从 4K 拉到 32K,KV Cache 是线性增长的,8 倍长度就是 8 倍显存。但问题不止于此:长上下文下,注意力计算本身也变重,decode 阶段每步要读取的缓存更大,显存带宽压力陡增,单 token 延迟会明显上升。
我踩过的一个坑是:以为只要显存够就能开长上下文,结果发现延迟高到没法用。后来才意识到,长上下文场景下 KV Cache 的读取带宽才是瓶颈。解决办法有几个:一是用 GQA 压缩缓存,二是对 KV Cache 做量化,三是用滑动窗口注意力只保留最近的一部分缓存。
滑动窗口是个很实用的技巧。很多任务其实不需要关注全部历史,只保留最近 N 个 token 的 KV 就够了。这样缓存大小被固定住,不随序列增长。代价是丢失了远距离信息,适合那些局部依赖为主的任务,比如流式对话、代码补全。
5.2 KV Cache 量化:省显存但别踩精度坑
把 KV Cache 从 FP16 量化到 INT8,显存直接减半,这是长上下文场景的救命稻草。但量化不是无脑开,有几个细节要注意。
首先是量化粒度。per-tensor 量化最简单,但精度损失大;per-channel 或 per-token 量化精度好很多,实现也复杂一些。我一般优先选 per-token,因为 K 和 V 在不同 token 上的分布差异比较大,逐 token 量化能更好地保留信息。
其次是哪些层可以量化。实测下来,浅层的 KV 对量化更敏感,深层相对鲁棒。如果精度掉得厉害,可以只量化后半部分的层,前面几层保持 FP16。这个策略能在省显存和保质量之间找到不错的平衡。
最后是校准数据。量化需要校准来确定缩放因子,校准数据的分布要贴近实际推理场景。用通用语料校准,结果在特定领域任务上掉点,这种情况我遇到过不止一次。建议用你实际业务的一小批样本做校准。
| 量化方案 | 显存节省 | 精度损失 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|
| FP16 原始 | 0% | 无 | 低 | 短上下文、质量优先 |
| INT8 per-tensor | 50% | 中等 | 低 | 对精度不敏感的任务 |
| INT8 per-token | 50% | 较小 | 中 | 长上下文通用场景 |
| 分层混合量化 | 30% 到 50% | 小 | 高 | 精度要求高的长上下文 |
5.3 常见问题速查
实际部署中,KV Cache 相关的问题翻来覆去就那么几类。我整理成一张表,方便你对照排查。
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 显存随生成持续上涨 | 缓存未释放或泄漏 | 检查请求结束后块是否回收 | 升级框架版本,检查块表释放逻辑 |
| 并发上不去 | 碎片或预分配浪费 | 看显存利用率指标 | 启用 PagedAttention,调小 block size |
| 长上下文延迟高 | 缓存读取带宽瓶颈 | 测单 token 延迟随长度变化 | 用 GQA、量化、滑动窗口 |
| 量化后质量掉点 | 校准数据不匹配 | 对比量化前后输出 | 换 per-token,用业务数据校准 |
| 前缀共享没效果 | prompt 前缀差异大 | 统计共享块命中率 | 统一 system prompt 或关闭共享 |
实操心得:排查 KV Cache 问题时,先看显存曲线。如果曲线是阶梯状上升然后突然下降,说明块在按请求生命周期分配回收,正常;如果只升不降,基本可以确定是泄漏。另外,压测时一定要用真实长度的请求,用固定短请求压出来的并发数没有参考价值。
6. 我个人的一些部署体会
KV Cache 这个东西,原理讲起来半小时能讲完,但真正在工程里用好,靠的是对显存账本的敏感度和对场景的准确判断。我自己最大的体会是:不要等到 OOM 了才去关注它。在模型选型和容量规划阶段,就应该把 KV Cache 开销算进去,把它当成和模型权重同等重要的显存预算项。
另一个体会是,没有万能的配置。GQA 不是永远比 MHA 好,量化不是永远划算,PagedAttention 也不是所有场景都能提吞吐。关键是想清楚你的瓶颈在哪:是显存容量不够,还是带宽不够,还是并发调度效率低。瓶颈不同,优化手段完全不同。我见过有人盲目上量化,结果质量掉了,显存其实本来够用,纯属白折腾。
最后分享一个小技巧:如果你在做容量规划,可以先用一个简化的公式快速估算——显存需求 ≈ 权重 + 2 × 层数 × 隐藏维度 × 序列长度 × 并发 × 精度字节。这个公式忽略了头数细节,但量级上足够准,能帮你在几分钟内判断一个配置是否可行,比跑一遍压测快得多。等粗略估算通过后,再上真实压测做精细调优。