ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Transformer核心算法演进:从注意力机制到MoE的工程实践

Transformer核心算法演进:从注意力机制到MoE的工程实践

1. 从一张注意力热力图说起:Transformer到底在算什么

第一次把Transformer的注意力权重可视化出来的时候,我盯着那张热力图看了很久。输入是一句普通的中文,模型在编码"它"这个代词时,权重明显压在了前文的某个名词上。那一刻我意识到,这个架构真正做的事情,不是"理解语言",而是在高维空间里动态地决定"当前这个位置应该看哪里、看多少"。

这就是Transformer的核心。它抛弃了循环结构,抛弃了卷积核的局部假设,用一套纯粹的注意力计算,把序列中任意两个位置之间的关系都变成了可学习的权重。2017年那篇《Attention Is All You Need》出来的时候,很多人第一反应是"这不就是把Seq2Seq的注意力单独拎出来了吗",但真正动手复现过的人才知道,去掉RNN之后带来的并行性和可扩展性,才是它统治接下来近十年深度学习的原因。

这篇文章我想聊的不是"Transformer是什么"这种入门科普,网上已经太多了。我想聊的是核心算法的演进脉络——从最原始的点积注意力,到多头机制,到位置编码的迭代,到稀疏化、线性化、分组查询,再到MoE这种把FFN层也拆开的路子。每一步演进背后都有明确的动机:要么是算力瓶颈,要么是长序列建模的困难,要么是训练稳定性问题。我会把每一步的"为什么"讲清楚,配上可以手算的小例子和关键参数的计算过程,最后给一份实操中踩过的坑和排查清单。

适合谁看?如果你已经能默写Transformer的结构图,但说不清楚"为什么除以根号dk""为什么多头要降维""为什么后来的模型都用RoPE",那这篇正好。如果你刚开始学,也能看懂,因为我会尽量用生活化的类比把数学直觉讲明白。

2. 注意力机制的原始形态与它的三个致命问题

2.1 缩放点积注意力:一个除法背后的数值稳定性

原始注意力公式写出来很简单:

Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V

Q是查询,K是键,V是值。用生活化的说法:你手里有一个问题(Q),面前有一堆资料卡片(K),每张卡片上写着内容(V)。你先拿问题去和每张卡片的标题比对相似度(QK^T),相似度高的卡片你就多看一眼,最后把所有卡片的内容按关注程度加权求和。

那个除以根号dk的操作,很多人初学时会忽略。我试过把它去掉,在dk=64的时候,训练到几百步loss就开始震荡。原因很直接:Q和K的每个分量如果都是均值0方差1的独立随机变量,那么它们的点积的方差就是dk。dk=64时,点积的数值范围会落在正负二三十这个量级。softmax对输入的尺度极其敏感,输入一大,输出就趋近于one-hot,梯度几乎消失。除以根号dk之后,方差被拉回1,softmax工作在梯度健康的区间。

注意:这个缩放因子是sqrt(d_k)而不是d_k,因为方差是线性累加的,标准差才是开根号。这个细节在面试里被问到的频率极高。

2.2 三个致命问题:串行、长程衰减、单视角

原始注意力本身没问题,但把它放进完整架构里,配合当时的工程条件,暴露出三个问题。

第一个是位置信息的缺失。注意力是置换等变的,你把输入序列打乱,输出只是跟着打乱,模型根本不知道谁在前谁在后。"猫追老鼠"和"老鼠追猫"在纯注意力眼里是一样的。所以必须额外注入位置信息,这就引出了后面位置编码的整条演进线。

第二个是单头注意力的表达瓶颈。一个softmax分布只能表达一种关注模式。但语言里的关系是多元的:有的位置需要关注语法主谓关系,有的需要关注指代,有的需要关注语义相似。一个头只能选一种,就像一个人同时只能盯一个方向。多头机制就是来解决这个的。

第三个是计算复杂度随序列长度平方增长。QK^T是一个n×n的矩阵,n是序列长度。n=512时还好,n=8192时这个矩阵就是六千多万个元素,显存直接爆炸。这条线催生了后面所有的稀疏注意力、线性注意力、FlashAttention等工作。

2.3 一个可以手算的小例子

假设序列长度n=3,dk=2,我们手动算一遍。

Q = [[1,0],[0,1],[1,1]],K = [[1,0],[0,1],[1,1]],V = [[1,2],[3,4],[5,6]]。

先算QK^T:

  • 位置0对位置0:1×1+0×0=1
  • 位置0对位置1:1×0+0×1=0
  • 位置0对位置2:1×1+0×1=1

第一行就是[1, 0, 1]。除以sqrt(2)≈1.414,得到[0.707, 0, 0.707]。softmax之后大约是[0.39, 0.22, 0.39]。也就是说位置0把注意力平均分给了位置0和位置2,忽略了位置1。

