Transformer从零复现:注意力机制、位置编码与训练调参实战笔记

发布时间:2026/10/23 3:23:20
Transformer从零复现:注意力机制、位置编码与训练调参实战笔记 第一次读Transformer相关材料的时候我心里其实挺抗拒的。注意力公式看懂了多头注意力名字也能说出来但一上手写代码就发现完全不是那么回事Q、K、V从哪里来mask加在哪一层训练和推理时Decoder的行为为什么不一样位置编码加上去之后效果差多少——这些问题不亲手复现一遍光靠看文章是永远记不住的。这篇笔记是系列的第一篇我不会把整篇架构科普从头讲到尾而是从“自己动手把Transformer拆开再拼回去”的角度把我踩过的坑、想明白的原理、以及一些可以直接抄走的代码片段记录下来。适合刚接触注意力机制、想真正搞懂Transformer而不只是调包的人参考。1. 为什么Transformer放弃了“顺序”这条老路1.1 从RNN到Attention一次思想上的转变在Transformer出现之前处理序列的主流工具是循环神经网络RNN以及它的变体LSTM、GRU。它们的基本思路很直接把序列一个一个地喂进去用一个隐藏状态把之前的信息“记”在脑子里每一步都基于当前输入和上一步的记忆来更新。这套思路本身没有问题但它有一个天然的结构瓶颈——顺序依赖。第t个时间步必须等第t-1个时间步算完才能开始GPU并行计算的优势完全发挥不出来。而且随着序列变长早期的信息要通过很多次门控或矩阵相乘才能传到后面梯度要么爆炸要么消失长距离依赖很难建模。注意力机制最初其实是作为RNN的辅助组件出现的用来让模型在解码时“回头看看”编码器输出的哪些位置更重要。但把注意力从配角扶正成主角彻底抛弃循环结构整个序列一次性并行处理这才是Transformer最核心的架构决策。Attention Is All You Need这篇论文最颠覆的地方不在于“用了注意力”而在于“除了注意力什么都不用”。1.2 Self-Attention的完整计算流程自注意力Self-Attention做的事情用一句话概括就是让序列里的每一个位置自己去衡量其他所有位置对自己的重要性然后按重要性加权整合信息。具体计算分成几步。第一步输入序列X经过三个不同的线性变换分别得到Query、Key、Value三个矩阵。直观理解就是Query是“我在找什么”Key是“我有什么”Value是“我实际给出的内容”。第二步每个位置的Query去和所有位置的Key做点积得到一个相似度分数这个分数就表示“我该关注对方多少”。第三步对相似度分数除以根号d_k做缩放再经过Softmax归一化成权重。第四步用这个权重对所有位置的Value做加权求和得到当前位置的输出。用矩阵形式写出来就是import torch import torch.nn.functional as F def self_attention(q, k, v, maskNone): # q, k, v: [batch_size, seq_len, dim] d_k q.size(-1) scores torch.matmul(q, k.transpose(-2, -1)) / torch.sqrt(torch.tensor(d_k, dtypetorch.float32)) if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) weights F.softmax(scores, dim-1) output torch.matmul(weights, v) return output这段代码虽然是简化版但已经把自注意力的骨架完整地表达出来了。在实际框架中Q、K、V来自同一个输入X的三个线性投影所以叫自注意力——注意力的对象是自己这个序列内部的位置。1.3 缩放点积中那个“除以根号d_k”到底在干嘛不少初学者会忽略除以根号d_k这一步觉得反正是Softmax数值上会做归一化除不除有什么区别。这个想法很危险。Softmax对输入数值的绝对大小非常敏感。当d_k比较小的时候比如64点积的方差还算可控但如果d_k变大比如到512、1024点积的数值范围会迅速膨胀。一旦数值变得很大Softmax会进入饱和区最大的那个值对应的概率接近1其他位置的权重几乎为0。换句话说梯度会变得极小模型基本学不动。除以根号d_k的作用就是让点积的方差保持在1的量级。为什么是根号d_k而不是直接用d_k因为如果Q和K的元素是独立的零均值、单位方差随机变量它们的点积方差正好是d_k标准差就是根号d_k。除以根号d_k相当于做了一次标准化把点积分布拉回到一个适合Softmax处理的区间。我在自己实现的时候试过不缩放直接跑小规模数据集上确实能收敛因为初始化碰巧很温和但只要把模型放大、序列拉长训练损失就会不稳。这个细节不是锦上添花而是保证训练稳定的基本操作。2. 多头注意力与位置编码Transformer的两个支点2.1 多头注意力的分工逻辑单个自注意力层学到的权重分布是“平均化”的每个位置对所有位置的注意力倾向于分散。而且一种相似度衡量方式往往只能捕捉一种关系——有的位置适合关注词法层面的搭配有的位置需要关注语法结构还有的位置关注语义上的远距离关联单头很难同时兼顾。多头注意力的做法是把Q、K、V的维度切分成h份每一份独立做一次自注意力最后拼起来再过一次线性层。假设d_model是512头数是8那么每个头负责的维度就是64。实际操作中就是把原来的一个大矩阵乘法拆成h个小矩阵乘法每个头得到一组权重再把这h组结果在特征维度上拼接起来。这样设计的好处是“分工”不同的头可以各自学到不同的注意力模式。有些头可能专门关注相邻词的局部关系有些头可能学会跨越大跨度捕捉长距离依赖还有些头可能学到一些类似语法标注的规律。这就像团队里有人看全局、有人抠细节视角不同但互补。多头注意力的计算量不会显著增加因为总维度和单头时一样只是拆分后再并行计算。这也是Transformer能高效利用GPU的原因之一。2.2 位置编码给模型一双有顺序感的眼睛Transformer没有循环结构也没有卷积结构序列里所有的位置在输入那一刻是完全对称的。如果不做任何处理“我爱你”和“你爱我”在模型看来就是同一个输入。所以必须把位置信息显式地注入进去。原论文用的是正弦余弦函数生成的位置编码矩阵每个位置的编码向量由一组不同频率的正弦和余弦值构成。公式大概是这样的对于位置pos的第i个维度偶数维用sin(pos / 10000的(i/d_model)次方)奇数维用cos(pos / 10000的(i/d_model)次方)。我当时看完公式第一反应是为什么要用正弦余弦而不是直接用整数嵌入原因有两点。第一三角函数可以很方便地处理任意序列长度不用为某个固定长度专门学习一套嵌入。第二正弦余弦之间有一个很漂亮的线性关系相对位置偏移可以通过一组线性变换从当前位置的编码中推出来。也就是说模型可以间接学会“相隔k个位置”的相对位置概念而不只是记住绝对位置。在实际代码里位置编码通常作为常量张量直接加到输入Embedding上不参与训练。也有人做可学习的位置嵌入效果在某些任务上略有提升但正弦编码的泛化性更好对训练中没见过的超长序列能保持一定的外推能力。2.3 我在复现多头注意力时踩过的一个坑我最早自己实现多头注意力的时候犯过一个非常典型的错误把Q、K、V拆分到头上之后忘了在注意力计算完成后把多头的结果合并回去。当时输出的维度一路保持[batch, heads, seq_len, dim_per_head]直到推到最后全连接层才发现形状对不上。排查了半天发现少了一行reshape。还有一个更隐蔽的问题出现在mask的处理上。训练时的padding部分需要mask掉但mask既要在注意力分数上生效也要在Padding位置的输出上归零。有些人只在Softmax之前填充负无穷却忘了最终输出里padding位置的Value信息还会被加权进去。正确的做法是对注意力权重和输出同时做处理或者直接让padding位置常数为0避免它们参与后续计算。这些坑看起来很简单但不亲手写一遍真的很难意识到。如果你也用PyTorch搭过建议直接用torch.nn.MultiheadAttention跑通整体流程之后再手动实现一遍多头拆分合并的细节这样对维度的理解会深很多。3. Encoder与Decoder整体结构是怎么拼起来的3.1 Encoder层的标准部件清单一个完整的Transformer由堆叠的Encoder层和Decoder层组成。每一层Encoder的结构其实只有四个关键部件多头注意力子层、前馈网络子层、残差连接、层归一化。输入向量先进入多头注意力层提取序列内部的关系信息然后经过残差连接和层归一化接着进入一个由两个线性层和一个非线性激活函数组成的前馈网络进一步变换特征最后再做一次残差连接和层归一化。论文里把这两个子层整体上叫残差结构就是为了让梯度能有一条短路通道保证深层网络也能训练。前馈网络的隐藏层维度一般是d_model的四倍也就是2048。这个网络是逐位置共用的也就是说序列里每个位置都经过同样的全连接变换参数共享但位置之间互不干扰。它给模型提供了对每个位置特征做非线性变换的能力这是多头注意力做不到的。[\text{FFN}(x) \max(0, xW_1 b_1)W_2 b_2]用ReLU激活中间维度放大再投影回d_model。我自己的体验是这个FFN占据了模型很大一部分参数量有时候效果上不去问题不是出在注意力上而是出在FFN宽度不够或者层数太浅。3.2 带Mask的Decoder训练和推理的差异Decoder的结构和Encoder有相似之处但多了一个关键部件带Mask的多头注意力层。这个Mask的作用是在计算第t个位置的输出时禁止它“看到”它右边的任何信息。因为我们希望模型按顺序从左到右地生成内容如果解码阶段直接看到未来信息那就变成了作弊。训练的时候整个目标序列是一次性喂进去的但通过Masked注意力每个位置只能关注它自己和它左边的位置从而保持自回归的性质。代码上的实现其实就是在注意力分数矩阵中把右上三角区域填充成负无穷。Softmax之后这些位置的权重就会变成0等于没看见。推理的时候就不一样了。训练时是一次性并行生成所有位置的结果推理时则是逐词生成的生成第一个词把它拼接进输入再生成第二个词以此类推。这也是训练和推理行为差异最大的地方之一。很多人在新手阶段容易把这两者搞混一看到推理慢就疑惑“为什么训练时看起来那么快”其实是因为训练阶段可以并行推理阶段本质上是一个串行过程。3.3 训练时一条数据在模型内部的完整流动路径把上面提到的东西串起来一条训练样本的完整流动路径是这样的词索引经过Embedding查找变成向量加上位置编码后进入第一层Encoder多头注意力提取全局关系FFN做非线性变换逐层堆叠得到编码表示然后进入Decoder先是Masked注意力处理已生成的部分再通过交叉注意力机制去“读取”Encoder的输出——这部分让Decoder能根据源序列的内容决定自己接下来该往哪个方向生成最后经过线性层和Softmax得到每个词表位置的预测概率。这里特别值得注意的就是交叉注意力Cross-Attention。它的Q来自Decoder的上层输出K和V来自Encoder的最后一层输出。模型用每一个目标位置去询问整个源序列中哪些信息最相关再把这些相关信息加权融合进来。这其实是整个机器翻译、摘要生成任务里最关键的信息流动环节理解了QKV在Encoder和Decoder之间的流向Transformer的整体架构就基本理解了。我用一个简单的表格总结一下不同组件的输入输出关系组件Q来源K/V来源作用Encoder自注意力Encoder上层Encoder上层编码端内部信息交换Decoder掩码自注意力Decoder上层Decoder上层解码端依赖已生成内容Decoder交叉注意力Decoder上层Encoder最终输出解码端读取编码端信息4. 从能跑通到跑得好训练过程中的教训4.1 初始化与数值稳定损失为什么突然变成NaN从理论到跑通之间的距离往往是一个NaN。我训练第一个小Transformer时大概跑到第三个epoch损失直接变成NaN查了半天才发现问题出在输出层的初始化范围上。某些权重被初始化得太大经过多层叠加之后中间特征数值膨胀最终导致梯度爆炸。解决的办法很直接对Transformer模型中所有线性层采用适当的初始化方式配合Pre-LayerNorm结构设计。建议使用方差较小的初始化方案比如Xavier初始化或者对权重做截断正态采样。还有一个很容易被忽略的点是输入Embedding的方差如果Embedding向量范围太大和位置编码相加后数值会失衡模型一开始就很难学。另外推荐养成一个习惯训练初期定期打印每一层的输出范数一旦发现某层输出数值持续膨胀立刻停下来检查。等损失NaN了再回头找根源成本要高得多。我自己的复现结构倾向于遵循原论文的超参设置层数6、d_model 512、头数8、FFN隐藏层2048、Dropout 0.1。这些参数在这个任务规模上是被验证可行的。不过如果你用的是小数据集不需要硬凑这些参数d_model 128、层数2就能先把流程跑通。4.2 学习率与Warmup的配合Transformer训练里最容易影响收敛效果的超参我觉得是学习率和warmup步数。由于Transformer在训练初期梯度变化很大直接使用比较大的学习率很容易导致震荡甚至发散。论文里使用的是动态调节学习率的方案先线性warmup再按训练步数的倒数开根号做衰减。公式大致是[ lr d_{\text{model}}^{-0.5} \cdot \min(\text{step}^{-0.5}, \text{step} \cdot \text{warmup_steps}^{-1.5}) ]这个设计思路其实就是前期用小的学习率让模型找到大致正确的方向避免刚起步就跑偏中后期逐渐降低学习率让模型能稳定收敛到更精细的极值点。自己实现的时候我一般设置warmup步数为训练总步数的1%到5%然后配合余弦衰减或上述倒数衰减效果都不错。顺便提一句优化器选择Adam是标配但β2从0.999往下调一点比如0.98在小模型上经常有正面效果因为序列建模任务常见的梯度比较稀疏过大的β2会让二阶动量更新滞后。4.3 一个可以抄走的调参经验表调参这件事听起来很玄学但有一些规律是可复用的。我根据自己的几个模拟项目整理了一张经验参考表现象可能原因处理手段训练Loss震荡不降学习率过大调低峰值学习率增加warmup步数Loss收敛但生成质量差模型容量不足层数/维度太小增加d_model或层数增大FFN宽度训练正常、验证集突然跃升dropout相关过拟合减少dropout或增加数据量训练Loss极低但生成全是重复内容模型偏置估计偏差减小温度采样参数加重复惩罚项这个表只是起点不代表有万能配方。每个任务的数据规模、序列长度、词表大小都会改变最优参数的位置但我至少可以确定如果你的模型跑不起来绝大多数时候不是模型结构的问题而是训练设置问题。5. 这篇笔记适合的下一步动作5.1 用一个小项目验证你对Transformer的理解看完再多的推导都不如自己搭一个精简单元。我建议你找一个小规模数据集——只是练手的话完全可以用一个简单的文本匹配任务或序列复制任务——试着不用现成的Transformer包手写Encoder和Decoder的完整代码然后在上面完成训练和推理。我自己的验证方式是先构造一个“逆序输出”任务给定一串数字序列让模型学会倒序输出。任务足够简单能很快验证位置编码、多头注意力、掩码等机制是否真正生效同时任务又足够明确如果模型没有真正理解序列关系输出会非常明显地错误。做完这个任务再回去对比nn.Transformer的API设计你会发现每个参数你都能说出它在干什么而不是对着文档猜。这种从代码到理解的闭环比看十篇教程都有用。5.2 后续可以往哪几个方向继续深挖Transformer这片领域越学越觉得深。如果这篇笔记你完整消化了下一步我建议按这几个方向取舍深入第一深入研究注意力变体。相对位置编码、稀疏注意力、线性注意力这些改良方案各自解决什么问题和标准多头注意力有什么本质差别建议都动手实现一遍。第二理解预训练与下游微调的关系。Efficient Encoder和Decoder在预训练时的目标是什么不同任务微调时的改动差异在哪里这能帮你把一个基础的Transformer串到实际工作流中。第三如果你想触及推理性能可以研究KV Cache的机制——推理阶段如何缓存Key和Value减少重复计算以及batch生成时的各种优化技巧这些在工程落地中很常见。第四跨模态扩展。Transformers现在不只是处理文字也可以处理图像、音频、甚至表格数据。核心结构不变但输入输出怎么映射、位置编码怎么适配不同模态非常值得探索。我学习下来最大的体会是Transformer看起来只是一个自带注意力机制的序列模型但它隐藏了很多工程细节。把每一个形状、每一个mask、每一个初始化想明白之后你会慢慢发现它其实没有想象中那么神秘但对于实践的要求也远比看一篇论文要复杂。后续我会继续记录我学到的新内容争取把每一篇都写成像今天这样可以参考的实践经验而不是满天飞的架构示意图。最后说一点我自己的心得学习这个架构最忌讳的是“收藏了很多文章但一行代码不写”。花一个周末老老实实用Python把一个小模型从零搭出来哪怕只是在一个玩具数据集上跑通你对整个机制的理解都会发生质变。这篇笔记能在你动手过程中帮你少走几个弯路就算完成它的使命了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询