Deep Read · Attention & KV Cache

MLA 之后,KV cache 确实得重新理解一遍

从 MHA 到 MLA 的数学账 | 答 0xSean 之问
推文 x.com/bookingX0/status/2098946815131525573 · 解读成稿 2026-09-13
上百个小瓶子经铜管汇入一只小烧瓶,旁边别着一张便签
MLA 的样子。一百多个头各自的 K、V,被蒸馏进每 token 一段 512 维的潜向量,旁边那张便签是 64 维的 RoPE 位置通道。

9 月 13 日凌晨,0xSean 在 X 上引了 @dongxi_nlp 一句话,问"那 MLA 把 KV 压缩之后,cache 的逻辑是不是又得重新理解一遍?"

短答,要。而且改动比"压缩"两个字深得多。MLA 改的不只是 cache 的大小,是 cache 存什么、怎么寻址、按什么粒度共享、误差由谁承担、kernel 怎么写。下面把这层窗户纸捅破,从标准 KV cache 的账算起,推到 MLA 的吸收恒等式,再回头回答这个问题。

先把标准 KV cache 的账摆出来

自回归生成时,每步都要拿当前 query 去和全部历史 token 的 key、value 做点积,所以历史 K、V 必须留在显存里,这就是 KV cache。标准多头注意力(MHA)下,每个 token 每层要存

每 token 每层缓存量 = 2 × n_heads × d_head 个元素

2 是 K 和 V 两份。拿 DeepSeek-V2 这个量级的配置比划,128 个头、每头 128 维,每 token 每层要存 2×128×128 = 32768 个元素,bf16 下 64 KB。60 层模型就是每 token 约 3.8 MB。128K 上下文下来,光 KV cache 就要吃掉近 480 GB 显存。长上下文推理时,显存的大头常常就是这份 cache。

GQA 和 MQA 是第一代解法,让多个 query 头共享同一份 K、V。缓存按共享组数缩小,代价是表达能力跟着缩,头数砍得越狠质量掉得越多。这是一条用质量换显存的路。

每 token 每层缓存元素数(线性比例) MHA(128 头) 32768 GQA(8 组) 2048 MQA(1 组) 256 MLA(潜向量+RoPE) 576(512 + 64)
图 1 | 缓存量的四个时代。GQA/MQA 用质量换显存,MLA 在 576 这档上保住 MHA 级别的表达。

MLA 的三步

MLA(Multi-head Latent Attention,DeepSeek-V2 首发)换了条思路。它不共享头,而是给 K、V 找一个公共的低秩"母体",只存母体。

第一步,训练时

输入 x 先被压进一个 512 维的潜向量,K 和 V 再分别从潜向量升维长出来

c_kv(t) = W_DKV · x(t)     # 下投影,512 维,这是要缓存的东西
k(t)    = W_UK  · c_kv(t)  # 上投影回 128 头 × 128 维
v(t)    = W_UV  · c_kv(t)

512 维的 c_kv 携带着重建全部 32768 维 K、V 所需的信息,因为 W_UK、W_UV 是训练学出来的固定映射。缓存的对象从"每个头各自的 K、V"换成"每个 token 一段 512 维摘要",外加一条后面要讲的位置便签。

第二步,推理时的吸收

关键一步。W_UK 是常量矩阵,注意力分数可以改写

score(i,j) = q(i)ᵀ · k(j)
           = q(i)ᵀ · W_UK · c_kv(j)
           = (W_UKᵀ · q(i))ᵀ · c_kv(j)   # 把 W_UK 预乘进 query

于是打分直接在潜向量 c_kv 上做,k 从头到尾不用真的算出来。输出同理

o(i) = Σ_j a(i,j) · v(j)
     = Σ_j a(i,j) · W_UV · c_kv(j)
     = W_UV · ( Σ_j a(i,j) · c_kv(j) )   # 潜空间加权和,最后一次性升维

第三步,结论

推理时的 MLA 在缓存层面等价于 MQA,所有头共享同一份"键值";在表达能力上仍是 MHA,每个头有自己吸收出来的 W_UKᵀ·q。训练时是多头,推理时是单头缓存,两头的好处都拿了。

数字对一下账。每 token 每层缓存 512(潜向量)+ 64(位置便签)= 576 个元素,对照 MHA 的 32768,正好就是 DeepSeek-V2 论文摘要里那个 93.3% 的压缩率,同一篇摘要还给了 5.76 倍的最大生成吞吐和 42.5% 的训练成本节省。

训练时(物化 K、V) 推理时(吸收,不物化) x(t) c_kv(t) 512 维 K = W_UK·c V = W_UV·c x(t) c_kv(t) 512 维,进缓存 q̂ = W_UKᵀ·q 每头一份 W_UV·Σa·c 末段升维
图 2 | 同一套权重两种形态。左,训练时 K、V 真的被算出来;右,推理时缓存里只有 c_kv,K、V 被吸收进 query 和输出投影。

RoPE 为什么必须解耦,这是全篇最妙的一步

吸收有个天敌,位置编码。RoPE 给 q 和 k 各乘一个随位置旋转的矩阵

score(i,j) = (R_i · q̃(i))ᵀ · (R_j · k̃(j)) = q̃(i)ᵀ · R_(j-i) · k̃(j)

如果把旋转矩阵塞进低秩路径,分数变成 (W_UKᵀ·q)ᵀ · R_j · c_kv(j)。R_j 站在缓存向量旁边且每个位置都不同,意味着每次算注意力都得把缓存的 c_kv 重新旋转一遍,吸收就废了,缓存的潜向量没法固定下来。

DeepSeek 的解法是给位置信息单开一条窄路。主路径彻底不带 RoPE,干干净净地被吸收;另造一个 64 维的共享 key

k_R(t) = RoPE( W_KR · x(t) )   # 64 维,所有头共用
score  = (q_C)ᵀ · k_C + (q_R)ᵀ · k_R

位置信号全由这条 64 维小通道扛着,它就是 MLA 每个 token 必须交的"位置税",也是缓存里除潜向量外唯一多出来的东西。

所以 cache 逻辑到底哪里要重写

回到推文的问题,逐条对。

收个尾

MLA 没有消灭 KV cache,自回归就甩不掉历史。它把 cache 从"每个头各自保存的回忆"换成"每个 token 一段压缩摘要加一小条位置便签"。cache 的哲学没变,cache 的对象、代数和工程实现的确实得重新理解一遍。0xSean 这一问,问到点子上了。