本文是《Transformer 与 LLM:结构、实现与算量》系列的第 1 篇(共十四篇)。下一篇:一个 token 的旅程——训练侧与推理侧。
2017 年之后,几乎所有你听说过的大模型——GPT、Llama、Qwen、DeepSeek、Claude、Gemini——用的都是同一种结构:Transformer。它取代了在它之前统治序列建模十年的循环网络(L3 第六篇讲了为什么),之后八年结构上只做了修补,没有被替换。所以”看懂一个大模型的内部”这件事,其实只需要看懂一种结构。
这一篇讲静态线:把一个 decoder-only Transformer 里的每一个方框打开,说清它是什么、为什么要有它、里面的数怎么算。全篇用一个 4 维、3 个 token 的玩具例子把每一步手算出来,再用 GPT-2 small(1.24 亿参数,2019 年)的真实数字对照——真实模型只是把 4 换成 768、把 3 换成 1024,公式一个字都不变。读完这一篇,你应该能对着任何一个模型的 config.json 画出它的结构图,并且说出每个部件在做什么;下一篇再讲一个 token 怎么在训练和推理时流过这些方框(动态线),第三篇把这两篇画的图变成 300 行代码。
本篇要回答的核心问题是:
一个 token 的编号进入模型,到词表上的一个概率分布出来,中间经过了哪些运算?每一个运算为什么必须在那里——去掉它模型会失去什么?1
一、总览:从一句话到下一个 token
1. 模型在做的唯一一件事
先把”大模型”这个词祛魅:一个语言模型做的事只有一件——给定前面的 token,输出下一个 token 是词表里每一个词的概率(L0 第四篇讲过它为什么是一个条件分布)。输入是一串整数(token 编号),输出是一张长度为词表大小 \(V\) 的概率表。Transformer 就是从这串整数到那张概率表之间的那台机器。
%%{init: {"flowchart": {"wrappingWidth": 460}}}%%
%% 图:decoder-only Transformer 的整体结构,以 GPT-2 small 的数字标注:token 编号查 embedding 表变成 768 维向量、加上位置信息、经过 12 个相同的 block(每个 block = attention 子层 + FFN 子层,各带残差和 LayerNorm)、最后一次 LayerNorm、lm_head 投影到 50257 个词的分数
flowchart TB
IN["输入:token 编号序列<br/>[The, cat, sat, on, the] → [464, 3797, 3332, 319, 262]"]
IN --> EMB["token embedding:查表<br/>50257 × 768 的表,每个编号取一行 → 5 个 768 维向量"]
EMB --> POS["+ 位置 embedding:查另一张表<br/>1024 × 768,第 i 个位置取第 i 行,逐元素相加"]
POS --> B
subgraph B["× 12 个相同的 block(各自的参数)"]
direction TB
A["attention 子层:LayerNorm → 多头 causal self-attention → 加回输入<br/>token 之间唯一的交流处:每个 token 看它左边的 token"]
F["FFN 子层:LayerNorm → 768 → 3072 → GELU → 768 → 加回输入<br/>每个 token 各自变换,token 之间互不影响"]
A --> F
end
B --> LNF["最后一次 LayerNorm"]
LNF --> HEAD["lm_head:768 × 50257 的矩阵<br/>每个位置的 768 维向量 → 词表上 50257 个分数(logits)"]
HEAD --> OUT["softmax → 概率<br/>第 5 个位置:p(下一个 token = mat) = 0.31,= floor 0.12 ……"]
图里每个方框的输入和输出都是”若干个 768 维向量”——一个 token 一个向量,从头到尾形状不变(\([T, d]\),\(T\) 是 token 数,\(d\) 是隐藏维度,GPT-2 small 的 \(d = 768\))。这是 Transformer 能”一层层叠”的前提,也是读结构图时最重要的一条线索:任何一个方框,问它的输入输出形状是什么,就知道它在干什么。
2. 五种部件
数一数图里有几种不同的东西:
| 部件 | 做什么 | 有没有参数 | 章 |
|---|---|---|---|
| token embedding | 整数编号 → 向量(查表) | 有:\(V \times d\) 的表 | 二 |
| 位置 embedding | 告诉模型”这是第几个 token” | 有(GPT-2)/ 无(RoPE) | 二 |
| attention 子层 | 每个 token 看别的 token、把有用的信息加到自己身上 | 有:四个 \(d \times d\) 的矩阵 | 三 |
| FFN 子层 | 每个 token 各自过一个两层的小网络 | 有:两个矩阵,\(d \times 4d\) 与 \(4d \times d\) | 四 |
| 残差连接 + LayerNorm | 让几十层能训得动 | LayerNorm 有 \(2d\) 个;残差没有 | 五 |
| lm_head | 向量 → 词表上的分数 | 有:\(d \times V\),常与 embedding 表共用 | 六 |
只有五种运算,其中 attention 是唯一让 token 之间交流的地方,其他四种都是”每个 token 各自算”。这是理解 Transformer 的第一把钥匙:把它想成 \(T\) 条平行的流水线,只在 attention 那一站互相传递信息。
3. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 从字到向量 | embedding 查表;attention 不知道顺序(换序实验);两种给位置的方法 |
| 三 | Attention | 为什么需要;Q / K / V 三个投影;d = 4 的六步手算;为什么除 √d、为什么 mask;多头;GPT-2 真实的 attention 图 |
| 四 | FFN | 为什么 attention 之后还要它;GELU 与 4d;知识存在哪 |
| 五 | 残差与 LayerNorm | 深了为什么训不动;LayerNorm 手算;pre-norm 与 post-norm |
| 六 | 叠起来 | 一个 block 的数据流;lm_head 与权重共享;GPT-2 small 的 1.24 亿参数逐项数出来 |
| 七 | 与 d2l 10.7 的 encoder-decoder 对照 | cross-attention 去哪了;为什么 GPT 只留 decoder |
| 八 | 本文小结 | |
| 九 | 自测 | 六道题 |
二、从字到向量:embedding 与位置
1. 查表
模型看到的不是字,是 token 编号(L0 第一篇;分词器怎么切在预训练系列第一篇)。GPT-2 的词表有 50257 个 token,”The cat sat on the” 被切成 [464, 3797, 3332, 319, 262]。
第一步是把每个整数变成一个向量。做法简单到令人失望:查表。有一张 \(50257 \times 768\) 的表(wte,word token embedding),编号 464 就取第 464 行,得到一个 768 维向量。这张表的每一行都是可训练参数——训练结束时,意思相近的词(cat / dog / kitten)的行会靠得很近,这不是设计出来的,是训练”顺便”学出来的。
为什么不直接把编号当数用?因为编号是任意的:464 和 465 之间没有任何关系。查表等价于先做 one-hot(一个 50257 维、只有第 464 位是 1 的向量)再乘一个矩阵,但没人真的去乘——取一行就够了,所以 embedding 不算矩阵乘法的 FLOPs(第十篇算账时单列)。
2. attention 不知道顺序
接下来要说一件容易被忽略、但决定了整个结构的事:下一章的 attention 完全不知道 token 的顺序。 它把输入当成一个集合,而不是一个序列。用下一章要用的玩具模型做个实验——把三个输入 token 的顺序打乱(原来是 t0、t1、t2,打乱成 t2、t0、t1),看 attention 的输出:
| token 顺序 | attention 输出(去掉 mask) | |
|---|---|---|
| 原顺序 | t0, t1, t2 | (0.92, 0.43, 0.57, 0.08) · (0.43, 0.92, 0.08, 0.57) · (0.73, 0.73, 0.27, 0.27) |
| 打乱后 | t2, t0, t1 | (0.73, 0.73, 0.27, 0.27) · (0.92, 0.43, 0.57, 0.08) · (0.43, 0.92, 0.08, 0.57) |
输出集合完全相同,只是跟着输入换了位置。也就是说,对 attention 来说,”猫 追 狗”和”狗 追 猫”是同一个输入。这显然不行——语言的意思依赖顺序。所以必须另外把位置信息喂进去,这就是”位置编码”这个部件存在的全部理由。
3. 两种给位置的方法
方法一:再查一张表。 GPT-2 的做法:另有一张 \(1024 \times 768\) 的表(wpe,position embedding),第 \(i\) 个位置取第 \(i\) 行,逐元素加到 token 向量上。第 0 个位置的 cat 和第 7 个位置的 cat 于是变成两个不同的向量,attention 就能区分它们了。这张表也是训练学出来的;代价是它只有 1024 行——训练时没见过第 1025 个位置,推理时就不能超过 1024 个 token(GPT-2 的上下文上限就是这么来的)。
方法二:不加向量,转角度。 Llama 之后的模型用 RoPE(旋转位置编码):不改输入向量,而是在 attention 算 \(q \cdot k\) 之前,把 \(q\)、\(k\) 按各自的位置旋转一个角度(L0 第三篇的正交矩阵),使得两者的内积只依赖位置差。它不需要那张表、外推到更长上下文也更自然。第七篇专门讲它;本篇的玩具例子和 GPT-2 都用方法一。
两种方法回答的是同一个问题:attention 是集合运算,顺序必须显式地喂给它。
三、Attention:让 token 互相看
1. 为什么需要它
看这句话:The cat sat on the mat because it was tired. 要预测 “tired” 后面的词,模型得知道 “it” 指的是 cat 而不是 mat——这个信息在 7 个 token 之前。任何一个 token 单独看自己的向量都不够,它需要看别的 token,而且要知道该看谁、看多少。
attention 就是这个”看”的机制:对每个 token,算出它对句子里每个其他 token 的关注权重(一组和为 1 的数),然后按权重把那些 token 的向量加权平均、加到自己身上。这不是比喻——GPT-2 small 内部真的在这么做。下面是它处理这句话时两个真实 attention 头的权重(第 4 层,模型自己学出来的,没有人告诉它 it 指 cat):
左边那个头学会了”看上一个词”——每一行的权重几乎全部落在对角线左下一格;右边那个头把句子里大部分 token 的注意力都指向主语 cat,”it” 那一行给 cat 的权重是 0.84。两张图都是下三角形:右上角全是 0,因为每个 token 只能看它左边的 token(本章第 5 节讲为什么)。
有了这张图,attention 的三个问题就具体了:权重怎么算出来的(第 2–4 节)、为什么只能看左边(第 5 节)、为什么一个头不够要 12 个头(第 6 节)。
2. Q、K、V:三个投影
“该看谁、看多少”要有个打分的办法。最朴素的想法是用两个 token 向量的内积(L0 第二篇:内积大 = 方向接近)当分数。但这有个毛病:一个 token”想找什么”和它”是什么”通常不是一回事——”it” 想找的是一个名词,它自己却是个代词;用同一个向量既当问题又当答案,分数就只会奖励”和我长得像的”。
所以 attention 给每个 token 算出三个不同的向量,各用一个可训练的矩阵从 \(x\) 投影出来:
| 名字 | 公式 | 比喻 | 用来 |
|---|---|---|---|
| query \(q = x W_Q\) | 查询 | “我在找什么” | 当打分的一方 |
| key \(k = x W_K\) | 键 | “我是什么、我能被怎么找到” | 被打分的一方 |
| value \(v = x W_V\) | 值 | “被选中后我提供什么内容” | 被加权求和的一方 |
分数是 \(q_i \cdot k_j\):第 \(i\) 个 token 的问题与第 \(j\) 个 token 的答案对得上多少。三个矩阵 \(W_Q, W_K, W_V\) 都是 \(d \times d\)(GPT-2 small:\(768 \times 768\)),是这个子层的主要参数。训练时模型会把 \(W_Q\) 调成”把代词投影到’我要找名词’的方向”、把 \(W_K\) 调成”把名词投影到’我是名词’的方向”——上面右图那个头就是这么来的。
3. 六步手算:d = 4、T = 3
把公式写出来只有一行:
\[\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^T}{\sqrt{d}} + M\right) V\]\(M\) 是 mask(第 5 节)。这行公式里每个符号都对应下面六步里的一步。取 \(d = 4\)、三个 token,投影矩阵用手写的小整数(让算出来的数能口算),\(W_V\) 取单位矩阵(这样 \(V = x\),加权求和后一眼能看出”混合了谁”):
逐步读:
- 投影:\(Q = xW_Q\)、\(K = xW_K\)、\(V = xW_V\),三个都是 \([3, 4]\)——每个 token 一行,三种角色各一份。
- 打分 \(S = QK^T\),形状 \([3, 3]\):\(S_{ij} = q_i \cdot k_j\)。第一行 \((4, 0, 3)\) 是 t0 的 query 与三个 key 的内积——t0 与自己对得最好(4),与 t1 完全不对(0)。这是 L0 第一篇说的”矩阵乘法的第三种看法:相似度表”。
- 缩放:除以 \(\sqrt{d} = 2\),得 \((2.0, 0.0, 1.5)\)。为什么要除,下一节。
- mask:右上角(t0 看 t1、t2,t1 看 t2)填 \(-\infty\)。
- softmax(L0 第五篇)逐行:\(e^{-\infty} = 0\),所以被 mask 的位置权重恰好为 0;t2 那一行 \((0.5, 0.5, 1.0)\) 变成 \((0.27, 0.27, 0.45)\)——\(e^{1.0} / (e^{0.5} + e^{0.5} + e^{1.0}) = 2.72 / 6.02 = 0.45\)。每行和为 1。
- 加权求和 \(\text{out} = PV\):t2 的输出 \(= 0.27 \cdot v_0 + 0.27 \cdot v_1 + 0.45 \cdot v_2 = (0.73, 0.73, 0.27, 0.27)\)。t0 只能看自己,输出就是 \(v_0\)。
输出形状 \([3, 4]\) 与输入相同——所以它能被加回输入(第五章的残差),也能一层层叠。这段手算在配套脚本里与 PyTorch 的 F.scaled_dot_product_attention(is_causal=True) 对拍,最大差 \(6 \times 10^{-8}\):
import math, torch, torch.nn.functional as F
x = torch.tensor([[1., 0., 1., 0.], [0., 1., 0., 1.], [1., 1., 0., 0.]]) # 3 个 token,d = 4
W_Q = torch.tensor([[1., 0., 0., 0.], [0., 1., 0., 0.], [1., 0., 1., 0.], [0., 1., 0., 1.]])
W_K = torch.tensor([[1., 0., 1., 0.], [0., 1., 0., 1.], [0., 0., 1., 0.], [0., 0., 0., 1.]])
W_V = torch.eye(4)
Q, K, V = x @ W_Q, x @ W_K, x @ W_V # ① 三个投影,各 [3, 4]
S = Q @ K.T # ② 分数表 [3, 3]
S = S / math.sqrt(4) # ③ 除以 √d
mask = torch.tril(torch.ones(3, 3)).bool() # ④ 下三角为 True
S = S.masked_fill(~mask, float("-inf")) # 右上角填 −∞
P = F.softmax(S, dim=-1) # ⑤ 逐行 softmax → 权重
out = P @ V # ⑥ 加权求和 [3, 4]
ref = F.scaled_dot_product_attention(Q[None, None], K[None, None], V[None, None], is_causal=True)[0, 0]
print((out - ref).abs().max()) # tensor(5.9605e-08)
F.scaled_dot_product_attention 是 PyTorch 2.0 起的内置函数,把 ②–⑥ 合成一个 kernel(Infra 地图 GPU Kernel 系列讲 FlashAttention 怎么做到不写出那张 \([T, T]\) 的表);第三篇的 nanoGPT 两条路径都有。
4. 为什么除以 √d
\(q \cdot k\) 是 \(d\) 项相加。\(d\) 越大,和的典型大小越大——各项独立、均值 0、方差 1 时,和的标准差正好是 \(\sqrt{d}\)(L0 第四篇用方差算过)。用随机向量实测:
| \(d\) | \(q \cdot k\) 的标准差 | \(\sqrt{d}\) | 除以 \(\sqrt{d}\) 后 |
|---|---|---|---|
| 4 | 1.99 | 2.00 | 1.00 |
| 64 | 8.01 | 8.00 | 1.00 |
| 128 | 11.33 | 11.31 | 1.00 |
| 1024 | 32.01 | 32.00 | 1.00 |
不除会怎样:\(d = 128\) 时分数的标准差是 11,随便两个 token 的分数就能差出 10 分以上,softmax 后是 0.99995 : 0.00005——一个 token 独占全部权重,其他全被忽略,而且这种极端概率对输入的微小变化几乎没有响应(梯度接近 0,L0 第五篇),训不动。除以 \(\sqrt{d}\) 把分数拉回”有区分度但不饱和”的区间。原论文把这个版本叫 scaled dot-product attention,”scaled” 就是指这一步。
5. 为什么 mask:只能看左边
mask 把”看未来的 token”的分数设成 \(-\infty\),softmax 后权重为 0。为什么要禁止看未来?因为模型的任务是预测下一个 token:如果算第 3 个位置的输出时允许看到第 4 个 token,模型直接把它抄过来就是标准答案,什么都学不到。
更深一层的原因在下一篇展开,这里先说结论:训练时一句话的 \(T\) 个位置同时各预测自己的下一个 token(一次前向算 \(T\) 个 loss),mask 保证第 \(i\) 个位置只用了前 \(i\) 个 token 的信息,这样训练时的每个位置和推理时”只有前文”的情形完全一致。这也是它叫 causal(因果)attention、模型叫 decoder-only 的原因:只往一个方向看。第二章那个”换序实验”里去掉了 mask,所以输出严格只是换位置;加上 mask 之后,位置就有了”先后”的意义——但 mask 只告诉模型”谁在我左边”,不告诉它”谁在我左边第几个”,位置编码仍然不可少。
6. 多头:12 个头各看各的
第 1 节那两张热力图来自同一层的两个不同的头:一个看上一个词,一个看主语。如果这一层只有一套 \(W_Q, W_K, W_V\),它只能学出一种”该看谁”的模式;语言里同时存在很多种关系(上一个词、指代、主谓、句首……),所以让一层里有 \(h\) 套独立的投影,各算一张自己的权重表——这就是多头(multi-head)。
实现上不是把 \(d\) 维的向量复制 \(h\) 份,而是切成 \(h\) 段:GPT-2 small 的 768 维切成 12 个头、每头 64 维(\(d_h = d / h\))。每个头在自己的 64 维上做上一节的六步,得到一个 \([T, 64]\) 的输出,12 个拼回 \([T, 768]\),再过一个 \(W_O\)(\(768 \times 768\))把各头的结果混合。形状变化在 L0 第一篇画过:
两件常被问的事:
- 多头不增加算量。 \(h\) 个 \([T, d_h]\) 的小乘法加起来与一个 \([T, d]\) 的大乘法 FLOPs 相同(\(h \times 2T^2 d_h = 2T^2 d\));多出来的只是 \(W_O\)。
- 为什么每头只有 64 维还够用? 每个头只需要判断一种关系,64 维足够表达”是不是名词”“是不是上一个词”这类问题;真实模型从 GPT-2 到 Llama-3 都保持 \(d_h = 64\)–\(128\),加大的是头数与层数。
7. 这一子层的参数
\(W_Q, W_K, W_V, W_O\) 四个 \(d \times d\) 矩阵(GPT-2 还各带一个 \(d\) 维 bias),GPT-2 small 一层 \(4 \times 768^2 + \ldots \approx 2.36\)M。注意 attention 本身——打分、softmax、加权求和——没有任何参数:它是一套固定的运算,”学”全发生在四个投影矩阵里。Llama 之后的模型把 \(W_K, W_V\) 缩小(GQA,第六篇)、把 bias 去掉,但四个矩阵的角色没变。
四、FFN:让每个 token 自己想一想
1. attention 之后为什么还要一步
回头看第三章第 3 节的第 ⑥ 步:输出是 \(V\) 的加权平均。加权平均是线性运算——不管权重多聪明,输出永远在输入向量张成的空间里,不能”算出”新东西。而且到这一步为止整个模型都是线性的(查表、加法、矩阵乘),L3 第一篇说过:没有非线性,一百层等于一层。
所以每个 block 的第二个子层是一个小小的两层神经网络,每个 token 各自过一遍(token 之间完全不交流,所以叫 position-wise / 逐位置 FFN):
\[\text{FFN}(x) = \text{GELU}(x W_1 + b_1)\, W_2 + b_2, \qquad W_1 \in \mathbb{R}^{d \times 4d},\; W_2 \in \mathbb{R}^{4d \times d}\]先把 768 维放大到 3072 维,过一个非线性函数,再压回 768 维。GELU 是 ReLU 的平滑版本(L3 第一篇),负半轴不是硬截到 0 而是缓缓压到 0,GPT-2 起成为标配。”4 倍”是原论文的经验选择,之后被沿用;Llama 换成三个矩阵的 SwiGLU、宽度改为约 2.7 倍(第五篇讲 14336 怎么来的),但”放大 → 非线性 → 压回”的形状没变。
2. 它在一层里占了三分之二
一层的参数数一数:attention 四个矩阵 \(4d^2\),FFN 两个矩阵 \(8d^2\)——FFN 占一层参数的三分之二(GPT-2 small:4.72M 对 2.36M)。这个比例在 Llama 上更高(约 80%,第五篇的表)。所以”大模型的参数主要在 attention 里”是个常见误解;attention 负责决定看谁,真正的”存储”在 FFN。
3. 知识存在哪
有一个有用的直觉:把 \(W_1\) 的 3072 列看成 3072 个”探测器”,每个探测器问输入向量一个问题(”这是不是在讲一种动物?”“前面是不是出现了 Paris?”),GELU 决定答”是”的强度,\(W_2\) 再把答”是”的探测器对应的”回答向量”加起来。可解释性研究(Geva 等,2021 起)确实在真实模型的 FFN 里找到了这种 key–value 结构:某些神经元专门在特定主题出现时激活,并把对应的词推向输出。这也是为什么”往模型里塞知识”(预训练)主要涨的是 FFN,而 MoE(第八篇)选择把 FFN 复制成多个专家而不是复制 attention。
五、残差与 LayerNorm:让几十层能训
1. 残差流
第三、四章的两个子层都不是”输入进去、输出出来”,而是把输出加回输入:
\[x \leftarrow x + \text{Attention}(\text{LN}(x)), \qquad x \leftarrow x + \text{FFN}(\text{LN}(x))\]这就是残差连接(L3 第二篇讲了它的数学:让梯度能沿着”+”直接传回去,深网络才训得动)。一个有用的读法:把那条从 embedding 一直通到 lm_head 的 \([T, d]\) 主干叫残差流(residual stream),每个子层都是从主干上读一份、算出一个”修正量”、再加回主干。12 层 = 24 次修正。GPT-2 small 的一个 token 从进到出,它的 768 维向量被修正了 24 次,每次修正量都比主干本身小得多——这也是为什么 Transformer 能到 100 层以上而 RNN 不行。
2. LayerNorm 做什么
每个子层读主干之前先做一次 LayerNorm(层归一化):把一个 token 的 \(d\) 维向量减去自己的均值、除以自己的标准差,再乘一个可学习的缩放 \(\gamma\)、加一个偏移 \(\beta\)(各 \(d\) 个参数)。用一个 4 维向量手算:
\[x = (2, 4, 4, 6) \;\to\; \mu = 4,\; \sigma^2 = 2 \;\to\; \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} = (-1.41,\, 0,\, 0,\, 1.41) \;\to\; \gamma \odot (\cdot) + \beta\]它解决的问题是尺度:残差流上 24 次相加,向量的数值会越来越大,直接喂给 attention 会让 \(q \cdot k\) 的分数失控(第三章第 4 节那个问题的另一个来源);LayerNorm 保证每个子层看到的输入都在同一个尺度上。注意它是每个 token 自己归一化,不跨 token、也不跨 batch——这是它与 CNN 里 BatchNorm 的区别,也是它在变长序列上好用的原因。Llama 换成 RMSNorm(不减均值,只除均方根,省一次运算,第五篇),作用相同。
3. pre-norm 与 post-norm
LayerNorm 放在子层之前(上面的公式,GPT-2 起的做法,叫 pre-norm)还是之后(原论文 2017 与 d2l 10.7 画的是 \(\text{LN}(x + \text{Sublayer}(x))\),叫 post-norm)?两者数学上不等价:pre-norm 下残差流本身不经过归一化,梯度有一条干净的直通路;post-norm 下每层输出都被重新归一化,深了以后需要 warmup 和小学习率才不发散(Xiong 等,2020)。现代大模型几乎全部用 pre-norm,代价是最后要多加一次 ln_f(图 1 里”最后一次 LayerNorm”那个框),否则残差流的尺度直接进 lm_head。
六、把它们叠起来
1. 一个 block
%%{init: {"flowchart": {"wrappingWidth": 360}}}%%
%% 图:一个 Transformer block 的数据流:残差流 x 进入,先 LayerNorm → 多头 causal self-attention → 加回 x,再 LayerNorm → FFN(768 → 3072 → GELU → 768)→ 加回 x;两条虚线是残差,输出形状与输入相同
flowchart TB
X["x [T, 768] 残差流"] --> LN1["LayerNorm"]
LN1 --> ATT["多头 causal self-attention<br/>12 头 × 64 维 → 拼接 → W_O"]
ATT --> ADD1(("+"))
X -. "残差" .-> ADD1
ADD1 --> H["x' [T, 768]"]
H --> LN2["LayerNorm"]
LN2 --> FFN["FFN:768 → 3072 → GELU → 768"]
FFN --> ADD2(("+"))
H -. "残差" .-> ADD2
ADD2 --> Y["下一个 block 的 x [T, 768]"]
这个图就是第三篇里 nanoGPT Block.forward 的两行代码:
x = x + self.attn(self.ln_1(x))
x = x + self.mlp(self.ln_2(x))
12 个这样的 block 串起来,各有自己的参数(\(12 \times 7.09\)M)。”层数”(n_layer)指的就是 block 的个数。
2. 输出层与权重共享
最后一个 block 出来的 \([T, 768]\) 过 ln_f,再乘 lm_head(\(768 \times 50257\))得到每个位置在词表上的 50257 个分数(logits),softmax 之后就是”下一个 token 是谁”的概率。
GPT-2 的 lm_head 直接复用 embedding 表的转置(tie weights):查表是”编号 → 向量”,lm_head 是”向量 → 每个编号的分数”,用同一张表做两件事既省了 3860 万参数,又让”输入端相近的词在输出端也相近”。大模型(Llama-3-70B)通常不共享,因为 embedding 那点参数相对总量已经不重要(第五篇)。
3. GPT-2 small 的 1.24 亿参数
把全文的部件加起来,就是 GPT-2 small 的参数量:
| 部件 | 形状 | 参数量 |
|---|---|---|
token embedding wte |
50257 × 768 | 38,597,376 |
位置 embedding wpe |
1024 × 768 | 786,432 |
| 每层 attention(\(W_Q, W_K, W_V\) 合成一个 768 × 2304,加 \(W_O\),各带 bias) | 768 × 2304 + 2304 + 768 × 768 + 768 | 2,362,368 |
| 每层 FFN | 768 × 3072 + 3072 + 3072 × 768 + 768 | 4,722,432 |
| 每层两个 LayerNorm | 2 × (768 + 768) | 3,072 |
| 一层合计 | 7,087,872 | |
| 12 层 | 85,054,464 | |
ln_f |
768 + 768 | 1,536 |
| lm_head | 与 wte 共享 |
0 |
| 总计 | 124,439,808 |
两个观察:embedding 在这个小模型里占了将近三分之一,模型越大这一项占比越小(Llama-3-8B 是 13%,70B 是 1.5%);12 层里三分之二的参数在 FFN。第三篇会用 model.get_num_params() 把这个数打印出来(nanoGPT 默认不计 wpe,报 123.65M),第五篇把同一套算法用到 Llama 上。
七、与 d2l 10.7 的 encoder-decoder 对照
1. 原始 Transformer 是两半
《动手学深度学习》10.7 节和 2017 年的原论文画的 Transformer 有两半:左边一个 encoder 读入源句子(比如英文),右边一个 decoder 生成目标句子(比如中文)——它是为机器翻译设计的。
%% 图:原始 Transformer(encoder-decoder)与 GPT(decoder-only)的对照:encoder 的 self-attention 双向、无 mask;decoder 每个 block 多一个 cross-attention 子层去读 encoder 的输出;GPT 去掉整个 encoder 与 cross-attention,只保留带 causal mask 的 decoder
flowchart LR
subgraph ED["原始 Transformer / d2l 10.7:encoder-decoder"]
direction LR
subgraph E["encoder × N"]
direction TB
E1["双向 self-attention<br/>(无 mask,每个词看整句)"] --> E2["FFN"]
end
subgraph D["decoder × N"]
direction TB
D1["causal self-attention<br/>(有 mask)"] --> D2["cross-attention<br/>query 来自 decoder,key / value 来自 encoder 输出"] --> D3["FFN"]
end
E --> D2
end
subgraph G["GPT / Llama:decoder-only"]
direction TB
G1["causal self-attention"] --> G2["FFN"]
end
对照本文讲的结构,有三处不同:
| encoder | 原始 decoder | GPT 的 decoder-only | |
|---|---|---|---|
| self-attention 的 mask | 无:每个词可以看整句(双向) | 有:只看左边 | 有:只看左边 |
| cross-attention 子层 | 无 | 有:query 是自己,key / value 是 encoder 的输出 | 无 |
| 位置编码 | 固定的正弦函数 | 同 | GPT-2 学习式表 / Llama RoPE |
| 归一化位置 | post-norm | post-norm | pre-norm |
2. cross-attention 去哪了
原始 decoder 每个 block 有三个子层:causal self-attention、cross-attention、FFN。cross-attention 与第三章的运算完全相同,只是 \(Q\) 来自 decoder 自己的 token、\(K, V\) 来自 encoder 的输出——”翻译到这里该看原文的哪个词”。GPT 把 encoder 整个去掉,cross-attention 自然也没了:要参考的内容直接拼在输入前面(prompt),用 self-attention 去看它。”翻译 → 输入英文,输出中文”变成”输入’英文 + 请翻译成中文:’,续写中文”。
3. 为什么 GPT 只留 decoder
2018–2019 年三条路都有人走:只留 encoder(BERT,双向,适合分类 / 理解)、只留 decoder(GPT,单向,适合生成)、两半都留(T5、BART)。decoder-only 最后胜出的原因:
- 一个目标做一切:next-token prediction 既是预训练目标又是使用方式,不需要为下游任务改结构;理解类任务也能变成生成(”这段话的情感是:正面 / 负面”)。
- 训练效率:causal mask 让一句话的 \(T\) 个位置同时提供 \(T\) 个训练信号(下一篇第二章),而 encoder-decoder 只在 decoder 侧有信号。
- KV cache(下一篇第三章):单向结构让推理时前面 token 的中间结果可以缓存复用,双向结构做不到。
所以本系列只讲 decoder-only;d2l 10.7 里 encoder 那一半,读者知道它就是”不加 mask 的第三章”即可。多模态模型里的 vision encoder(第十三篇)是 encoder 这一半在今天的主要去处。
八、本文小结
- 一个 decoder-only Transformer 只有五种运算:embedding 查表、attention、FFN、残差 + LayerNorm、lm_head;从头到尾每个方框的输入输出都是 \([T, d]\) 的向量,所以能一层层叠。
- attention 是唯一让 token 之间交流的地方:每个 token 用 query 与所有 key 打分(\(QK^T\)),除 \(\sqrt{d}\) 防止 softmax 饱和,mask 禁止看未来,softmax 得权重,加权求和 value。\(d = 4\)、\(T = 3\) 的六步手算与 PyTorch 对拍差 \(6 \times 10^{-8}\);GPT-2 真实的头学出了”看上一个词”与”it 指回 cat”。
- attention 不知道顺序(换序实验:输出只是跟着换位置),所以必须另加位置信息:GPT-2 查一张位置表,Llama 用 RoPE 转角度。
- FFN 是每个 token 各自过的两层小网络,提供非线性,占一层参数的三分之二,知识主要存在这里。
- 残差流让 24 次修正能训得动,LayerNorm 让每个子层看到同一尺度的输入;现代模型用 pre-norm,代价是末尾多一次
ln_f。 - GPT-2 small:12 层 × 7.09M + embedding 38.6M + 位置表 0.79M = 1.244 亿,lm_head 与 embedding 共享。
- 原始 Transformer 是 encoder-decoder;GPT 去掉 encoder 与 cross-attention,把”要参考的内容”拼进输入用 self-attention 看——一个目标、更高的训练效率、可缓存的推理,让 decoder-only 成为今天所有 LLM 的形状。
配套脚本 attention_by_hand.py(ai-learning-labs/transformer-and-llm)打印本文六步手算的每一个中间矩阵、对拍 PyTorch、做换序实验、测 \(\sqrt{d}\) 表;tools/gen_gpt2_attention_heatmap.py 生成第三章那张 GPT-2 真实 attention 图。
九、自测
-
一个 block 的输入是 \([T, d]\),输出是什么形状?为什么必须相同?
-
用第三章的玩具模型:t1 的 query 是 \((0, 2, 0, 1)\),三个 key 是 \((1, 0, 2, 0)\)、\((0, 1, 0, 2)\)、\((1, 1, 1, 1)\)。算出 t1 那一行 mask 后的 softmax 权重。
答案
内积:\(0, 4, 3\);除 \(\sqrt 4 = 2\):\(0, 2, 1.5\);mask 掉 t2:\((0, 2, -\infty)\);softmax:\(e^0 / (e^0 + e^2) = 1 / (1 + 7.39) = 0.12\),\(e^2 / 8.39 = 0.88\),0。与手算图第 ⑤ 步第二行一致。
-
把位置 embedding 那张表删掉,模型还能训吗?会失去什么?
答案
能训、会收敛,但它分不清”猫追狗”和”狗追猫”——attention 是集合运算,第二章第 2 节的换序实验说明输出只随输入换位置。causal mask 给了”左右”的信息,但不给”距离”,所以位置信息仍然必须显式提供。(有趣的是带 causal mask 的 decoder 在没有位置编码时也能学到一点位置信息——通过”能看到几个 token”间接推断——但远不如显式编码。)
-
为什么 FFN 占一层参数的三分之二,而不是 attention?
答案
attention 四个 \(d \times d\) 矩阵共 \(4d^2\);FFN 是 \(d \times 4d\) 加 \(4d \times d\) 共 \(8d^2\)。attention 本身(打分、softmax、加权求和)没有参数,参数只在投影矩阵里。见第四章第 2 节。
-
GPT-2 的上下文上限是 1024 个 token,这个限制来自哪个部件?Llama 为什么没有同样的硬上限?
答案
来自位置 embedding 表只有 1024 行——第 1025 个位置没有对应的向量。Llama 用 RoPE,位置是一个角度而不是查表,任何位置都能算(能不能算得好是另一回事,第七篇讲外推)。见第二章第 3 节。
-
原始 Transformer 的 decoder 有三个子层,GPT 只有两个,少了哪个?它的功能在 GPT 里由什么代替?
答案
少了 cross-attention(读 encoder 输出的那个)。GPT 把要参考的内容直接拼在输入前面(prompt),由 causal self-attention 去看——见第七章第 2 节。
下一篇
本篇画的是静态的结构。下一篇《一个 token 的旅程:训练侧与推理侧》让数据流过这些方框:训练时一句话的 \(T\) 个位置怎么同时算出 \(T\) 个 loss、反向传播沿哪条路走回来;推理时 prefill 与 decode 有什么不同、KV cache 为什么能让每一步只算一个 token。
- Transformer 长什么样——从一句话到下一个 token
- 一个 token 的旅程——训练侧与推理侧
- 手搓 GPT(上)——nanoGPT model.py 逐行解析
- 手搓 GPT(下)——nanoGPT train.py 与训一个会续写的模型
- Transformer 解剖与参数量
- Attention 变体与 KV cache
- 位置编码与长上下文
- MoE 的路由、激活参数量与通信形态
- MTP——改训练目标而不改主干的多 token 预测
- 前向的算量与访存量
- 浮点格式、数值稳定性与混合精度
- 量化、投机解码与 LoRA
- 多模态:vision encoder 的算量与 image token 的 KV 代价
- Transformer 与 LLM:系列总结与通关自测
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/transformer-architecture-from-a-sentence-to-the-next-token.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。