这个手算过程建议你自己拿纸推一遍,比看十遍公式都管用。理解了单头的计算,多头只是把它并行做几次再拼接。

3. 多头注意力:为什么要拆、怎么拆、拆完怎么合

3.1 多头的本质是子空间投影

多头注意力的做法是:把dk维的Q、K、V分别用不同的线性层投影到更低的维度,每个头在自己的低维子空间里独立做注意力,最后把结果拼接再投影回来。

为什么要降维?假设模型维度d_model=512,头数h=8。如果不降维,每个头都在512维上做注意力,8个头就是8倍计算量。实际做法是每个头降到512/8=64维,8个头加起来的总计算量和单头在512维上做是一样的。所以多头不是"更贵",而是"同样的预算下换8个视角看问题"。

这个设计背后的直觉:不同的头会自发学到不同的关注模式。我实际训练过的小模型里,确实能观察到有的头专门盯相邻位置(类似局部卷积),有的头盯句首的[CLS]或句尾,有的头盯标点符号。这种分工不是人为设计的,是训练自己涌现出来的。

3.2 头数怎么选:一个被低估的超参数

头数h和每头维度dk_head的关系是dk_head = d_model / h。这里有个经验性的取舍。

h太小,比如h=1,就退化成单头,表达力不够。h太大,比如d_model=512时h=64,每头只有8维,每个头的表达能力太弱,注意力分布容易退化。实践中d_model=512配h=8,d_model=768配h=12,d_model=1024配h=16,是比较常见的配置,每头维度保持在64左右。

我踩过的坑:有一次为了省显存把h设成16但d_model只有256,每头16维,训练loss能降但验证集效果很差,注意力图看起来非常杂乱。后来调回h=8就好了。每头维度低于32时,要警惕表达力不足的问题。

3.3 拼接与输出投影的必要性

多头算完之后是拼接(concat),然后过一个输出投影矩阵Wo。这个Wo经常被忽略,但它不是可有可无的。拼接只是把8个头的输出在通道维度上摞起来,各头之间还没有交互。Wo的作用是让不同头的信息在这个线性层里混合,相当于一个"汇总"操作。去掉Wo,各头就是完全独立的,模型容量会下降。

代码上大概是这个结构:

class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() assert d_model % num_heads == 0 self.d_model = d_model self.num_heads = num_heads self.d_k = d_model // num_heads self.W_q = nn.Linear(d_model, d_model) self.W_k = nn.Linear(d_model, d_model) self.W_v = nn.Linear(d_model, d_model) self.W_o = nn.Linear(d_model, d_model) def forward(self, q, k, v, mask=None): batch_size = q.size(0) # 投影并拆头: (B, n, d_model) -> (B, h, n, d_k) Q = self.W_q(q).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) K = self.W_k(k).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) V = self.W_v(v).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) # 缩放点积注意力 scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn = F.softmax(scores, dim=-1) out = torch.matmul(attn, V) # (B, h, n, d_k) # 合并头 out = out.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) return self.W_o(out)

提示:view之前一定要contiguous(),否则transpose之后内存不连续,view会报错。这个错误我见过太多次了。

4. 位置编码的演进:从正弦波到旋转位置编码

4.1 正弦位置编码:优雅但外推能力有限

原始Transformer用的是正弦位置编码,用不同频率的sin和cos函数生成位置向量,直接加到词嵌入上。公式是:

PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))

这个设计的好处是它允许模型通过线性变换表达相对位置关系,因为PE(pos+k)可以写成PE(pos)的线性函数。但实际用下来,它的外推能力一般。训练时序列长度512,推理时给1024,效果会明显下降,因为模型没见过那么大的位置值。

4.2 可学习位置编码:简单但无法外推

BERT用的是可学习的位置嵌入,就是一个nn.Embedding(max_len, d_model)。简单直接,效果也不错。但缺点更明显:max_len是硬编码的,超过这个长度直接报错,完全没有外推能力。而且位置嵌入和词嵌入一样是查表,没有利用位置的连续性先验。

4.3 相对位置编码与RoPE:当前的主流选择

真正解决外推问题的是相对位置编码的思路。核心想法是:模型关心的不是"这个token在第5位",而是"这两个token相距多远"。T5用的相对位置偏置、Transformer-XL的相对编码都属于这一类。

目前最主流的是旋转位置编码(RoPE)。它的做法不是加位置向量,而是把Q和K向量按位置进行旋转。具体来说,把d维向量两两配对,每一对看作复平面上的一个点,根据位置pos旋转一个角度。这样两个位置的内积只依赖于它们的相对距离,天然具备相对位置的性质。

