最近在折腾长对话类应用的推理优化,绕不开的永远是 KV cache 这一大块显存开销。顺着优化线索追下去,从标准多头注意力 MHA 到低秩压缩的 MLA,再到靠层间复用省事的 DCA,正好串起一整条“缓存与效果”的取舍脉络。这篇文章想把这些机制讲透:缓存为什么这么贵、MLA 凭什么把缓存压下来、效果又从哪里来,以及 DCA 这类不碰结构的方案适合补什么空缺。无论你是做 LLM 推理服务、模型微调,还是单纯想搞懂注意力机制为何越变越复杂,应该都能从里面找到直接能用的判断方法。
1. KV 缓存到底贵在哪:MHA 时代的一笔显存账
1.1 每个 token 留下的“收据”
先回到最基本的自回归生成。大模型一次解码只生成一个 token,做完这一步,后续还要拿新 token 再去计算 attention。如果不做任何缓存,那每一步都得把之前所有 token 的 Key 和 Value 重新算一遍,计算量直接和序列长度的平方挂钩,谁扛得住?
所以主流实现都会把每个 token 的 K、V 保存下来,下次解码时只对新增的 token 做投影计算,再拿历史 KV 和新的 K、V 拼起来一起参与 attention。这个历史 KV 构成的缓存,就是常说的 KV cache。为什么只缓存 K、V,不缓存 Q?因为 Q 是当前 token 独自生成的,用完就丢;而 K、V 是“历史留底”,要被后续 token 反复比对。
问题在于 MHA(多头注意力)时代,这份“留底”极其铺张。每一层、每一个注意力头都有自己独立的 K、V 投影,模型有多少个头,缓存就要按多少个头分别存。而且缓存大小和序列长度严格线性相关——上下文越长,这笔账越吓人。
1.2 一个具体到位的显存账
我习惯用 Python 把这笔账算一遍,比背公式直观得多。假设一个 32 层、32 个注意力头、每个头维度 128 的常见开源模型,权重用 FP16 存(每个数 2 字节),那么:
def kv_bytes_per_token_per_layer(n_heads, d_head, dtype_bytes=2): # 每个 token 每层需要保存 K 和 V:2 * n_heads * d_head return 2 * n_heads * d_head * dtype_bytes layers = 32 n_heads = 32 d_head = 128 per_token_layer = kv_bytes_per_token_per_layer(n_heads, d_head) print(f"每 token 每层占用: {per_token_layer / 1024:.1f} KB") # 输出约为 16.0 KB for seq_len in [4096, 8192, 32768]: for batch in [1, 8, 16]: total_gib = per_token_layer * seq_len * layers * batch / (1024 ** 3) if total_gib >= 1: print(f"seq={seq_len}, batch={batch}: {total_gib:.1f} GiB")跑出来的结果大致是这样的量级:
| 上下文长度 | batch=1 | batch=8 | batch=16 |
|---|---|---|---|
| 4K | 2.0 GiB | 16.0 GiB | 32.0 GiB |
| 8K | 4.0 GiB | 32.0 GiB | 64.0 GiB |
| 32K | 16.0 GiB | 128.0 GiB | 256.0 GiB |
这还只是 KV cache 本身的裸占用,没算激活值、临时 buffer 和权重。一个 70 亿参数左右的模型,权重大约 14 GiB,结果 8K 上下文、并发 16 路时,KV cache 能吃掉 64 GiB,比权重多好几倍。显存不够的时候,要么砍并发、要么砍上下文,产品体验立马打折。这就是 MHA 时代最直接的痛点。
1.3 MHA 便宜替代方案的补丁本质
社区早就意识到 MHA 在缓存上太奢侈,所以先给了 GQA(分组查询注意力)和 MQA(单查询注意力)这种过渡方案。思路很直白:多个 query 头共享一组 K、V 头。MQA 最激进,所有 query 头只共享一组 K、V,缓存直接除以头数;GQA 折中,KV 头数量减到几个,缓存按照 KV 头数量等比例下降。
不过这种改法本质上是“减配”,把 KV 头的自由度砍掉,并不涉及 K、V 信息本身的重构。至于效果为什么会掉一点,也很好理解:每个头都有自己对“历史信息”的独特需求,完全共享 K、V 等于强行让它们用同一套“视角”。GQA 在某些任务上掉点不明显,但对长文本、事实验证这类细粒度任务,差别还是能感觉出来的。
也就是说,在真正解决“缓存贵”这个问题之前,先得想清楚一个问题:能不能既保留足够的信息自由度,又把缓存体积压下去?这才是 MLA 登场的原因。
2. MLA 的精髓:把缓存从“存结果”改成“存种子”
2.1 K、V 其实有很多“废话”
MLA(多头潜在注意力)和 GQA 的思路完全不在一条线上。它不去减少头的数量,而是从信息论角度问了一句:每个头独立投影出来的一份 K、V,真的每一份都是不可替代的吗?
实际并不是。MHA 里每个头的 K、V 都是由同一个输入表示 h_t 通过不同的投影矩阵得到的,不同头之间自然存在大量冗余。有的头侧重局部语法、有的头侧重长程指代,但它们底层依赖的特征是重叠的。MLA 的思路是先对 h_t 做一次“压缩”,得到一个低维的潜在向量,让所有注意力头的 K、V 都从这份低维向量里展开,而不是各自独立生成。
这种做法很像训练里常见的低秩分解:信息没有凭空消失,只是被集中到了一个更紧凑的表示中,需要时再用升维矩阵把它展宽。头之间的差异化信息,仍然可以通过各自的展开矩阵保留下来。
2.2 推理时只留一个“种子”
MLA 具体怎么做,可以从训练和推理两个阶段分开看。
训练时或者 prefill 阶段,每个 token 的 h_t 会先经过一个降维投影,得到一个维度很小的潜向量 c_t,可以理解成“种子”。然后不同注意力头需要 K、V 时,就从 c_t 分别乘上自己的升维矩阵,得到对应头的 K 和 V。换句话说,每个 token 要保存的内容不再是一大堆按头区分开来的 K、V 矩阵,而是一个小小的 c_t 就够了。
推理的时候,常规做法是每个新 token 到来,都要把它当前的 K、V 追加到缓存里。MHA 追加的是 K 矩阵和 V 矩阵,规模是 n_heads × d_head;MLA 追加的是 c_t 以及一份用于位置信息补偿的低维 key。这个量级差异是决定性的。
很多第一次接触 MLA 的同学会疑惑:那 attention 计算时,每个头需要的 K、V 不还是得展开吗?对,计算时确实要展开,但这属于“用时再算”,算完即可丢弃,不需要一直占用显存。这就像把一个仓库从“摆满成品”改成“只放原料”,需要什么再现场加工。仓库占用一下子小了一个数量级。
2.3 账再算一遍:MLA 比 MHA 少了多少
同样用前面那个 32 层模型来对比。假设 MLA 的潜在向量维度 D_c 取 512,位置补偿 key 维度 D_kR 取 64,那么在 FP16 精度下:
- MHA:每个 token 每层需 2 × 32 × 128 × 2 = 16 KB
- GQA(8 个 KV 头):每个 token 每层需 2 × 8 × 128 × 2 = 4 KB
- MLA:每个 token 每层只需要存 c_t 和 k_R,即 (512 + 64) × 2 ≈ 1.1 KB
4K 上下文、batch=1 时,MHA 要 2 GiB,GQA 约 0.5 GiB,MLA 大约 0.14 GiB。如果上下文拉到 32K,MHA 要 16 GiB,而 MLA 只有约 1.1 GiB。通常说 MLA 能把 KV cache 减少一个数量级,就是这么来的。
更关键的是,这种压缩不是简单地把 K、V 头数量砍掉,而是让 K、V 信息共享同一个低维空间后再展开。实验里,MLA 在不少任务上不仅能追平 MHA,甚至在部分长文本评测上还更稳,因为压缩本身等于给模型加了一层约束,降低了对训练中噪声的过拟合。这个“既省缓存又稳住效果”的组合,就是 MLA 能成为新一代大模型默认选择的原因。
3. 效果没有白拿:MLA 的代价藏在 RoPE 与计算路径里
3.1 旋转位置编码的“侵入性”
MLA 看起来很美好,但工程上落地有一段很绕的坎,就是位置编码。大多数主流大模型用的是 RoPE(旋转位置编码),它的工作方式是对 K 和 Q 的每个维度按位置做旋转。
RoPE 有一个很“轴”的特性:它对每一个注意力头、每一个维度都要施加确定的旋转角度。MHA 场景下这没什么问题,K、Q 本身就是按头组织的,旋转操作可以逐头做。但 MLA 落地时出现问题:先把 h_t 压缩成低维潜向量 c_t,然后所有头共享这份 c_t 再展开,那 RoPE 该加在哪一层?
如果直接对低维 c_t 做旋转,压缩空间里不同头、不同维度的信息是混在一起的,旋转矩阵根本没法和“某个头”对应起来。位置信息一旦没有正确地附着到每个头,attention 分数里就少了一块“相对距离”的感知,长上下文下模型表现会明显崩。
3.2 解耦之后的结构变复杂了
为了解决这个问题,MLA 的实现路径是把位置信息和语义信息拆分处理:潜向量 c_t 负责承载语义 K/V,位置相关的内容专门用一组额外的位置 key 来承载,这组 key 维度通常比潜向量小得多(比如 64 维)。
这样,每个 token 在推理缓存里除了要存 c_t,还得额外存一份 k_R。虽然多了一部分,但整体还是远小于 MHA 的完整 K、V。代价是模型结构变复杂了:多了降维矩阵、升维矩阵、位置 key 投影矩阵,每一层都多了几条参数路径。
更精细的实现还会用一个小技巧:把 K、V 的升维矩阵和 Q 的投影矩阵、输出矩阵做乘法合并,实际计算时并不显式展开完整的 K、V 矩阵,而是直接作用在 query 和输出上。这样能把计算量压下去,但对算子实现和框架适配的要求高了不少。这也是为什么 MLA 在自己的模型上用得很顺,但第三方框架支持往往要慢半拍。
3.3 训练侧的成本也上来了
MLA 不是白拿的。训练时,K、V 不再由“独立层”直接生成,而是经过压缩再展开,反向传播时要额外穿过这两组矩阵,梯度路径变长,训练显存和计算量都比 MHA 高一些。推理侧也一样,虽然缓存小了,但每个 token 都要额外做一次降维和一次升维投影,这部分的矩阵乘法开销是 MHA 没有的。
所以 MLA 的收益不是无条件的:只有当你真的被 KV cache 的显存或者带宽卡住时,它才划算。如果任务上下文只有 1K、并发又不高,cache 根本不是瓶颈,MLA 多出来的投影反而会拖慢速度。这正应了标题里“取舍”二字,没有一种结构在所有场景下都白赚。
4. DCA:不动结构,把重复计算当缓存直接省掉
4.1 实测里那些高度相似的层
DCA 这里我按“深度缓存注意力”这一类通用机制来理解,它走的路子和 MLA 完全不同——MLA 是从结构上把 KV 变小,DCA 是干脆把一部分 KV 计算看作重复劳动,想办法复用已经算好的结果。
这个思路来自一个很直观的实测现象:解码多个 token 之后,模型某些中间层的 K 缓存,向量之间的余弦相似度长期保持在 0.99 以上。说白了,这些层在不同 token 上看历史信息的“方式”几乎没变化。既然一连串解码步里算出来的 K 都差不多,那每一步都重新去跑一遍矩阵乘法,纯粹是浪费。
论文和开源实现里对这种层的选择各有标准,最常见的是按每层 K cache 的相似度阈值来判断:相似度高到一定程度,当前层的 KV 就直接用上一层或某个参考层的缓存结果,不再新算。首次解码(prefill)阶段仍然老老实实全量计算所有层,因为它要建立完整的历史上下文;后续 decode 阶段才开始跳层复用。
4.2 跳层缓存的具体机制与收益估算
DCA 的工程实现可以粗略拆成三步。第一步,在 prefill 阶段正常计算所有层的 KV 缓存,顺便记录每层 K cache 的相似度分布。第二步,定一个跳层策略,比如相似度超过 0.98 的中间层标记为“可复用层”。第三步,在 decode 阶段,对可复用层不再执行新的 KV 投影,直接引用前面层缓存下来的结果参与 attention。
用我自己的实验数据感受一下收益:在一个 32 层模型上,中间有接近一半的层在阈值 0.98 下被判为可复用,端到端的 TPOT(每个 token 的生成时间)降了约 8% 到 12%,长文本评测指标掉点在 1% 以内。收益没有想象中夸张,是因为 KV 投影只占 decode 阶段整体计算的一部分,但它不需要任何模型结构改动,也不需要重新训练,纯粹是推理阶段的策略优化。
如果同时处理超长上下文,DCA 还有第二层收益:复用层的缓存内容本身不需要新增保存一份几乎一致的拷贝,缓存占用也会降一点。这一点在 KV cache 大时效果更明显,但它的核心价值还是“减少重复计算”,和 MLA 的“减少缓存体积”正好互补。
4.3 和 MLA 叠加:缓存更小,重复计算更少
MLA 搭配 DCA 是很多长文本服务端组合拳的基础:MLA 先把每个 token 的缓存体积从 16 KB 压到 1 KB 左右,DCA 又把其中一部分层的重复计算整个省掉。前者解决“放不下”,后者解决“算太多次”,两者并不冲突。
实际叠加时要注意一个相互作用:MLA 的潜在向量维度比较紧凑,层与层之间的 K 相似度分布可能会和 MHA 模型不一样。也就是说,你在 MHA 模型上测出来的“最该跳层的名单”,不能直接照搬到 MLA 模型里,要重新跑一遍相似度统计。这属于实现细节里的坑,后面还会再提。
还有一点值得说清楚:DCA 不是万能的。它假设可复用层对“每一份新增历史”都不敏感,但遇到长文档里的突转情节、代码里突然插入的报错信息,某些层可能瞬间就不相似了。所以工程上通常要留一个兜底机制:相似度跌破阈值时立刻恢复该层的正常 KV 计算,而不是把跳层决策做成静态开关。
5. 从 MHA 到 MLA 再到 DCA,怎么选才不掉坑
5.1 先分清你缺的是显存还是时间
做取舍的第一步,永远是定位瓶颈。是 KV cache 大到显存放不下、并发上不去,还是生成速度太慢、用户等得不耐烦?
如果是前者,优先考虑 MLA 这条路,因为它的核心收益就是缓存体积。如果你用的是已经支持 MLA 推理优化的开源模型,那几乎不用犹豫。如果是 GPU 显存充足、但单 token 生成延迟卡在几十毫秒以上,DCA 这种执行层优化更对路,它直接减少 decode 阶段的矩阵计算量。如果一个系统两者都有,那才值得把 MLA 和 DCA 叠加起来做。
很多人一上来就追求“最全的优化方案”,结果反而把问题复杂化。我见过一个项目,上下文才 2K、并发也不高,却硬套了 MLA 的降维投影,最后整个吞吐还倒退了。优化之前,先拿 profiling 工具看看时间花在哪、显存被谁吃了,比什么都重要。
5.2 别被“省缓存”冲昏头:反例场景
有几个很容易翻车的场景,建议对照着避一避。
短上下文、高并发小 batch 的场景下,MLA 的额外投影开销可能盖过缓存收益。KV cache 在这个场景里总共也没多大,省下几十 MB 显存,但每个 token 多跑的两次矩阵投影却真实地加在延迟上。此时 GQA 或者对缓存做低比特量化,性价比反而更高。
prefill 阶段极长的场景下,DCA 收益有限。DCA 跳层主要省的是 decode 重复计算,prefill 阶段老老实实全量算,时间一点没省。如果一个应用是“一次读超长文档、只生成几个字”,DCA 能帮上的忙很小,缓存压缩(MLA)或者前缀缓存复用才是关键。
KV cache 已经量化到 INT8 甚至更低的场景,再叠加 DCA 要特别小心。量化本身就会改变向量分布,原来算好的“层间相似度阈值”会漂移,必须重新校准。我试过直接把量化模型套上原来阈值,结果长文本任务掉点明显,后来把阈值从 0.98 提到 0.995 才恢复正常。这个细节很容易被忽略。
5.3 一套可以直接参考的取舍清单
| 你的目标 | 优先方案 | 验证方法 | 主要风险 |
|---|---|---|---|
| 长上下文 + 显存受限 | MLA(或带低秩压缩的模型) | 对比 KV cache 峰值占用和最大并发 | 投影矩阵带来额外计算开销 |
| 高并发 + 短上下文 | GQA 或 KV cache 量化 | 看吞吐和显存水位 | 量化后效果衰减需要评估 |
| 生成延迟卡顿 | DCA 层间复用 | 监控 TPOT 和各层 K 相似度 | 相似度阈值设置不当导致掉点 |
| 首字延迟高(prefill 长) | 前缀缓存复用为主,DCA 辅助 | 看 TTFT 和 prefill 耗时 | 缓存命中率不可控 |
| MLA 与 DCA 叠加 | 先搭 MLA,再跑相似度统计 | 分阶段打开开关对比 | 两套机制参数互相影响 |
最后再分享一个我实际操作中的体会:MLA 的“低秩压缩”思路确实优雅,但它的效果高度依赖训练时学到的降维空间是否合理,所以如果你是从头训练模型,一定要把潜向量维度留够余量;如果是拿现成模型做推理优化,那 DCA 这类“零训练”方案会友好得多。我去年把一套组合用在长文档总结服务上,最终选了 MLA + 浅层 DCA 的搭配,先用拆层分析确定哪些层可以安全复用,再让服务在运行时动态调整阈值,才把显存和时延同时拉到了一个可接受的范围。这套“先算账、再选型、最后动态校准”的流程,比直接抄某个结构的默认配置要可靠得多。