
在2017年之前我做过一段时间的机器翻译流水线那时候一个中英翻译模型上线要改的东西特别多encoder 得配双向 LSTMdecoder 得单独调注意力对齐训练一慢就是好几天梯度裁剪的阈值稍微手抖一下整个模型就跑飞。所以2017年夏天《Attention Is All You Need》这篇论文出来的时候我第一反应不是这东西好优雅而是少了循环、少了卷积句子顺序真的还能记住吗。后来自己用 PyTorch 从零手写了一遍 Transformer又拿它做过文本分类和序列标注才慢慢体会到这篇论文真正的价值不在注意力机制本身——注意力早就有——而在于它把序列建模必须靠串行结构这个默认前提直接拆掉了。这篇我打算按精读的路子走一遍把论文里那些一句话带过的公式、那些藏在附录里的超参、还有那些论文没写但复现必踩的细节全部摊开讲清楚。适合已经大致知道 Transformer 长什么样、但看论文时总觉得字我都认识连起来看不懂的读者也适合准备手写一遍但不想反复调试一个星期的人。1. 先把论文要打的靶子说清楚2017年之前序列建模卡在哪不搞清楚前人的痛后面所有的设计选择都会显得像是在炫技。论文的第一段其实就是在划靶子只是写得特别克制。1.1 RNN 系的串行诅咒与梯度传不远当时主流的序列建模方案是 RNN、LSTM、GRU 这一族。它们的核心思想是维护一个隐藏状态h_t每一步把上一个时刻的状态和当前输入一起喂进去算出新的状态。这个结构在直觉上特别自然就像人读书一样一个字一个字往下读。但问题也恰恰出在这个一个一个字上h_t的计算必须等h_{t-1}算完这是硬性的数据依赖。在 GPU 这种靠大规模并行吃饭的硬件上一个长度 50 的句子里50 个时间步只能排队执行。我当年调 LSTM 翻译模型时最直观的体感就是把 batch size 从 64 提到 256GPU 利用率上去了但单步耗时几乎没降因为瓶颈根本不在矩阵乘而在那一串无法并行的串行计算。更麻烦的是长距离依赖。哪怕 LSTM 的门控机制已经比原始 RNN 强很多信息从第 1 个词传到第 50 个词中间依然要经过 49 次状态更新和 49 次非线性变换。梯度沿着这条路反向传播会被反复乘以权重矩阵和激活函数的导数衰减是必然的。论文在第三页里用了一句话带过这个现象长距离依赖的学习在 RNN 中是一个根本性的障碍。1.2 CNN 系的做法与它的感受野代价另一条路是用卷积做序列建模类似 ConvS2S、ByteNet 这些工作。卷积的好处是时间步之间可以并行每个位置的输出只依赖附近窗口内的输入整层可以一次性算完。这在 GPU 上效率高得多。但卷积的代价是感受野。一个 kernel size 为 k 的一维卷积单层只能看到左右各 k/2 个位置。想让第 1 个词的信息传到第 50 个词要么堆很多层要么用空洞卷积把感受野撑开。论文里那个复杂度表格写得很直白卷积层的最大路径长度是O(log_k(n))虽然比 RNN 的O(n)好但依然不是常数。层数一多模型就深深了就又回到梯度传播的老问题上。1.3 论文真正想做的事把距离从路径长度里解放出来我自己读这篇论文最大的一个啊哈时刻是意识到它盯住的其实是一个更本质的指标任意两个位置之间信息流动的路径长度。RNN 里位置 i 和位置 j 之间的路径长度是|i - j|。卷积里是O(log_k(|i-j|))。Self-Attention 里是O(1)。路径长度是 1 意味着什么意味着不管这两个词隔着 5 个词还是 500 个词它们之间都只隔一次注意力计算梯度不需要穿越一条长长的链条。这不是把 RNN 换掉这么简单而是把序列建模里的距离概念重新定义了位置之间的关系不再由物理距离决定而是由一个数据驱动的权重决定。我自己总结的一句话RNN 是必须按顺序读卷积是只能看附近而 self-attention 是一上来就先看全图再决定看谁。这个视角转换才是这篇论文真正的贡献。2. 逐层拆骨架Encoder-Decoder 里的每个零件都不是随手放的论文的 Figure 1 那张图左边是 encoder、右边是 decoder很多人第一眼看过去觉得不就是堆了六层嘛。但每一层里放什么、顺序怎么排、哪些地方加残差都是有明确理由的。2.1 编码器6层堆叠里重复的那两个子层编码器由 N6 个完全相同的层堆叠而成每一层里只有两个子层一个多头自注意力Multi-Head Self-AttentionQ、K、V 全部来自上一层输出。一个逐位置的前馈网络Position-wise Feed-Forward Network。每个子层外面都套了一层残差连接 层归一化即LayerNorm(x Sublayer(x))。论文里明确写了为了让残差相加能成立所有子层和 embedding 层的输出维度都统一取d_model 512。这里有个细节值得单独说为什么是残差在前、归一化在后。残差连接的初衷是给梯度提供一条高速公路让梯度可以从深层直接回传到浅层不必穿过每一层的非线性变换。而 LayerNorm 放在残差之后意味着归一化看到的是原始输入 子层输出这个和。这种顺序后来被称作 Post-LN它在训练初期对学习率很敏感往往需要 warmup 才能稳住——这也解释了为什么论文要用那个很奇怪的学习率公式。后来的工作比如 Pre-LN把顺序换成x Sublayer(LayerNorm(x))训练稳定性会好很多这是后话但如果你复现论文遇到 loss 爆炸第一件该怀疑的事就是这个顺序配合了过大的学习率。2.2 解码器为什么要多加一层被遮住的注意力解码器每层有三个子层比编码器多出来的那个夹在中间的叫 Masked Multi-Head Attention。它和普通自注意力的唯一区别是在 softmax 之前把所有未来位置的注意力分数置成负无穷让它们经过 softmax 之后权重变成 0。为什么必须这么做因为解码是自回归的——生成第 t 个词时模型只能看到第 1 到第 t-1 个词。如果你不遮训练时模型就能从正确答案里偷看下一个词loss 会降得非常漂亮但推理时没有答案可看效果直接崩掉。这类错误有个很贴切的名字叫标签泄漏label leakage而且它特别隐蔽训练曲线完美验证集指标也正常因为验证集同样在偷看只有真正做推理才会暴露。训练时一次性喂入完整目标序列用上三角 mask 遮住未来。推理时一个一个词生成每步只喂已生成的部分天然看不到未来。这两条路径算出来的结果在理论上应该完全一致这也是我写完代码后必做的一个验证——拿同一段输入分别用整段喂 mask和逐词喂跑一遍检查输出是不是逐位对齐。这一步能抓出 90% 的 mask 实现错误。2.3 前馈网络看着最无聊参数却占了大头两层线性变换加一个 ReLUFFN(x) max(0, xW_1 b_1)W_2 b_2。中间层维度d_ff 2048输入输出都是 512。这个先升维再降维的 4 倍扩张比几乎成了后来的标准配置。为什么升到 2048论文没给推导只说了一句内外层维度不同。但从参数量角度算一下就很清楚单层 FFN 有512×2048 2048×512 ≈ 210万个参数而单层多头注意力大约只有4×512×512 ≈ 105万个参数。也就是说FFN 承担了每一层里三分之二的参数。它在做的事是把注意力聚合来的信息做一次非线性的、逐位置的深加工。注意力负责往哪看FFN 负责看完之后怎么想。论文里还特意强调了一句这个 FFN 在不同位置之间参数共享但层与层之间不共享。也就是说它本质上是两个 kernel size 为 1 的卷积只是没有跨位置的信息交换。2.4 一句话总结数据流把整个结构串起来看一个 token 从输入到输出经过的路径是embedding 加位置编码 → 六次自注意力 → 残差归一 → FFN → 残差归一→ 解码器端交叉注意力查询编码器输出 → 线性层 → softmax。理解这条链路之后论文正文里那些公式其实都是在描述其中某一个环节的细节。3. Scaled Dot-Product Attention那个除以根号 d_k 到底在防什么这是整篇论文里我最喜欢的一个细节因为它是一个如果不做就会慢做了才知道为什么的设计。3.1 Q、K、V 三个矩阵的物理含义先别被符号吓到。用数据库检索打个比方Query你输入的检索词代表我现在想找什么。Key每条记录的标签代表我这条记录是关于什么的。Value每条记录真正的正文内容。计算过程就是拿 Query 和所有 Key 做点积得到一组相似度分数softmax 归一化成权重然后按权重把 Value 加权求和。相似度高的 Key 对应的 Value 就会在结果里占更大比重。在自注意力里Q、K、V 全都来自同一份输入只是各自乘了一个不同的可学习投影矩阵W^Q、W^K、W^V。这三个矩阵的作用是让模型在不同的表示子空间里去问、去答、去取值——如果三者共用同一个投影表达能力会明显变弱。3.2 点积注意力 vs 加性注意力为什么选了更简单的那个论文明确比较了两种注意力类型打分函数特点加性注意力score v^T tanh(W_1 q W_2 k)需要额外参数适合 q、k 维度不同的情况点积注意力score q · k无额外参数可用高度优化的矩阵乘实现论文的选择很务实当d_k不大时两者精度接近但点积注意力可以利用成熟的矩阵乘法库速度更快、内存更省。这不是理论上的优劣而是工程上的取舍——这一点在论文里写得非常坦率。3.3 维度一上去 softmax 就饱和一个可以手算的推导关键问题来了既然点积注意力又快又好为什么要额外除以√d_k假设 q 和 k 的每个分量都是均值 0、方差 1 的独立随机变量那么它们的点积q · k Σ_{i1}^{d_k} q_i k_i的均值是 0方差是d_k。也就是说维度越大点积结果的数值范围就越大。举个例子d_k 64时点积的标准差大约是 8d_k 512时标准差大约是 22.6。这意味着在不缩放的情况下softmax 的输入会分布在一个非常宽的区间里。softmax 有个特性输入值差距越大输出就越接近 one-hot——某一个位置接近 1其余全部接近 0。而 softmax 的梯度里含有p_i(1 - p_i)这一项。当输出接近 one-hot 时p_i要么接近 0 要么接近 1这个乘积就趋近于 0梯度几乎消失。模型会卡在一个注意力越来越尖锐、梯度越来越小的死循环里。除以√d_k做的事正好是把点积的方差拉回 1让 softmax 工作在一个梯度健康的区间里。这个操作只有一行代码但它决定了深层的注意力能不能训起来。论文在脚注里连推导都写了很值得自己动手推一遍。3.4 手写实现的三个坑import math import torch import torch.nn.functional as F def scaled_dot_product_attention(q, k, v, maskNone, dropout_p0.0): # q: (B, h, Lq, d_k) k: (B, h, Lk, d_k) v: (B, h, Lk, d_v) d_k q.size(-1) scores torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k) if mask is not None: # mask 中 True 表示保留False 表示屏蔽 scores scores.masked_fill(~mask, float(-inf)) attn F.softmax(scores, dim-1) if dropout_p 0.0: attn F.dropout(attn, pdropout_p) return torch.matmul(attn, v), attn第一个坑mask 的填充值。用float(-inf)而不是-1e9。虽然-1e9在实践中通常也能工作但如果某一行的所有位置都被 mask 掉-1e9经过 softmax 会得到均匀分布而-inf会得到 NaN。NaN 反而是好事它会立刻报错告诉你 mask 写错了而均匀分布会悄悄把你的模型带偏。如果你确实需要处理全 mask 的行比如 padding 位置记得用masked_fill之后再nan_to_num或者显式把整行置零。第二个坑mask 的维度广播。scores的形状是(B, h, Lq, Lk)而 padding mask 通常只有(B, 1, 1, Lk)或(B, Lk)。忘记扩维会静默广播成错误的形状或者在 batch 内出现莫名其妙的错位。我的习惯是在写完之后立刻打印一次 mask 的 shape跟 scores 对齐检查。第三个坑causal mask 和 padding mask 的合并。解码器同时需要这两种 mask前者遮未来后者遮填充。合并方式是用逻辑与combined pad_mask causal_mask其中 causal mask 是一个下三角矩阵。如果分开做两次masked_fill要保证顺序不会互相覆盖。4. Multi-Head多头不是多搞几份而是把子空间切开看论文里 Multi-Head Attention 的公式写得很浓缩MultiHead(Q,K,V) Concat(head_1, ..., head_h) W^O其中head_i Attention(QW_i^Q, KW_i^K, VW_i^V)。这几行公式背后的设计意图值得展开讲。4.1 单头到底缺什么如果只用一组 Q、K、V 投影模型在某个位置上做注意力时只能用一种相似度标准来分配权重。但语言里的关系是多样的有的位置需要关注句法上的主语有的位置需要关注语义上的修饰语有的位置需要关注指代关系。用一组权重去同时满足这些需求结果必然是折中——注意力分布会变得模糊什么都关注一点什么都不够准。这就像开会时让一个人同时记录财务、技术和人事三块内容最后记下来的东西大概率是三块都记不全。多头做的事就是雇 8 个人各管一摊。4.2 d_model512、h8、d_k64 这组数字是怎么配平的论文里有一个约束d_k d_v d_model / h 512 / 8 64。这个设计很讲究。如果每个头自己独立拥有一套完整的d_model维度投影那么 8 个头的参数量会膨胀 8 倍计算量也涨 8 倍。论文的做法是把 512 维平均切成 8 份每个头只在 64 维的子空间里做注意力。这样总计算量和单头 512 维注意力基本持平但模型获得了 8 个独立的表示子空间每个子空间维度降低计算复杂度也降了。用切蛋糕来类比蛋糕总量没变但切成了 8 块每块可以抹不同口味的奶油。4.3 拼回去之后为什么还要乘 W^O8 个头的输出各是 64 维直接 Concat 起来正好是 512 维。既然维度都对上了为什么还要再乘一个输出投影W^O原因是Concat 只是把 8 个头的结果并排摆在一起它们之间没有任何信息交互。第 1 个头学到的模式和第 2 个头学到的模式在向量里是彼此独立的区块。W^O是一次跨头的线性混合让不同头的信息能够重新组合。少了它8 个头就真的只是8 个平行的小模型而不是一个整体。实操提醒我第一次手写的时候嫌麻烦把W^O省了结果是训练能跑、loss 也在降但最终指标比论文低了一截。原因就是各头信息没有融合。这个投影层的参数量不小512×512千万别当装饰。4.4 多头到底在关注什么论文附录里放了一张可视化图Figure 3-5展示了不同注意力头学到的模式。有意思的是不同的头确实分化出了不同的功能有的头明显在做句法层面的依赖比如动词和它的主语、介词的宾语有的头专门盯相邻位置类似一个小范围的局部窗口还有一个头非常典型专门关注句子里指代关系的远距离位置。这种自发分工是训练出来的不是设计好的。也正因为如此多头数不是越多越好——后面第 8 节的消融实验会说明这一点。5. 位置编码没有循环也没有卷积句子顺序靠什么记住这是整篇论文里最容易被低估的一块。因为 self-attention 是置换等变的你把输入序列里任意两个词交换位置输出只是对应地交换注意力权重和计算结果完全不变。换句话说模型压根不知道我吃了一个苹果和苹果吃了我有什么区别。位置信息必须显式注入。5.1 正余弦编码的公式与手算验证论文给的公式是PE(pos, 2i) sin(pos / 10000^(2i / d_model))PE(pos, 2i1) cos(pos / 10000^(2i / d_model))其中pos是位置i是维度索引偶数维用 sin奇数维用 cos。手动算两个位置感受一下会更清楚。取d_model 512那么对于第 0 维i0分母是10000^0 1波长是2π对于第 510 维i255分母是10000^(510/512) ≈ 9000波长大约是2π × 9000 ≈ 56000。也就是说低维度对应高频、短波长相邻位置之间变化很快高维度对应低频、长波长位置变化很慢。这形成了一种类似二进制编码的效果——低位快速翻转高位缓慢变化。多个不同频率的正弦波叠加起来就能给每个位置一个近乎唯一的指纹。5.2 为什么这样设计能表达相对位置这是论文里最漂亮的一个性质。对于任意固定的偏移量kPE(pos k)都可以表示成PE(pos)的一个线性变换用三角恒等式展开即可验证。这意味着模型只要学会一组固定的线性映射就能处理相对位置是 k这一关系而且这个能力对训练时没见过的位置偏移同样成立。这正是论文声称它能外推到比训练序列更长的原因——不是因为模型聪明而是因为编码函数本身在结构上就支持平移。5.3 可学习位置编码 vs 正弦编码论文做了一个消融实验用可学习的位置 embedding 替换正弦编码结果两者效果几乎完全一致。这个结论非常关键它说明位置信息本身是必需的具体用哪种方式注入只要表达力够影响不大。既然效果一样论文为什么选正弦原文给的理由是可以让模型外推到训练时未见过的、更长的序列。但说实话从我自己的实测经验看这个外推能力在实践中并不算强——稍微超出训练长度性能就会明显下滑。所以后来 GPT 系列用了可学习的位置 embedding再后来又演化出 RoPE 这类相对位置编码本质上都是在修补这一块的短板。5.4 位置编码是加还是拼论文的做法是把位置编码逐元素相加到词 embedding 上而不是拼接。相加的好处是不增加维度、不增加参数代价是位置信息和词义信息混在同一个向量里理论上可能互相干扰。拼接的话维度会变成2 × d_model模型得额外学一层降维。两种做法都有人用但我个人在短序列任务上更倾向相加因为它省参数、收敛快而且在序列长度几百以内的场景中没观察到明显劣势。6. 训练配置论文里那些一笔带过的超参反而最影响复现论文的第 5 节只有两页但真的动手复现的时候我发现这一节的信息密度是最高的也是最容易漏看的。6.1 Adam 的 beta 和 epsilon 为什么不走默认值论文用的是beta1 0.9, beta2 0.98, epsilon 1e-9。标准 Adam 默认是beta2 0.999, epsilon 1e-8。改动的原因藏在 Adam 更新规则的二阶矩估计里。当beta2 0.999时二阶矩的滑动平均窗口非常长大约需要1/(1-0.999) 1000步才能有效累积历史信息。而 Transformer 的训练在早期几百步内梯度变化很剧烈长窗口会导致二阶矩估计严重滞后有效学习率被放大容易引发震荡。把beta2调到 0.98窗口缩短到 50 步左右二阶矩能更快跟上梯度的真实尺度更新步长就更稳。epsilon从1e-8降到1e-9则是为了让分母上的修正项在梯度很小时不至于过度平滑。这两个数字看起来微不足道但我实测下来beta2用默认值时训练初期的 loss 曲线明显更抖。6.2 学习率 warmup那个带负幂次的公式怎么读论文的学习率公式是lrate d_model^(-0.5) * min(step_num^(-0.5), step_num * warmup_steps^(-1.5))warmup_steps 4000。这个式子分两段理解前 4000 步取step_num * 4000^(-1.5)学习率随步数线性增长。4000 步之后取step_num^(-0.5)学习率随步数的平方根反比衰减。前半段是 warmup后半段是衰减。warmup 的作用是让模型在参数还完全随机、梯度方向极不可靠的初期用小步长慢慢走。这和前面提到的 Post-LN 结构直接相关——Post-LN 在训练初期对学习率极其敏感没有 warmup 很容易在头几百步就发散。我踩过的一个坑直接把warmup_steps从 4000 改成 500想着快速收敛。结果训练在第 200 步左右 loss 直接飙到 NaN。后来才想明白warmup 的长度是跟模型规模、batch 大小耦合的不能随便砍。经验法则是warmup 步数至少要覆盖模型从随机初始化到梯度方向稳定的过渡期而这段过渡期的长度大致和层数、d_model正相关。6.3 Dropout、label smoothing、checkpoint 平均三种正则化手段作用位置完全不同手段作用位置参数效果Dropout每个子层输出相加之前、embedding 与位置编码相加之后、注意力权重上base 0.1 / big 0.3抑制过拟合Label Smoothing训练时的目标分布ε 0.1BLEU 提升但困惑度变差Checkpoint 平均训练最后阶段保存多份权重取平均最后 20 个 checkpoint稳定提升约 0.5 BLEULabel Smoothing 这一项特别值得说。它把 one-hot 目标里的 1 换成1 - ε把 0 换成ε/(V-1)。这相当于告诉模型正确答案确实最可能但别把它当成唯一可能。论文原话很有意思这么做让 BLEU 从 25.3 涨到 25.7但同时让困惑度perplexity变差了。原因是平滑后的目标分布熵更高模型永远无法把概率压到 1困惑度自然下不去。这提醒我们一件事困惑度低不等于翻译质量好两者优化的目标不一样。6.4 训练规模到底有多大论文的 base 模型在 8 张 P100 上训了 10 万步约 12 小时big 模型训了 30 万步约 3.5 天。每个 batch 大约包含 25000 个源语言 token 和 25000 个目标语言 token。这个信息对复现很重要如果你的数据集远小于 WMT 英德450 万句对那么按论文的步数训会严重过拟合。我一般会按总步数 ≈ 数据集大小 / batch 大小 × 5~10来估算一个合理的训练轮数再配合验证集上的早停。7. 复杂度那张表self-attention 的 O(n²) 到底贵在哪论文 Table 1 那张对比表是很多人后来拿来说Transformer 有长度瓶颈的源头但真正读懂它的人其实不多。7.1 三层复杂度逐项对比层类型每层复杂度顺序操作数最大路径长度Self-AttentionO(n² · d)O(1)O(1)RecurrentO(n · d²)O(n)O(n)ConvolutionalO(k · n · d²)O(1)O(log_k n)Restricted Self-AttentionO(r · n · d)O(1)O(n/r)这里的n是序列长度d是表示维度k是卷积核大小r是受限注意力的窗口大小。7.2 什么时候 self-attention 反而更省关键洞察在于两条曲线的交叉点。把O(n² · d)和O(n · d²)放在一起比较当n d时n² · d n · d²self-attention 更省。当n d时RNN 的理论复杂度反而更低。论文在正文里明确写了这个观察并说对于典型的序列长度和表示维度self-attention 更快。以 base 模型d 512为例只要序列长度不超过 512self-attention 在计算量上就不亏。而当时的机器翻译任务里绝大多数句子长度都在 100 以内。这也是为什么 Transformer 一开始能在翻译任务上大杀四方却在后来遇到长文档任务时被诟病——当n涨到几千甚至几万n²的平方级增长就变成了实实在在的显存和算力瓶颈。后来的 Longformer、BigBird 这类稀疏注意力工作本质上就是通过限制每个位置只看一个窗口r把复杂度拉回线性。7.3 最大路径长度那个容易被忽略的第三列很多人只盯着复杂度忽略了最大路径长度这一列。但对训练稳定性来说这一列比复杂度更重要。RNN 的路径长度是O(n)所以有梯度消失的固有难题卷积是O(log_k n)靠堆层来缩短代价是模型很深Self-Attention 是O(1)任意两个位置一步可达。O(1)的路径长度意味着梯度传播不衰减这是 Transformer 能堆到几十层甚至上百层还不崩的根本原因。我在做长文本分类时对比过 4 层 BiLSTM 和 6 层 Transformer后者的训练 loss 下降明显更快更稳这一列就是解释。8. 消融实验教会我们的事哪些设计是必要的论文的 Table 3 是整篇里我最推荐细读的部分因为它回答的是如果我把某个设计换掉会怎样。这种反向验证比正向陈述更有说服力。8.1 头数不是越多越好论文做了 (A) 组实验改变注意力头数和对应的d_kbase 配置8 头d_k 64BLEU 27.316 头d_k 32BLEU 25.132 头d_k 16BLEU 24.9单头d_k d_v 512BLEU 24.9结论非常清楚头数增加但每个头的维度被压缩性能反而下降。用单头也不行。论文的解释是把d_k减小会削弱每个头对兼容性的判断能力而单头则失去了多子空间的多样性。对我实际工作的启发是如果我的任务里关系类型比较单一可以适当减少头数如果想减少参数优先砍的是其他部分而不是把d_k压到 32 以下。8.2 位置编码换掉会怎样(D)(E) 两组实验对比了可学习位置编码和正弦编码结果几乎一致。这印证了前面第 5.3 节的判断位置编码的具体形式不敏感有没有位置信息才敏感。8.3 去掉注意力权重上的 dropout(F) 组实验调整了 dropout 的作用位置只在残差连接处加、只在注意力权重上加、两种都加。结果是两种都加效果最好。注意力权重上的 dropout 特别重要因为它直接作用于那个n × n的注意力矩阵能有效防止某些头过早地锁定到特定的少数位置上。我的经验在小数据集上做微调时注意力 dropout 可以稍微调大我常用 0.2因为过拟合往往就发生在注意力分布上——模型开始死记几个关键位置而不是真正理解句子的关系。9. 从论文到代码一份最小可跑实现与验证方法读完论文和写出来之间有一段距离这段距离主要靠形状追踪和验证小技巧来跨越。9.1 张量形状的全程追踪我自己的习惯是在纸上把形状写一遍因为 Transformer 里 90% 的 bug 都是形状错位输入 token id(B, L)Embedding 后(B, L, d_model)加位置编码后(B, L, d_model)经过 Q/K/V 投影后(B, L, d_model)每个拆成多头并转置(B, h, L, d_k)注意力分数(B, h, L, L)乘 V 之后(B, h, L, d_v)合并多头(B, L, d_model)线性输出层(B, L, vocab_size)关键的两步转置是拆头和合头这两步最容易搞错轴的顺序。我一般会写一个自检函数用随机张量跑一遍断言输出形状符合预期。9.2 两个 mask 的合并技巧def build_masks(src, tgt, pad_id0): # src: (B, Ls) tgt: (B, Lt) B, Ls src.shape Lt tgt.shape[1] # padding mask: (B, 1, 1, Ls)True 表示有效位置 src_pad (src ! pad_id).unsqueeze(1).unsqueeze(2) tgt_pad (tgt ! pad_id).unsqueeze(1).unsqueeze(2) # causal mask: (1, 1, Lt, Lt) 的下三角 causal torch.tril(torch.ones(Lt, Lt, dtypetorch.bool)).view(1, 1, Lt, Lt) # 编码器自注意力只需 padding mask enc_mask src_pad # 解码器自注意力padding 与 causal 相与 dec_self_mask tgt_pad causal # 交叉注意力Q 来自解码器K/V 来自编码器mask 形状按 Ls cross_mask src_pad return enc_mask, dec_self_mask, cross_mask这段代码里有两个容易忽略的点。第一causal的形状是(1, 1, Lt, Lt)前两维靠广播。如果你的实现里多头维度不广播就会出现越界或者静默错误。保险的做法是在用之前expand到完整形状。第二tgt_pad的形状是(B, 1, 1, Lt)和causal的(1, 1, Lt, Lt)相与之后是(B, 1, Lt, Lt)。这个形状能和(B, h, Lt, Lt)的分数广播是正确的。很多人在这里写成tgt_pad.unsqueeze(-1)或者忘了维度结果 mask 作用的位置完全错位。9.3 三个测试验证实现正确性写完代码之后别急着上真实数据训练先跑这三个测试能快速定位大部分问题形状测试用B2, L7, d_model32, h4的随机张量走一遍完整前向断言输出形状是(2, 7, 32)。因果性测试把目标序列的第t个位置改成完全不同的 token检查输出在第1..t-1位置上是否完全不变。如果变了说明 causal mask 有泄漏。置换一致性测试无位置编码时暂时去掉位置编码对输入序列做一次随机置换输出应该对应地置换。这个测试能验证注意力实现本身是否对称、有无隐藏的顺序依赖。我用这三个测试抓到过至少三类 bugmask 维度广播错误、拆头时轴顺序写反、以及W^O忘记乘。这些都是训练曲线看不出来的问题——它们会训练只是训练出来的东西不对。9.4 从能跑到跑得好还差什么最后说几个论文没写、但我自己踩出来的经验。梯度裁剪是必须的。虽然论文正文没重点强调但实际训练中裁剪阈值设在 1.0 左右是很常见的做法。Post-LN 结构在个别 batch 上会突然产生很大的梯度没有裁剪就有可能一步炸掉。学习率公式里的d_model是耦合项。如果你把d_model从 512 改成 256那个d_model^(-0.5)会让整体学习率变大。这意味着换模型规模时不能只调d_model不管学习率两者得一起改。batch 内的 token 数是相对恒定的不是句数。论文说的 25000 源 token 25000 目标 token 是按 token 计的。如果你按固定句数组 batch长句和短句混在一起会导致 batch 内 token 数剧烈波动训练不稳定。我一般会按 token 预算动态拼 batch把总 token 数控制在 20000 到 30000 之间。PyTorch 里TransformerEncoderLayer的默认参数和论文不一致。默认是 Post-LNnorm_firstFalsedim_feedforward默认是 2048但dropout默认是 0.1。如果你的任务数据量小别直接照搬 0.1从 0.1 开始往上试我一般在 0.1 到 0.3 之间找一个验证集最优的值。最后一点也是我最想强调的这篇论文最容易被误读的地方是把它当成注意力打败了循环。实际上真正的转折点在于它证明了序列建模可以完全通过全局可见 位置编码来实现不必依赖任何结构上的顺序归纳偏置。理解这一点之后你看后面所有的 Transformer 变体——无论是视觉领域的 Swin 那种局部窗口设计还是各种稀疏注意力的改进——都能一眼看出它们在修补什么。它们改的都是如何在保留全局可见性的同时控制计算量而不是在否定这个核心思想。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。