大模型Attention发展历程
MHA Multi Head Attention
MHA 相比于传统的 Self-Attention 提升了模型的表达能力。例如一个专家组对比一个专家。
先看代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 def forward ( self, hidden_states: torch.Tensor, position_embeddings: tuple [torch.Tensor, torch.Tensor], attention_mask: Optional [torch.Tensor], past_key_values: Optional [Cache] = None , cache_position: Optional [torch.LongTensor] = None , **kwargs: Unpack[TransformersKwargs], ) -> tuple [torch.Tensor, torch.Tensor]: input_shape = hidden_states.shape[:-1 ] hidden_shape = (*input_shape, -1 , self.head_dim) query_states = self.q_proj(hidden_states).view(hidden_shape).transpose(1 , 2 ) key_states = self.k_proj(hidden_states).view(hidden_shape).transpose(1 , 2 ) value_states = self.v_proj(hidden_states).view(hidden_shape).transpose(1 , 2 ) cos, sin = position_embeddings query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin) attn_output, attn_weights = attention_interface( self, query_states, key_states, value_states, attention_mask, dropout=0.0 if not self.training else self.attention_dropout, scaling=self.scaling, **kwargs, ) attn_output = attn_output.reshape(*input_shape, -1 ).contiguous() attn_output = self.o_proj(attn_output) return attn_output, attn_weights
这个 attention 是 Multi-head self-attention,注意力有头这个概念。其中,Q K V 这三个向量分别是由 hidden_states 乘上一个 Linear 层来计算的。这里面由于是多头注意力机制,所以需要让 Q K V 的 shape 变为 (batch, seq_len, num_head, head_dim)。之后 transpose 成 (batch, num_head, seq_len, head_dim),这么做的目的是为了让后续计算时,每个头可以独立 进行注意力计算,让 num_head 变为“批次”的一部分,注意力仅在 seq_len 维度计算,而不把不同的 head 混在一起算。
再算完这几个向量后,需要对 Q K 向量添加位置信息,这就是 apply_rotary_pos_emb 的作用。之后就是 attention 的计算,也就是我们说的公式:
Attention ( Q , K , V ) = softmax ( Q K T d k ) V \begin{aligned}
\text{Attention}(Q, K, V) &= \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V
\end{aligned}
Attention ( Q , K , V ) = softmax ( d k Q K T ) V
其中,QK 的意思是 Q 和 K 的点积,表示 Q 和 K 之间的相似性,更进一步,这个向量是词向量,是词在高维空间的数值映射。词向量之间相关度高表示什么?是不是在一定程度上(不是完全)表示,在关注词 A 的时候,应当给予词 B 更多的关注?除以 d k \sqrt{d_k} d k 的作用是训练时防止梯度消失。
算完 attention 后,再被 o_projection 映射,结果就算出来了。
MHA (Multi-Head Attention):假设头个数为32,
配置:每个 Query 头都有自己独立的 Key 头和 Value 头。
数量:32 个 Q 头,对应 32 个 K 头 和 32 个 V 头。
特点:完全独立,信息隔离性好,模型表达能力强。
总结:MHA 多头自注意力机制是效果最好的也是最慢的,而 MQA 和 GQA 则是通过不同的方式来提升计算效率。
一些比较出名的模型,如 LLama2-7B、LLama2-13B 使用的都是这种架构。
MQA Multi Query Attention
相比 MHA,MQA 允许一个 Query 头同时关注多个 Key 头和 Value 头,从而在保证信息隔离性的前提下,提升模型的计算效率。
MQA (Multi-Query Attention):假设头个数为32,
配置:所有的 Query 头共享同一组 Key 和 Value 头。
数量:32 个 Q 头,对应 1 个 K 头 和 1 个 V 头。
特点:极端压缩,推理速度极快,但模型效果下降明显(因为所有头被迫看同样的 K/V 信息)。
目前没听说哪个模型全程使用这个架构。但是 DeepSeek V3.2 的 decode 阶段使用了该方式。
GQA Group Query Attention
GQA 相比 MHA,其主要区别在于,GQA 是先进行“组内”注意力计算,再进行“组间”注意力计算。也就是说,GQA 首先将 Q K V 分成多组,然后在组内进行注意力计算,最后在组间进行注意力计算。推理速度更快,普遍用于 70B 及更大的模型中。MHA 则用于稍微小一点的模型中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 def forward ( self, hidden_states: torch.Tensor, attention_mask: Optional [torch.Tensor] = None , position_ids: Optional [torch.LongTensor] = None , past_key_values: Optional [Cache] = None , output_attentions: bool = False , use_cache: bool = False , cache_position: Optional [torch.LongTensor] = None , position_embeddings: Optional [tuple [torch.Tensor, torch.Tensor]] = None , ) -> tuple [torch.Tensor, Optional [torch.Tensor], Optional [tuple [torch.Tensor]]]: bsz, q_len, _ = hidden_states.size() query_states = self.q_proj(hidden_states) key_states = self.k_proj(hidden_states) value_states = self.v_proj(hidden_states) query_states = query_states.view(bsz, q_len, -1 , self.head_dim).transpose(1 , 2 ) key_states = key_states.view(bsz, q_len, -1 , self.head_dim).transpose(1 , 2 ) value_states = value_states.view(bsz, q_len, -1 , self.head_dim).transpose(1 , 2 ) cos, sin = position_embeddings query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin, position_ids) key_states = repeat_kv(key_states, self.num_key_value_groups) value_states = repeat_kv(value_states, self.num_key_value_groups) attn_weights = torch.matmul(query_states, key_states.transpose(2 , 3 )) / math.sqrt(self.head_dim) if attention_mask is not None : causal_mask = attention_mask[:, :, :, : key_states.shape[-2 ]] attn_weights = attn_weights + causal_mask attn_weights = nn.functional.softmax(attn_weights, dim=-1 , dtype=torch.float32).to(query_states.dtype) attn_weights = nn.functional.dropout(attn_weights, p=self.attention_dropout, training=self.training) attn_output = torch.matmul(attn_weights, value_states) if attn_output.size() != (bsz, self.num_heads, q_len, self.head_dim): raise ValueError( f"`attn_output` should be of size {(bsz, self.num_heads, q_len, self.head_dim)} , but is" f" {attn_output.size()} " ) attn_output = attn_output.transpose(1 , 2 ).contiguous() attn_output = attn_output.reshape(bsz, q_len, self.hidden_size) attn_output = self.o_proj(attn_output) if not output_attentions: attn_weights = None return attn_output, attn_weights
可以看见代码里有一个 repeat_kv 的操作,作用是将较少的 Key/Value 头复制扩展,使它们能和 Query 的头数一一对应进行矩阵乘法。这正是 GQA 的典型操作:“分组共享,通过复制来对齐”。
GQA (Grouped Query Attention):
配置:将 Query 头分组,每组 Query 头共享一组 Key 和 Value。
数量:例如分为 8 组(Group=8)。32 个 Q 头,对应 8 个 K 头 和 8 个 V 头(每4个Q头共享1个K/V头)。
特点:介于 MHA 和 MQA 之间。比 MQA 效果好(保留了部分独立性),比 MHA 速度快(KV Cache 大小减少了 4 倍)。
GQA 相比于 MHA,效果上几乎相近,而速度却能达到 MQA 的水平,被广泛的应用在现在的大模型上。
LLama2-70B、LLama3、Qwen2 全系列使用的都是这种架构。
MLA Multi Latent Attention
原版
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 def forward (x, past_kv=None ): q_latent = q_a_norm(q_a_proj(x)) q = q_b_proj(q_latent) q_nope, q_rope = split(q, [nope_dim, rope_dim]) q_rope = RoPE(q_rope) kv = kv_a_proj(x) c_kv, k_rope = split(kv, [kv_lora_rank, rope_dim]) c_kv = kv_a_norm(c_kv) k_rope = RoPE(k_rope) if past_kv is not None : c_kv = cat(past_kv.c_kv, c_kv, dim=seq) k_rope = cat(past_kv.k_rope, k_rope, dim=seq) new_kv = (c_kv, k_rope) kv_up = kv_b_proj(c_kv) k_nope, v = split(kv_up, [nope_dim, v_dim]) k = cat(k_nope, k_rope.expand(-1 , -1 , n_heads, -1 ), dim=-1 ) attn = softmax(q @ k.transpose(-2 , -1 ) * scale) @ v out = o_proj(attn) return out, new_kv
上面代码是 MLA(MHA版本 for prefill)的流程。可以分为以下几步:
1、Q、K、V的下采样线性计算
2、Q上采样运算
3、KV上采样运算(吸收与非吸收两种形态)
4、attention计算
5、O线性运算
在推理中可根据阶段的特征来选择形态,因为序列的变化会让两种计算形态的计算量、显存占用量不一样。序列的变化包括每次输入MLA的序列长度,以及历史的KV cache长度。
prefill 阶段的输入序列长度就是提问的 prompt 长度,而decode阶段的输入序列长度为1;而KV cache的长度取决于特性开启,比如prefix-cache、chunk等。
矩阵吸收
最终参与计算的 query_states 和 key_states 是由“非旋转部分”和“旋转部分”拼接而成的。这样做的好处是,位置信息不会破坏压缩向量的低秩结构。为什么位置编码要这么做呢?因为在实现 MLA (MQA版本 for decode)的时候,有一个小技巧叫做“矩阵吸收”。吸收矩阵形态实际上是把KV的上采样计算移动到Q和O的计算位置。
x W d o w n Q W u p , i Q ( W u p , i K ) T ( c K V ) T x W_{down}^Q W_{up,i}^Q (W_{up,i}^K{})^T (c^{KV}{})^T
x W d o w n Q W u p , i Q ( W u p , i K ) T ( c K V ) T
在这个公式中,可以将 kv 的上投影矩阵W u p , i K W_{up,i}^K W u p , i K 看成是W u p , i Q W_{up,i}^Q W u p , i Q ,这样子,位置信息部分就可以通过矩阵吸收的方式,被W u p Q W_{up}^Q W u p Q “吸收”进去了。但是如果加了常规旋转位置编码的话,位置信息部分就无法被W u p Q W_{up}^Q W u p Q “吸收”进去了。可以看见被隔断了。无法吸收,会导致推理效率下降。
x W d o w n Q W u p , i Q R m R n T ( W u p , i K ) T ( c K V ) T x W_{down}^Q W_{up,i}^Q R_{m} R_{n}^T (W_{up,i}^K{})^T (c^{KV}{})^T
x W d o w n Q W u p , i Q R m R n T ( W u p , i K ) T ( c K V ) T
所以引入了 Decoupled RoPE (解耦 RoPE)。q_rot 和 k_rot 分开处理,这样位置编码和内容编码就可以分开处理,互不干扰。
除了W u p , i K W_{up,i}^K W u p , i K 被吸收到W u p , i Q W_{up,i}^Q W u p , i Q 以外,同理,W u p , i V W_{up,i}^V W u p , i V 也被吸收到W O W^O W O 中。
主要差异点:非吸收形态要经过上采样后再进行attention计算,而吸收形态不用。吸收形态KV的head只有一份(相当于MQA)。为了清晰表达,对head维度进行了广播,使得进入Attention计算时,矩阵维度能完全匹配。
吸收后的MQA版本如下图:
MLA (Multi-Head Latent Attention)
配置:通过生成 compressed_kv,将 KV Cache 的大小压缩了 90% 以上。存储上 compressed_kv 只有一份,接近 MQA。
数量:通过 kv_b_proj 动态还原 KV,保留了多头注意力的表达能力。每个头一个 KV 数量,这一部分接近 MHA。
特点:通过 split 和 cat 操作,把位置信息和内容信息分开处理,使得压缩机制能与 RoPE 完美兼容。
NSA Natural Sparse Attention
该注意力机制首次被提出是在 2025 年的 2 月。由 DeepSeek 针对长序列处理提出的原生稀疏注意力机制,属于 block-wise 的稀疏方案。这个目前只存在于论文中,没有实际用于某个模型。直到 Deepseek-V3.2 中,他们提出了 DSA,可以说是 NSA 的一个变种实现, 属于 token-wise 的稀疏方案。
压缩块,计算块间注意力
标准自注意力需要计算序列中每个词元(Token)与其他所有词元的关系。对于一个长度为 N 的序列,需要计算 N*N 个注意力权重,随长度二次增长。
解决方案是,NSA 不再以单个 token 为基本计算单元,而是将连续的多个 token 聚合成一个 block。例如,将 4096 个词元的序列划分为 64 个块,每个块包含 64 个 token。
计算流程转变:
之前(标准注意力): 处理 4096 个 token -> 需要处理 4096 * 4096≈16.7M 个关系对。
之后(块压缩): 处理 64 个 block -> 先计算块与块之间的注意力。此时,需要处理的关系对数量骤降至 64 * 64=4096 个。
重要性筛选:选出重要的 K 个块
在压缩后的 block 中,筛选出需要详细看的部分:
块压缩是假设所有 block 都同等重要。但实际上,对于当前要处理的 token 来说,某些 block 是关键的而其他的是次要的。NSA 引入动态机制,根据当前的 Q 内容,评估并筛选出最相关的少量关键 block。
系统会为每个 block 计算一个“重要性分数”。这个分数通常基于当前 Q 与每个 block 的“摘要向量”(通常是 block 内 token 的均值或通过一个小型网络生成)的相似度。
在块压缩的基础上增加重要性筛选,系统从64个块中选出最相关的 K 个块(例如 K=8),额外计算当前查询向量与这 K 个关键块内全部 token 的注意力。
滑动窗口
为了保证局部上下文信息的完整性,token 不但要关注遥远的关键块,还要关注紧挨着它的其他 token。NSA 强制性地规定,无论重要性筛选的结果如何,每个 token 都必须关注以其自身为中心的一个局部窗口内的所有其他token。这个窗口是“滑动”的,因为它随着 token 位置的变化而移动。例如,假设滑动窗口大小为 256。那么对于序列中第 500 个 token,无论如何它都会关注第 500-255 至第 500 这个范围内的所有 token。
DSA DeepSeek Sparse Attention
DSA 首次被应用在 Deepseek V3.2 Exp 中,属于 token-wise 的稀疏方案。DeepSeek-V3.2-Exp 仅在 DeepSeek-V3 的基础上新增了 Lightning Indexer 模块,用于选择参与 attention 的 token。
该模块的主要输入是 compressed_q 与 MLA 的输入矩阵 hidden_states,输出则是每个 token 所对应的 2048 个可参与 attention 计算的历史 token 的 index。
有效去除不需要参与 attention 的 token。这样实现了高效的稀疏化,显著降低了计算量。从O(L^2)降到了 O(LK) ,其中 K<<L 为 2048。
总体框架如下:
闪电索引器伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 def lightning_indexer_and_topk (x, q_latent, index_kv_cache=None , k=2048 ): """ DSA 新增部分:闪电索引器 + Top-K 选择器 x: [B, S, D] 当前输入隐藏状态 q_latent: [B, S, q_rank] 来自 MLA 的 query 低秩潜向量 index_kv_cache: 索引器历史 Key 缓存 """ q_idx = index_q_proj(q_latent) k_idx = index_k_proj(x) w_idx = index_w_proj(x) q_idx = partial_RoPE(q_idx) k_idx = partial_RoPE(k_idx) if index_kv_cache is not None : k_idx_full = concat(index_kv_cache, k_idx, dim=seq) else : k_idx_full = k_idx new_index_kv = k_idx_full raw_scores = q_idx @ k_idx_full.transpose(-2 , -1 ) raw_scores = ReLU(raw_scores) weighted = w_idx * raw_scores scores = weighted.sum (dim=heads) topk_indices = TopK(scores, k) return topk_indices, new_index_kv
公式如下:
I t , s = ∑ j = 1 H I w t , j I ⋅ ReLU ( q t , j I ⋅ k s I ) I_{t,s} = \sum_{j=1}^{H_{I}} w_{t,j}^{I} \cdot \operatorname{ReLU}\left( \boldsymbol{q}_{t,j}^{I} \cdot \boldsymbol{k}_{s}^{I} \right)
I t , s = j = 1 ∑ H I w t , j I ⋅ R e L U ( q t , j I ⋅ k s I )
其中,绿色部分是 indexer 实现。而框住的部分是函数 fp8_index 部分。可以看到,虽然说 attention 的计算复杂度降低了,但是实际上选择不同 token 重要性这个 indexer 模块,也算是个 attention。还是需要计算查询相对于之前每个 token 的 attention 的重要性分数,复杂度还是 O ( n 2 ) O(n^2) O ( n 2 ) 。只不过这个过程是在 fp8 精度下进行的,所以效率会比较高。
为什么需要哈达玛变换"rotate_activation"?
直接量化 q 和 k 向量可能会造成精度损失。引入旋转变换操作:将大范围数值打散到小范围。可以与随机的正交矩阵相乘来做这件事,使得向量数值分布均匀且模长不变。不过矩阵乘法代价较大。于是使用小代价的哈达玛变换。
DeepSeek V4 Attention: Sliding Window Attention + Compressed Sparse Attention + Heavily Compressed Attention
总体框架如下:
对于 SWA,没有 indexer 也没有 compressor, compress_ratio = 0。
对于 CSA,既有 indexer 也有 compressor, compress_ratio = 4。
对于 HCA,没有 indexer 只有 compressor, compress_ratio = 128。
SWA Sliding Window Attention
关于 attention sink:
如何让大模型处理超出其预训练长度的对话?直接的方案是使用“滑动窗口”,即只保留最近一部分token的键值缓存(KV Cache),丢掉最旧的。这种方法虽然高效,但实验结果却出乎意料地失败了。一旦最开始的几个 token(哪怕只是一个起始符或 the 之类的普通词)被从缓存中移除,模型的困惑度(Perplexity)就会急剧飙升,输出的文本瞬间变得语无伦次。这个现象令人费解,因为这些初始token在语义上似乎并不重要,但移除它们却导致了灾难性的后果。
后来原因找到了:在模型的多数层中,无论当前模型在预测哪个词,总有大量的注意力会“回头看”,集中在序列最开始的几个 token 上。因为 softmax 函数有个硬性约束,概率和为1.这意味着注意力机制不能“弃权”,必须把注意力分配到某个地方。当一个注意力头找不到特别相关的上下文时,它就会把这份“预算”投向那些因在训练中频繁出现而具有轻微偏见的初始位置,这些位置逐渐演变成专门存放闲置注意力的“泄压阀”。
这个发现让之前滑动窗口失败的原因豁然开朗。当移除初始 token 时,我们不只是丢掉了旧的上下文,而是破坏了 softmax 函数分母中的一个重要部分,从而摧毁了模型维持注意力稳定的基础机制。解决方案因此变得异常简单:既然模型如此依赖这些注意力池,那我们就永远不要丢掉它们。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def swa_attention_forward (x, past_kv=None ): """ SWA:纯滑动窗口注意力(compress_ratio = 0) """ q_latent = q_norm(q_a_proj(x)) q = q_b_proj(q_latent) kv = kv_norm(wkv(x)) kv_full = concat(past_kv.kv, kv) if past_kv else kv mask(i, j) = 0 if (i - W < j <= i) else -inf attn = softmax( (q · kv_fullᵀ) * scale + mask ) · kv_full out = o_proj(attn) return out, kv_full
compressor
对于 compressor,主要将多个 kv cache 按照压缩率压缩:
1 2 3 4 5 token 0 1 2 3 | 4 5 6 7 | 8 9 10 11 ↓ ↓ ↓ score softmax+加权求和 ↓ ↓ ↓ kv_cache[0] kv_cache[1] kv_cache[2]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 def compressor_forward (kv, past_state=None ): """ kv: 当前窗口的输入 [1, hidden_size] past_state: 前一个窗口缓存的状态 (pre_state) """ kv_state = kv_proj(kv) score_state = gate_proj(kv) cur_state = kv_state if past_state is not None : pre_state = past_state cat_state = cat(pre_state, cur_state) else : cat_state = cur_state score = softmax(score_state + APE) weighted = kv_state * score compressed = RMSNorm(sum (weighted)) new_past_state = cur_state return compressed, new_past_state
CSA Compressed Sparse Attention
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 def csa_attention_forward (x, past_kv=None , past_window=None , W=128 ): """ CSA:滑动窗口 + 轻度压缩(m=4)+ 稀疏 Top-K """ q_latent = q_norm(q_a_proj(x)) q = q_b_proj(q_latent) kv = kv_norm(wkv(x)) window_kv = concat(past_window.kv, kv) if past_window else kv window_kv = keep_last(window_kv, W) compressed = compressor(kv, m=4 , overlap=True ) compressed_full = concat(past_kv.compressed, compressed) if past_kv else compressed q_idx = partial_RoPE(index_q_proj(q)) k_idx = partial_RoPE(index_k_proj(compressed_full)) w_idx = index_w_proj(q) scores = Σ( w_idx · ReLU(q_idx · k_idxᵀ) ) topk_idx = TopK(scores, k) kv_sparse = gather(compressed_full, topk_idx) kv_combined = concat(window_kv, kv_sparse) attn = softmax( (q · kv_combinedᵀ) * scale ) · kv_combined out = o_proj(attn) return out, compressed_full, window_kv
具体来说,CSA 的主干是先做 token 压缩(每 4 个压缩成 1 条),再用 Lightning Indexer 做 top-k 稀疏选择;但压缩会让相邻 token 之间失去直接互看的能力,所以会叠加一个滑动窗口来保留“最近未压缩的精确 KV”。这个窗口在 DeepSeek V4 里一般是 128 个 token。
indexer 作用和 DSA 完全相同,不同的事,筛选前的 kv cache 是经过 compressor 压缩的 kv cache。即:
1 2 3 4 5 256 个原始 token ↓ 压缩 4 倍 (ratio=4) 64 个压缩 KV ↓ Indexer 打分 + topk 从这 64 个里选出 index_topk 个 id
Heavily Compressed Attention
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 def hca_attention_forward (x, past_kv=None , past_window=None , W=128 ): """ HCA:滑动窗口 + 重度压缩(m=128)+ 稠密注意力 """ q_latent = q_norm(q_a_proj(x)) q = q_b_proj(q_latent) kv = kv_norm(wkv(x)) window_kv = concat(past_window.kv, kv) if past_window else kv window_kv = keep_last(window_kv, W) compressed = compressor(kv, m=128 , overlap=False ) compressed_full = concat(past_kv.compressed, compressed) if past_kv else compressed kv_combined = concat(window_kv, compressed_full) attn = softmax( (q · kv_combinedᵀ) * scale ) · kv_combined out = o_proj(attn) return out, compressed_full, window_kv
Kimi K3: KDA (Kimi Delta Attention) + Gated MLA
KDA Kimi Delta Attention
示意图如上,伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def kda_forward (x, cache ): q = self.q_conv1d(self.q_proj(x)) k = self.k_conv1d(self.k_proj(x)) v = self.v_conv1d(self.v_proj(x)) z = f_b_proj(f_a_proj(x)) alpha = exp(-5.0 * sigmoid(exp(A_log) * z)) beta = sigmoid(b_proj(x)) gate = sigmoid(g_proj(x)) S = cache.state S = S * alpha v_old = k @ S S = S - beta * k * v_old S = S + beta * k * v x = q @ S x = RMSNorm(gate * x) return o_proj(x)
逐个模块解释
q/k/v 与短卷积。 三条独立投影把 7168 升到 96×128,随后各自沿序列做一次 k=4 的因果卷积,只用到当前位置和前 3 个位置。作用是让 key 带上局部上下文。否则写进状态的 key 是单 token 的裸特征,delta 规则很难表达"这个词在短语里是什么角色"。
遗忘强度 α。 形状是 [96, 128]:每个头的 128 个通道各有各的衰减速度,这是 KDA 相对 Gated DeltaNet(每头一个标量、共 96 个值)的唯一差别。为了省参数,门先经过 7168 → 128 → 12288 的低秩瓶颈;最后用 -5·sigmoid(·) 再取指数,把 α 压进 (e⁻⁵, 1]。下限 −5 是数值考虑:保证 1/Γ 落在 BF16 范围内,分块算法里的对角块才能用稠密矩阵乘。
写入强度 β。 每头一个标量。它在擦除和写入两步里共用,含义是「沿当前 key 这个方向,重写多少份额」。
输出端。 q @ S 一次 GEMV 拿到全部结果——没有 softmax,也没有对历史的扫描。之后乘一个全秩的 input-dependent 门、过 RMSNorm、再 o_proj 回到 7168。注意 KDA 层有 这个 RMSNorm。
计算流程解释
① 遗忘:S ← α ⊙ S。 α 是逐通道的([96, 128]),所以同一个头里不同通道的旧信息按不同速度褪色——「当前主语是谁」可以很快忘掉,「这个变量名映射到哪个函数」可以留很久。这一步不区分方向,是整个矩阵一起打折。
② 擦除:S ← S − β·k(kᵀS)ᵀ。 先看 kᵀS:用当前 key 去读一遍状态,得到「状态里针对这个 key 已经存着的 value」,形状是 [128](value 方向)。然后沿 k 方向把它按 β 的比例抹掉:β=1 时这一方向的旧值被完全清空,β=0.5 时只擦一半。注意擦的是衰减之后的旧值 ,所以遗忘和擦除不会重复打折。
③ 写入:S ← S + β·k vᵀ。 同样是沿 k 方向,把这次的 v 按 β 的比例写进去。擦除和写入用同一个 k、同一个 β,于是这一方向最终变成 (1−β)·旧值 + β·新值——一个可覆盖的字典,而不是只增不减的账本。
三步合起来就是:
S t = ( I − β t k t k t ⊤ ) ( α t ⊙ S t − 1 ) + β t k t v t ⊤ S_t = \big(I-\beta_t k_t k_t^{\top}\big)\big(\alpha_t \odot S_{t-1}\big) + \beta_t\, k_t v_t^{\top}
S t = ( I − β t k t k t ⊤ ) ( α t ⊙ S t − 1 ) + β t k t v t ⊤
展开后与论文里那个递推式完全等价 :
S t = α t ⊙ S t − 1 + β t k t ( v t − k t ⊤ ( α t ⊙ S t − 1 ) ) ⊤ S_t = \alpha_t \odot S_{t-1} + \beta_t\, k_t \big(v_t - k_t^{\top}(\alpha_t \odot S_{t-1})\big)^{\top}
S t = α t ⊙ S t − 1 + β t k t ( v t − k t ⊤ ( α t ⊙ S t − 1 ) ) ⊤
特点:
Kimi K3 完全取消了 RoPE,在所有注意力层(包括 KDA 和 MLA)都使用 NoPE(No Positional Embeddings)。位置信息由 KDA 层自身来编码。
把 Query 与所有历史 Key 的相似度,转化为一组加权系数,用来对历史 Value 做加权求和。所以KDA的attention输出没有 softmax。
由于linear attention的特殊性,面对prefix caching,处理方式也不同。传统KV Cache是"存储所有历史信息",而KDA的Prefix Cache是"在关键时刻保存状态的快照,需要时从快照恢复"。sglang 是在radix tree分叉节点保存快照S,vllm是按固定的 token 间隔(如每 32K token)保存检查点。
Gated MLA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 def Gated_MLA (x, cache ): q = Linear_Q_down(x) q = RMSNorm(q) q = Linear_Q_up(q) q_nope, q_rope = split(q) q_rope = RoPE(q_rope) Q = cat(q_nope, q_rope) kv = Linear_KV_down(x) kv_lora, k_rope = split(kv) kv_norm = RMSNorm(kv_lora) k_nope, V = split(Linear_KV_up(kv_norm)) K = cat(k_nope, k_rope) gate = Sigmoid(Linear_Gate(x)) attn_out = Attention(Q, K, V) gated_out = attn_out * gate out = Linear_O(gated_out) return out
基本上就是MLA加了个门控单元。而加入门控后,模型根据当前层的原始输入 x 计算出一个 0~1 之间的门控信号。这个信号相当于一个软开关(Soft Switch):决定哪些通道的注意力信息应该被保留,哪些应该被抑制。这是一种数据依赖的非线性特征选择。
图中 KV 侧的 k_rope 标有 False(不应用 RoPE),说明位置信息主要由 Q 侧或外部机制提供。门控机制在这里起到了内容与位置信息的解耦调节器作用。它根据当前的内容状态x,动态决定是否需要采纳带有位置信息的注意力结果,防止位置偏差导致注意力汇聚(Attention Sink)等异常。