RoPE的数学形式:对第i对分量,旋转角度θ_i = pos / 10000^(2i/d)。用复数表示就是乘以e^(i·pos·θ_i)。实现上等价于:

def apply_rope(x, sin, cos): # x: (B, h, n, d_k) x1, x2 = x[..., ::2], x[..., 1::2] # 旋转 rotated = torch.stack([-x2, x1], dim=-1).flatten(-2) return x * cos + rotated * sin

RoPE的好处是:相对位置性质天然具备,外推能力比正弦编码好很多,而且不增加额外参数。现在主流的开源大模型基本都用RoPE或者它的变体(比如NTK-aware插值、YaRN),用来支持超长上下文。

注意:RoPE的频率基数是10000,这个值在长上下文场景下会被调整。把基数调大(比如改成500000)可以让模型在更长序列上保持区分度,这就是所谓的NTK-aware插值思路。

4.4 ALiBi:另一种极简方案

ALiBi(Attention with Linear Biases)走的是另一条路:不加位置编码,而是在注意力分数上直接加一个和距离成正比的负偏置。距离越远,惩罚越大。这个方案极其简单,没有额外参数,外推能力也不错。但它在需要精确位置信息的任务上(比如某些结构化预测)表现不如RoPE。

5. 效率演进:从O(n²)到线性与稀疏

5.1 平方复杂度的瓶颈到底卡在哪

标准注意力的计算量是O(n²·d),显存也是O(n²)。n=2048时,注意力矩阵是4M个元素,还能接受。n=32768时,就是10亿个元素,单是存这个矩阵就要几个GB。这就是长上下文的核心障碍。

5.2 稀疏注意力:只算该算的部分

稀疏注意力的思路是:不是所有位置对都需要计算。比如Longformer用了滑动窗口加全局token的模式,每个token只关注附近w个token和少数几个全局token,复杂度降到O(n·w)。BigBird在此基础上加了随机注意力,理论上能逼近全注意力的表达能力。

这类方法的坑在于:稀疏模式是人为设计的,可能和任务真正需要的关系不匹配。我试过在需要长距离精确检索的任务上用滑动窗口,效果明显不如全注意力。

5.3 线性注意力:用核函数替换softmax

线性注意力的核心技巧是把softmax(QK^T)V重新结合。利用矩阵乘法的结合律,(QK^T)V = Q(K^T V)。先算K^T V,得到一个d×d的矩阵,再和Q相乘,复杂度就变成O(n·d²),和序列长度线性相关。

但这里有个问题:softmax是非线性的,不能直接交换顺序。线性注意力用核函数φ(·)替换softmax,把注意力写成φ(Q)(φ(K)^T V)的形式。代价是表达力下降,因为核函数是对softmax的近似。

5.4 FlashAttention:不改变数学,只改变计算顺序

FlashAttention是这几年的一个重要工作,它的思路和上面几种完全不同:数学上完全等价于标准注意力,但通过分块计算和重计算,把显存占用从O(n²)降到O(n),同时利用GPU的SRAM减少HBM读写,速度还更快。

它的核心是tiling:把Q、K、V分块,每次只加载一块到高速缓存里算,用在线softmax的技巧累积结果,避免把完整的n×n矩阵写回显存。这个工作让我意识到,很多时候瓶颈不在算法数学,而在内存访问模式。

实操建议:如果你用的是PyTorch 2.0以上,F.scaled_dot_product_attention会自动选择FlashAttention后端,直接用它就行,别自己手写。

6. 架构层面的演进:从标准块到MoE与分组查询

6.1 分组查询注意力GQA:推理加速的关键

标准多头注意力在推理时有个问题:KV cache。自回归生成时,每生成一个token都要缓存之前所有token的K和V。头数h=32、d_k=128、序列长度4096时,单层的KV cache就是32×4096×128×2×2字节≈64MB,几十层下来就是几个GB。

GQA(Grouped Query Attention)的做法是:Q保持多头,但K和V分成更少的组,每组共享。比如32个Q头,8个KV头,每4个Q头共享一组KV。这样KV cache直接降到四分之一,推理速度明显提升,效果损失很小。现在Llama系列、Mistral等基本都用GQA。

MQA(Multi-Query Attention)是GQA的极端情况,只有1个KV头,压缩最狠但效果损失也最大。

6.2 MoE:把FFN也拆开

Transformer块里,注意力之后是FFN层,通常是两层线性加激活,参数量占整个块的2/3左右。MoE(混合专家)的思路是:把FFN拆成多个专家,每个token只激活其中少数几个。

