第 7 讲:自注意力与 Transformer

Natural Language Processing with Deep Learning · Lecture 07。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应:Stanford CS224N Winter 2026 Lecture 5(Transformers)与作业 3;Winter 2025 Lecture 8(Self-Attention and Transformers);Manning 主讲的 Winter 2023 版第 8 讲(录像在 YouTube 上)。 配套代码:code/lecture07/(04、05 号文件导入第 5 讲的代码;transformer.py 会被第 9、10 讲复用) 预计阅读时间:5–6 小时(第一遍通读 2.5 小时,第二遍对照代码 3 小时)


0. 本讲导航

0.1 学完本讲,你应该能够

  1. 说出 RNN 的两个结构性问题(交互距离随长度线性增长、无法沿时间并行),以及注意力是怎样同时解决它们的。
  2. 用”查询、键、值”写出自注意力的公式和矩阵形式,并能手算一个小例子。
  3. 解释为什么点积要除以 dk\sqrt{d_k}:不缩放时 softmax 会饱和、梯度消失。
  4. 说出把自注意力当作积木时的三个缺陷,以及对应的三个补丁:位置编码、前馈网络、因果掩码。
  5. 比较正弦位置编码、可学习的位置嵌入和 RoPE,并证明 RoPE 的注意力打分只取决于相对位置。
  6. 解释多头注意力为什么有用、怎样实现,以及它的计算量为什么和单头一样。
  7. 画出一个 Transformer 块:残差连接、层归一化、前馈网络;说出 Pre-LN 和 Post-LN 的区别;会算参数量(每块 12d2+13d12d^2 + 13d)。
  8. 区分编码器、解码器、编码器—解码器三种结构,知道它们各自对应哪些著名模型。
  9. 分析自注意力 O(n2d)O(n^2 d) 的计算量,说出它什么时候成为瓶颈。

0.2 先修知识

知识点用在哪里最低要求
第 6 讲全讲注意力的计算步骤(打分、softmax、加权求和);查询—键—值的一般形式;残差连接
第 5 讲第 1、11 节RNN 语言模型;困惑度;主谓一致测试
第 3 讲第 3、7 节矩阵乘法的形状;softmax 的导数;初始化;批归一化(了解即可)
三角函数第 5 节和角公式;二维旋转矩阵(不熟悉也能看懂结论)

0.3 本讲地图

第1节  从 RNN 到"只用注意力"
第2节  自注意力:查询、键、值                              ← 代码 01
第3节  缩放点积:为什么除以 √d_k                           ← 代码 01
第4节  自注意力作为积木的三个缺陷与补丁                    ← 代码 01
第5节  位置编码:正弦、可学习、RoPE                        ← 代码 02、05
第6节  多头注意力                                          ← 代码 03
第7节  Transformer 块:残差、层归一化、前馈网络            ← 代码 03(本讲重点)
第8节  三种结构:编码器、解码器、编码器—解码器
第9节  训练 Transformer 的细节
第10节 计算量与长序列
第11节 实验:迷你 GPT vs RNN                              ← 代码 04、05
第12节 今天的 Transformer 改了什么
第13–17节 小结、与作业的关系、练习、延伸阅读、下一讲预告

transformer.py 是本讲所有零件的完整实现(约 400 行,大部分是注释)。建议读完第 7 节之后通读一遍——到那时你会发现,GPT 的结构其实没有多少东西。


1. 从 RNN 到”只用注意力”

1.1 RNN 的两个结构性问题

第 5、6 讲的 RNN(包括 LSTM)按顺序一个词一个词地处理句子。这种”顺序处理”带来两个问题,而且它们是结构本身的问题,换更好的门控也解决不了。

问题一:交互距离随长度线性增长。 第 1 个词的信息要影响第 nn 个词,必须穿过中间的 n−1n-1 步。每穿过一步,信息都要被压缩进固定大小的隐藏状态、和新的输入混合一次。距离越远,信息越难保持,梯度也越难传回去(第 6 讲第 2 节)。LSTM 缓解了这个问题,但路径长度仍然是 O(n)O(n)。

而语言里偏偏有大量远距离的依赖:

The chef who ran to the store and bought some fresh vegetables was tired.

决定动词 was 的 chef 和它隔了 10 个词。一个好的模型应该能让 was “直接看到” chef。

问题二:无法沿时间并行。 要算第 tt 步的隐藏状态,必须先算出第 t−1t-1 步。一个长度为 nn 的句子,前向和反向传播都有 O(n)O(n) 个必须依次进行的操作。GPU 擅长同时做大量相互独立的计算,RNN 却让它大部分时间在等待。这限制了我们能在多大的数据上训练多大的模型——而第 9 讲会看到,“规模”恰恰是后来一切进展的关键。

1.2 注意力已经给了我们什么

回顾第 6 讲的注意力:解码器的每一步,都直接给编码器的所有隐藏状态打分、加权求和。

  • 任意两个位置之间只隔一步:交互距离是 O(1)O(1)。
  • 不同查询的注意力计算互不依赖,可以同时进行。

第 6 讲的注意力是在 RNN 之上”加”的一个模块。一个自然的问题:如果注意力这么好,能不能把 RNN 整个去掉,只用注意力来构建模型?

1.3 Attention Is All You Need

2017 年,Google 的 Vaswani 等人发表了论文 Attention Is All You Need,提出 Transformer:完全没有循环,只用注意力和逐位置的前馈网络。它在机器翻译上超过了当时最好的 RNN 模型,训练速度却快得多。

此后几年,Transformer 几乎”吃掉”了整个 NLP,然后是语音、视觉、蛋白质结构预测等领域。BERT(2018)、GPT 系列(2018 年起)、T5(2019),以及今天所有的大语言模型,主体都是 Transformer。

本讲从最核心的自注意力开始,一块一块地把 Transformer 搭起来。


2. 自注意力:查询、键、值

2.1 注意力是”软的”字典查找

第 6 讲第 9 节说过,注意力的一般形式是:用一个查询(query)去和一组键(key)比较,按相似度对对应的值(value)加权求和。

用 Python 的字典来类比:

  • 字典是”硬”查找:d[query],查询必须和某个键完全相等,才返回那一个值。
  • 注意力是”软”查找:查询和每个键都算一个相似度,经过 softmax 变成权重,返回所有值的加权平均。

动手:代码 01_self_attention_numpy.py 演示 1。三个键 (1,0)(1,0)、(0,1)(0,1)、(0.7,0.7)(0.7,0.7),对应的值是 10、20、30:

查询权重输出
(10,0)(10, 0)0.953、0.000、0.04710.95
(1,0)(1, 0)0.474、0.174、0.35118.77
(0.5,0.5)(0.5, 0.5)0.31、0.31、0.37920.69

方向相同的查询 (10,0)(10,0) 和 (1,0)(1,0),长的那个几乎把全部权重给了第 1 个值(接近”硬”查找),短的那个权重分散得多。打分的大小决定了 softmax 有多”尖”——第 3 节会看到这一点为什么重要。

2.2 自注意力的定义

第 6 讲的注意力是”解码器看编码器”:查询来自一个序列,键和值来自另一个序列。自注意力(self-attention)是”一个序列看它自己”:句子里的每个词都当一次查询,去看句子里的所有词(包括它自己)。

设输入是 nn 个词的向量 x1,…,xn∈Rdx_1, \dots, x_n \in \mathbb{R}^d。用三个矩阵 WQ,WK∈Rd×dkW^Q, W^K \in \mathbb{R}^{d \times d_k}、WV∈Rd×dvW^V \in \mathbb{R}^{d \times d_v},把每个词分别投影成查询、键、值:

qi=xiWQ,ki=xiWK,vi=xiWVq_i = x_i W^Q, \qquad k_i = x_i W^K, \qquad v_i = x_i W^V

然后对每个词 ii:

eij=qi⋅kjdk,αij=exp⁡(eij)∑j′exp⁡(eij′),oi=∑j=1nαij vje_{ij} = \frac{q_i \cdot k_j}{\sqrt{d_k}}, \qquad \alpha_{ij} = \frac{\exp(e_{ij})}{\sum_{j'} \exp(e_{ij'})}, \qquad o_i = \sum_{j=1}^{n} \alpha_{ij}\, v_j

翻译成大白话:词 ii 的新表示 oio_i,是所有词的”值”的加权平均;权重由词 ii 的”查询”和每个词的”键”有多匹配决定。(除以 dk\sqrt{d_k} 的原因见第 3 节。)

2.3 矩阵形式

把所有词的向量排成矩阵 X∈Rn×dX \in \mathbb{R}^{n \times d}(每行一个词),上面的计算可以一次完成:

Q=XWQ,K=XWK,V=XWV,Attention(Q,K,V)=softmax ⁣(QK⊤dk)VQ = XW^Q,\quad K = XW^K,\quad V = XW^V, \qquad \text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V

形状:Q,K∈Rn×dkQ, K \in \mathbb{R}^{n \times d_k},QK⊤∈Rn×nQK^\top \in \mathbb{R}^{n \times n}(第 ii 行第 jj 列是词 ii 对词 jj 的打分),softmax 按行做,结果再乘 V∈Rn×dvV \in \mathbb{R}^{n \times d_v},得到 n×dvn \times d_v 的输出。

整个计算就是几次矩阵乘法加一次 softmax——这正是 GPU 最擅长的事。

动手:代码 01 演示 2 用 4 个词(the cat sat down)、d=4d = 4、dk=dv=3d_k = d_v = 3 一步一步地算:打印 QQ、打分矩阵、权重矩阵和输出,并验证”对每个词循环计算”和”矩阵一次计算”的结果完全一致。参数是随机的,所以这里的权重没有语言学含义;训练之后,模型会学到”动词关注主语”这样的模式(第 11.4 节)。

2.4 为什么要三个不同的投影?

为什么不直接用 xi⋅xjx_i \cdot x_j 打分、对 xjx_j 加权求和?

  1. 查询和键扮演不同的角色。 “我在找什么”(查询)和”我能提供什么”(键)不是一回事。比如动词想找它的主语:动词的查询应该和名词的键匹配,而不是和别的动词匹配。如果用同一个向量,xi⋅xjx_i \cdot x_j 是对称的,ii 关注 jj 的程度就必然等于 jj 关注 ii 的程度;而且每个词都会最关注它自己(xi⋅xi=∥xi∥2x_i \cdot x_i = \|x_i\|^2 通常最大)。
  2. “用来匹配的信息”和”被取走的信息”也不同。 键决定”谁会被关注”,值决定”被关注之后传递什么”。分开投影,模型可以用名词的”名词性”来匹配,却把它的”单复数”或”语义”传递过去。
  3. 投影到较小的维度 dkd_k,还能减少计算量,并为多头注意力(第 6 节)做准备。

2.5 一个直觉例子

The animal didn’t cross the street because it was too tired.

处理 it 时,一个好的自注意力层应该把大量权重分给 animal——这样 it 的新表示就”吸收”了 animal 的信息,后面的层就知道”累的是动物”。如果把句子改成 “…because it was too wide”,it 就应该关注 street。

同一个词 it,在不同的句子里关注不同的词,得到不同的表示——这就是上下文相关的表示(contextual representation),第 9 讲会看到它是预训练模型强大的根本原因。而 RNN 要做到这一点,得把 animal 的信息一步一步地带过 7 个词。


3. 缩放点积:为什么除以 dk\sqrt{d_k}

3.1 点积的方差随维度增长

假设查询和键的每一维都是均值 0、方差 1 的独立随机数(训练初期大致如此)。点积 q⋅k=∑j=1dkqjkjq \cdot k = \sum_{j=1}^{d_k} q_j k_j 是 dkd_k 个独立随机变量之和,每一项的均值为 0、方差为 Var(qj)Var(kj)=1\text{Var}(q_j)\text{Var}(k_j) = 1,所以

E[q⋅k]=0,Var(q⋅k)=dk\mathbb{E}[q \cdot k] = 0, \qquad \text{Var}(q \cdot k) = d_k

标准差是 dk\sqrt{d_k}。dk=64d_k = 64 时打分的典型大小是 8,dk=1024d_k = 1024 时是 32。

3.2 打分太大,softmax 会饱和

第 2.1 节看到:打分越大,softmax 越”尖”。当打分的量级达到几十时,softmax 几乎变成 argmax——最大的那个位置拿走几乎全部权重。

这为什么是问题?看 softmax 的导数(第 3 讲):

∂αi∂ej=αi(δij−αj)\frac{\partial \alpha_i}{\partial e_j} = \alpha_i(\delta_{ij} - \alpha_j)

如果某个 αi≈1\alpha_i \approx 1、其余 ≈0\approx 0,那么每一项都接近 0:αi(1−αi)≈0\alpha_i(1 - \alpha_i) \approx 0,αiαj≈0\alpha_i \alpha_j \approx 0。梯度几乎传不回打分,也就传不回 WQW^Q 和 WKW^K——注意力”学不会”该看哪里。这和 sigmoid 饱和导致梯度消失(第 6 讲)是一回事。

3.3 解决:除以 dk\sqrt{d_k}

把打分除以 dk\sqrt{d_k},方差就回到 1,与维度无关。

动手:代码 01 演示 3。对每个 dkd_k 做 2000 次实验(1 个查询、10 个键,都是标准正态随机向量)。“梯度范数”是 softmax 雅可比矩阵的范数,衡量梯度能有多少从权重传回打分:

dkd_k点积的标准差不缩放:最大权重不缩放:梯度范数缩放:最大权重缩放:梯度范数
42.00.4970.3350.3080.340
164.00.7330.2610.3170.346
648.10.8560.1690.3170.349
25616.00.9340.0870.3190.347
102432.20.9650.0480.3200.348

不缩放时,维度越高,最大权重越接近 1,梯度范数缩小到原来的约七分之一;缩放之后,两者都几乎不随维度变化。

为什么第 6 讲的乘性注意力没有缩放?那里的维度不大,问题不明显;而且 WattW_{\text{att}} 的初始化可以吸收这个因子。Vaswani 等人在论文里明确指出,dkd_k 较大时不缩放的点积注意力效果变差,这才加上了 1/dk1/\sqrt{d_k}——所以 Transformer 的注意力叫缩放点积注意力(scaled dot-product attention)。


4. 自注意力作为积木的三个缺陷与补丁

只用自注意力能搭出一个好的语言模型吗?还不行。它有三个缺陷,每个都需要一个”补丁”。

4.1 缺陷一:不知道词序 → 位置编码

看自注意力的公式:eij=qi⋅kj/dke_{ij} = q_i \cdot k_j / \sqrt{d_k} 只取决于两个词的向量,和它们在第几个位置无关。如果把输入的词打乱顺序,每个词得到的输出完全一样,只是输出的顺序跟着打乱。数学上叫置换等变(permutation equivariant)。

动手:代码 01 演示 4。“dog bites man” 和 “man bites dog” 中,dog、bites、man 三个词的输出向量完全相同。给每个位置加上不同的位置向量之后,dog 的输出就不同了。

对自注意力来说,一个句子只是一个”词的集合”(就像词袋模型)。可是”狗咬人”和”人咬狗”意思完全不同。RNN 天生按顺序处理,不存在这个问题;自注意力需要我们显式地告诉它每个词的位置——这就是第 5 节的位置编码。

4.2 缺陷二:没有逐元素的非线性 → 前馈网络

自注意力的输出是值向量的加权平均。虽然 softmax 是非线性的,但对值来说,自注意力只是做线性的混合:把几层自注意力叠起来,每个位置得到的仍然只是各个值向量的加权平均(再乘一些矩阵)。

深度学习的威力来自非线性(第 3 讲)。补丁很简单:在每个自注意力层之后,对每个位置单独加一个小的前馈网络:

FFN(x)=W2 ReLU(W1x+b1)+b2\text{FFN}(x) = W_2\, \text{ReLU}(W_1 x + b_1) + b_2

(GPT、BERT 用 GELU 代替 ReLU。)每个位置用的是同一组参数。一个直觉的分工:自注意力负责在位置之间”搬运”信息,前馈网络负责在每个位置上”加工”信息。

4.3 缺陷三:能看到未来 → 因果掩码

用自注意力做语言模型时,第 tt 个位置的输出要用来预测第 t+1t+1 个词。如果它能关注第 t+1t+1 个词,就等于偷看了答案:训练损失会降得很低,模型却什么也没学会。

一个笨办法是每一步只把前 tt 个词送进模型——这样就失去了并行性。更好的办法是掩码(masking):把”不允许看”的位置的打分设为 −∞-\infty,softmax 之后权重恰好为 0:

eij={qi⋅kj/dk,j≤i−∞,j>ie_{ij} = \begin{cases} q_i \cdot k_j / \sqrt{d_k}, & j \le i \\ -\infty, & j > i \end{cases}

这叫因果掩码(causal mask),掩码矩阵是一个下三角矩阵。

动手:代码 01 演示 5 打印了掩码和加掩码之后的注意力权重(右上角全是 0),并验证:把最后一个词换掉,前面所有位置的输出都不变。

有了因果掩码,一个句子的所有位置可以一次性并行地算完,而每个位置的输出都和”只给模型看前 ii 个词”时完全相同。训练时一个长度为 nn 的句子同时提供了 nn 个预测任务。这是 Transformer 语言模型训练快的关键。

注意:生成文本时仍然要一个词一个词地生成(第 t+1t+1 个词要等第 tt 个词生成出来)。并行的只是训练和读入提示的阶段。

4.4 最小的 Transformer

把三个补丁加上,就得到了一个”最小”的 Transformer 层:

输入词向量 + 位置编码
      │
  自注意力(带因果掩码)
      │
  前馈网络(逐位置)
      │
     ……重复 N 层……
      │
  线性层 + softmax → 下一个词的概率

真正的 Transformer 还加了几样让训练更稳定的东西(多头、残差连接、层归一化),第 6、7 节逐一介绍。先看位置编码。


5. 位置编码

5.1 最朴素的想法为什么不行

我们想给每个位置 pp 一个向量 PEp∈Rd\text{PE}_p \in \mathbb{R}^d,把它加到词向量上:x~p=xp+PEp\tilde{x}_p = x_p + \text{PE}_p。(为什么是加而不是拼接?加法不增加维度;而且在高维空间里,词向量和位置向量可以大致占据不同的”方向”,加起来之后模型仍能把两种信息分开。)

最朴素的做法:PEp=p\text{PE}_p = p(每一维都等于位置编号)。问题是数值没有上界——第 500 个位置的编码是 500,会完全淹没词向量(量级在 1 左右)。归一化成 p/np / n?那同一个位置在长短不同的句子里编码就不同了,模型无法学到”往前数 3 个词”这样的规律。

我们希望位置编码:(1) 每个位置都不同;(2) 数值有界;(3) 不同长度的句子里,同一个位置的编码相同;(4) 最好能方便地表达相对位置(“往前 3 个词”)。

5.2 正弦位置编码

原始 Transformer 的方案:

PEp,2i=sin⁡ ⁣(p100002i/d),PEp,2i+1=cos⁡ ⁣(p100002i/d)\text{PE}_{p, 2i} = \sin\!\left(\frac{p}{10000^{2i/d}}\right), \qquad \text{PE}_{p, 2i+1} = \cos\!\left(\frac{p}{10000^{2i/d}}\right)

直觉:一组转速不同的时钟指针。 每一对维度 (2i,2i+1)(2i, 2i+1) 是一根指针,位置每增加 1,它转过角度 ωi=1/100002i/d\omega_i = 1/10000^{2i/d}。前面的维度转得快(ω0=1\omega_0 = 1,大约 6 个位置转一圈),后面的维度转得慢(最后一对的周期长达几万个位置)。就像钟表的秒针、分针、时针:每根针单独看会”重复”,合在一起,每个时刻的组合都不同。所有值都在 [−1,1][-1, 1] 之间,满足了前三个要求。

动手:代码 02_positional_encoding.py 演示 1 打印了 d=16d = 16 时前几个位置的编码和一张字符热图(安装了 matplotlib 时还会另存 figures/sinusoidal_pe.png):左边的维度变化很快,右边的维度在 50 个位置内几乎不变(sin ≈ 0,cos ≈ 1)。

相对位置:点积只取决于距离。 由 sin⁡asin⁡b+cos⁡acos⁡b=cos⁡(a−b)\sin a \sin b + \cos a \cos b = \cos(a - b):

PEp⋅PEp+k=∑icos⁡(k ωi)\text{PE}_p \cdot \text{PE}_{p+k} = \sum_{i} \cos(k\, \omega_i)

只和距离 kk 有关,和起点 pp 无关。

动手:代码 02 演示 2(d=64d = 64):

距离 kk01248163264
PEp⋅PEp+k\text{PE}_p \cdot \text{PE}_{p+k}32.030.928.323.922.419.419.613.9

起点 p=0p = 0、20、100 时的结果完全相同;距离越远,点积大体上越小。

相对位置:平移是线性变换。 由和角公式,每一对 (sin⁡pω,cos⁡pω)(\sin p\omega, \cos p\omega) 往后挪 kk 个位置,相当于乘一个只依赖 kk 的 2×22 \times 2 旋转矩阵:

(sin⁡(p+k)ωcos⁡(p+k)ω)=(cos⁡kωsin⁡kω−sin⁡kωcos⁡kω)(sin⁡pωcos⁡pω)\begin{pmatrix} \sin (p+k)\omega \\ \cos (p+k)\omega \end{pmatrix} = \begin{pmatrix} \cos k\omega & \sin k\omega \\ -\sin k\omega & \cos k\omega \end{pmatrix} \begin{pmatrix} \sin p\omega \\ \cos p\omega \end{pmatrix}

所以存在矩阵 MkM_k 使得对所有 pp 都有 PEp+k=MkPEp\text{PE}_{p+k} = M_k \text{PE}_p(代码 02 演示 3 做了数值验证)。原论文的设想是:这样线性层就有可能学会”关注往前 kk 个位置”这样的模式。

但有一个问题:位置编码是加在词向量上的。打分 (xm+PEm)WQ⋅(xn+PEn)WK(x_m + \text{PE}_m)W^Q \cdot (x_n + \text{PE}_n)W^K 展开后有四项,其中只有”位置×位置”那一项是相对的,其他项把词和绝对位置搅在了一起。上面那些漂亮的性质,经过投影之后并不能被干净地利用。

5.3 可学习的位置嵌入

另一种做法简单粗暴:位置也当作”词”,每个位置一个可学习的向量,组成一个 Lmax⁡×dL_{\max} \times d 的矩阵 PP,和词嵌入一起训练。BERT(Lmax⁡=512L_{\max} = 512)、GPT-2(Lmax⁡=1024L_{\max} = 1024)都这样做。

  • 优点:最灵活,每个位置的向量想学成什么样都可以。
  • 缺点:最多只能处理 Lmax⁡L_{\max} 个位置;更要紧的是,训练时从没出现过的位置,它们的向量也从没被训练过(还是随机初始化的值)。代码 02 演示 5 演示了超出表长度时直接出错。

5.4 旋转位置编码(RoPE)

今天的大多数大模型(LLaMA、Qwen、Mistral 等)使用 RoPE(Rotary Position Embedding,Su 等 2021)。它的想法是:不把位置加到输入上,而是在每一层计算注意力时,把查询和键按各自的位置”旋转”一下。

二维的情形。 设查询和键都是二维向量。把位置 mm 的查询旋转 mθm\theta 角,位置 nn 的键旋转 nθn\theta 角:

q~m=R(mθ) q,k~n=R(nθ) k,R(ϕ)=(cos⁡ϕ−sin⁡ϕsin⁡ϕcos⁡ϕ)\tilde{q}_m = R(m\theta)\, q, \qquad \tilde{k}_n = R(n\theta)\, k, \qquad R(\phi) = \begin{pmatrix} \cos\phi & -\sin\phi \\ \sin\phi & \cos\phi \end{pmatrix}

旋转矩阵满足 R(a)⊤R(b)=R(b−a)R(a)^\top R(b) = R(b - a)(先转回 aa 再转 bb,等于转 b−ab - a),所以

q~m⋅k~n=q⊤R(mθ)⊤R(nθ) k=q⊤R((n−m)θ) k\tilde{q}_m \cdot \tilde{k}_n = q^\top R(m\theta)^\top R(n\theta)\, k = q^\top R\big((n - m)\theta\big)\, k

打分只取决于相对位置 n−mn - m,和绝对位置无关。

用复数看更简洁:把二维向量看成复数,旋转就是乘 eimθe^{i m\theta}。打分是 Re[qeimθ⋅keinθ‾]=Re[qkˉ ei(m−n)θ]\text{Re}\big[q e^{im\theta} \cdot \overline{k e^{in\theta}}\big] = \text{Re}\big[q \bar{k}\, e^{i(m-n)\theta}\big],同样只依赖 m−nm - n。

高维的情形。 把 dkd_k 维向量的维度两两配对,看成 dk/2d_k/2 个二维平面,第 ii 个平面用角速度 θi=10000−2i/dk\theta_i = 10000^{-2i/d_k}(和正弦编码一样的一组频率)。每个平面各自旋转,打分是各个平面的点积之和,仍然只依赖相对位置。

动手:代码 02 演示 4。同一个查询和同一个键放在不同的位置:

(m,n)(m, n)(5, 2)(10, 7)(100, 97)(5, 5)(50, 50)(2, 5)(60, 63)
m−nm - n33300−3−3
RoPE 打分−3.724−3.724−3.724−1.468−1.468−3.823−3.823
加正弦编码后的打分1.1772.000−2.1353.0410.3310.8290.842

用 RoPE 时,相对位置相同的三组打分完全相同;把正弦编码加到向量上再做点积,结果随绝对位置变化。旋转也不改变向量的长度(位置 0、1、10、100、1000 处都是 1.8627)。

RoPE 的好处:

  • 打分天然只依赖相对位置,而相对位置往往才是语言里真正重要的(“前一个词""隔了 3 个词”);
  • 不需要任何参数;
  • 作用在每一层的 Q、K 上(不作用于 V),位置信息不会在深层被”冲淡”。

transformer.py 的 apply_rope 用的是”前一半维度和后一半维度配对”的写法(GPT-NeoX、LLaMA 的实现),和原论文”相邻两维配对”只是维度顺序不同,效果完全一样。

5.5 位置编码与长度泛化

如果测试时的句子比训练时更长,模型还能工作吗?这叫长度泛化(length generalization)。直觉上:

  • 可学习的位置嵌入:没训练过的位置是随机向量,很可能出问题;
  • 正弦编码:每个位置都有定义,但模型从没见过那些编码值;
  • RoPE:打分只依赖相对位置,如果训练时见过的相对距离足够,可能推广得更好;
  • 还有一种激进的做法:不加任何位置信息(NoPE)。对于带因果掩码的解码器,这并非完全没有位置信息——第 tt 个位置只能看到 tt 个词,模型可以从”能看到多少东西”推断出自己的位置。

第 11.3 节用实验检验这些直觉——结果和直觉并不完全一致。


6. 多头注意力

6.1 一个头只有一个注意力分布

在一个句子里,一个词往往同时和好几个词有关系。比如处理动词 chased 时,模型可能既想知道主语是谁(谁在追),又想知道宾语是谁(追谁)。但一个注意力头在每个位置只产生一个概率分布:它要么只看一个地方,要么把权重分给几个地方——得到的是这几个值向量的平均,平均之后就分不清哪个信息来自哪里了。

动手:代码 03_multihead_and_block.py 演示 3 设计了一个需要”同时看三个位置”的任务:随机词序列(词表 10 个词,瞎猜 10%),在每个位置 tt 同时预测 xt−1x_{t-1}、xt−2x_{t-2}、xt−4x_{t-4}。模型只有一层注意力,总维度都是 24(头越多,每个头的维度越小),参数量完全相同(3,678 个),每种 3 个随机种子:

头数xt−1x_{t-1}xt−2x_{t-2}xt−4x_{t-4}最差的目标
169.1%31.3%61.2%10.0%
298.4%94.1%96.7%91.9%
3100%100%100%100%
6100%100%100%100%

1 个头时,每次训练总有一个目标只能瞎猜(“最差的目标”恰好是 10%);2 个头好得多但没有全对;头数够用之后全对。

6.2 多头注意力的定义

多头注意力(multi-head attention)同时运行 hh 个独立的注意力”头”,每个头有自己的投影矩阵,最后把结果拼起来:

headℓ=Attention(XWℓQ, XWℓK, XWℓV),ℓ=1,…,h\text{head}_\ell = \text{Attention}(XW^Q_\ell,\ XW^K_\ell,\ XW^V_\ell), \qquad \ell = 1, \dots, h MultiHead(X)=[head1;… ;headh] WO\text{MultiHead}(X) = [\text{head}_1; \dots; \text{head}_h]\, W^O

其中 WℓQ,WℓK,WℓV∈Rd×d/hW^Q_\ell, W^K_\ell, W^V_\ell \in \mathbb{R}^{d \times d/h},WO∈Rd×dW^O \in \mathbb{R}^{d \times d}。每个头在一个 d/hd/h 维的”子空间”里计算注意力,可以关注不同的东西;WOW^O 把各个头的结果混合起来。

计算量和单头一样。 每个头的维度是 d/hd/h,hh 个头的投影矩阵拼起来正好是 d×dd \times d。实现时根本不需要 hh 个独立的矩阵:先用一个 d×dd \times d 的矩阵算出整个 QQ,再把最后一维 reshape 成 h×(d/h)h \times (d/h),把”头”这一维挪到前面,后面的矩阵乘法就对每个头分别进行:

Q: (B, T, d) → view → (B, T, h, d/h) → transpose → (B, h, T, d/h)
打分: (B, h, T, d/h) @ (B, h, d/h, T) → (B, h, T, T)
输出: (B, h, T, T) @ (B, h, T, d/h) → (B, h, T, d/h) → 拼回 (B, T, d) → 乘 W^O

动手:代码 03 演示 1 把每一步的形状都打印出来;演示 2 把我们的参数复制进 PyTorch 自带的 nn.MultiheadAttention,两者的输出和注意力权重的差别只有 10−710^{-7} 量级(浮点误差)。

6.3 头在学什么?

研究者分析训练好的模型,发现不同的头确实有分工。Clark 等(2019)在 BERT 里找到了专门关注”动词的直接宾语""名词的限定词""介词的宾语”的头,有的头的注意力几乎就是依存句法树的弧(第 4 讲);还有很多头只是关注前一个词、后一个词,或者句子的分隔符。第 11.4 节会在我们的迷你 GPT 里找”盯着主语”的头。

另一个著名的例子是归纳头(induction head,Olsson 等 2022):两个头配合,实现”如果前面出现过 A B,现在又看到 A,就预测 B”。这被认为是大模型”上下文学习”能力的重要来源之一(第 9 讲)。


7. Transformer 块:残差、层归一化、前馈网络

7.1 残差连接

第 6 讲第 4.1 节说过,梯度消失是所有深层网络的问题,残差连接是通用的解决办法:

x(ℓ+1)=x(ℓ)+F(x(ℓ))x^{(\ell+1)} = x^{(\ell)} + F(x^{(\ell)})

每一层不是把输入”替换”成新的表示,而是在输入上加一点修改。反向传播时 ∂x(ℓ+1)/∂x(ℓ)=I+∂F/∂x\partial x^{(\ell+1)} / \partial x^{(\ell)} = I + \partial F / \partial x,梯度总有一条直达的路径。

对 Transformer 来说,残差连接还有一个特别的作用。自注意力在每个位置都对所有位置做加权平均——如果没有残差,一层层平均下去,不同位置的表示会越来越像,最后所有位置变成同一个向量(Dong 等 2021 证明了纯注意力网络的这种”秩坍缩”以极快的速度发生)。残差连接给每个位置保留了一条”原样传过去”的通路。

动手:代码 03 演示 6 训练了 8 层的网络(任务:预测 xt−3x_{t-3};瞎猜的损失 2.30、准确率 10%;300 步):

初始化时的坍缩程度前 50 步损失最后 50 步损失准确率
Pre-LN(有残差)0.1261.0620.003100%
Post-LN(有残差)0.2351.0440.003100%
Pre-LN 去掉残差1.0002.3172.30510.1%

“坍缩程度”是最后一层不同位置的表示之间的平均余弦相似度。去掉残差后,还没训练,所有位置的表示就已经是同一个向量了(余弦相似度 1.000),网络完全学不动。

7.2 层归一化

层归一化(Layer Normalization,Ba 等 2016)对每个位置的向量单独做标准化,再做可学习的缩放和平移:

LN(x)=γ⊙x−μσ2+ϵ+β,μ=1d∑jxj,σ2=1d∑j(xj−μ)2\text{LN}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta, \qquad \mu = \frac{1}{d}\sum_j x_j, \quad \sigma^2 = \frac{1}{d}\sum_j (x_j - \mu)^2

为什么需要它? 经过很多层之后,向量的数值可能越来越大或越来越小,后面的层看到的输入范围不断变化,训练不稳定。层归一化让每一层的输入都保持在稳定的范围内。γ\gamma 和 β\beta 是可学习的,模型需要时可以把标准化”撤销”掉。

为什么不用批归一化? 第 3 讲的批归一化沿着批的维度统计均值和方差。NLP 的批里句子长短不一(还有填充),批里的句子随时在变,按批统计很不稳定;生成文本时批大小可能是 1。层归一化只看单个位置的向量,和批里的其他东西无关。

动手:代码 03 演示 4 手算了层归一化:输入 (2,4,6,8)(2, 4, 6, 8) 和 (100,101,102,103)(100, 101, 102, 103) 量级差了 25 倍,标准化之后完全相同(都是 (−1.342,−0.447,0.447,1.342)(-1.342, -0.447, 0.447, 1.342)),并验证和 nn.LayerNorm 一致。

7.3 前馈网络

第 4.2 节已经介绍过:FFN(x)=W2 GELU(W1x+b1)+b2\text{FFN}(x) = W_2\, \text{GELU}(W_1 x + b_1) + b_2,隐藏层宽度通常是 4d4d。

它看起来不起眼,却占了每个块三分之二的参数(8d28d^2 对比注意力的 4d24d^2)。一种有影响的解读(Geva 等 2021)把前馈层看作”键—值记忆”:W1W_1 的每一行是一个”键”,检测输入里的某种模式;W2W_2 的对应列是”值”,在模式出现时往输出里加上相应的内容。模型记住的大量事实性知识,可能主要存储在前馈层里。

7.4 拼起来:Pre-LN 与 Post-LN

一个 Transformer 块由两个子层组成:多头自注意力和前馈网络,每个子层都带残差连接和层归一化。层归一化放在哪里,有两种写法:

Post-LN(原始 Transformer,2017)       Pre-LN(GPT-2 及以后的大部分模型)
x = LN(x + Attention(x))                x = x + Attention(LN(x))
x = LN(x + FFN(x))                      x = x + FFN(LN(x))
                                        (所有块之后再加一个 LN)

区别在于残差的”主干”上有没有层归一化:

  • Post-LN:每次相加之后都归一化,主干上的信号每层都被”重新缩放”。Xiong 等(2020)证明,Post-LN 在初始化时靠近输出的层梯度偏大,所以必须用学习率预热(第 9 节),否则训练一开始就可能发散;层数越多越难训练。
  • Pre-LN:主干是一条干净的”加法高速公路”,归一化只作用在每个子层的输入上。梯度更均匀,训练更稳,对学习率不那么敏感。

在代码 03 演示 6 的小任务上两者都能训练;Post-LN 的问题在更深、更大的模型上才明显。今天几乎所有大模型都用 Pre-LN(通常还把 LayerNorm 换成更简单的 RMSNorm,见第 12 节)。transformer.py 默认使用 Pre-LN。

7.5 参数量

一个块的参数(Pre-LN,含偏置):

部分参数
WQ,WK,WVW^Q, W^K, W^V(合成一个 d→3dd \to 3d 的层)3d2+3d3d^2 + 3d
输出投影 WOW^Od2+dd^2 + d
前馈网络 d→4d→dd \to 4d \to d(4d2+4d)+(4d2+d)(4d^2 + 4d) + (4d^2 + d)
两个层归一化4d4d
合计12d2+13d\mathbf{12d^2 + 13d}

整个解码器模型 = 词嵌入 VdVd + 位置嵌入 Lmax⁡dL_{\max} d + NN 个块 + 最后的层归一化 2d2d(输出层和词嵌入共用同一个矩阵,见第 8.2 节)。

动手:代码 03 演示 5 验证了 d=64d = 64、128、768 时公式和实际参数量一致。然后用同样的代码搭了一个 GPT-2 small 大小的模型(V=50257V = 50257,Lmax⁡=1024L_{\max} = 1024,d=768d = 768,12 层,12 个头):参数恰好是 124,439,808 个,和 OpenAI 公布的 GPT-2 small 完全相同。其中词嵌入占 31.0%,12 个块占 68.3%。

我们的 transformer.py 和 GPT-2 的结构完全一样,只是小得多。


8. 三种结构:编码器、解码器、编码器—解码器

同样的 Transformer 块,按”能看到哪些位置”和”怎样组合”,可以搭出三种结构。

8.1 编码器:双向

不加因果掩码,每个位置能看到整个句子(左边和右边)。输出是每个词的上下文相关表示,适合”理解”类任务:分类、命名实体识别、抽取式问答。

代表:BERT(2018)。因为能看到右边的词,编码器不能直接当语言模型用(“预测下一个词”时答案就在输入里);BERT 用”把一些词遮住、预测被遮住的词”来训练(第 9 讲)。

transformer.py 里 TransformerLM(causal=False) 就是编码器。处理填充过的批时,要传 pad_mask,让真实的词不去关注填充位置。

8.2 解码器:单向(因果)

加因果掩码,每个位置只能看到自己和左边。就是一个语言模型,适合生成文本。

代表:GPT 系列,以及今天几乎所有的大语言模型(LLaMA、Qwen、Claude、Gemini 等的主体)。第 9 讲会讨论为什么”只用解码器”最终成了主流。

一个常用的技巧是权重共享(weight tying):输出层(d→Vd \to V)和输入词嵌入(V→dV \to d)用同一个矩阵(Press & Wolf 2017)。直觉:输入时”词 → 向量”和输出时”向量 → 词的打分”用的是同一套词的表示,预测某个词的打分就是隐藏状态和这个词的嵌入的点积。这样能省下 VdVd 个参数(GPT-2 small 里是 3860 万个),通常效果还更好。

8.3 编码器—解码器

原始 Transformer 的结构,用于机器翻译。编码器读源句子(双向);解码器生成目标句子(因果),并且在每个块的自注意力和前馈网络之间,多一个交叉注意力(cross-attention)子层:

CrossAttention=softmax ⁣((HdecWQ)(HencWK)⊤dk)HencWV\text{CrossAttention} = \text{softmax}\!\left(\frac{(H^{\text{dec}} W^Q)(H^{\text{enc}} W^K)^\top}{\sqrt{d_k}}\right) H^{\text{enc}} W^V

查询来自解码器,键和值来自编码器的输出——这正是第 6 讲 seq2seq 里的注意力,只是现在两边都是 Transformer。

代表:原始 Transformer、T5(2019)、BART(2019)、Whisper(语音识别)。

8.4 小结

结构掩码擅长代表
编码器无(双向)理解:分类、标注、抽取BERT、RoBERTa
解码器因果生成:语言模型、对话GPT 系列、LLaMA
编码器—解码器编码器双向,解码器因果 + 交叉注意力序列到序列:翻译、摘要原始 Transformer、T5、BART

9. 训练 Transformer 的细节

Transformer 对训练细节比 RNN 更敏感。原论文和后来的实践中,几个常见的做法:

  • Adam / AdamW:Transformer 几乎都用 Adam 类的优化器(第 3 讲)。AdamW(Loshchilov & Hutter 2019)把权重衰减从梯度更新中”解耦”出来,是今天的标准选择。
  • 学习率预热(warmup)+ 衰减:前几百到几千步,学习率从接近 0 线性增加到最大值,之后再逐渐减小(原论文按 1/step1/\sqrt{\text{step}} 衰减,今天常用余弦衰减)。为什么要预热?训练刚开始时参数是随机的,梯度方向不可靠;Adam 对梯度二阶矩的估计也还没积累起来,有效步长可能很大;Post-LN 的结构更是在初始化时梯度偏大(第 7.4 节)。先用小学习率”热身”,能避免一开始就把模型推到很差的区域。
  • Dropout:加在注意力权重、每个子层的输出、嵌入层之后。
  • 标签平滑(label smoothing):训练目标不再是 one-hot,而是给正确答案 1−ϵ1 - \epsilon、其余词平分 ϵ\epsilon(原论文 ϵ=0.1\epsilon = 0.1)。它让模型不要过于自信,在翻译上提高了 BLEU,代价是困惑度变差。
  • 梯度裁剪:和 RNN 一样(第 6 讲),防止偶尔出现的巨大梯度。
  • 初始化:GPT-2 把线性层和嵌入层初始化为 N(0,0.022)\mathcal{N}(0, 0.02^2),并且把残差路径上最后一个线性层的初始化再缩小 1/N1/\sqrt{N}(NN 是层数),让残差主干上的数值在初始化时不随层数增长。

transformer.py 的 warmup_cosine 实现了预热 + 余弦衰减;代码 04 的 train_gpt 用了 AdamW、预热、梯度裁剪和 dropout。


10. 计算量与长序列

10.1 自注意力的计算量

处理长度为 nn、维度为 dd 的序列,一个 Transformer 层的乘加运算:

部分计算量
Q、K、V、输出投影(4 个 d×dd \times d 矩阵乘)4nd24nd^2
前馈网络(d→4d→dd \to 4d \to d)8nd28nd^2
打分 QK⊤QK^\top 和加权求和 AVAV2n2d2n^2 d

前两部分对 nn 是线性的,注意力矩阵的部分是 n2n^2。两者之比是 n/6dn / 6d:序列长度超过 6d6d 时,注意力矩阵开始占大头。

动手:代码 01 演示 6(d=768d = 768):

nn128512102440961638465536
注意力矩阵的计算占比2.7%10.0%18.2%47.1%78.0%93.4%

对 GPT-2(n=1024n = 1024)来说,注意力矩阵只占不到 20% 的计算;但到了几万、几十万个词元的长上下文,它就成了主要的开销。除了计算,n×nn \times n 的注意力矩阵还要占显存(每个头、每一层都有一个)。

和 RNN 比较:RNN 每层的计算是 O(nd2)O(nd^2),但有 O(n)O(n) 个不能并行的步骤;Transformer 每层的计算是 O(n2d+nd2)O(n^2 d + nd^2),但所有位置可以并行,任意两个位置之间的路径长度是 O(1)O(1)。在 GPU 上、对通常的句子长度,Transformer 快得多。

10.2 长序列的应对方法(简介)

  • FlashAttention(Dao 等 2022):不改变数学结果,通过分块计算避免把完整的 n×nn \times n 矩阵写入显存,大幅减少显存读写。今天的标准做法。(算法见 CS336 第 5 讲 §8,用 Triton 实现见第 6 讲)
  • 稀疏注意力 / 滑动窗口注意力:每个位置只关注附近的一个窗口,或者固定的一些位置(Longformer、Mistral 的滑动窗口)。
  • 线性注意力、状态空间模型(Mamba 等):把计算量降到 O(n)O(n),代价是表达能力上的一些折中;它们在某种意义上又回到了”RNN 式”的递推。(CS336 第 4 讲)
  • KV 缓存:生成文本时,把之前位置的键和值缓存起来,每生成一个词只需要为新位置计算一次。(CS336 第 10 讲)

11. 实验:迷你 GPT vs RNN

现在把所有零件拼起来。代码 04_mini_gpt.py 在第 5 讲的玩具语言上训练一个解码器结构的 Transformer:d=64d = 64,4 个头,2 层,可学习的位置嵌入,Pre-LN,权重共享——和 GPT-2 相同的结构,只是小得多。

11.1 训练与困惑度

动手:代码 04 演示 1、2。迷你 GPT 有 109,248 个参数(第 5 讲的 RNN 有 33,455 个),在 20,000 个句子上训练 8 轮(AdamW,最大学习率 3×10−33 \times 10^{-3},预热 200 步,余弦衰减),在编写讲义的电脑上约 40~50 秒:

轮次12348
开发集困惑度4.574.364.334.314.28

测试集困惑度:RNN(第 5 讲)4.33,迷你 GPT 4.27。

两者很接近:这门玩具语言的大部分词只依赖近处的上下文,两种模型都已经接近这门语言本身的”不确定性下限”了。差别要看专门的测试。

11.2 主谓一致

动手:代码 04 演示 3。训练语料里最多 3 个修饰语,最长的句子 20 个词元(含 <s>、</s>);4 个及以上修饰语的句子比训练时的任何句子都长:

修饰语个数0123468
平均距离03.77.511.214.822.329.7
最长前缀(含 <s>)481216202836
RNN100%100%100%100%100%100%100%
迷你 GPT100%100%100%100%98.5%98.8%99.2%

在训练时见过的长度上,迷你 GPT 和 RNN 一样全对:自注意力可以直接看到任意远的主语。在更长的句子上,迷你 GPT 也几乎全对,只比 RNN 略低一点。

这其实有点出乎意料(编写这一讲时,我本来预期它会明显变差):位置嵌入表第 20 行以后的向量在训练中从没出现过,还是初始化时很小的随机值。模型仍然能找到主语,说明它找主语主要靠内容(“哪个词是名词、是单数还是复数”),而不是精确的位置;而那些没训练过的位置向量很小,几乎相当于”没有位置信息”,没有把模型带偏。

11.3 位置编码与长度泛化

代码 05_length_generalization.py 用完全相同的数据和设置,比较第 5.5 节的四种做法(每种 2 个随机种子,测试到 10 个修饰语)。

动手:代码 05。四种做法在训练分布内的测试集困惑度几乎一样(4.268~4.278 之间,包括完全不加位置信息的模型)。主谓一致准确率(2 个随机种子的平均):

修饰语个数0~346810
最长前缀(含 <s>)≤ 1620283644
可学习的位置嵌入100%95.8%97.8%99.0%97.8%
正弦编码100%100%99.9%95.4%83.5%
RoPE100%84.9%72.5%70.9%66.4%
无位置信息100%100%100%100%99.6%

正弦编码在 10 个修饰语时两个种子差别很大(约 68% 和 99%)。

这个结果和第 5.5 节的直觉几乎反过来:

  • 无位置信息的模型推广得最好。 在这个任务里,找主语只需要看内容(哪个词是名词、单数还是复数),一个根本不使用位置的模型,自然不存在”没见过的位置”的问题。因果掩码给它的那点位置信息,已经足够让它在训练分布内和其他模型一样好。
  • 可学习的位置嵌入只轻微下降。 没训练过的位置向量还是初始化时很小的随机值,在这些位置上模型几乎相当于”无位置信息”。
  • RoPE 最差。 它的打分取决于相对距离,而训练时主动词和主语之间的距离最多二十个词左右。更远的距离对应的旋转角度——尤其是转得慢的那些维度——模型从没见过,打分变得不可预测。“相对位置”的好处,只在训练时见过的相对距离之内成立。

这和文献的发现一致:RoPE 本身不能外推到比训练时更长的序列。今天的大模型想支持更长的上下文时,会对 RoPE 做位置插值、NTK 缩放、YaRN 等调整(大意是把没见过的角度”压缩”回见过的范围),再用长文本继续训练一段时间。另一种思路是 ALiBi(Press 等 2022):不用位置编码,而是在打分上直接减去一个和距离成正比的惩罚。Kazemnejad 等(2023)在小规模实验中也发现,不加位置信息的解码器长度泛化最好。

但不要因此得出”位置编码没用”的结论。真实语言里,精确的位置信息对复制、计数、局部词序等都很重要,真实的大模型几乎都使用 RoPE。我们的玩具任务只考察了”找主语”这一件事——这正是玩具实验的【边界】:它能清楚地展示一个现象,却不能告诉你在真实任务上哪种做法更好。

另外请注意:如果只跑一个随机种子,正弦编码在 10 个修饰语时的结果可能是 68%,也可能是 99%,结论会完全不同。第 8 讲会专门讨论这个问题。

11.4 注意力在看哪里?

在预测主动词的那个位置(前缀的最后一个词),模型的每个头把注意力分给了哪些词?

动手:代码 04 演示 4 统计了 1000 个含 2 个修饰语的句子。如果均匀分配,每个位置平均分到 0.09。表中是每个头分给主语名词和分给干扰名词(单复数和主语不同的名词)的平均权重:

头 0头 1头 2头 3
第 1 层主语 0.36 / 干扰 0.040.01 / 0.120.28 / 0.050.10 / 0.07
第 2 层0.00 / 0.000.31 / 0.000.00 / 0.000.46 / 0.00

例句 1(两个修饰语都是介词短语):第 1 层头 2 在这类句子里平均把 0.78 的权重给主语。在 “the old girl near the houses near the angry trees ___” 中,它给 girl 的权重是 0.87,给两个干扰名词 houses、trees 的权重都是 0.00。

例句 2(含主语关系从句):第 2 层头 3 在 “the cat that sees the boys that likes the teacher ___” 中,给主语 cat 的权重是 0;它把 0.25 和 0.20 分给了 sees 和 likes,剩下的落在 <s>(0.25)和句首的 the(0.29)上。

几点观察:

  1. 有的头学会了”找主语”。 没有人告诉模型什么是主语,它只是在训练”预测下一个词”的过程中发现:要选对动词的形式,就得看主语。例句 1 的那个头几乎只看主语,完全忽略中间的干扰名词。
  2. 模型会利用任何能用的线索。 例句 2 中,主语关系从句里的动词 sees、likes 和主语的单复数一致——它们的形式同样”泄露”了答案。于是这个头去看这些动词,而不是主语本身。
  3. 注意力可以分几步传递信息。 第 2 层的头看到的不再是原始的词,而是第 1 层输出的表示。第 1 层可能已经把主语的信息搬到了后面的位置上,第 2 层再从那些位置读取。所以”第 2 层的某个头给主语的权重是 0”并不说明它没有用到主语的信息。
  4. 注意力汇聚(attention sink)。 例句 2 中超过一半的权重落在 <s> 和句首的 the 上,而因果掩码下,这两个位置根本”看不到”主语。很多模型里都能看到这种现象:一个头在某个位置”没事可做”时,就把注意力停在序列开头的几个位置上(Xiao 等 2023)。

这些分析很有启发,但要小心:注意力权重大,不等于这个位置”决定了”输出(第 6 讲第 8.5 节)。哪一层、哪个头负责什么,每次训练也都可能不同。第 17 讲会介绍更严谨的分析方法。

11.5 Transformer vs RNN

RNN / LSTMTransformer
任意两个位置之间的路径长度O(n)O(n)O(1)O(1)
沿序列的并行性无(必须逐步计算)训练时完全并行
每层计算量O(nd2)O(nd^2)O(n2d+nd2)O(n^2 d + nd^2)
词序信息天生按顺序处理需要位置编码
生成时每步的代价固定(只更新隐藏状态)随上下文变长(需要 KV 缓存)
长度泛化天生没有”位置表”的问题取决于位置编码
规模扩展难好(第 9 讲)

在我们的玩具实验里,两者的困惑度和主谓一致都差不多。Transformer 真正的优势在规模:并行训练让它能在海量数据上训练巨大的模型,而这正是第 9 讲的主题。


12. 今天的 Transformer 改了什么

从 2017 年到今天,Transformer 的骨架几乎没变(自注意力 + 前馈网络 + 残差 + 归一化),但很多零件被换成了更好的版本。CS336 第 3 讲有详细的讨论,这里列出最常见的几个:

零件2017 年原始 Transformer今天的典型做法(如 LLaMA)
归一化位置Post-LNPre-LN
归一化方法LayerNormRMSNorm(不减均值,只除以均方根,更简单)
位置编码正弦(加在输入上)RoPE(每层旋转 Q、K)
前馈网络ReLU,4d4dSwiGLU(门控的前馈网络,宽度约 83d\frac{8}{3}d)
偏置项有大多去掉
注意力多头分组查询注意力(GQA:多个查询头共用一组键和值,减小 KV 缓存)
前馈层稠密部分模型用混合专家(MoE:每个词只激活一部分前馈网络,见 CS336 第 4 讲)

这些改动大多是为了训练更稳定或推理更省,而不是改变模型”能做什么”。理解了本讲的原始结构,读任何新模型的结构图都不会有障碍。


13. 本讲小结

  1. RNN 的结构性问题:交互距离 O(n)O(n)、无法沿时间并行。注意力让任意两个位置一步可达、所有位置同时计算——于是 Transformer 完全去掉了循环。
  2. 自注意力:每个词用查询去匹配所有词的键,对值加权平均:softmax(QK⊤/dk)V\text{softmax}(QK^\top / \sqrt{d_k})V。查询、键、值用不同的投影,分别负责”找什么""被谁找到""传递什么”。
  3. 缩放:点积的方差随 dkd_k 增长,打分太大会让 softmax 饱和、梯度消失,所以除以 dk\sqrt{d_k}。
  4. 三个补丁:自注意力不知道词序 → 位置编码;没有逐元素的非线性 → 前馈网络;能看到未来 → 因果掩码(有了它,训练可以完全并行)。
  5. 位置编码:正弦编码(不同频率的时钟,点积只依赖距离);可学习的位置嵌入(灵活,但受最大长度限制);RoPE(旋转 Q、K,打分只依赖相对位置,今天的主流)。
  6. 多头注意力:一个头只有一个注意力分布;多个头可以同时关注不同的位置和关系,计算量和单头相同。
  7. Transformer 块:多头自注意力 + 前馈网络,每个子层都有残差连接(防止梯度消失和表示坍缩)和层归一化(稳定数值)。Pre-LN 比 Post-LN 更好训练。每块 12d2+13d12d^2 + 13d 个参数。
  8. 三种结构:编码器(双向,BERT)、解码器(因果,GPT)、编码器—解码器(交叉注意力,T5)。
  9. 代价:注意力的计算和显存是 O(n2)O(n^2),长序列时成为瓶颈。

14. 与作业的关系

Winter 2026 的作业 3(自注意力与 Transformer),以及往年专门讲 Transformer 的那次作业(大约从 Winter 2021 起每年都有,题目名称大致是 Self-Attention, Transformers, and Pretraining,在不同年份是第 4 或第 5 个作业),通常包含:

  1. 书面题:注意力的探索。 例如:(a) 什么条件下注意力的输出几乎等于某一个值向量(“复制”)?——查询和某个键的点积远大于和其他键的点积(第 2.1 节的 (10,0)(10, 0))。(b) 怎样让输出是两个值向量的平均?(c) 如果键带有随机噪声,单头注意力会有什么问题?多头注意力怎样缓解?——和第 6.1 节”一个头只有一个分布”的讨论相关。
  2. 书面题:位置编码。 例如证明 RoPE 的打分只依赖相对位置(第 5.4 节的复数推导),讨论置换等变性(第 4.1 节)。
  3. 编程题:在 minGPT(Karpathy 的精简 GPT 实现)的基础上实现注意力、RoPE 等组件,先在文本上预训练,再微调去回答”某人出生在哪里”之类的问题,观察预训练带来的差别——这部分和第 9 讲(预训练)直接相关。

本讲的 transformer.py 和 minGPT 的结构几乎一样,读懂它之后再做作业会轻松很多。

作业的具体题目以当年官方发布的版本为准。


15. 练习

练习 1(手算自注意力)★:两个词,q1=(1,0)q_1 = (1, 0)、q2=(0,1)q_2 = (0, 1),k1=(1,1)k_1 = (1, 1)、k2=(1,−1)k_2 = (1, -1),v1=(1,0)v_1 = (1, 0)、v2=(0,1)v_2 = (0, 1),dk=2d_k = 2。计算缩放后的打分矩阵、注意力权重和两个词的输出。

参考答案

q1⋅k1=1q_1 \cdot k_1 = 1,q1⋅k2=1q_1 \cdot k_2 = 1,q2⋅k1=1q_2 \cdot k_1 = 1,q2⋅k2=−1q_2 \cdot k_2 = -1。除以 2\sqrt{2}:打分矩阵 (0.7070.7070.707−0.707)\begin{pmatrix} 0.707 & 0.707 \\ 0.707 & -0.707 \end{pmatrix}。

第 1 行 softmax:(0.5,0.5)(0.5, 0.5),输出 o1=(0.5,0.5)o_1 = (0.5, 0.5)。 第 2 行:e0.707/(e0.707+e−0.707)=1/(1+e−1.414)≈0.804e^{0.707} / (e^{0.707} + e^{-0.707}) = 1/(1 + e^{-1.414}) \approx 0.804,权重 (0.804,0.196)(0.804, 0.196),输出 o2=(0.804,0.196)o_2 = (0.804, 0.196)。

练习 2(缩放)★:假设 qq 和 kk 的每一维独立,均值 0、方差 σ2\sigma^2。(a) q⋅kq \cdot k 的方差是多少?(b) 应该除以什么数才能让打分的方差为 1?

参考答案

(a) 每一项 qjkjq_j k_j 的方差是 E[qj2]E[kj2]=σ4\mathbb{E}[q_j^2]\mathbb{E}[k_j^2] = \sigma^4,dkd_k 项独立相加,方差 dkσ4d_k \sigma^4。 (b) 除以 σ2dk\sigma^2 \sqrt{d_k}。标准的 dk\sqrt{d_k} 对应 σ=1\sigma = 1 的情形;实际中层归一化和初始化让 Q、K 的量级大致可控,dk\sqrt{d_k} 是一个简单而有效的选择。

练习 3(置换等变)★:证明:设 PP 是一个置换矩阵(把 XX 的行打乱),不加位置编码、不加掩码的自注意力满足 SA(PX)=P SA(X)\text{SA}(PX) = P\,\text{SA}(X)。加上因果掩码后,这个性质还成立吗?

参考答案

Q′=PXWQ=PQQ' = PXW^Q = PQ,同理 K′=PKK' = PK,V′=PVV' = PV。打分 Q′K′⊤=PQK⊤P⊤Q'K'^\top = PQK^\top P^\top:行和列都按同样的方式打乱。softmax 按行进行,对每一行内部元素的打乱是等变的,所以 softmax(PSP⊤)=P softmax(S) P⊤\text{softmax}(PSP^\top) = P\,\text{softmax}(S)\,P^\top。输出 PAP⊤PV=PAVP A P^\top P V = PAV(因为 P⊤P=IP^\top P = I)。

加因果掩码后不成立:掩码是固定的下三角矩阵,打乱输入顺序后,原来能看到某个词的位置可能看不到了。这也是为什么第 5.5 节说”带因果掩码的解码器即使不加位置编码,也能得到一些位置信息”。

练习 4(复制与平均)★★:键 k1,…,knk_1, \dots, k_n 两两正交、长度都为 1,值是 v1,…,vnv_1, \dots, v_n。(a) 怎样选查询 qq,使输出近似等于 vav_a?(b) 怎样选 qq,使输出近似等于 12(va+vb)\frac{1}{2}(v_a + v_b)?(这里不考虑缩放。)

参考答案

(a) q=c kaq = c\, k_a,cc 很大。此时 q⋅ka=cq \cdot k_a = c,q⋅kj=0q \cdot k_j = 0(j≠aj \ne a),softmax 后 kak_a 的权重 ecec+n−1→1\frac{e^c}{e^c + n - 1} \to 1。 (b) q=c (ka+kb)q = c\,(k_a + k_b),cc 很大:kak_a、kbk_b 的打分都是 cc,其余是 0,权重趋近 (1/2,1/2)(1/2, 1/2)。这也说明了单头注意力的局限:它能”平均”两个值,但平均之后,下游无法再把 vav_a 和 vbv_b 分开(第 6.1 节)。

练习 5(RoPE)★★:在二维情形下,设 q=(1,0)q = (1, 0)、k=(1,0)k = (1, 0),θ=π/6\theta = \pi/6。(a) 计算位置 m=2m = 2 的 q~\tilde{q} 和位置 n=1n = 1 的 k~\tilde{k},以及它们的点积。(b) 换成 m=5m = 5、n=4n = 4 呢?(c) m=1m = 1、n=2n = 2 呢?

参考答案

(a) q~=(cos⁡π3,sin⁡π3)=(0.5,0.866)\tilde{q} = (\cos\frac{\pi}{3}, \sin\frac{\pi}{3}) = (0.5, 0.866),k~=(cos⁡π6,sin⁡π6)=(0.866,0.5)\tilde{k} = (\cos\frac{\pi}{6}, \sin\frac{\pi}{6}) = (0.866, 0.5),点积 =0.433+0.433=0.866=cos⁡π6= 0.433 + 0.433 = 0.866 = \cos\frac{\pi}{6}。 (b) 相对距离同样是 1,点积同样是 cos⁡π6=0.866\cos\frac{\pi}{6} = 0.866。 (c) 点积是 cos⁡(−π6)=0.866\cos(-\frac{\pi}{6}) = 0.866——在这个例子里和 (a) 相同,因为 qq、kk 方向相同、cos⁡\cos 是偶函数。一般情况下 q⊤R(ϕ)kq^\top R(\phi) k 和 q⊤R(−ϕ)kq^\top R(-\phi) k 不相等(代码 02 演示 4 中,距离 3 和 −3 的打分分别是 −3.724 和 −3.823),所以 RoPE 能区分”在前面”和”在后面”。

练习 6(参数量)★:一个解码器模型:V=32000V = 32000,d=4096d = 4096,32 层,最大长度 4096,用 RoPE(没有位置嵌入表),输出层和词嵌入不共享。用 12d2+13d12d^2 + 13d 估算总参数量。(这大约是 LLaMA-7B 的规模;LLaMA 实际用 SwiGLU、RMSNorm、无偏置,数字会略有不同。)

参考答案

每块 12×40962+13×4096≈2.013×10812 \times 4096^2 + 13 \times 4096 \approx 2.013 \times 10^8,32 层约 6.44×1096.44 \times 10^9。词嵌入和输出层各 32000×4096≈1.31×10832000 \times 4096 \approx 1.31 \times 10^8,合计 2.62×1082.62 \times 10^8。总计约 6.7×1096.7 \times 10^9,即约 67 亿参数——这就是”7B”模型的由来。

练习 7(计算量)★:d=4096d = 4096 时,序列长度多少时注意力矩阵的计算量开始超过线性层?如果上下文是 128K(131072)个词元,注意力矩阵占多少?

参考答案

n>6d=24576n > 6d = 24576。n=131072n = 131072 时比值 n/6d≈5.33n / 6d \approx 5.33,占比 5.33/6.33≈84%5.33 / 6.33 \approx 84\%。

练习 8(掩码):一个编码器处理一批句子,句子被右侧填充到相同长度。(a) 为什么需要填充掩码?(b) 填充掩码应该加在注意力矩阵的行上还是列上?(c) 一个解码器处理右侧填充的批时,为什么可以不加填充掩码?

参考答案

(a) 编码器是双向的,真实的词会关注到右边的填充位置,填充向量没有意义,会污染表示,而且结果会依赖于填充了多少。 (b) 加在列上(被关注的键):任何查询都不应该关注填充位置。填充位置自己作为查询(行)时的输出没有意义,但只要计算损失时忽略它们即可。transformer.py 中 pad_mask[:, None, None, :] 就是对列做掩码。 (c) 因果掩码已经让每个位置只能看到左边;右侧填充的位置都在真实的词右边,真实的词本来就看不到它们。

练习 9(Pre-LN vs Post-LN)★★:考虑 LL 层的 Pre-LN 网络,x(L)=x(0)+∑ℓ=1LFℓ(⋅)x^{(L)} = x^{(0)} + \sum_{\ell=1}^{L} F_\ell(\cdot)。(a) ∂x(L)/∂x(0)\partial x^{(L)} / \partial x^{(0)} 中一定包含哪一项?(b) Post-LN 的 x(ℓ+1)=LN(x(ℓ)+F(x(ℓ)))x^{(\ell+1)} = \text{LN}(x^{(\ell)} + F(x^{(\ell)})) 中,这一项还存在吗?

参考答案

(a) 包含单位矩阵 II(来自从 x(0)x^{(0)} 一路”加”上去的主干)。无论子层的雅可比矩阵多小,梯度都能通过主干原样传到最底层。 (b) 不存在:每一层的主干都要穿过一次层归一化,∂x(ℓ+1)/∂x(ℓ)=JLN(I+∂F/∂x)\partial x^{(\ell+1)} / \partial x^{(\ell)} = J_{\text{LN}}(I + \partial F/\partial x),LL 个 JLNJ_{\text{LN}} 连乘,梯度的大小随层的位置变化,层数越多越不均匀——这就是 Post-LN 需要预热、难以训练很深模型的原因。

练习 10(动手):

(a) 在代码 03 演示 3 中,把任务改成同时取回 4 个位置,需要几个头才能全对? (b) 在代码 04 中,把 n_layers 改成 1,主谓一致的准确率会怎样?第 11.4 节说”注意力可以分几步传递信息”,一层的模型还能找到主语吗? (c) 在代码 04 中把位置嵌入去掉(pos="none"),看第 11.4 节的注意力模式有什么变化。 (d) 在代码 05 中,只用 0~1 个修饰语的短句训练(generate_corpus(..., modifier_probs=(0.6, 0.4)),和第 6 讲代码 03 的设置相同),四种位置编码的长度泛化差别会变大吗?

练习 11(思考):第 4.3 节说训练时 Transformer 可以并行,生成时却必须一个词一个词地来。(a) 为什么?(b) 一个长度为 nn 的提示,生成 mm 个新词,不用 KV 缓存时总共要做多少次”位置的计算”?用了 KV 缓存呢?

参考答案

(a) 第 t+1t+1 个词要以第 tt 个词为输入,而第 tt 个词只有在上一步生成之后才知道。 (b) 不用缓存:第 ii 次生成要重新计算 n+i−1n + i - 1 个位置,总共 ∑i=1m(n+i−1)=mn+m(m−1)/2\sum_{i=1}^{m}(n + i - 1) = mn + m(m-1)/2,约 O(mn+m2)O(mn + m^2)。用 KV 缓存:提示的 nn 个位置算一次(可以并行),之后每生成一个词只算 1 个新位置,总共 n+mn + m 个位置(但每个新位置的注意力仍要和之前所有的键做点积)。代码 04 的 sample 函数为了简单没有用 KV 缓存。


16. 延伸阅读

  • Vaswani et al.,Attention Is All You Need(2017):Transformer 的原始论文。
  • Jay Alammar,The Illustrated Transformer(博客,2018):最有名的图解。
  • Harvard NLP,The Annotated Transformer:逐行注释的 PyTorch 实现。
  • Andrej Karpathy,minGPT / nanoGPT 代码,以及视频 Let’s build GPT: from scratch, in code, spelled out(2023):和本讲代码 04 的思路一致。
  • Su et al.,RoFormer: Enhanced Transformer with Rotary Position Embedding(2021):RoPE。
  • Xiong et al.,On Layer Normalization in the Transformer Architecture(2020):Pre-LN 与 Post-LN。
  • Dong, Cordonnier & Loukas,Attention is not all you need: pure attention loses rank doubly exponentially with depth(2021):残差连接与秩坍缩。
  • Clark et al.,What Does BERT Look At? An Analysis of BERT’s Attention(2019):注意力头在学什么。
  • Kazemnejad et al.,The Impact of Positional Encoding on Length Generalization in Transformers(2023):位置编码与长度泛化,包括不加位置信息的解码器。
  • CS224N 课程笔记:Self-Attention & Transformers(John Hewitt 编写)。
  • CS336 第 3 讲(同目录):现代 Transformer 的结构选择与超参数。

17. 下一讲预告

到这里,我们已经有了今天所有大模型的核心结构。下一讲(深度学习实践方法与期末项目)暂时离开模型本身,讨论”怎样把一个 NLP 项目做好”:

  • 怎样选题、找数据、建立基线;
  • 训练 / 验证 / 测试集怎样划分,怎样避免数据泄漏;
  • 模型不好使时怎样排查:先在一个小批次上过拟合、读学习曲线、监控梯度;
  • 超参数搜索:为什么随机搜索常常比网格搜索好;
  • 随机种子带来的波动有多大,怎样判断一个改进是真的还是运气。

本讲的实验里已经出现过这些问题的影子:第 11.2 节的结果和我的预期不一致、多个随机种子的结果有差别——下一讲会系统地讨论怎样面对它们。

← 返回课程目录