深入拆解 Transformer:从注意力到完整架构

很多 Transformer 教程一上来就把整张架构图拍到你脸上,然后默认你已经知道图里的每根箭头在干什么。

我第一次看 Transformer 也是这样:Q、K、V 的类比似乎听懂了,合上文章,还是说不清一个词到底怎样穿过模型。后来发现,问题出在学习顺序。完整架构应该是最后拼起来的东西,不该成为第一张考卷。

所以这次换一种讲法。全文只反复使用两个例子:

例子一:小猫没追上汽车,因为它跑得太快了。
        用它拆开自注意力、Q/K/V、多头和 Encoder。

例子二:我爱猫 → I love cats
        用它串起 Encoder、Decoder、训练和逐词生成。

位置编码需要观察一串连续数字,中间会临时换成一个更方便列表计算的短句,其余章节都尽量回到这两条主线。

先不谈多头,也不背公式。我们会先看清一个 token 怎样向其他 token 取信息,再把它装进 Encoder,最后看 Decoder 怎样一个词一个词生成结果。公式和矩阵会等具体例子走通之后再出现。

先别看细节:翻译到底分几步

把 Transformer 当成两个配合工作的团队:

  • **Encoder(编码器)**阅读完整的中文句子,为每个 token 写一份结合上下文的说明;
  • **Decoder(解码器)**拿着这些说明,根据已经生成的英文,预测下一个英文 token。

大致过程是:

“我 爱 猫”
    ↓ 分词、查表,变成三组向量
Encoder 反复整理上下文
得到“我”“爱”“猫”各自的上下文表示
Decoder 先看到 <BOS>,预测 I
Decoder 再看到 <BOS> I,预测 love
Decoder 再看到 <BOS> I love,预测 cats
Decoder 最后预测 <EOS>,停止

这里的 <BOS> 可以理解为“开始生成”,<EOS> 表示“句子结束”。

原版 Transformer 的完整结构如下。现在不用硬看懂,只需要认出左边是 Encoder、右边是 Decoder,中间那条线把源句信息送给 Decoder。后面我们会把图里的方块逐个解释。

Transformer 完整架构:左侧 Encoder 阅读输入,右侧 Decoder 结合编码结果逐步生成输出

第一步:文字怎样进入模型

token 不是“一个单词”的正式叫法

模型接收的是数字,不认识“我爱猫”这三个字。Tokenizer 会先把文字切成 token,再把 token 换成整数 id。

为了方便,这篇文章假设它正好被切成:

[“我”, “爱”, “猫”]

真实情况不一定这么整齐。一个英文单词可能被拆成几个子词,一个中文词也可能对应多个 token。token 是分词器处理文本的基本单位,不保证等于字或单词。

接下来,Embedding 层按照 id 查表,把每个 token 换成一个向量。可以暂时把向量想成一张写满数字的小纸条:

“我” → [ 0.2, -0.7,  0.1, ...]
“爱” → [-0.4,  0.3,  0.8, ...]
“猫” → [ 0.6,  0.1, -0.2, ...]

这些数字没有人工填写的含义标签,它们是训练出来的参数。刚初始化时它们近乎随机;训练久了,模型逐渐把有用的语言特征编码进去。

光有 token 向量还不够

自注意力会比较“有哪些 token”,却天然不知道谁在前、谁在后。假如没有位置信息,“猫爱我”和“我爱猫”只是在输入行的排列上不同,模型没有额外线索理解语序。

原论文给每个位置生成一组固定的正弦和余弦数值,再与 token embedding 逐元素相加:

最终输入 = token embedding + position encoding

这里很容易产生一个误会:“高频位置”和“低频位置”分别属于不同 token 吗?其实不是。频率属于位置向量的维度,每个 token 都会同时拿到高频、中频和低频分量。 token 之间的区别,只是它们在不同的 pos 上读取这些波形。

原论文的公式是:

$$ PE_{(pos,2i)}=\sin\left(\frac{pos}{10000^{2i/d_{model}}}\right) $$$$ PE_{(pos,2i+1)}=\cos\left(\frac{pos}{10000^{2i/d_{model}}}\right) $$

