1. 为什么说自注意力是Transformer的魂
前几年做序列建模,大家手里最趁手的工具无非是RNN、LSTM那套东西。它们在处理短句时表现不错,可一旦句子超过几十个词,信息传递就开始衰减。后来有人把卷积搬进序列任务,缓解了一些问题,但终究只是在局部窗口里打转,距离捕捉全局依赖还是差了口气。
Transformer的出现把这件事彻底换了个玩法:它不再按时间步逐字推进,而是让序列里的每一个位置同时去“看”其他所有位置,在一层之内建立全局关联。这个机制就是自注意力,而多头注意力则是它最重要的工程化升级。简单说,自注意力是Transformer理解语义关系的手段,多头注意力让模型能从不同角度同时观察。这篇文章我会把这两个机制的原理、计算过程、代码实现和训练里那些容易踩的坑,按实际项目中我自己的理解完整过一遍。适合刚接触Transformer、看论文看得很吃力的人,也适合已经会调库但想搞清楚内部细节的工程师。
在动手拆公式之前,有必要先把一个最基础的直觉建立起来:注意力机制本质上是在做“信息检索”。你有一个查询(Query),手里有一堆键(Key),每个键都对应一个值(Value)。注意力要做的事,就是拿着查询去跟每个键做匹配,算出相关性,然后按相关性把值混合起来。这个过程跟你在搜索引擎里输入关键词、匹配出结果、再把结果按相关度加权汇总,是非常相似的。自注意力只不过是把这句话里的查询、键、值全部来自序列自身,所以叫“自”。理解了这张图,后面所有公式都是在这句话上打补丁。
2. 自注意力机制的核心拆解
2.1 Q、K、V三个角色从哪里来
在Transformer里,输入是一串词嵌入向量,比如一个形状为[batch_size, seq_len, d_model]的张量。自注意力的第一步,是把这个张量分别乘上三个权重矩阵,得到Query、Key、Value。
用公式表达就是:
Q = X @ W_q K = X @ W_k V = X @ W_v其中W_q、W_k、W_v的维度都是[d_model, d_k](严格来说Key和Query的维度必须一致,Value的维度不一定,但在Transformer原始实现里三者都是d_k)。
这里的实际含义是什么?W_q负责把“我当前这个位置想找什么”编码成查询向量;W_k负责把“我当前这个位置能提供什么线索”编码成键向量;W_v负责把“我当前这个位置真正携带的内容”编码成值向量。在训练之前这三个矩阵是随机初始化的,网络通过梯度下降逐渐调整它们,让模型学会什么样的查询该去匹配什么样的键。
有个细节值得注意:d_model一般取512或768这类偏大的维度,而d_k通常是64。为什么要把维度压小?完全是工程考量。注意力分数的计算复杂度是序列长度的平方,d_k定得太大,计算量和显存都会指数上涨;而把维度压缩到64,让多头注意力去并行处理,可以在总参数量不变的情况下获得更好的性能。
2.2 缩放点积注意力的完整计算过程
自注意力的计算分四步。
第一步,用Query和所有Key做点积,得到注意力分数。假设序列里有两个词“猫”和“鱼”,q_cat和k_fish的点积值越大,说明模型认为“猫”在查询时“鱼”这个位置提供的信息越重要。
第二步,把算出的分数除以sqrt(d_k)。这里是为了防止点积结果过大,把Softmax推向饱和区。等会儿我会单独解释为什么必须是sqrt(d_k)而不随便除个10。
第三步,对分数做Softmax归一化,让同一Query下所有位置的权重之和等于1,这组权重描述的就是“当前词应该从哪些词那里汲取信息、各汲取多少”。
第四步,用这组权重对所有Value做加权求和,得到当前位置的输出向量。
整张计算图用公式表示:
Attention(Q, K, V) = softmax(Q @ K^T / sqrt(d_k)) @ V这四步里有一个特别容易忽视的点:Q @ K^T这一步在代码里是两次矩阵乘法,对应形状是[batch, heads, seq_len, d_k]乘以[batch, heads, d_k, seq_len],得到[batch, heads, seq_len, seq_len]。这个[seq_len, seq_len]的矩阵就是注意力权重矩阵,它每一行代表某个token对所有token的关注度。绝大多数的可视化工作、剪枝工作、可解释性分析,盯的都是这张矩阵。
2.3 为什么要除以根号d_k,背后有数学逻辑
这个问题面试里经常被问,也是理解自注意力数值稳定性的关键。
假设Query和Key的每个分量都是均值为0、方差为1的随机变量,那么它们的点积q·k = Σ q_i * k_i,期望值为0,方差为d_k。也就是说,点积之后数值的标准差是sqrt(d_k),维度越大,点积结果的波动范围越大。
如果不去缩放这个值,把它直接丢进Softmax,当d_k达到512时,点积很容易落到十几甚至几十的量级。Softmax在输入值绝对值很大的区域,梯度会变得非常平缓,几乎趋近于0。这带来的直接后果是:模型训练时梯度消失,更新迟缓。
除以sqrt(d_k)之后,点积结果的方差被拉回到1附近,Softmax就能稳定落在梯度比较明显的区间。这个操作本质上是一个数值归一化的手段,不是为了“让分数更好看”,而是为了保证训练动态稳定。
顺带说一个我见过的错误实现:有人把缩放因子写成了/ d_k,甚至干脆不除,训练出来的模型要么Loss震荡很凶,要么收敛极慢,表现都在基线以下。所以这个sqrt一定不能省,同时也要确认除以的位置是在Softmax之前,而不是之后。
3. 多头注意力:让模型从不同子空间同时观察
3.1 单头注意力的信息瓶颈
自注意力有个隐含问题:一个注意力头只能产生一组Query/Key/Value映射,这意味着模型在某一层里对“词与词之间该怎么关联”只有一种判断视角。
但真实的语言结构显然不是单一的。比如看这句话:“小明把苹果放在桌上,然后拿起了刀”。模型可能需要一个头去关注语法依赖(小明→拿起),另一个头去关注语义指代(苹果→刀),还有一个头去关注位置关系(苹果→桌上)。单头注意力没能力把这么多关系同时建模好——它要么平均分配权重,要么只能牺牲部分关系。
多头注意力就是把d_model维的向量切分成h个d_k维的子空间,每个头在自己的子空间里独立做自注意力,最后把h个结果拼接起来再过一层线性变换。
这样做的好处非常直观:每个头相当于一个“观察专员”,自由选择自己擅长的关系模式去学习,多个专员并行劳作,最后汇总。模型容量不盲目增大,但表达能力大大提升。
3.2 多头注意力的形状变化与拼接过程
把多头注意力完整过一遍形状变化,就能彻底搞懂这个机制的工程实现。假设d_model=512,h=8,那么每个头的d_k=64。
第一步,输入X形状为[batch, seq_len, 512],分别乘上W_q、W_k、W_v,得到[batch, seq_len, 512]的Q、K、V。
第二步,把最后一维拆成8个头,也就是把512变成[8, 64],形状变成[batch, seq_len, 8, 64],再交换维度得到[batch, 8, seq_len, 64]。为什么要交换?因为要确保每个头的自注意力是独立计算的,交换维度之后,矩阵乘法可以一次性把所有头的计算全部并行做掉。
第三步,执行缩放点积注意力。输出形状仍是[batch, 8, seq_len, 64]。
第四步,把头和头的结果交换回来并拼接,[batch, seq_len, 8, 64]变回[batch, seq_len, 512]。
第五步,乘上输出投影矩阵W_o,维度[512, 512],输出[batch, seq_len, 512]。这一步的作用是把多个子空间的信息融合到一起。
有一个初学者常问的问题:既然最后还要拼接和线性变换,为什么不是直接用一个大的注意力矩阵?其实区别就在于“独立”两个字。直接算大矩阵,等价于所有信息混合在一起进行一次打分,各关系之间会互相干扰;拆成8个子空间后,每个头内部只管自己子空间里的关联,反而能学到更干净的模式。我做过一个对比实验:在相同参数量下,8头模型比单头模型在翻译任务上大概提升了接近两个BLEU点,差距非常显著。
3.3 多头注意力的参数量怎么算
把参数量算清楚,部署剪枝和量化的时候心里才有底。
以d_model=512、h=8为例,注意力模块的权重包括:
W_q:512×512,约26万参数W_k:512×512,约26万参数W_v:512×512,约26万参数W_o:512×512,约26万参数
四者合计约105万参数。这还没算位置编码、LayerNorm、FFN模块。对一个标准的Transformer Encoder层来说,注意力模块大约占整层参数的三分之一左右。
这里有个容易被误导的地方。虽然W_q、W_k、W_v理论上可以写成[d_model, d_model]的整体矩阵,但在代码里通常不是先算完整矩阵再拆头,而是直接用[d_model, d_model]的权重乘完再reshape。两种方式数学上是等价的,但直接乘完整矩阵的实现更简洁、更高效,因为一次GEMM操作比8次小GEMM更利于GPU并行。
4. 位置编码与注意力的配合
4.1 自注意力看不到词的先后顺序
自注意力机制有一个天然缺陷:它对位置不敏感。把“猫追老鼠”和“老鼠追猫”里的词顺序打乱,只要词集合没变,注意力计算出来的结果是一样的,因为注意力分数只取决于两两向量之间的点积,跟它们在序列中的绝对位置无关。
为了解决这个问题,Transformer在输入词向量上叠加了位置编码。原始论文用的是正余弦位置编码,公式是:
PE(pos, 2i) = sin(pos / 10000^(2i / d_model)) PE(pos, 2i + 1) = cos(pos / 10000^(2i / d_model))这个设计值得观察的点很多。每个维度的频率不同:偶数维用sin,奇数维用cos;维度越低,频率越高。这样做的效果是,位置编码向量在高维空间里形成一种连续的螺旋结构,相邻位置的编码在欧氏距离上很接近,而较远位置的编码差异更大。模型可以通过学习线性变换捕捉相对位置信息。
还有一点值得注意:位置编码是直接加到词向量上的,不是拼接。为什么这样设计?因为拼接会把d_model翻倍,参数量和计算量都跟着涨,而直接相加则保持了每个位置的嵌入维度不变。训练时模型会自行学会在词义信息和位置信息之间做权衡。
后来业界又发展出可学习位置编码、旋转位置编码(RoPE)、ALiBi等方法,各有取舍。但理解正余弦位置编码仍然是基础,因为它解释了“为什么注意力里需要额外注入位置信号”这个最核心的问题。
4.2 把位置编码加入自注意力的正确姿势
在实现层面,位置编码应当在进入QKV投影之前就完成相加。也就是说,先算X_embedding + positional_encoding,再把结果送去乘W_q、W_k、W_v。
如果你在Q、K、V都已经算出来之后才加位置信息,就不太对了,因为此时位置信号无法影响注意力分数的计算,只会改变输出向量的内容,模型对“顺序”仍然是无感知。这个顺序问题是实际调试中比较容易踩坑的地方,我确实见过有初学者把位置编码加在注意力输出之后,导致模型怎么训都无法区分词序。
5. 手写实现:从一行行代码里吃透两个机制
5.1 自注意力的最小实现
很多人看公式觉得懂了,写代码才发现很多细节没想清楚。我建议你直接照着下面这个版本自己敲一遍,然后逐行理解。
import torch import torch.nn as nn import torch.nn.functional as F class ScaledDotProductAttention(nn.Module): def __init__(self, d_k): super().__init__() self.d_k = d_k def forward(self, q, k, v, mask=None): # q, k, v shape: [batch, heads, seq_len, d_k] scores = torch.matmul(q, k.transpose(-2, -1)) / (self.d_k ** 0.5) # scores shape: [batch, heads, seq_len, seq_len] if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) attn_weights = F.softmax(scores, dim=-1) output = torch.matmul(attn_weights, v) return output, attn_weights这段代码里有几个关键选择值得解释。
scores = torch.matmul(q, k.transpose(-2, -1)):k.transpose(-2, -1)把Key矩阵的最后两维交换,这样q和k^T的矩阵乘法恰好得到每个Query与每个Key之间的点积分数。
masked_fill(mask == 0, float('-inf')):这里把所有被mask掉的位设为负无穷,经过Softmax之后这些位置的权重趋近于0。为什么用负无穷而不是0?直接设为0的问题是,Softmax对所有exp值做归一化,0仍然会有一定的概率权重,达不到完全屏蔽的效果。
F.softmax(scores, dim=-1):dim=-1指的是在同一个Query下的所有Key方向归一化,而不是在整个二维矩阵上归一化。这个维度错误会导致权重行不收敛到1。
5.2 多头注意力的完整实现
单头注意力写完,多头就是对它做几次变换:
class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() assert d_model % n_heads == 0 self.d_model = d_model self.n_heads = n_heads self.d_k = d_model // n_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, x, mask=None): batch, seq_len, _ = x.shape q = self.w_q(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) k = self.w_k(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) v = self.w_v(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) attn_output, attn_weights = ScaledDotProductAttention(self.d_k)(q, k, v, mask) attn_output = attn_output.transpose(1, 2).contiguous().view(batch, seq_len, self.d_model) output = self.w_o(attn_output) return output, attn_weightsview之后紧跟transpose是这里最容易出错的点。view把[batch, seq_len, d_model]重塑成[batch, seq_len, n_heads, d_k],此时数据在内存里的排布是按seq优先的;transpose(1, 2)把头的维度提前到序列维度之前。由于transpose之后张量在内存中不再连续,后续如果要接view,必须先调用.contiguous()。很多人报错“view size is not compatible with input tensor‘s size and stride”,就是漏了这一行。
5.3 残差连接和LayerNorm为什么不能省
Transformer的每个子层后面都跟着残差连接 + LayerNorm。多头注意力的输出先跟输入相加,再经过LayerNorm归一化。
残差连接解决的是深层网络的退化问题。12层Transformer堆叠起来,梯度要穿过所有层回传,如果没有残差,浅层的梯度很容易被连乘效应压得很小。有了残差结构,梯度可以从输出层“抄近道”直达输入层,这对训练深网络至关重要。
LayerNorm的作用是稳定每一层的输出分布。它把同一个样本的所有特征维度归一化到均值为0、方差为1,然后通过可学习的缩放和平移参数恢复表达能力。这里特别要区分的是,BatchNorm是跨样本归一化,LayerNorm是单样本内归一化。Transformer选LayerNorm的原因主要在于序列任务中batch内不同样本长度往往不同,跨样本统计会带来噪音;LayerNorm不依赖batch统计,训练和推理行为一致,也让模型在变长输入下更稳定。
5.4 手写实现与调库实现之间要留个心眼
如果你用PyTorch自带的nn.MultiheadAttention,它参数的组织方式跟上面手写版本不太一样。embed_dim对应d_model,num_heads对应n_heads。默认batch_first=False,也就是输入是[seq_len, batch, embed_dim],这一点跟TransformerEncoderLayer里默认行为一致。我之前有段时间总在这个batch维度顺序上吃亏,后来习惯性在构造nn.MultiheadAttention时显式写上batch_first=True,才省掉后续转置的麻烦。
调库解决的问题是快速出结果,但手写一遍解决的问题是深层理解。如果时间紧张,至少手写一遍单头注意力的实现。能把那20行代码的正确性讲清楚,Transformer这关基本就算过了。
6. 训练中的常见问题与排查实录
6.1 注意力熵值过低:模型注意力坍缩怎么办
一个很常见的训练现象是,注意力权重分布越来越“尖锐”,最后几乎退化成了one-hot形式。比如翻译任务里“the”这个词把所有注意力集中在下一个词上,其他位置权重几乎为0。这种现象叫注意力坍缩。
坍缩带来的问题是梯度无法传播到远处的词,上下文信息大量丢失,模型对长距离依赖的建模能力严重退化。
排查思路一般从这几条入手:第一,检查是否有残差连接和LayerNorm被误删,这两个组件对维持注意力多样性很重要;第二,看看学习率是否设置过高,高学习率容易让网络的输出分布过早固化;第三,尝试增大d_k或减少头数,有时是子空间太小导致每个头只能学到最显著的关联,而无法保留多样性;第四,检查Positional Encoding是否在QKV投影之前加入,位置信号缺失时注意力更容易集中在部分高频词上。
我实际遇到的一个典型case是:模型在训练5000步后,训练Loss还在下降,但BLEU却开始掉。观察注意力矩阵发现大部分行趋近于均匀分布,等于模型已经不怎么使用注意力了。那时候我的做法是调低学习率并加了warmup步数,同时把Dropout从0.1提高到0.15,训练一小时后注意力矩阵的熵值回升,BLEU也重新开始涨。
6.2 Padding Mask与因果Mask的区别和坑
Transformer里有两种Mask,很多人混合在一起导致Debug很久。
Padding Mask处理的是batch内序列不等长的问题。短序列在尾部会补0,这些填充位置不应该参与注意力计算,所以把对应分数设为-inf。它的形状是[batch, 1, 1, seq_len],通过广播机制作用在所有头上。
因果Mask用在Decoder的自注意力里。Decoder在预测第i个token时,不能看到第i个之后的内容,否则就相当于提前知道答案。所以注意力矩阵的上三角区域应设为-inf。它的形状是[1, 1, seq_len, seq_len]。
一个常踩的坑是:Encoder的Padding Mask作用在Key维度上,所以这个Mask的形状是[batch, 1, 1, seq_len_k];但Decoder里处理的是[seq_len_q, seq_len_k]矩阵,当Query是当前解码位置、Key是全部编码位置时,需要额外考虑如何把Encoder的Padding Mask广播到正确的位置上。很多快速实现的代码在这里直接复用同一套Mask,结果在batch内序列长度差异很大的数据上莫名其妙地出现NaN Loss,就是因为填充位被计算出了梯度。
6.3 注意力权重的数值稳定性:NaN是怎么来的
Transformer训练中出现NaN,很多情况都跟注意力分数溢出有关。当d_k偏大且没有正确缩放时,Q@K^T的值可能非常大,Softmax里exp一算就直接溢出成inf,然后产生NaN。
标准解法就是缩放和数值稳定的Softmax。PyTorch的F.softmax内部默认做了一次最大值平移,所以大部分情况下不会溢出。但如果你在自定义的损失函数或者计算图里显式用了torch.exp(scores),就要自己先减去最大值。
另外,float('-inf')与0相乘的情形也要警惕。如果某个位置既被Padding Mask设为-inf,又因为某些操作被乘以0,得到的NaN会以极快的速度在整个梯度里传播开。调试时遇到NaN,第一时间查Mask和-inf的交互,往往很快就能定位。
6.4 显存占用与推理速度的工程经验
自注意力的复杂度是O(n^2·d),也就是说序列长度翻倍,注意力计算量翻四倍。这个特性让它在长文本任务里成为性能瓶颈。
我处理长文本时的几个工程建议:
- 对512长度以内的序列,直接用标准自注意力,实现简单,效果稳定。
- 对1024到2048的序列,考虑用固定窗口注意力或者局部注意力,让每个token只关注前后一定范围内的token,计算量从平方级降为线性级。
- 对超过2048的序列,使用稀疏注意力和分块注意力策略是更主流的做法,业界很多长文本模型也走这条路,代价是某些远距离依赖的捕捉能力会弱一些。
- 训练阶段可以用梯度检查点(gradient checkpointing)减少激活显存,代价是训练时间变长。
是选择“能算”还是“效果好”,本质上是个权衡问题。我一般先跑一个短序列和标准注意力的baseline,再逐步换成长序列的近似策略,用评测指标来决定取舍,不盲目追求复杂度最优。
7. 理解注意力机制之后,还能往哪个方向走
聊到这里,自注意力和多头注意力的原理、实现、调试基本都覆盖了。我自己在这些年的折腾里有一个很深的体会:看Transformer的代码,比自己以为的简单。它真正的复杂度不在于某一条公式,而在于QKV的维度变换、Mask的广播机制、残差和LayerNorm的顺序这些工程细节。把这些细节捋顺了,一层Transformer就通了,后面的Encoder、Decoder、BERT、GPT,都只是在这个骨架上堆不同的模块。
最后再分享一个我一直在用的小技巧:每次写完一个注意力模块,务必手动构造一个非常简单的输入样例去验证输出形状,并且打印一张注意力权重矩阵,看看里面有没有NaN、有没有全零行、有没有不合理的单向关注。这一步五分钟之内就能做完,却能在极早期把绝大多数隐蔽问题拦住。
自注意力这条路理解透了之后,可以继续去看看相对位置编码、线性注意力、稀疏注意力这些方向。它们本质上都在回答同一个问题:如何在保持平级建模能力的同时,让注意力做更少的无用功。套路虽多,核心还是今天讲的这一套。