从第一次读到《Attention Is All You Need》原论文到现在,我已经在不同的项目里反复接触Transformer:最早是机器翻译,后来是文本分类、序列生成,再后来是视觉模型甚至推荐系统里的特征交叉。我发现一个很有意思的现象——大部分初学者都能说清楚Transformer“大概长什么样”,比如有encoder和decoder、有自注意力、有位置编码,但一问到“为什么Q、K、V要分开?为什么除以根号d_k?为什么多头比单头效果好?”就答不上来了。这篇文章我想把这些“为什么”从头捋一遍,不只是复述结构,而是讲清楚设计背后的考虑和我在实操中踩过的坑。
适合读这篇文章的人主要有三类:正在学NLP基础、看论文看得云里雾里的学生;已经在用各种预训练模型做微调、但不太了解底座机制的应用工程师;以及想自己从零写一个Transformer练手、却不知道从哪下手的开发者。文章会从动机开始讲,拆解自注意力、多头注意力、位置编码、训练稳定性这些关键设计,最后聊一聊原版架构在当下生态里的延续与变化。我会尽量用生活化的类比解释抽象概念,同时也保留必要的数学直觉和工程细节。
1. 从“翻译模型”到一切模型:Transformer到底改了什么?
1.1 为什么当时的循环神经网络让人又爱又恨
在Transformer出现之前,序列建模的主流方案是循环神经网络,包括LSTM和GRU。当时做机器翻译、语音识别、文本生成,基本都离不开这些结构。循环神经网络的思路很直接:按时间步逐个处理输入,每走一步,把上一步的隐状态传下来,当作一种“记忆”。这确实有效果,但有几个很让人头疼的问题。
第一个问题是并行化困难。因为每一步都要等前一步算完,这在GPU时代非常致命。训练文本序列一长,效率直线下降。第二个问题是长程依赖。虽然LSTM通过门控机制缓解了梯度消失,但本质上信息仍然要一步一步传,路径太长之后,早期位置的信息很容易被稀释。我当年跑文本分类实验时,经常遇到一个现象:句子一长,模型的表现就明显滑坡,仿佛模型把开头的内容“忘掉”了。第三个问题是双向信息利用不自然。处理一个词的时候,虽然理论上可以通过后续步看到未来信息,但训练和推理的复杂度都上去了。
这些痛点不是靠“把循环网络做得更深”能解决的,它更像是一个结构性瓶颈。大家当时其实也有另一个共识:在机器翻译这种任务里,真正起决定性作用的,常常是词语之间的对齐关系——源语言里的某个词到底应该对应目标语言里的哪个词。这种对齐关系是动态的,与距离无关。句子再长,两个词之间只要存在强语义关系,模型就该直接建立连接,而不是隔着整个句子慢慢传信息。这个想法后来被整理成注意力机制,而Transformer做得更彻底:既然注意力能抓住任意距离的依赖,还要循环结构干什么?干脆只靠注意力做整个序列的建模。
1.2 从“辅助组件”到“唯一的引擎”
注意力机制并不是Transformer首创。更早的时候,机器翻译里的注意力通常是在encoder和decoder之间加一个“对齐模块”,辅助循环神经网络把需要的信息从源端取出来。那时候注意力的地位是“补丁”或者“辅助组件”,循环网络仍然是主干。
《Attention Is All You Need》这篇论文提出的主张非常激进:把注意力当作唯一的核心操作,彻底取消循环和卷积。整个模型由多头自注意力、逐位置前馈网络、残差连接和层归一化组成。为了补充顺序信息,又加了位置编码。结构看起来特别简单,实验效果却非常能打:在机器翻译任务上训练速度更快,效果更好。这个“把某个辅助机制扶正为唯一主干”的思路,后来被反复验证是极其有效的。现在我们熟悉的BERT、GPT系列,以及视觉、语音、多模态领域里那些Transformer变体,本质都是沿着这个思路往前走的。
我自己的体会是,理解Transformer的第一步,不是急着看多头注意力的公式,而是先接受一个观念转变:序列建模不再需要“一个词一个词地往前走”,而是让序列中所有位置同时开始处理信息,并且通过两两之间的注意力权重来决定“谁应该关注谁”。这个观念一旦转过弯,后面所有结构细节都会顺理成章。
2. 自注意力机制到底在做什么:一次全员参与的投票大会
2.1 Q、K、V的直觉:不是魔法,是数据库查询
自注意力的输入是一串向量,比如一个句子经过词嵌入之后得到一组向量。它的核心运算可以概括为一句话:序列中每个位置的输出,是其他所有位置向量按“相关度”加权求和的结果。为了算出相关度,模型为每个位置同时准备了三份表达:Query(查询)、Key(键)、Value(值)。
我有时候会把它比喻成一场全员参与的投票大会。假设我们现在要更新句子中第一个词的表示,这个过程相当于第一个词当“提问者”,它带着自己的Query去向所有词(包括自己)提问:“你们谁跟我相关?谁能给我提供我需要的信息?”每个被问到的词会亮出自己的Key,与Query做匹配,匹配得分高就说明很相关。最后,大家按相关度作为权重,把各自的Value拿出来投票,加权求和的结果就是第一个词拿到的新表示。
这里有一个很容易被忽略的细节:Value和Key不是同一个东西。Key决定“我该不该被你关注”,Value决定“如果你要关注我,你从我这里拿到什么内容”。类比生活中的场景:Key像是封面上印的关键词标签,用来决定这本书是否进入你的候选清单;Value像是书里的正文内容,当这本书被选中之后,才是真正被阅读和吸收的部分。把两者分开,让模型可以独立学习“如何吸引注意力”和“如何提供内容”,表达能力更强。
在实际实现里,输入向量会分别乘上三个权重矩阵得到Q、K、V。这三个矩阵就是模型需要学习的参数。所以从本质上说,自注意力层不是一种“特殊的排序算法”,它只是一组可学习的线性变换,再加上一个加权的信息汇聚过程。
2.2 缩放点积公式里的温度:为什么非要除以根号d_k
自注意力内部计算匹配度时,最常用的方法是缩放点积。把Q和K做点积,再除以一个缩放因子,然后过softmax得到权重,最后用权重对V加权。公式我可以写成:
Attention(Q, K, V) = softmax(Q * K^T / sqrt(d_k)) * V我第一次看这个公式的时候,最不理解的就是那个除法。为什么好端端的点积算出来还要缩一下?直接softmax不行吗?
问题出在维度效应上。点积算出来的分数,其方差近似等于d_k(键向量的维度)。当d_k比较大的时候(比如512),分数会拉得很开,softmax之后会变得非常“极端”——大部分权重趋近于0,只有个别位置几乎是1。这会让梯度变得很小,模型几乎训练不动。除以sqrt(d_k)之后,分数的方差被压回到一个比较温和的范围,softmax的输出不容易过早陷入饱和区。这相当于给注意力动态加了一个“温度控制器”,让竞争保持在一个健康的强度。
这里有个工程上的小提示:我自己写注意力模块时,经常看到有人忘记缩放,或者缩放因子用d_model而不是d_k。如果d_model和d_k相等当然没关系,但在多头注意力里,每个头的d_k通常是d_model除以head数,写错的话效果会莫名其妙地差,排查起来还不好找原因。建议大家动手实现时把缩放这一步单独写成一行,注释里标明这是为了稳定梯度用的温度项。
2.3 掩码到底在保护什么:padding mask和look-ahead mask
自注意力层在应用场景里通常还带着掩码,这是初学者容易漏掉的部分。Transformer的注意力分为两种使用场景。
在encoder里,我们一般只需要处理padding带来的问题:因为一个batch里的句子要凑成相同长度,短句子后面会补一堆没有实际意义的占位符。注意力计算的时候,这些位置不应该被分配真实权重,所以要把它们的logit设为一个非常大的负数,比如-1e9,这样softmax之后权重就趋近于0。这就是padding mask,也叫key mask。
在decoder里更复杂一些。除了padding mask之外,解码器在生成第t个位置时,理论上不应该“偷看”t之后的内容。否则就相当于考试的时候提前看到了答案。实现上通常会构建一个上三角矩阵,把未来的logit全部掩掉,让注意力只能落在当前和之前的位置上。这个掩码叫look-ahead mask或者causal mask。我第一次实现的时候没有注意形状问题,结果mask和attention scores的维度对不上,代码跑起来报了一堆错误。后来我习惯先把attention scores的shape写出来,再设计mask的形状,基本就不会乱了。
2.4 从零实现一个最简单的自注意力
说了这么多理论,我建议你动手写一个极简版的自注意力。不需要完整的多头和残差,就几十行代码,能让你把矩阵形状彻底搞清楚。我用PyTorch风格的伪代码演示一个单头版本:
import torch import torch.nn as nn import math class SimpleSelfAttention(nn.Module): def __init__(self, d_model, d_k): super().__init__() self.d_k = d_k self.w_q = nn.Linear(d_model, d_k) self.w_k = nn.Linear(d_model, d_k) self.w_v = nn.Linear(d_model, d_k) def forward(self, x, mask=None): # x: [batch, seq_len, d_model] Q = self.w_q(x) # [batch, seq_len, d_k] K = self.w_k(x) V = self.w_v(x) scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) # scores: [batch, seq_len, seq_len] if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) weights = torch.softmax(scores, dim=-1) output = torch.matmul(weights, V) # [batch, seq_len, d_k] return output这里有一个容易写错的地方:K.transpose(-2, -1)是对倒数第二维和倒数第一维做转置,而不是K.T。如果batch维度存在,直接转置会把batch维也换了,结果完全乱掉。还有masked_fill的默认填充值,之前提过要用一个绝对值很大的负数,不能填0,因为0过softmax之后仍然会有不小的权重。这类细节看起来很微小,但在工程里真的会让人排查到崩溃。
3. 多头注意力:为什么要分成8个头来“各执己见”
3.1 一个头学一种关系,8个头学8类特征
单头注意力的问题在于它的建模能力有限。一组合参数只能学到一种“关注模式”,但语言里的关系类型非常多样:有句法上的依赖关系,比如动词和宾语;有指代关系,比如“它”指向前文中的人或物;也有位置上的邻近关系,比如形容词一般修饰它后面的名词。把所有这些关系都压到单一注意力头里是不现实的。
多头注意力的思路很朴素:既然一种关注模式不够,那就同时维护多组Q、K、V投影,每一组独立计算注意力,然后拼接起来再做一次线性变换。论文里默认用8个头,每个头在d_model=512的情况下,d_k = d_v = 64。每个头的参数规模只有单头注意力的1/8,所以参数量没有暴涨,但表达能力从“单一视角”变成了“多视角并行”。
你可以把多头注意力想象成团队的多个成员,每个人带着不同的偏好去读同一段文本:有的人专盯语法关系,有的人专盯距离近的词,还有的人对某些特定词性更敏感。最后把大家收集到的信息汇总起来,形成一个更全面的表示。实验也证明,不同头确实会学到不同的关注模式,虽然我们不会去给每个头指定“你要学什么”,但训练之后它们自然而然分化了。
3.2 拼接之后再压缩:多头不是多个独立的“专家结论堆叠”
这里有一个容易误解的点:多头注意力并不是简单地把8个头的输出拼在一起直接往下传。拼完之后还有一个线性变换层,把拼接向量重新映射回d_model维度。也就是说,模型不会满足于“8份各自独立的意见”,而是再学一个组合方式,把意见融合起来。
我自己在实现时会这样做:先把所有头的输出拼接成一个[d_model]维度的向量,然后过一个Linear层,得到形状和输入一样的结果。这样后续的残差连接和层归一化都可以复用同一套维度,结构非常干净。不少开源代码库也是这样做的——把多个头的矩阵运算通过reshape变成batch维度上的批量乘法,效率很高,但原理还是这一套。
3.3 头数怎么选:为什么8是起点而不是标准答案
论文里8个头是默认值,但这并不意味着所有任务、所有模型都应该用8个头。我在这几年的项目里总结了一些选头数的经验。通常来说,小模型选4到8个头,中等模型选8到16个头,更大规模的模型会考虑更大的头数或调整每头维度。一个比较常见的做法是保持每头维度d_k在64左右,然后通过增大d_model和头数来扩容模型。
头数过少,模型很难同时捕捉多种关系,表达能力受限。头数过多,每个头的维度会变得很小,单个头可能学不到足够丰富的特征,而且头与头之间容易退化出大量相似模式,白白浪费参数。我自己在某个文本分类项目里做过一个简单对比:头数从1涨到8时,验证集上的表现提升非常明显;从8继续涨到16时,提升就开始变小了;再往上涨,收益已经很微弱,甚至偶尔过拟合。
还有一个调试技巧:如果你观察到训练中某个或某几个注意力头收敛后关注模式几乎完全相同,可以试着减少头数或调整维度,而不是无脑加大模型。很多时候多头退化意味着模型容量已经足够,再往上堆参数只是增加训练成本。
3.4 从“多头”到“多模态”:注意力机制为什么到处都能用
多头注意力不仅适用于文本,它其实是一个非常通用的“信息汇聚”结构。后来很多视觉Transformer把图像切成patch,然后把每个patch当作“词”来做自注意力;多模态模型把文本、图像、音频的片段放在同一个序列里做交互注意力。这种通用性正是Transformer后来迅速扩张的核心原因——注意力机制本身对输入是什么领域并不敏感,它只需要你有“一系列元素”,并能定义元素之间的相似度。
在我自己的项目里,有一段时间需要做跨模态的检索任务,我当时没有自己设计复杂网络,而是直接把文本序列和图像特征序列拼接,用多层Transformer做交互建模。效果还不错,调试难度比传统CNN和RNN的组合方案低很多。这让我更加确信,理解自注意力和多头注意力的本质,比记住某个具体模型的结构更有价值。
4. 位置编码:给单词发坐标,才能听懂语序
4.1 自注意力不认顺序:一袋散装乐高需要编号才能拼出房子并感知顺序
前面讲了自注意力让所有位置同时开始计算,这让并行变得高效。但这也带来了一个致命问题:如果把输入序列的顺序随机打乱,自注意力输出的结果也完全不变。因为注意力权重只取决于向量之间的相似度,和它们在序列里的先后位置没有关系。对于语言来说,这显然不行——“我打你”和“你打我”的单词完全一样,但语义完全相反。
所以Transformer需要想办法把位置信息注入到输入表示里。论文的方案是位置编码:为每个位置生成一个固定的向量,加到词嵌入上。这样模型在处理第i个词时,看到的不仅有“这个词长什么样”,还有“它在序列的哪个位置”。
4.2 三角函数式位置编码为什么用正弦和余弦
论文中使用的是三角函数式的位置编码,公式是用不同频率的正弦和余弦函数生成位置维度的值。对于位置pos、维度i,编码值为:
PE(pos, 2i) = sin(pos / 10000^(2i / d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i / d_model))这个设计的精妙之处在于,不同频率的正弦余弦波构成了一个“编码空间”,每个位置在这个空间里都有自己独特的坐标。而且因为正弦、余弦函数之间存在加法公式,相邻位置的编码之间具备线性相关性,模型更容易通过学习捕捉相对位置关系。
我常用一个生活比喻:这就像给序列里的每个词发了一张带编号的入场券,但编号不是直接用“1、2、3”贴上去,而是用不同颜色的条纹组合来表示。条纹之间既有区别,又有渐变关系,你即使只看到一部分条纹,也能大致推断出这个人坐在第几排。
4.3 可学习位置编码、相对位置编码和旋转位置编码
三角函数式位置编码是效果稳定、开箱即用的经典方案,但它不是唯一的选择。后续研究里发展出了很多变体,我简单梳理一下。
可学习位置编码就更好理解了:既然模型能学词嵌入,为什么不能学位置嵌入?把位置也当作一个可学习的嵌入表,从零开始训练或者从预训练模型里加载。BERT时期很多模型就采用这种方法。优点是灵活,缺点是需要额外的参数量,并且外推到超长序列时表现不稳定——训练时没见过超过某一长度的位置,推理时遇到更长序列就没法处理了。
相对位置编码则换了一个思路:不再给每个绝对位置打分,而是建模“两个词之间的距离”。这类方法在长序列任务上往往表现更好,因为距离关系是平移不变的,模型更容易泛化到训练时没见过的长度。
旋转位置编码(RoPE)是近年来很流行的方案,它把位置信息通过旋转矩阵作用到Q和K上,让点积结果自动带有相对位置信息。很多开源大模型都采用这种编码,它在处理长文本和推理阶段的长度外推时表现比较突出。如果你要在新项目里选位置编码,我个人建议优先考虑RoPE这类相对位置方案,尤其是任务里可能出现比训练序列更长的情况时。
4.4 长文本场景下的位置编码实战建议
在实际项目里,位置编码的选择往往和你的任务长度直接挂钩。如果你的任务长度基本固定,可学习位置编码和三角函数式编码都能用,差别不大。如果需要在推理时处理比训练时更长的文本,就要小心了。我自己做过一次实验:用固定最大长度训练一个可学习位置编码的模型,推理时直接给它喂两倍长的文本,结果后半段的表现崩得非常厉害,几乎和无序输入差不多。后来换成RoPE方案,并配合一些长度外推技巧,情况好了很多。
还有一个工程细节值得提一下:位置编码一般加到输入嵌入之后、进入第一层Transformer之前。很多人以为位置编码只需要加一次,实际上每一层并不需要单独再加,因为第一层的信息已经带着位置信息往后续传播了。不过有些模型为了强化位置感,会在每层输入里都注入位置信息,这属于设计取舍,不是固定规则。
5. 残差、归一化与训练细节:让深层Transformer真正跑得稳
5.1 没有残差连接,深层Transformer基本训不起来
Transformer里每个子层外面都包了一层残差连接,结构是“子层 + 残差”。这个设计原则在更早的ResNet里已经被验证过:网络加深时,梯度需要一条“近道”从深层传回浅层,否则训练会非常困难。
举个具体的例子:一个完整的Transformer block,注意力子层做完之后不会直接输出,而是把输入加回去;前馈网络子层也是同样操作。这个“输入+子层输出”的结果再过LayerNorm。如果没有残差,所有信息变换都必须“无损地”通过网络传递,深度一旦增加,梯度很容易消失。
我在最初自己实现Transformer时,曾经偷懒把残差连接去掉,想看看会发生什么。结果模型loss下降极慢,训练几千步之后基本还在原地打转,加回残差之后立刻恢复正常。这个对比让我对残差的重要性有了最直观的感受。
5.2 LayerNorm:为什么是层归一化而不是批归一化
Transformer里的归一化用的是LayerNorm,不是BatchNorm。两者最大的区别在于归一化的维度。BatchNorm沿着batch维度统计均值方差,对同一个特征维度、不同样本做归一化。这在CV领域很常用,因为一个batch里的图片可以认为是来自同一分布的。但在NLP里,不同样本的句子长度各不相同、词序也复杂,BatchNorm的统计量会很不稳定;而且在解码器这种逐个生成token的场景里,测试时batch大小变化会直接影响归一化参数。
LayerNorm则是对单个样本内的特征维度做归一化。对每个token来说,无论它对应多长句子的一部分,模型只看它自己那一层的向量分布,把d_model维的向量减去均值、除以标准差,再做一次缩放和平移。这样做的好处是,归一化和batch大小无关,训练和推理的行为一致性更好。Transformer中残差加LayerNorm的组合,让深层网络的梯度流动和信息表达都保持在比较健康的范围。
有些实现里还会用Pre-LN(先归一化再进子层)替代Post-LN(子层后再归一化)。原版论文用的是Post-LN,也就是每个子层输出后做一次LayerNorm。但很多现代实现更倾向Pre-LN,因为它在训练时更稳定,尤其是深层模型。初学者做实现时,不必纠结谁好谁坏,先跑通原版结构,再对比实验体会差异。
5.3 训练细节:warmup、dropout、label smoothing是一套组合拳
Transformer模型的训练有几个几乎必用的技巧,理解它们比照抄更重要。
第一个是学习率warmup。论文里给出的学习率策略是:先线性增加到峰值,再按步数的倒数平方根衰减。这个设计是为了让模型在训练初期避免因为学习率过大而震荡。Transformer里各层参数初始化后,早期梯度的方差比较大,直接使用大学习率很容易导致loss爆炸。warmup给了模型一个“热身期”,让参数先稳定下来。
第二个是dropout。它的作用相当于给模型加噪声,防止过拟合。在Transformer里,通常会同时作用于输入嵌入、位置编码、注意力权重以及前馈网络的输出。我常看到有人只在输入层加dropout,中间层完全不加,导致模型在训练集上很快收敛但验证集上一直上不去。正确做法是每一层都适度加,但不要过大,否则模型变得难以收敛。
第三个是label smoothing。这是一个很有意思的细节:标准cross entropy损失鼓励模型把正确答案的概率推到接近1,其他接近0。这其实是一种过于“自信”的目标,容易导致过拟合和泛化性能下降。label smoothing的做法是,给正确的类别概率留一点空间,把一部分概率质量均匀分给其他类别,让模型学得更稳。
我手工实现时建议按顺序来:先跑通不加任何trick的版本,确认前向传播和反向传播没有错误;再加warmup和dropout,观察训练曲线是否更平滑;最后再考虑label smoothing。这种“由简到繁”的做法可以让你更快定位问题出现在结构上还是训练策略上。
6. 原版架构的遗产:从翻译任务到更广阔的生态
6.1 Encoder-Decoder在翻译任务里的分工,以及后来被打破的边界
原版Transformer是为机器翻译设计的,所以是标准的Encoder-Decoder架构。Encoder负责把源语言句子编码成一系列上下文表示,Decoder负责根据这些表示逐词生成目标语言句子。两者之间通过交叉注意力连接:解码器的Query来自目标语言一侧,而Key和Value来自源语言编码器的输出,从机制上保证生成时能“随时查阅”源语言信息。
后来这个边界很快被打破。BERT类模型只保留Encoder部分,学习双向上下文表示,适合做理解类任务;GPT类模型只保留Decoder部分,训练单向语言模型,适合做生成类任务。这两条路线衍生出了今天绝大多数预训练模型。理解“Encoder双向、Decoder单向”这个区别,对选择预训练模型、设计下游任务都有直接的指导意义。
6.2 为什么Transformer能跨界到图像和推荐系统
Transformer能跨界,一个很大的原因是注意力机制的两个特点:输入是集合和关系是动态计算的。图像可以被看成patch的集合,推荐系统里的用户行为序列可以被看成事件的集合,甚至分子结构、知识图谱里的节点也能被组织成序列集合。只要有“元素”和“元素之间的关联”,Transformer就有用武之地。
当然,跨界不是说把文本Transformer原封不动搬过去就行。图像有自己的局部性、尺度性,推荐序列有自己的时间衰减规律,这些都体现在输入的组织方式和位置编码的设计上。但底层的自注意力操作依然是一致的属性。我在做一个推荐系统项目时,把用户历史行为序列扔进Transformer里做特征提取,对比之前的池化方法,模型对行为顺序和交叉关系的建模明显更强了。
6.3 二次复杂度和长上下文:当前绕不开的现实问题
Transformer有一个明显的缺点是,注意力计算的时间复杂度是O(n²),n是序列长度。序列一长,计算量和显存占用都爆炸式增长。早期模型一般只能处理几百个token的输入,后来通过稀疏注意力、滑动窗口、FlashAttention等工程优化,才在长上下文场景下逐步推开边界。
我自己的经验是:在做长文本任务时,第一优先级不是选一个更复杂的模型,而是先评估你的序列长度。如果任务里最长文本也就一两千token,常规Transformer配合标准注意力完全够用;如果动不动就几万token,就得考虑稀疏注意力、分块策略,或者直接用已经优化好长文本支持的预训练模型。很多项目里的所谓“长文本难题”,其实是文本切分策略和检索策略没设计好,而不是模型本身不够强。
6.4 从“Attention Is All You Need”到现在的再看一遍
全文解读完,回头再看“Attention Is All You Need”这个标题,会发现它其实表达了双重含义:一层是说,有了注意力之后,不再需要循环和卷积,整个序列建模靠注意力就够了;另一层是说,注意力机制本身是一个极简、通用且强大的算子,它的潜力远远超过论文当初展示的翻译任务。
我每隔一段时间会重新翻一遍这篇论文的原文。每看一次,都会因为自己的实战经验增加而有新的体会。第一次看,我关注的是结构图和公式;第二次看,我开始关注训练细节和为什么这么设计;第三次看,我开始不断拿它和当前的新模型对比,理解变体是怎么演化的。对初学者来说,我建议你暂时不要追求“把所有细节一次看懂”,先跑通一个小小的文本分类或翻译demo,再回头读论文。实践中遇到一个问题,再回来读论文找答案,这种学习方式的效率通常是最高的。
最后分享一个小经验:如果你打算从零实现Transformer,不要从完整结构开始,先写单头自注意力,再扩展多头,然后再加残差和LayerNorm,最后组装encoder和decoder。每一步都把形状写清楚、把维度标注出来。你会发现在所有报错里,一大半都是维度不匹配。能把矩阵形状想清楚,Transformer对你来说就已经不是一个黑盒了。