先别被下标吓到。把正弦波写成 $\sin(\omega pos)$,$\omega$ 就表示变化速度:$\omega$ 越大,波动越快,频率越高。位置编码中:

$$ \omega_i=\frac{1}{10000^{2i/d_{model}}} $$

$i$ 越大,分母越大,$\omega_i$ 越小,所以靠后的维度变化得更慢。这就是高频和低频的判断依据。

放进一句话里看

假设一句话被切成 8 个 token:

位置: 0    1    2    3    4    5    6     7
token:小明  把   一本  有趣  的   书   送给   小红

为了让数字还能手算,假设 $d_{model}=8$,暂时只展示位置编码的前两组 sin/cos:

第 0、1 维:[sin(pos),    cos(pos)]      变化快
第 2、3 维:[sin(pos/10), cos(pos/10)]   变化慢

各个 token 在自己位置上读到的数值大约是:

位置token高频 sin高频 cos低频 sin低频 cos
0小明0.0001.0000.0001.000
10.8410.5400.1000.995
2一本0.909-0.4160.1990.980
3有趣0.141-0.9900.2960.955
4-0.757-0.6540.3890.921
5-0.9590.2840.4790.878
6送给-0.2790.9600.5650.825
7小红0.6570.7540.6440.765

看相邻的“有趣”和“的”。高频组从 [0.141, -0.990] 变到 [-0.757, -0.654],变化很明显;低频组只从 [0.296, 0.955] 变到 [0.389, 0.921]。两组都随着位置变化,只是速度不同。

为什么不全部使用高频?因为高频波很快就会绕一圈。sin(pos)cos(pos) 大约每 $2\pi\approx6.28$ 个位置重复一个周期,位置 0 和位置 6 的高频读数已经有些接近。低频组的周期约为 $2\pi\times10\approx62.8$ 个位置,这时还能明显看出两者处在不同区段。

反过来,如果全部使用低频,相邻 token 的数值变化又太小。多种频率放在一起,有点像里程表的个位、十位和百位:个位变化快,却会不断归零;百位变化慢,却看不出很短的移动。把它们一起读,位置才更容易区分。

所以,对位置 3 的“有趣”来说,它拿到的不是一个“高频编码”或“低频编码”,而是一整组读数:

位置 3 的编码:
[
  sin(3),       cos(3),       ← 高频
  sin(3/10),    cos(3/10),    ← 较低频
  sin(3/100),   cos(3/100),   ← 更低频
  sin(3/1000),  cos(3/1000)   ← 最低频
]

这组位置向量再与“有趣”的 token embedding 相加。句子中每个 token 使用的是同一组频率,只是 pos 不同,最终读数也不同。

下图把更多位置和更多维度放在一起。每一行对应一个位置,每一列对应位置向量的一个维度;左侧维度变化快,右侧维度变化慢。

正弦位置编码热力图:每个 token 同时拥有多种频率,左侧维度变化较快,右侧维度变化较慢

现在可以把位置编码理解为:它告诉模型“你是‘猫’,并且你位于第 3 个位置”,但不是额外塞进去一个数字 3,而是加上一组不同速度的波形读数。后来的模型常改用可学习位置向量或 RoPE,要解决的仍是顺序和距离信息。

走到这里,每个 token 已经有了两类信息:Embedding 大致说明“我是谁”,位置编码说明“我在哪里”。但这些 token 仍然各拿着自己的小纸条,彼此还没有交换内容。“它”究竟指小猫还是汽车,单靠身份和位置仍然判断不了。下一步要解决的,就是怎样让一个 token 读取其他 token 的信息

第二步:为什么一个词必须看其他词

单独看一个词,它的意思经常不完整。

苹果发布了新手机。

这里的“苹果”指公司。换成“苹果熟了,从树上掉下来”,“苹果”又变成了水果。词还是同一个词,周围内容变了,我们对它的理解也跟着变。再看一个指代关系的例子:

小猫没追上汽车,因为它跑得太快了。

“它”更可能指汽车。换成“因为它太累了”,“它”又更可能指小猫。

