Transformer核心原理大揭秘:从零读懂Self-Attention,这一篇就够了

张开发
2026/6/23 8:07:10 15 分钟阅读
Transformer核心原理大揭秘:从零读懂Self-Attention,这一篇就够了
你每天都在用的ChatGPT、文心一言、通义千问背后最核心的技术都叫“Transformer”。别被这个英文单词吓到今天我用最直白的大白话把它的每一个零件都拆开给你看。全文配有图解和可运行的代码示例注释写到你能背下来。下一章咱们直接上手写代码做项目这一章先把“心法”练好。一、RNN这个老同志到底哪里不行了在Transformer横空出世之前处理句子、翻译、写诗这些活儿基本都靠RNN循环神经网络家族。RNN的思路很朴素读句子就像我们看小说一个字一个字按顺序读每读一个字就更新一下自己的“记忆”然后把记忆传给下一个字。比如处理“我爱中国”读到“我”记忆里记下“我”读到“爱”结合“我”的记忆知道是“我爱”读到“中”结合前面记忆知道是“我爱中”读到“国”最终知道整个句子是“我爱你中国”听起来很合理对吧但它有两个致命伤第一不能并行。要读第100个字必须先把前面99个字读完。这就好比让你同时读10本书你只能一本一本地读没法同时进行。GPU再厉害也只能干瞪眼训练速度上不去。第二记不住太远的内容。RNN的记忆会随着距离慢慢“遗忘”。比如一个长句子“小明昨天去了超市他买了一瓶牛奶然后他遇到了小红他俩一起喝了咖啡最后……他付了钱。”这里的“他”指的是小明但RNN读到后面时前面的“小明”信息已经衰减得差不多了很容易搞错指代对象。这就是所谓的长距离依赖问题。后来大家发明了注意力机制Attention解码的时候可以回头去“看”编码器里所有位置的信息哪个位置重要就多看两眼。这招很管用但RNN这个老骨架还在效率问题没根除。直到2017年Google的几位大神发了一篇论文标题就叫《Attention Is All You Need》——我们只需要注意力。他们把RNN整个踢出局设计了一个纯靠注意力机制的新模型Transformer。从此NLP的黄金时代开始了。二、Transformer长什么样5张图看懂全局Transformer仍然采用编码器-解码器这套组合拳但里面的零件全换了。简单说编码器Encoder负责读懂输入的句子比如“我 爱 你”把它转化成一组富含上下文信息的向量。解码器Decoder负责根据编码器的理解一个字一个字地生成目标句子比如“I love you”。编码器和解码器都不是单层结构而是由多个相同的层堆叠起来。原论文用了6层编码器 6层解码器。堆得越深模型提取特征的能力就越强。每个编码器层内部包含两个子层自注意力层Self-Attention让每个词去看句子中所有词包括自己确定该重点关注谁。前馈神经网络层Feed-Forward对每个词的表示再做一次加工提升表达能力。每个解码器层内部包含三个子层带掩码的自注意力层Masked Self-Attention生成当前词时只能看它左边的词不能偷看后面的词。编码器-解码器注意力层Encoder-Decoder Attention让解码器的当前词去“问”编码器的输出找到源语言中最相关的信息。前馈神经网络层同上。每个子层后面还都跟着残差连接和层归一化这两兄弟是训练深层网络的定海神针后面会细说。下面我们一头扎进编码器把自注意力这个灵魂组件彻底搞懂。三、自注意力Self-Attention每个词都要“眼观六路”3.1 为什么要自注意力假如有一句话“那只动物没有过马路因为它太累了。” 你要理解“它”指代什么光看这个词本身是不够的你需要把它和前面的“那只动物”联系起来。自注意力要做的就是给句子里的每个词生成一个融合了全局信息的新表示。在自注意力出现之前RNN是通过隐藏状态逐层传递来“融合”上下文慢且容易丢。自注意力一步到位每个词直接跟所有词打交道谁的关联强就多吸收谁的信息。3.2 第一步生成Query、Key、Value这是自注意力最经典的一步。对于输入序列中的每一个词比如它的初始向量是 $x_i$我们通过三个不同的矩阵把它映射成三个新向量Query查询相当于这个词发出去的“问题”它想知道句子中哪些词和自己相关。Key键相当于这个词贴在外面的“标签”用来回答别人的Query。Value值这个词真正要传递的“内容信息”。数学上就是# 假设输入矩阵 X 形状为 (seq_len, d_model) # 三个参数矩阵形状均为 (d_model, d_k) 或 (d_model, d_v) Q X W_Q # (seq_len, d_k) K X W_K # (seq_len, d_k) V X W_V # (seq_len, d_v)其中 d_k 和 d_v 是缩放后的维度原论文取 d_k d_v d_model / 8 64当 d_model512 时。3.3 第二步计算注意力分数现在每个词都有了它的 Query 和 Key。接下来我们要计算每对词之间的“相关程度”。具体做法是用第 $i$ 个词的 Query 去和所有词包括自己的 Key 做点积。点积越大说明这两个词越相关。但是当向量维度 d_k 比较大时点积的值可能会变得很大导致后面的 softmax 进入梯度极小的饱和区。所以需要除以 $\sqrt{d_k}$来缩放score(i,j)qi⋅kjdkscore(i,j)dkqi⋅kj对整个序列而言我们可以把所有 Query 堆成矩阵 $Q$所有 Key 堆成矩阵 $K$一次性算出所有分数矩阵SQKTdkSdkQKT$S$ 的形状是 (seq_len, seq_len)$S_{ij}$ 表示第 $i$ 个词对第 $j$ 个词的原始注意力分数。3.4 第三步Softmax 归一化成权重拿到原始分数后需要对每一行也就是每个词对所有词的分数做 Softmax让它们变成概率分布每一行加起来等于1AttentionWeightssoftmax(S)对每一行独立做AttentionWeightssoftmax(S)对每一行独立做这样每个词对其他所有词的关注程度就变成了0到1之间的权重。3.5 第四步加权求和得到最终输出最后模型会根据注意力权重对所有位置的 Value 向量进行加权求和得到每个位置融合全局信息后的新表示。对于整个序列同样可以通过矩阵运算一次性计算所有位置的输出如下图所示综上所述可得整个自注意力机制的完整的计算公式如下四、多头注意力一个脑子不够多来几个自注意力看起来很完美但它有一个潜在问题每个词只做一次匹配。可自然语言中的关系往往是多层次的。再看那个句子“那只动物没有过马路因为它太累了。” 这里面至少有三层关系代词“它”和名词“动物”的指代关系“因为”连接的前后因果逻辑“过马路”这个动宾短语内部的搭配关系。如果只用一套 $W^Q, W^K, W^V$模型很难同时兼顾这么多不同性质的关系。怎么办呢多头注意力Multi-Head Attention的思路非常直接我不只用一组参数而是用 $h$ 组原论文 $h8$。每组独立计算自注意力得到 $h$ 个不同的输出矩阵。每个“头”可以专注于不同的关系类型。最后把这 $h$ 个输出拼起来再经过一个线性变换就得到了最终的多头注意力输出。合并多头注意力多个输出矩阵按维度拼接再乘以得到最终多头注意力的输出。多头注意力是Transformer能够深刻理解语言的关键之一。你可以把它想象成一个团队每个人头用不同的视角观察同一个句子最后把大家的观察结果汇总起来就得到了更全面的理解。五、前馈网络给每个词“再加工”自注意力或多头注意力的输出已经让每个词融合了全局信息。但这还不够——注意力机制本质上是线性变换加权求和。为了增强模型的非线性表达能力每个编码器层和解码器层都接了一个前馈神经网络FFN。这个FFN非常简洁两个全连接层中间夹一个ReLU激活函数。其计算公式如下写成代码class FeedForward(nn.Module): def __init__(self, d_model, d_ff2048): super().__init__() self.linear1 nn.Linear(d_model, d_ff) # 先升维 self.linear2 nn.Linear(d_ff, d_model) # 再降回d_model def forward(self, x): # x shape: (batch, seq_len, d_model) return self.linear2(F.relu(self.linear1(x)))注意这个FFN是对每个位置独立作用的即同一个FFN参数会应用到序列中的每一个词向量上但不同词之间不互相影响。这就像给每个词单独过一个小型神经网络。六、残差连接和层归一化让深层网络“活”起来你可能会问上面不是说每个子层后面都跟着“残差连接”和“层归一化”吗它们有什么用6.1 残差连接Residual Connection深层神经网络很容易出现梯度消失——反向传播时越往底层梯度越小最后底层参数几乎不更新网络就学不动了。残差连接的解法简单粗暴把子层的输入直接加到输出上。这样就有了一个“捷径”梯度可以绕过子层直接传回去。将子层的输入直接与其输出相加形成一条跨越子层的“捷径”其数学形式为具体计算过程如图所示残差连接确保反向传播时梯度至少有一条稳定通路可回传是深层网络可稳定训练的关键结构。6.2 层归一化Layer Normalization在残差连接之后还要做一次层归一化。它的作用是让每个样本的每一个特征维度的数值分布稳定下来均值为0方差为1避免训练过程中数值过大或过小从而加速收敛。具体步骤对于某个样本的某个词向量 $x \in \mathbb{R}^{d}$计算均值 $\mu \frac{1}{d}\sum_{i1}^d x_i$计算方差 $\sigma^2 \frac{1}{d}\sum_{i1}^d (x_i - \mu)^2$归一化$\hat{x}_i \frac{x_i - \mu}{\sqrt{\sigma^2 \epsilon}}$再学习两个参数 $\gamma$ 和 $\beta$$y_i \gamma \hat{x}_i \beta$注意层归一化和批归一化BatchNorm不同。LayerNorm是对每个样本的每一层做归一化不依赖batch大小非常适合处理变长序列。在Transformer中每个子层自注意力或FFN之后的操作顺序是残差连接 → 层归一化。也有论文采用先层归一化再进子层的Pre-LN结构但原Post-LN更为经典。class EncoderLayer(nn.Module): def __init__(self, d_model, num_heads, d_ff, dropout0.1): super().__init__() self.self_attn MultiHeadAttention(d_model, num_heads) self.feed_forward FeedForward(d_model, d_ff) self.norm1 nn.LayerNorm(d_model) self.norm2 nn.LayerNorm(d_model) self.dropout nn.Dropout(dropout) def forward(self, x, maskNone): # 自注意力子层带残差和层归一化 attn_output self.self_attn(x, x, x, mask) x self.norm1(x self.dropout(attn_output)) # 残差 dropout 层归一化 # 前馈子层 ff_output self.feed_forward(x) x self.norm2(x self.dropout(ff_output)) return x七、位置编码没有顺序感怎么办RNN天生有顺序第一个词先进入第二个词后进入。但Transformer的核心是自注意力它对输入序列的所有位置是一视同仁的——换句话说如果把“我爱你”换成“你爱我”在没有任何位置信息的情况下Transformer看到的是完全一样的词袋分不清谁在前谁在后。这显然不行。我们需要给模型注入位置信息。位置编码Positional Encoding就是干这个的为每个位置 $pos$ 生成一个向量然后加到对应的词向量上。最简单的想法是用绝对位置第0个词加0第1个词加1第2个词加2……依此类推这样做虽然简单但有一个明显的问题越靠后的 token 位置编码就越大若直接与词向量相加会造成数值倾斜让模型更关注位置而忽视词义。另一种想法是归一化到0~1之间用 $pos / T$$T$ 是句子长度。但问题来了同一个位置比如第5个词在长度为10的句子中编码是0.5在长度为100的句子中编码是0.05不一致。Transformer采用了一种非常巧妙的正余弦位置编码对于位置 $pos$ 和维度 $i$如果 $i$ 是偶数$PE_{(pos, 2i)} \sin\left(\frac{pos}{10000^{2i / d_{model}}}\right)$如果 $i$ 是奇数$PE_{(pos, 2i1)} \cos\left(\frac{pos}{10000^{2i / d_{model}}}\right)$这种编码有几个好处值始终在 $[-1, 1]$ 之间不会破坏词向量对于固定的偏移量 $k$$PE_{posk}$ 可以表示为 $PE_{pos}$ 的线性函数便于模型学习相对位置关系不依赖训练可以提前计算好def get_positional_encoding(seq_len, d_model): 返回形状 (seq_len, d_model) 的位置编码矩阵 pe torch.zeros(seq_len, d_model) position torch.arange(0, seq_len, dtypetorch.float).unsqueeze(1) # (seq_len, 1) div_term torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) # 偶数维 pe[:, 1::2] torch.cos(position * div_term) # 奇数维 return pe实际使用时位置编码会加到输入嵌入上embedding nn.Embedding(vocab_size, d_model) x embedding(input_ids) # (batch, seq_len, d_model) x x positional_encoding[:seq_len, :].to(x.device)八、解码器的特殊之处Mask和Cross Attention解码器和编码器结构相似但多了两个关键点Masked自注意力和编码器-解码器注意力。8.1 Masked自注意力防止“偷看未来”解码器生成句子时是自回归的先输出第一个词然后用第一个词输出第二个词依此类推。在训练时我们为了效率会把整个目标句子一次性喂给解码器希望模型并行地预测每个位置的词。但如果不加限制模型预测第2个词时就能看到第3、第4个词未来的词这就会导致信息泄露。解决办法在自注意力计算分数矩阵时把当前位置对未来位置的分数设为 $-\infty$或者一个非常大的负数。这样softmax之后未来位置的权重就会变成0模型就看不到未来了。这个掩码只在解码器的第一个自注意力子层使用而且训练和推理时都要用推理时虽然每次只有一个新词但掩码逻辑一样。8.2 编码器-解码器注意力让解码器“回看”源句子这个子层的作用就是经典的注意力机制解码器当前的Query去和编码器输出的Key、Value做注意力。这样解码器在生成每个词时都能动态地从源句子中提取最相关的信息。Query来自解码器前一子层的输出已经包含了目标侧前文信息Key / Value来自编码器的最终输出整个源句子的表示class DecoderLayer(nn.Module): def __init__(self, d_model, num_heads, d_ff, dropout0.1): super().__init__() self.masked_attn MultiHeadAttention(d_model, num_heads) self.cross_attn MultiHeadAttention(d_model, num_heads) self.feed_forward FeedForward(d_model, d_ff) self.norm1 nn.LayerNorm(d_model) self.norm2 nn.LayerNorm(d_model) self.norm3 nn.LayerNorm(d_model) self.dropout nn.Dropout(dropout) def forward(self, x, encoder_output, src_maskNone, tgt_maskNone): # 1. Masked自注意力只看前文 attn_output self.masked_attn(x, x, x, tgt_mask) x self.norm1(x self.dropout(attn_output)) # 2. 编码器-解码器注意力Q来自解码器K,V来自编码器 attn_output self.cross_attn(x, encoder_output, encoder_output, src_mask) x self.norm2(x self.dropout(attn_output)) # 3. 前馈网络 ff_output self.feed_forward(x) x self.norm3(x self.dropout(ff_output)) return x其中 src_mask 可以用来屏蔽源句子中的填充位置比如 padtgt_mask 是未来词掩码加上填充掩码。九、训练和推理一个并行一个串行9.1 训练阶段训练时我们手里有完整的源句子和目标句子比如“我爱你”和“I love you”。我们可以把源句子一次性送进编码器把目标句子前面加一个起始符sos一次性送进解码器利用掩码机制让解码器并行地预测每个位置的下一个词计算预测结果和真实目标之间的交叉熵损失反向传播注意解码器的输入是sos I love you长度4输出是I love you eos长度4每个位置预测的是下一个词。掩码保证了预测“love”时只能看到sos I看不到后面的you。这种并行训练比RNN逐词训练快了几个数量级。9.2 推理阶段生成推理时我们只知道源句子目标句子要一个字一个字地生成。过程如下编码器先跑一次得到源句子的表示。解码器输入只有一个sos预测第一个词比如“I”。把sos I作为新输入预测第二个词“love”。重复直到预测出eos结束。注意每一步都要重新把整个已生成的序列送进解码器因为Transformer没有记忆每次都要从头计算注意力。这就没法并行了只能串行。不过通常推理长度有限速度可以接受。十、总结与展望好啦Transformer的核心原理我们已经从头到尾捋了一遍。我们来快速回顾一下抛弃RNN完全基于注意力机制实现并行训练解决了长距离依赖问题。自注意力每个词通过Q、K、V机制与所有词交互生成融合全局信息的表示。多头注意力多个注意力头并行捕捉不同类型的语义关系。前馈网络增加非线性提升表达能力。残差连接 层归一化让深层网络训练更稳定。位置编码给模型注入顺序信息弥补并行结构的缺陷。解码器掩码防止训练时看到未来信息保持因果一致性。编码器-解码器注意力让解码器在生成时动态参考源句子。你可能会问我什么时候能看到代码别急下一章我会用PyTorch从零搭建一个完整的Transformer模型并用它来做一个真实的机器翻译项目。从数据预处理到训练、推理逐行代码带你跑通。Transformer不仅统治了NLPBERT、GPT、T5都是它的子孙还被用到了计算机视觉ViT、语音识别、蛋白质结构预测等领域。理解Transformer就是拿到了现代深度学习的半张门票。如果你觉得这篇文章对你有帮助记得点赞收藏也欢迎在评论区留下你的问题。下一章我们代码见

更多文章