这样做的好处是参数量可以做得极大,但每个token的实际计算量不变。比如8个专家,每次激活2个,总参数量是8倍,但FLOPs只有2倍。这就是为什么现在很多大模型能到千亿参数但推理成本可控。

MoE的难点在负载均衡。如果所有token都涌向同一个专家,其他专家就浪费了。所以需要加辅助损失来鼓励均匀分配。我调过的一个小MoE,辅助损失系数设0.01时负载还算均衡,设0.1时反而影响了主任务效果,这个系数需要仔细调。

架构变体核心改动主要收益主要代价
MHA标准多头表达力强KV cache大
MQA单KV头推理快效果略降
GQA分组KV平衡需调组数
MoEFFN分专家参数效率高负载均衡难
线性注意力核函数替换长序列快表达力降

6.3 归一化位置的迁移:Pre-LN与Post-LN

原始Transformer用的是Post-LN,也就是残差之后做LayerNorm。但Post-LN在深层网络里训练不稳定,需要warmup。后来GPT系列改成Pre-LN,残差之前做归一化,训练稳定多了,warmup需求也降低。

再后来RMSNorm出现了,去掉了均值中心化,只做缩放,计算更快,效果基本持平。现在主流大模型基本都用Pre-RMSNorm的组合。

7. 实操中的常见问题与排查清单

7.1 训练不收敛或loss震荡

这是最常见的问题。排查顺序我一般是这样的:

先看学习率和warmup。Transformer对学习率很敏感,没有warmup直接上大学习率,前几百步很容易炸。warmup步数一般是总步数的1%到5%。

再看初始化。Q、K、V的投影层初始化方差要合适,太大太小都会影响。默认的xavier或kaiming一般够用,但如果自己改过要检查。

然后看缩放因子。确认除以的是sqrt(d_k)而不是d_k,这个错误很隐蔽,loss能降但收敛慢。

最后看mask。padding mask和causal mask有没有正确应用,mask的位置是不是-1e9而不是0。用0的话softmax之后padding位置还有权重,会污染结果。

7.2 注意力图看起来"糊"成一片

如果可视化出来所有位置的注意力权重都差不多,说明softmax没有拉开差距。可能的原因:缩放因子没加导致数值太小、温度参数不对、或者模型还没训练充分。也有可能是d_k太大导致点积方差过大,反而让softmax饱和。

7.3 长序列显存爆炸

优先用FlashAttention。如果硬件不支持,考虑GQA减少KV cache,或者用梯度检查点(gradient checkpointing)用时间换显存。再不行就上稀疏或线性注意力,但要评估效果损失。

7.4 位置编码外推失效

如果推理长度超过训练长度效果骤降,检查位置编码类型。正弦编码和可学习编码外推都差,换成RoPE并配合NTK-aware插值。RoPE的基数调整是个经验活,一般从10000往上调,调到500000能支持到训练长度的几十倍。

问题现象可能原因排查动作
loss震荡学习率过大/无warmup加warmup,降lr
收敛慢缩放因子错误检查sqrt(d_k)
注意力糊温度/缩放问题检查softmax输入尺度
显存爆炸序列过长FlashAttention/GQA
外推失效位置编码类型换RoPE+插值
推理慢KV cache大上GQA/MQA

7.5 几个容易被忽略的细节

第一个是dropout的位置。注意力权重上的dropout和残差上的dropout作用不同,前者防止过度关注某些位置,后者防止过拟合。原始论文里两个都有,但现在的趋势是减少dropout,因为训练数据量大了。

第二个是权重共享。嵌入层和输出投影层共享权重能减少参数量,在机器翻译这类任务上有效,但在大模型上不一定。要看具体任务。

第三个是初始化时输出投影的缩放。有些实现会把输出投影的初始权重除以sqrt(2×层数),用来稳定深层网络的训练。这个技巧在GPT-2的代码里能看到。

8. 我个人的一些体会

Transformer的演进史,本质上是一部"在表达力和计算效率之间反复权衡"的历史。每一次改动都不是拍脑袋,而是被某个具体的瓶颈逼出来的。理解了瓶颈,就理解了为什么会有这个改动。

我建议学的时候不要只背结构图,而是拿一个小任务(比如序列复制、简单翻译)从头实现一遍,把每个模块去掉试试看效果怎么变。我当年把位置编码去掉跑了一次,模型完全学不会顺序相关的任务,那个印象比看任何论文都深。

另外,现在的很多"新架构"其实是在Transformer基础上的微调,核心的注意力机制没有变。与其追每一个新名词,不如把注意力、位置编码、归一化这三块吃透,剩下的都是组合和工程优化。真正难的不是理解公式,而是知道在什么场景下该选哪个变体,以及为什么。这个判断力只能靠实际跑实验积累,没有捷径。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进