这就是上下文的作用。Embedding 刚查表时,同一个 token 拿到的还是相近的初始向量;位置编码虽然补充了顺序,也不会自动把“小猫”“汽车”和“它”的含义联系起来。模型还需要一个实际的信息交换机制,让“它”能够读取句中其他 token,再更新自己的表示。

这个机制就是注意力。名字听上去有些抽象,落到当前例子里,它做的事情其实很朴素。

让“它”去句子里找信息

继续用这句话:

小猫 没 追上 汽车,因为 它 跑得 太快了。

为了便于观察,先粗略切成:

[小猫, 没, 追上, 汽车, 因为, 它, 跑得, 太快, 了]

现在轮到“它”更新自己的表示。只看“它”这个 token,我们不知道它指谁;模型需要让它和句中所有位置逐个比较。可以把这次查询翻译成人话:

“它”想找:前面哪个对象最可能是“跑得太快”的主体?

模型不会真的生成这句中文问题。实际发生的是:“它”的向量经过投影变成 Query,然后与每个 token 的 Key 做点积。假设某个注意力头产生了下面这组教学用权重

提供信息的 token小猫追上汽车因为跑得太快
“它”分配的权重0.080.020.080.420.040.100.110.120.03

权重总和是 1。其中“汽车”得到 0.42,表示这个头在更新“它”时,会从“汽车”所在位置取回较多信息;“跑得”和“太快”也获得一些权重,为“它”补充当前谓语和状态。最后,“它”的新表示是所有 Value 按这些权重混合后的结果:

“它”的新表示
= 0.08 × Value(小猫)
 + 0.02 × Value(没)
 + 0.08 × Value(追上)
 + 0.42 × Value(汽车)
 + ……

所以注意力并不是从句子里硬选一个词,也不会把“汽车”原样复制到“它”上。它把多个位置的信息按比例混合,只是在这个示例中“汽车”占得最多。换成“因为它太累了”,训练良好的某些头可能会给“小猫”更高权重;但真实模型的注意力往往没有这么整齐,这里只是为了看清计算过程。

Q、K、V 在这句话里分别做什么

现在再看 Query、Key、Value,就不用只靠搜索引擎的类比了:

  • “它”的 Query:表达当前这个位置想找什么,例如“哪个对象适合作为当前指代对象”;
  • 每个 token 的 Key:表达自己有哪些特征可供匹配,例如“汽车”是前文出现的名词和可运动对象;
  • 每个 token 的 Value:保存匹配后真正要传回的内容,例如“汽车”在当前上下文中的语义表示。

这几句话仍然有点像定义。下面干脆打开 Q、K、V,看一眼里面的数字可能怎样参与计算。

把 Q、K、V 打开看一眼

真实模型中的向量可能有几十甚至上百维,而且单个维度通常没有“第 3 维就是名词”这种稳定、可直接命名的含义。为了能手算,我们虚构一个只有 3 个匹配维度的注意力头。假设这是 Encoder 较后面的一层,前面的层已经给“它”融入了一些“跑得太快”的上下文。

先把 Q/Key 所在的匹配空间临时解释成:

第 1 维:是否像一个可被指代的前文实体
第 2 维:是否能够运动
第 3 维:是否适合与“跑得太快”搭配

于是,“它”的 Query 可能是:

Query(它) = [0.9, 0.8, 1.0]
               ↑    ↑    ↑
             找实体  能运动  适合“跑得太快”

句中几个候选位置的 Key 假设为:

Key(小猫) = [1.0, 0.7, 0.5]
Key(汽车) = [1.0, 1.0, 0.95]
Key(太快) = [0.0, 0.2, 0.3]

现在用“它”的 Query 分别与这些 Key 做点积。先忽略 $\sqrt{d_k}$,只看匹配关系:

“它”对“小猫”的分数
= 0.9×1.0 + 0.8×0.7 + 1.0×0.5
= 1.96

“它”对“汽车”的分数
= 0.9×1.0 + 0.8×1.0 + 1.0×0.95
= 2.65

“它”对“太快”的分数
= 0.9×0.0 + 0.8×0.2 + 1.0×0.3
= 0.46

在这组虚构参数里,“汽车”的 Key 与“它”的 Query 最匹配。这里只列出了 3 个分数;实际还会同时计算句中另外 6 个位置,再把全部 9 个分数一起送进 softmax,得到前面表格中的完整权重,例如“小猫”是 0.08、“汽车”是 0.42、“太快”是 0.12。

到这里,Q 和 K 的工作已经结束了。它们负责产生权重,却不会直接成为注意力输出。接下来才轮到 Value。

Value 可以携带另一组完全不同的特征。为了演示,假设它有 4 维:

Value(小猫) = [1.0, 0.0, 0.8, 0.6]
Value(汽车) = [0.0, 1.0, 1.0, 0.9]
Value(太快) = [0.0, 0.0, 0.1, 1.0]

这里可以把前两维暂时想成“动物相关信息”和“交通工具相关信息”,后两维则是其他语义和上下文特征。注意力用完整的 9 个权重混合 Value。只展开其中三项是:

输出(它)
= 0.08 × Value(小猫)
 + 0.42 × Value(汽车)
 + 0.12 × Value(太快)
 + 其余 6 个位置的加权 Value

这个输出同时带有多个位置的信息,其中“汽车”的贡献最大。所有加权 Value 相加后仍是一个 4 维向量,它会成为“它”进入后续子层时的新表示,再由 FFN 和下一层继续加工。

现在就能看出三者的区别:

Query(它)             写着“我想找什么”
Key(小猫/汽车/太快)   用来回答“我是否匹配”
Value(小猫/汽车/太快) 真正装着要传回的内容

上面的维度名称和数字全是为了教学手工设计的,不能拿来当真实模型的内部解释。实际的 Q、K、V 是 $X$ 乘以训练得到的矩阵后产生的稠密向量;一个语言概念通常分散在很多维度里,一个维度也可能同时参与多种特征。这个例子只想说明计算职责:Q/K 产生权重,权重再作用到 V。

之所以使用三组投影,是因为用什么条件匹配匹配后传回什么内容不一定是同一组特征。

如果强行令 $Q=K=V=X$,注意力仍然可以计算:

$$ softmax\left(\frac{XX^T}{\sqrt d}\right)X $$

但同一组向量要同时承担“我想找什么”“我能否被匹配”和“我携带什么内容”三项工作。分别学习 $W_Q,W_K,W_V$,模型就能把这些职责拆开。Query 和 Key 分开还有一个好处:匹配关系可以有方向。“它”查找“汽车”和“汽车”查找“它”未必是同一件事;若 Q、K 完全相同,softmax 前的 $XX^T$ 是对称矩阵,而使用不同投影后通常不再对称。

因此,Q、K、V 并不是数学上少一组就无法运行。它们更像一种经过验证的职责划分:Q/K 决定从哪里取,V 决定取回什么。后面的多头注意力还会同时准备多套 Q/K/V,让不同头学习不同的信息交换方式。

写回数学表达,就是同一份输入经过三种可学习的线性投影:

$$Q=XW_Q,\qquad K=XW_K,\qquad V=XW_V$$

$X$ 是当前所有 token 的表示,$W_Q$、$W_K$、$W_V$ 是训练出来的参数。同一份输入经过三种投影,得到三种用途不同的表示。

一个 token 怎样完成一次注意力

下图把计算顺序画了出来。先从输入得到 Q/K/V,再用 Q 和 K 计算匹配分数,最后拿权重去混合 V。

自注意力计算流程:输入投影为 Q、K、V,计算匹配权重后汇总 Value

还是以“它”为例:

  1. 取出“它”的 Query;
  2. 用这个 Query 与句中所有 token 的 Key 做点积;
  3. 分数越大,说明当前投影下越匹配;
  4. 用 softmax 把一排分数变成总和为 1 的权重;
  5. 按这些权重混合所有 token 的 Value。

最后得到的是一份混合后的新向量,并非某个原词的复制品。假如“汽车”的权重最高,新向量就会带入较多“汽车”的信息。

用公式写就是:

$$ Attention(Q,K,V)=softmax\left(\frac{QK^T}{\sqrt{d_k}}\right)V $$

第一次读时,可以先把它翻译成人话:

QKᵀ                 给所有候选位置打分
除以 √dₖ            别让分数大得太离谱
softmax             把分数变成权重
再乘 V              按权重取回并混合内容

一张注意力矩阵应该怎样读

这句话有 9 个 token,每个 token 都向 9 个位置查询一次,因此会产生 $9\times9$ 个权重。

注意力矩阵教学示意:行是发起查询的 token,列是提供信息的 token

读这类图时只记两个方向:

  • 行:谁正在更新自己,也就是 Query;
  • 列:它从谁那里取信息,也就是 Key/Value 所在的位置。

图中“它”那一行在“汽车”那一列颜色最深,对应前面手算的 0.42。横向看完整一行,就能看到“它”怎样在句中各个位置之间分配权重。

但别把注意力热力图当成模型的“思维过程截图”。真实模型的头经常很杂,有的偏向相邻 token,有的偏向标点,有的彼此重复。权重只告诉我们信息怎样路由,最终结果还取决于 Value 里装了什么,以及后续层怎样继续加工。

第三步:把整句话一起放进矩阵

前面一直盯着“它”这一个 token,实际计算时,“小猫”“追上”“汽车”等位置也会同时更新。继续使用这 9 个 token:

[小猫, 没, 追上, 汽车, 因为, 它, 跑得, 太快, 了]

假设每个 token 当前有 8 个数字,把 9 张小纸条叠起来就是输入矩阵:

X:[9, 8]
    ↑  ↑
  9个token,每个8维

为了演示,设这个注意力头把 Q、K 投影到 3 维,把 V 投影到 4 维:

Q:[9, 3]
K:[9, 3]
V:[9, 4]

QKᵀ 会一次算出所有 token 两两之间的分数:

Q       Kᵀ        分数矩阵
[9,3] × [3,9]  =  [9,9]

这个 [9,9] 矩阵可以想成一张表:

  • 第 1 行:小猫准备从 9 个位置分别取多少信息;
  • 第 4 行:汽车准备从 9 个位置分别取多少信息;
  • 第 6 行:它准备从 9 个位置分别取多少信息。

我们前面手算的 [0.08, 0.02, ……, 0.03],就是“它”这一行经过 softmax 后的结果。其他 8 个 token 也各有自己的一行,并且全部可以并行算出。

最后,整个权重矩阵一起乘 V:

注意力权重     V         输出
[9,9]     ×  [9,4]  =  [9,4]

输出仍有 9 行:每个 token 都得到一个 4 维新表示。行数没有减少,改变的是每一行都混入了上下文。

真实实现还会多一个 batch 维,常见输入形状是:

[batch_size, sequence_length, d_model]

框架中的 reshapetranspose 看起来很多,主要是在排列 batch、token、注意力头和特征维。把这句 [9,8] → Q/K/V → [9,9] → [9,4] 的路线看懂,代码就不再像凭空变形。

为什么必须除以 $\sqrt{d_k}$

点积会把 $d_k$ 个乘积加起来。假设 Q 和 K 的每个分量均值为 0、方差为 1,而且近似独立,那么点积的方差约为 $d_k$,标准差约为 $\sqrt{d_k}$。

$d_k$ 越大,送入 softmax 的分数越容易散得很开。softmax 随后会变得过于尖锐:某一项接近 1,其余项接近 0,梯度也会变小。除以 $\sqrt{d_k}$,相当于把分数重新压回比较稳定的尺度。

缩放点积注意力:除以平方根前后的分数分布,以及 softmax 的饱和趋势

右图用两类 softmax 等价的 sigmoid 切片展示趋势;多分类时梯度是一个雅可比矩阵,不会恰好只有一条 $p(1-p)$ 曲线,但“分数差距太大容易饱和”这个结论不变。

第四步:为什么要同时算多个头

还是这句话:

小猫没追上汽车,因为它跑得太快了。

刚才我们只演示了一个头,让“它”较多读取“汽车”。但这句话里显然不只有指代关系:

“没”需要和“追上”组合,表示动作没有发生;
“追上”需要联系“小猫”和“汽车”,分清谁追谁;
“太快”需要联系“跑得”;
“因为”还连接着前后两部分因果关系。

如果只有一套 $W_Q,W_K,W_V$,所有关系都要挤在同一套匹配方式里。多头注意力干脆准备多套独立投影。用教学化的说法,某一层里可能出现:

头 1:让“它”更多读取“汽车”         —— 指代线索
头 2:让“追上”读取“小猫”和“汽车”   —— 动作参与者
头 3:让“太快”读取“跑得”            —— 相邻搭配
头 4:让各位置收集更分散的整句信息   —— 全局信息

这些职责不是程序员预先指定的,真实模型也未必分得这么整齐。例子只是说明:不同参数的头可以同时建立不同的信息通道。

多头注意力的做法,是准备多套独立投影:

第 1 头:用第 1 套 WQ/WK/WV 计算
第 2 头:用第 2 套 WQ/WK/WV 计算
……

每个头在自己的子空间里完成一次注意力,所有头的结果拼起来,再经过输出投影 $W_O$ 混合。

$$ head_i=Attention(XW_i^Q,XW_i^K,XW_i^V) $$$$ MultiHead(X)=Concat(head_1,\ldots,head_h)W_O $$

多头注意力教学示意:不同参数的头可以形成局部、前向、全局或长距离等模式

工程代码常常一次性投影,再把结果 reshape 成 [batch, heads, seq_len, head_dim],所以教程喜欢说“把向量切成多个头”。更准确的理解是:模型在一次大矩阵运算中,高效实现了多套独立投影。 它不是把原始 embedding 随便切几段,各段原封不动地计算。

图中的四种模式只是为了说明多头能不同,并不表示设计者提前规定“第 1 头必须看相邻词”。头的行为由训练形成,可能清晰,也可能杂乱和冗余。

第五步:把注意力装进 Encoder

现在把镜头拉远一点,看看这句话经过一个 Encoder block 会发生什么。

多头自注意力结束后,9 个 token 都拿到了各自收集的信息。例如“它”的表示混入了“汽车”“跑得”“太快”,“追上”的表示可能混入“小猫”和“汽车”。但“收集回来”不等于已经理解完毕,这些混合向量还需要继续加工。

因此,一个 Encoder block 主要有两个子层:

输入
多头自注意力:token 之间交换信息
FFN:每个 token 单独加工自己的信息
输出给下一层

FFN 为什么不能省

以“它”为例,注意力输出可能同时带着“汽车占比较高”“和跑得太快有关”等信息。FFN 会在“它”自己的向量内部做非线性变换,把这些收集到的特征重新组合。它不会在这一步再次读取“小猫”或“汽车”;token 之间的交换已经由注意力完成。

注意力输出本质上仍是 Value 的加权组合。模型需要 FFN 这样的非线性变换,才能把收集到的信息重新组织成更有用的特征。

原版 FFN 是两层全连接:

$$ FFN(x)=\max(0,xW_1+b_1)W_2+b_2 $$

它会先升维,例如从 512 维升到 2048 维,经过 ReLU,再降回 512 维。FFN 对每个 token 分别计算,但同一层的所有位置共享参数。

还是那句比较好记的话:

Attention:出去收集信息
FFN:回到自己的工位加工信息

很多现代模型会把 ReLU 换成 GELU、SwiGLU 等。细节变了,分工没有变。

残差和 LayerNorm 是在保住什么

每个子层旁边还有一条绕行路线:输入不经过子层,直接加回输出。这是残差连接。

新的表示 = 原来的表示 + 子层学到的改动

这样一层即使暂时没学好,也不至于把原信息完全破坏;反向传播时,梯度也有更直接的通道。

LayerNorm 则在单个 token 的特征维上做归一化,帮助控制数值尺度。它不依赖 batch 中还有哪些句子,因而更适合长度不一的序列。

原论文使用 Post-Norm:

$$x'=LN(x+Sublayer(x))$$

许多更深的模型使用 Pre-Norm:

$$x'=x+Sublayer(LN(x))$$

初学阶段不必争论谁绝对更好。只要知道不同模型的 LayerNorm 位置可能不同,架构图不完全一样并不代表其中一张画错了。

堆很多层以后发生了什么

第一层可能让“它”读取附近名词,FFN 再加工这份信息;下一层拿到的已经不是原始词向量,而是带上下文的表示,于是还能建立更复杂的关系。

单层全局注意力已经允许任意两个位置直接接触。继续堆层,是为了反复执行“交换信息—加工信息”,逐层形成复杂表示;这和卷积逐层扩大感受野不太一样。

Encoder 最终会为每个输入 token 输出一个上下文化向量,而非只交出一个句子向量。对“我爱猫”来说,可以想成三份已经互相参考过的说明书。Decoder 随后会来查询这些说明书。

第六步:Decoder 为什么比 Encoder 多一块

Encoder 已经读完“我爱猫”,接下来轮到 Decoder 生成英文。假设它目前只收到:

<BOS> I

下一步应该预测 love。为了完成这件事,Decoder 要处理两个问题:先读懂已经生成的英文前缀,再回头查询中文原文。因此,一个原版 Decoder block 有三部分:

带因果掩码的自注意力
交叉注意力
FFN

FFN 与 Encoder 中一样。区别集中在两种注意力上。

因果自注意力:不准提前看答案

翻译时,Decoder 预测 love 只能利用已经生成的 I,不能偷看后面的 cats。否则训练时它会学会抄答案,真正生成时却无答案可抄。

解决办法是在 softmax 前给未来位置的分数加上负无穷。softmax 后,这些位置的权重就变成 0。

因果掩码:每个 Query 位置只能读取自己以及左侧输入位置

图中的下三角可见,上三角屏蔽。除此之外还有 padding mask:同一 batch 中句子长度不一时,短句会补齐,padding mask 负责让模型忽略这些补位 token。两种 mask 目的不同,经常组合使用。

交叉注意力:回头查中文原文

Decoder 只看已生成英文还不够,否则它不知道该翻译什么。交叉注意力会让 Decoder 去查询 Encoder 的输出:

Query:来自 Decoder 当前表示
Key:来自 Encoder 输出
Value:来自 Encoder 输出

生成 love 时,Decoder 的 Query 可能与中文“爱”的 Key 更匹配,于是取回更多“爱”的 Value。这个动作会在每个 Decoder 层、每个生成位置反复发生。

三种注意力可以这样区分:

类型Q 来自哪里K、V 来自哪里能看哪些位置
Encoder 自注意力Encoder同一份 Encoder 表示整个源句,除 padding
Decoder 因果自注意力Decoder同一份 Decoder 表示当前及左侧位置
交叉注意力DecoderEncoder 最终输出整个源句,除 padding

判断“自注意力还是交叉注意力”,看 Q 和 K/V 是否来自同一条数据流就行。

第七步:训练时为什么可以并行

前面为了叙述方便,说 Decoder 先生成 I,再生成 love。这确实是推理过程,但训练时如果也一步一步算会很慢。

已知正确目标是:

I love cats <EOS>

训练时会把输入和答案错开一位:

Decoder 输入:<BOS>  I     love  cats
监督答案:     I      love  cats  <EOS>

四个输入位置可以一次送入 GPU。因果 mask 会保证:

  • <BOS> 只能预测时使用自己已有的信息;
  • I 所在位置可以看到 <BOS>I,用来预测 love
  • love 所在位置看不到右侧的 cats

模型在每个位置输出词表中所有 token 的分数,也就是 logits。softmax 把它们变成概率,再与正确答案计算交叉熵。误差一路反向传播,更新 Embedding、Q/K/V 投影、FFN 等全部参数。

这里有两个容易混在一起的概念:

  • Teacher forcing:训练输入使用真实的目标前缀;
  • Causal mask:每个位置不能读取未来的目标 token。

一个决定“喂什么”,一个决定“能看什么”。

第八步:推理为什么慢,KV Cache 又缓存了什么

真正生成时没有完整答案,只能循环:

<BOS>              → 预测 I
<BOS> I            → 预测 love
<BOS> I love       → 预测 cats
<BOS> I love cats  → 预测 <EOS>

这就是自回归。后一个 token 依赖前一个 token 的结果,所以 token 之间无法像训练那样全部并行。

不过,生成 cats 时,<BOS>Ilove 的 Key 和 Value 前面已经算过,而且不会改变。实际推理会把每一层历史 token 的 K/V 保存下来,新一步只计算新 token 的 Q/K/V,再让新 Query 查询缓存中的所有 Key。这就是 KV Cache。

它省掉了大量重复投影和历史前向计算,却不是免费午餐:

  • 缓存会随着层数、序列长度和 batch 增长,占用显存;
  • 新 Query 仍要和全部历史 Key 比较;
  • 因此上下文越长,单步生成仍然会变贵。

第九步:BERT、GPT、T5 到底改了哪一边

理解原版 Encoder-Decoder 后,几个常见名字就容易定位了:

  • BERT:主要保留 Encoder。它能同时看左右文,适合理解、分类和抽取;
  • GPT、Llama:主要保留带因果 mask 的 Decoder 自注意力,但通常没有原版翻译架构中的 Encoder 和交叉注意力;
  • T5:保留 Encoder-Decoder,适合把任务统一成“文本到文本”;
  • ViT:把图像切成 patch,当作一串 token 交给 Encoder 式结构。

所以“Decoder-only”不等于把原版 Decoder 整块原样搬走。没有 Encoder 时,也就没有那层 Encoder-Decoder 交叉注意力。它保留的核心是因果自注意力、FFN、残差和归一化等结构。

第十步:它为什么有效,又付出了什么

Transformer 最关键的改变,是让每个位置可以通过注意力直接读取其他位置,同时让所有位置在训练时并行计算。RNN 必须沿时间一步步传递状态,远距离信息路径长;自注意力把任意位置之间的路径缩短成一次交互。

代价也很具体。长度为 $n$ 的序列会产生 $n\times n$ 的注意力分数矩阵。序列长度翻倍,这一部分的计算和中间存储大约变成四倍。

更细一点,一层注意力有两类成本:

  • Q/K/V 和输出线性投影:约 $O(nd_{model}^2)$;
  • 注意力打分与加权汇总:约 $O(n^2d_{model})$。

短序列、宽模型中,线性投影也可能很贵;序列足够长时,$n^2$ 才逐渐成为主角。

FlashAttention 没有把精确注意力从二次复杂度变成线性复杂度。它主要改变计算分块和显存访问方式,避免频繁读写完整中间矩阵,所以实际速度更快、显存更省。滑动窗口和稀疏注意力则是从“哪些位置需要互相看”这件事上减少计算。

位置长度也是限制。固定正弦位置编码理论上对任意位置有定义,不代表模型在远超训练长度时仍能正确使用它。RoPE、ALiBi 以及各种长度外推方法,都在继续处理这个问题。

最后再走一次完整流程

回到最初的“我爱猫”,现在可以把每一步说完整了:

  1. Tokenizer 把文本切成 token,并映射到 id;
  2. Embedding 查表得到向量,再加入位置信息;
  3. Encoder 自注意力让三个 token 交换信息;
  4. FFN 分别加工它们的新表示;
  5. 多层堆叠后,Encoder 产出三份上下文化表示;
  6. Decoder 用因果自注意力读取已经生成的英文前缀;
  7. Decoder 用交叉注意力查询中文的 Encoder 输出;
  8. FFN 继续加工,Linear 层映射到词表 logits;
  9. softmax 得到下一个 token 的概率;
  10. 推理时选择一个 token 放回输入,直到生成 <EOS>

如果现在再看开头那张架构图,你应该能解释里面每一种箭头在搬什么:自注意力在同一序列内部搬信息,交叉注意力从 Encoder 搬到 Decoder,残差旁路保留原表示,FFN 则不搬 token,只在每个位置内部加工。

我觉得理解 Transformer 真正的分水岭,并不在于能否默写 attention 公式。看到一个新变体时,如果你会追问几个朴素的问题——它让谁看谁?位置信息放在哪里?token 之间在哪里交互?每个 token 又在哪里单独加工?训练和推理的数据流有什么不同?——架构就已经有地方落脚了。

这些问题能答出来,架构就不再是一张需要背诵的电路图了。

参考资料

版权声明: 本文首发于 指尖魔法屋-深入拆解 Transformer:从注意力到完整架构https://blog.thinkmoon.cn/post/1034-deep-dive-transformer-architecture/) 转载或引用必须申明原指尖魔法屋来源及源地址!