系列 《Transformer 原理与实现:从论文到手搓 GPT-2》 第 1 / 5 篇 进阶
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 4 / 14 个系列 · AI 算法工程师学习地图 第 5 / 12 个系列
- Transformer 长什么样——从一句话到下一个 token
- 一个 token 的旅程——训练侧与推理侧
- 手搓 GPT(上)——nanoGPT model.py 逐行解析
- 手搓 GPT(下)——nanoGPT train.py 与训一个会续写的模型
- Transformer 原理与实现:系列总结与通关自测
上一个系列:PyTorch 深度实践下一篇:一个 token 的旅程——训练侧与推理侧
本篇在系列中的位置。 《Transformer 原理与实现》系列的第一篇。先看本系列要拆解的 GPT-2 small 总览,再回到 2017 年的 encoder-decoder Transformer,比较三条路线,最后逐个打开 GPT-2 的部件;下一篇让一个 token 在这张图上动起来,第 03 篇再把它写成代码。完整地图见总纲。
先从一张 GPT-2 small 的 decoder-only 结构图开始,再回到 2017 年的《Attention Is All You Need》,看 encoder-decoder 如何为翻译而设计、后来又分出哪三条路线。随后逐个打开模型部件,说明它们做什么、为什么在那里。
本文讲静态结构:把每个方框打开,说清它是什么、为什么在那里、里面的数怎么算。全篇用一个 4 维、3 个 token 的玩具例子把每一步手算出来,再用 GPT-2 small 的真实数字对照。读完这一篇,你应该能对着模型的 config.json 画出结构图;下一篇《一个 token 的旅程:训练侧与推理侧》再讲一个 token 怎么在训练和推理时流过这些方框(动态线),本系列第三篇《手搓 GPT(上)——nanoGPT model.py 逐行解析》把这两篇画的图变成代码。
本篇要回答的核心问题是:
GPT-2 的 decoder-only 结构里,一个 token 的编号到词表上的概率分布要经过哪些运算?它与原始 Transformer 的 encoder-decoder 又有什么不同?1
术语与缩写
| 缩写 | 全称 | 一句话解释 |
|---|---|---|
| token | — | 文本切出的最小单位,每个对应一个整数编号 |
| GPT / GPT-2 / GPT-3 | Generative Pre-trained Transformer | OpenAI 的 decoder-only 模型系列,GPT-2 small 是本文蓝本 |
| encoder / decoder | — | encoder 读完整段输入产出表示;decoder 逐个生成输出 |
| decoder-only | — | 去掉 encoder、只留带 causal mask 的 decoder 的结构 |
| nanoGPT | — | Karpathy 写的极简 GPT 训练代码库 |
| embedding | — | 把 token 编号查表变成向量 |
| block | — | 一层 Transformer:attention 与 FFN 两个子层加残差 |
| LayerNorm | layer normalization | 把每个 token 的向量减均值、除标准差,统一尺度 |
| lm_head | language modeling head | 把最后的向量投影成词表上每个词的分数 |
| self-attention | — | 序列内部的 token 互相看的 attention |
| causal mask | — | 把「看未来 token」的分数设成 −∞ 的遮罩 |
| FFN | feed-forward network | 逐 token「放大 → GELU → 压回」的前馈子层 |
| Q / K / V | query / key / value | attention 里的查询、键、值三组向量 |
| RoPE | rotary position embedding | 按位置旋转 q、k 的位置编码,Llama 采用 |
| GELU | Gaussian error linear unit | ReLU 的平滑版激活函数,GPT-2 的 FFN 用它 |
| MLP | multi-layer perceptron | 多层感知机;在 Transformer 里就是 FFN 子层 |
| Llama | Large Language Model Meta AI | Meta 的开源模型系列,本文多处拿它对照 |
| FLOPs | floating point operations | 浮点运算次数,算力开销单位 |
| GQA | grouped-query attention | 多个 query 头共享一组 K、V,缩小 KV cache |
| SiLU | sigmoid linear unit | x·sigmoid(x) 形式的激活函数,Llama 的 FFN 用它 |
| cross-attention | — | decoder 用自己的 query 去读 encoder 输出的 attention |
| d2l | Dive into Deep Learning | 《动手学深度学习》,本文拿它的 10.7 节做术语对照 |
| encoder-only / encoder-decoder | — | 只用 encoder 的路线 / encoder 与 decoder 都用的路线 |
| one-hot | — | 只有一位是 1、其余全 0 的向量 |
| pre-norm / post-norm | — | LayerNorm 放在子层之前 / 之后 |
| head | — | 多头 attention 里各自一套投影的一个「头」 |
| Add & Norm | — | 原论文的「残差相加 + LayerNorm」 |
| BERT | Bidirectional Encoder Representations from Transformers | Google 2018 年的 encoder-only 模型 |
| T5 | Text-to-Text Transfer Transformer | Google 的 encoder-decoder 模型,把任务都写成文本到文本 |
| BART | Bidirectional and Auto-Regressive Transformers | Facebook 的 encoder-decoder 去噪预训练模型 |
| span corruption | — | T5 的预训练目标:遮掉连续片段让模型补出来 |
| LLM | large language model | 大语言模型 |
| Hugging Face | — | 托管模型权重、提供 transformers 库的平台 |
| SVD | singular value decomposition | 奇异值分解 |
| kernel | — | 在 GPU 上执行的一个计算函数 |
| FlashAttention | — | 分块计算 attention、不写出整张 T×T 权重表的 kernel |
| bf16 | bfloat16(brain floating point) | 16 位浮点格式,指数位与 FP32 相同 |
| HBM | high bandwidth memory | GPU 的显存,容量大但比片上存储慢 |
| SRAM | static random-access memory | GPU 片上的小而快的存储 |
| KV cache | key–value cache | 推理时缓存前面 token 的 K、V,后续步不用重算 |
| ReLU | rectified linear unit | 负半轴截成 0 的激活函数 |
| SwiGLU | Swish-gated linear unit | 带门控的 FFN 激活,Llama 用三个矩阵实现 |
| MoE | mixture of experts | 把 FFN 复制成多个专家、每个 token 只走其中几个 |
| RNN | recurrent neural network | 按时间步递推的循环神经网络 |
| CNN | convolutional neural network | 卷积神经网络 |
| BatchNorm | batch normalization | 跨 batch 统计均值方差的归一化,CNN 常用 |
| RMSNorm | root mean square layer normalization | 只除均方根、不减均值的 LayerNorm 简化版 |
| warmup | — | 训练初期把学习率从很小逐步升上去 |
| logits | — | softmax 之前每个词的原始分数 |
| prefill / decode | — | 推理时一次处理整段提示 / 每步生成一个新 token |
| BLEU | bilingual evaluation understudy | 机器翻译常用的自动评分指标 |
| NeurIPS | Conference on Neural Information Processing Systems | 机器学习顶级会议 |
一、总览:从一句话到下一个 token
本系列要拆解的是 decoder-only 结构,以 GPT-2 small 为蓝本。第二至四章回到 2017 年的论文,解释这条路线从哪里来;第五章起再逐个打开它的部件。
1. 模型在做的唯一一件事
先把”大模型”这个词祛魅:一个语言模型做的事只有一件——给定前面的 token,输出下一个 token 是词表里每一个词的概率(《算法工程师的数学(04):概率入门——语言模型是一个条件分布》讲过它为什么是一个条件分布)。输入是一串整数(token 编号),输出是一张长度为词表大小 \(V\) 的概率表。Transformer 就是从这串整数到那张概率表之间的那台机器。
图分左右两半,自下而上读:
- 左半是整台机器。最底下进来的是一串整数(token 编号);经过 token embedding 与位置 embedding 两次查表,变成每个 token 一个 768 维向量;然后进入 12 个结构完全相同、参数各自一份的 block;出来后做最后一次 LayerNorm(layer normalization,
ln_f),乘 lm_head(language modeling head)得到词表上 50257 个分数,softmax 变成概率。淡蓝底的框把一个 block展开:两个子层,每个子层都是”LayerNorm → 变换 → 加回原输入”,虚线就是那条加回去的残差(第九章)。 - 右半把 attention 子层放大。它是整张图里唯一让 token 之间交流的地方(第七章):同一个输入分别乘三个矩阵得到 Q、K、V(query、key、value),Q 与 K 打分、加 causal mask 只保留左边、softmax 成权重、按权重把 V 加起来;12 个头各自在 64 维上算完再拼成 768 维,最后乘 W_O。这一子层的全部参数就是四个 768 × 768 的矩阵。
图里每个方框的输入和输出都是”若干个 768 维向量”——一个 token 一个向量,从头到尾形状不变(\([T, d]\),\(T\) 是 token 数,\(d\) 是隐藏维度,GPT-2 small 的 \(d = 768\);只有最后 lm_head 把它变成 \([T, V]\))。这是 Transformer 能”一层层叠”的前提,也是读结构图时最重要的一条线索:任何一个方框,问它的输入输出形状是什么,就知道它在干什么。
2. 六种部件
数一数图里有几种不同的东西——六种:
| 部件 | 做什么 | 有没有参数 | 章 |
|---|---|---|---|
| token embedding | 整数编号 → 向量(查表) | 有:\(V \times d\) 的表 | 五 |
| 位置 embedding | 告诉模型”这是第几个 token” | 有(GPT-2)/ 无(RoPE) | 六 |
| attention 子层 | 每个 token 看别的 token、把有用的信息加到自己身上 | 有:四个 \(d \times d\) 的矩阵 | 七 |
| FFN 子层 | attention 只是对 \(V\) 加权平均(线性);FFN 逐 token「放大 → GELU → 压回」,给模型提供非线性,也是主要参数与知识的所在(第八章) | 有:两个矩阵,\(d \times 4d\) 与 \(4d \times d\) | 八 |
| 残差连接 + LayerNorm | 让几十层能训得动 | LayerNorm 有 \(2d\) 个;残差没有 | 九 |
| lm_head | 向量 → 词表上的分数 | 有:\(d \times V\),常与 embedding 表共用 | 十 |
六种部件里,attention 是唯一让 token 之间交流的地方,其他五种都是”每个 token 各自算”——两次查表、FFN(feed-forward network)、残差 + LayerNorm、lm_head 都只看自己这一行。这是理解 Transformer 的第一把钥匙:把它想成 \(T\) 条平行的流水线,只在 attention 那一站互相传递信息。
这张表和《算法工程师的数学》第一篇《向量、矩阵与形状》第六章的「七个权重矩阵」数的是同一台机器,只是切法不同。那一章只数一层 block 里带参数的矩阵:attention 四个(\(W_Q, W_K, W_V, W_O\))加 MLP(multi-layer perceptron)三个(Llama 的 \(W_{\text{gate}}, W_{\text{up}}, W_{\text{down}}\)),按形状规则算出 Llama-3-8B 一层 218M 参数、81% 在 MLP。本文数的是整台机器有几种不同的东西,所以多出了三样不在那七个里的部件——两张 embedding 表、LayerNorm 的 \(2d\) 个数、以及 lm_head。两张表对起来是这样:
| 本文的部件 | 对应「七个权重矩阵」里的哪几个 | GPT-2 small 里的实际矩阵(nanoGPT 命名) | 两个模型的差别 |
|---|---|---|---|
| token embedding | 不在七个里 | wte:50257 × 768 |
查表不是矩阵乘,那一章的 FLOPs 账里不算它 |
| 位置 embedding | 不在七个里 | wpe:1024 × 768 |
Llama 用 RoPE,没有这张表 |
| attention 子层 | \(W_Q, W_K, W_V, W_O\) 四个 |
|
GPT-2 的 K、V 与 Q 同宽;Llama-3 用 GQA,\(W_K, W_V\) 只有 4096 × 1024 |
| FFN 子层 | \(W_{\text{gate}}, W_{\text{up}}, W_{\text{down}}\) 三个 |
|
GPT-2 是两个矩阵夹一个 GELU;Llama 多一个门 \(W_{\text{gate}}\)、用 SiLU,所以是三个 |
| 残差连接 + LayerNorm | 不在七个里 | ln_1、ln_2 各 768 + 768 个数 |
残差没有参数;LayerNorm 只有缩放与偏置两个向量,不是矩阵 |
| lm_head | 不在七个里 | 与 wte 共享 |
Llama 不共享,单独一个 4096 × 128256 |
所以那一章的 \(QK^\top\)、\(\mathrm{softmax}(\cdot)V\) 是数据之间的运算、没有参数,这里的残差与 LayerNorm 同理;两边的数字也能互相印证——第十一章第 2 节数出 GPT-2 small 一层 7.09M 参数,几乎全在 c_attn、c_proj、c_fc、c_proj 这几个矩阵里,两个 LayerNorm 只占 3072 个;把形状换成 Llama-3-8B 的,就是那一章的 218M。
3. 本文的章节安排
第一章先立路线图;第二至四章回到论文,解释 GPT-2 这条路线的来历;第五至十章按上一节表里六种部件的顺序逐个展开。第十一章再把它们叠成一个 block、数出整台 GPT-2 small 的参数。
| 章 | 主题 | 内容 |
|---|---|---|
| 一 | 总览 | 从一句话到下一个 token |
| 二 | 原始 Transformer |
|
| 三 | 三条路线 |
|
| 四 | 为什么从 GPT-2 开始 |
|
| 五 | token embedding |
|
| 六 | 位置 embedding |
|
| 七 | attention 子层 |
|
| 八 | FFN 子层 |
|
| 九 | 残差连接与 LayerNorm |
|
| 十 | lm_head |
|
| 十一 | 把它们叠起来 |
|
| 十二 | 本文小结 | |
| 十三 | 自测 | 六道题 |
二、从论文出发:为机器翻译设计的 encoder-decoder
《Attention Is All You Need》提出 Transformer 时,研究对象是机器翻译这样的序列到序列任务:encoder 把源句子变成一串表示,decoder 逐步生成目标句子。论文的 base 配置在 encoder、decoder 各堆 \(N=6\) 层,\(d_{\text{model}}=512\)、\(h=8\) 个 attention heads、\(d_{\text{ff}}=2048\)。
encoder 每层由双向 self-attention + FFN组成,不加 causal mask,源句子的每个位置都能利用整句上下文。decoder 每层依次做因果 self-attention + cross-attention + FFN:因果 mask 保证预测目标位置时看不到未来;cross-attention 的 query 来自 decoder,key/value 读取 encoder 的输出。
图分三列,每列都自下而上:
- 左列 encoder 读整句英文,每个 block 是”双向 self-attention → Add & Norm → FFN → Add & Norm”。双向的意思是 self-attention 不加 mask,每个词能看到整句——它只负责”读懂”,不负责生成。
- 中列 decoder 逐词生成中文。它的 block 比 encoder 多一个子层:causal self-attention 之后先做一次 cross-attention——Q 来自 decoder 自己,K、V 来自 encoder 的输出(图中横过来的那条线),”翻译到这里该看原文的哪个词”;然后才是 FFN。
- 右列 GPT 是第一章总览的那台机器,第五章起会逐个拆开。红色虚线框住的两样东西被整个去掉:encoder 没了,cross-attention 自然也没了。剩下的 decoder block 只有 causal self-attention 与 FFN 两个子层,而且 LayerNorm 从子层之后(Add & Norm)挪到了子层之前(pre-norm,第九章)。
对照本文讲的结构,有四处不同:
| encoder | 原始 decoder | GPT 的 decoder-only | |
|---|---|---|---|
| self-attention 的 mask | 无:每个词可以看整句(双向) | 有:只看左边 | 有:只看左边 |
| cross-attention 子层 | 无 | 有:query 是自己,key / value 是 encoder 的输出 | 无 |
| 位置编码 | 固定的正弦函数 | 同 | GPT-2 学习式表 / Llama RoPE |
| 归一化位置 | post-norm | post-norm | pre-norm |
cross-attention 去哪了
decoder 的 cross-attention 不是让 decoder 再读一遍自己的前缀:它从 encoder 输出中取信息,因此可以将“已译出的目标前缀”与“输入源句”同时用于下一个词的预测。GPT-2 没有独立的 encoder 和源序列,因此没有这一子层;要参考的内容直接拼在输入前面(prompt),由 causal self-attention 读取。
d2l 10.7 的 encoder-decoder 对照
《动手学深度学习》10.7中的 encoder 对应图中左列的双向 self-attention 与 FFN;decoder 对应中列的因果 self-attention、读取 encoder 输出的 cross-attention 与 FFN。这里保留它作为术语对照;后文只在切换到 GPT-2 时说明这些结构如何取舍,不重复画第二张对照图。
三、三条路线:encoder-only、decoder-only 与 encoder-decoder
它们共享 Transformer block 这一基本构件,区别主要在可见范围、预训练目标,以及模型如何接收任务输入、产出结果。
| 路线 | 结构 | 训练目标 | 典型用途 | 年份 |
|---|---|---|---|---|
| encoder-only(BERT) | 双向 self-attention encoder | Masked Language Modeling(原论文还加入 Next Sentence Prediction) | 文本表示、分类、抽取式问答 | 2018 |
| decoder-only(GPT / GPT-2) | 带 causal mask 的 decoder,无 encoder 与 cross-attention | 从左到右的 next-token prediction | 续写、开放式生成,也可把理解任务写成提示后的生成 | 2018 / 2019 |
| encoder-decoder(T5 / BART) | 双向 encoder + 带 cross-attention 的 causal decoder | T5 的 span corruption 与 text-to-text;BART 的文本去噪重建 | 翻译、摘要等条件生成 | 2019 |
这张表描述的是几类代表模型及其原论文目标,不意味着某条路线只能做表中的一种任务。
四、为什么 decoder-only 成为主流,本系列为什么以 GPT-2 为蓝本
decoder-only 后来成为许多 LLM(large language model)的常见形态,可以从几种工程与建模上的便利来理解;这不是排除其他路线的定论。next-token prediction 既是预训练目标,也与逐步生成的使用方式一致;把任务说明和材料放进同一段 token 序列,就能用同一套模型接口处理续写、问答和工具调用。训练时仍可并行计算所有位置的表示;这不是 decoder-only 独有的优势,encoder-decoder 也能并行训练目标序列,差别在于 decoder 还通过 cross-attention 读取独立的源序列。
本系列以 GPT-2 为蓝本,是因为它结构完整又足够小:公开的 GPT-2 权重与配置可以用 Hugging Face 实现对拍;nanoGPT 的训练配方与代码适合在单张 GPU 上跑通从数据到生成的闭环。这样,先把经典 decoder-only 拆明白,再接到 《现代 LLM 结构(01):从 GPT-2 到 Llama——五处改动与参数量》,看 Llama 式结构具体替换了哪些部件。
五、token embedding:把编号变成向量
1. 查表
模型看到的不是字,是 token 编号(《算法工程师的数学(01):向量、矩阵与形状》;分词器怎么切在《预训练(02):分词与词表——BPE、词表大小与 token 效率》)。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)的行会靠得很近,这不是设计出来的,是训练”顺便”学出来的。
2. 查表等价于 one-hot 乘矩阵
编号本身不能直接当数用:编号是任意的,464 和 465 之间没有任何关系。查表等价于先做 one-hot(一个 50257 维、只有第 464 位是 1 的向量)再乘一个矩阵,但没人真的去乘——取一行就够了,所以 embedding 不算矩阵乘法的 FLOPs(floating point operations,现代 LLM 结构(02)算账时单列)。
这里先说明输入端的查表;稀疏梯度、词向量如何学出相似度,以及表示如何随上下文变化,见《深度学习基础(09):Embedding 与表示学习——从查表到句向量》「one-hot 与 embedding 表」和「从静态词向量到上下文表示」。
六、位置 embedding:告诉模型这是第几个 token
1. 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 来说,”猫 追 狗”和”狗 追 猫”是同一个输入。这显然不行——语言的意思依赖顺序。所以必须另外把位置信息喂进去,这就是”位置编码”这个部件存在的全部理由。
2. 两种给位置的方法
方法一:再查一张表。 GPT-2 有一张 \(1024 \times 768\) 的表(wpe,position embedding)。第 \(i\) 个位置取第 \(i\) 行,逐元素加到 token 向量上;第 0 个位置的 cat 和第 7 个位置的 cat 因此变成不同的向量,attention 就能区分它们。
这张表也是训练学出来的。表有几行,由训练前定下的上下文长度决定。
GPT-2 的 n_positions = 1024,所以表有 1024 行;行数和上下文长度是同一个数。这种做法的代价是上限被写死在参数里:第 1025 个位置没有训练过的表行,推理时不能直接超过 1024;想加长,只能扩表并训练新增行。
方法二:不加向量,转角度。 Llama 之后的模型用 RoPE(rotary position embedding,旋转位置编码):不改输入向量,而是在 attention 算 \(q \cdot k\) 之前,把 \(q\)、\(k\) 按各自的位置旋转一个角度(《算法工程师的数学(03):正交与旋转、特征值与 SVD》的正交矩阵),使得两者的内积只依赖位置差。它不需要那张表、外推到更长上下文也更自然。现代 LLM 结构(03)专门讲它;本篇的玩具例子和 GPT-2 都用方法一。
两种方法在上下文上限上的差别是:位置表只覆盖训练过的行数,RoPE 则按位置计算旋转角度,没有这道表行数的硬上限。1024 在当时并不算小——attention 的算量和 \(T \times T\) 权重表都随 \(T^2\) 增长(第七章第 6 节);早期 BERT 是 512、GPT-3 是 2048,后来 4K、8K、128K 上下文则在 RoPE 上配合专门的扩展技术,现代 LLM 结构(03)会继续讲。
用上一节那两句话把两种方法各走一遍。为了能口算,token 向量取二维:猫 = (2, 0)、追 = (0, 2)、狗 = (2, 1);方法一的位置表取 4 行:位置 0 = (1, 0)、1 = (0, 1)、2 = (−1, 0)、3 = (0, −1);方法二让每个位置转 30°(\(q\)、\(k\) 直接取 token 向量本身,省掉 \(W_Q, W_K\)):
| 猫追狗 | 狗追猫 | 「猫追狗」整体后移一位(前面多一个 token) | |
|---|---|---|---|
| 方法一:token 向量 + 位置表那一行 | 猫@0 = (3, 0),追@1 = (0, 3),狗@2 = (1, 1) | 狗@0 = (3, 1),追@1 = (0, 3),猫@2 = (1, 0) | 猫@1 = (2, 1),追@2 = (−1, 2),狗@3 = (2, 0) |
| 方法二:猫 看 狗 的分数 \(q \cdot k\) | 狗在猫右边 2 格,\(k\) 相对 \(q\) 转 +60°:0.27 | 狗在猫左边 2 格,转 −60°:3.73 | 位置差仍是 +2:0.27(不变) |
- 方法一:同一个「猫」在第 0 位是 (3, 0)、第 2 位是 (1, 0),两句话送进 attention 的已经是两组不同的向量,上一节的换序实验不再成立。但它记的是绝对位置——整句后移一位,三个向量全变了,”猫@1 和猫@0 是同一个词”要靠训练自己学会;位置表有几行就只认几个位置。
- 方法二:向量不动,打分时 \(q\) 按自己的位置 \(m\) 转 \(m\theta\)、\(k\) 按位置 \(n\) 转 \(n\theta\),两个旋转合起来等于只把 \(k\) 相对 \(q\) 转 \((n - m)\theta\)(《算法工程师的数学(03):正交与旋转、特征值与 SVD》:\(R(a)^\top R(b) = R(b - a)\)),所以分数只和相对位置差有关:猫看”右边两格的狗”永远是 0.27、看”左边两格的狗”永远是 3.73,整句后移分数不变——词序进了分数,而且天然是相对的。真实模型里 \(d_h = 128\) 维被分成 64 对,每一对像这里的二维平面一样各转一个角度,只是转速不同,现代 LLM 结构(03)展开。
两种方法回答的是同一个问题: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 内部真的在这么做。
先说清下面两张图在结构里的位置。GPT-2 small 有 12 个 block,每个 block 的 attention 子层里并排放着 12 个头(head):每个头各有自己的一套 \(W_Q, W_K, W_V\),各算一张 \(T \times T\) 的权重表,所以整个模型一共有 \(12 \times 12 = 144\) 张这样的表(怎么切成 12 个头、为什么各头看的不一样,本章第 6 节讲)。下面取的是第 4 层的两个头处理这句话时的权重——模型自己学出来的,没有人告诉它 it 指 cat:
左边那个头(第 4 层第 11 头)学会了”看上一个词”——每一行的权重几乎全部落在对角线左下一格;右边那个头(同一层的第 3 头)把句子里大部分 token 的注意力都指向主语 cat,”it” 那一行给 cat 的权重是 0.84。同一层的两个头、同样的输入,学出了完全不同的”该看谁”。两张图都是下三角形:右上角全是 0,因为每个 token 只能看它左边的 token(本章第 5 节讲为什么)。
有了这张图,attention 的三个问题就具体了:权重怎么算出来的(第 2–4 节)、为什么只能看左边(第 5 节)、为什么一个头不够要 12 个头(第 6 节)。
2. Q、K、V:三个投影
“该看谁、看多少”要有个打分的办法。最朴素的想法是用两个 token 向量的内积(《算法工程师的数学(02):内积、范数与余弦相似度》:内积大 = 方向接近)当分数。但这有个毛病:一个 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\),加权求和后一眼能看出”混合了谁”)。先把四个矩阵摆出来——图里每一个数都能从它们口算出来:
\[x = \begin{pmatrix} 1 & 0 & 1 & 0 \\ 0 & 1 & 0 & 1 \\ 1 & 1 & 0 & 0 \end{pmatrix},\quad W_Q = \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 1 & 0 & 1 & 0 \\ 0 & 1 & 0 & 1 \end{pmatrix},\quad W_K = \begin{pmatrix} 1 & 0 & 1 & 0 \\ 0 & 1 & 0 & 1 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix},\quad W_V = I_4\]\(x\) 每行一个 token(t0、t1、t2),\(W_Q\)、\(W_K\)、\(W_V\) 都是 \([4, 4]\)。\(Q = xW_Q\) 的第一行就是 t0 那行 \((1, 0, 1, 0)\) 乘 \(W_Q\)——挑出 \(W_Q\) 的第 1 行与第 3 行相加,得 \((2, 0, 1, 0)\);三行都这么算:
\[Q = xW_Q = \begin{pmatrix} 2 & 0 & 1 & 0 \\ 0 & 2 & 0 & 1 \\ 1 & 1 & 0 & 0 \end{pmatrix},\quad K = xW_K = \begin{pmatrix} 1 & 0 & 2 & 0 \\ 0 & 1 & 0 & 2 \\ 1 & 1 & 1 & 1 \end{pmatrix},\quad V = xW_V = x\]这就是六步里的第 ① 步;后面五步全是在 \(Q\)、\(K\)、\(V\) 上算:
逐步读:
- 投影:\(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)。这是《算法工程师的数学(01):向量、矩阵与形状》说的”矩阵乘法的第三种看法:相似度表”。
- 缩放:除以 \(\sqrt{d} = 2\),得 \((2.0, 0.0, 1.5)\)。为什么要除,下一节。
- mask:右上角(t0 看 t1、t2,t1 看 t2)填 \(-\infty\)。
- softmax(《算法工程师的数学(05):从最大似然到交叉熵》)逐行:\(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]\) 的表);本系列第三篇《手搓 GPT(上)——nanoGPT model.py 逐行解析》的 nanoGPT 两条路径都有。
4. 为什么除以 √d
把点积写开就是 \(q \cdot k = q_1k_1 + \cdots + q_dk_d\):一共 \(d\) 项,每项都是可能为正、也可能为负的数。这里说的标准差,可以先理解成这些和「典型地离 0 有多远」。
为什么是 \(\sqrt{d}\),不是 \(d\)?这些项有正有负,会互相抵消一部分。若每项独立、均值为 0、方差为 1,独立项的方差可以相加:总和的方差是 \(d\),标准差就是 \(\sqrt{d}\)(《算法工程师的数学(04):概率入门——语言模型是一个条件分布》用方差算过)。
用 \(d = 4\) 做个能手算的例子:四项各为 \(+1\) 或 \(-1\),总和范围是 \(-4\) 到 \(4\);但正负通常会抵消,典型大小是 \(2 = \sqrt{4}\),不是 4。用随机向量实测:
| \(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\),假设两个分数相差 10,softmax([10, 0]) 约为 \((0.99995, 0.00005)\):几乎全部权重都给了第一个 token。除以 \(\sqrt{128} \approx 11.3\) 后,分数变成 \((0.88, 0)\),softmax 约为 \((0.71, 0.29)\),两个 token 都还能贡献信息。
饱和的 softmax 对分数变化也不敏感。对第一个概率 \(p_1\),\(\partial p_1 / \partial s_1 = p_1(1-p_1)\);上面两种情况的梯度分别约为 \(0.00005\) 和 \(0.21\)。前者几乎不响应分数变化,学习就会变慢(《算法工程师的数学(05):从最大似然到交叉熵》)。除以 \(\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\))把各头的结果混合。
“切”这个字容易让人以为是把输入向量的 768 维分给 12 个头、每头只看其中 64 维——不是。被切的是投影之后的结果:\(W_Q\) 是一个 \(768 \times 768\) 的矩阵,\(x W_Q\) 得到 768 维,再平均切成 12 段;而 \(W_Q\) 的第 \(i\) 段 64 列本身就是一个独立的 \(768 \times 64\) 小矩阵 \(W_Q^{(i)}\)。所以”切成 12 段”等价于”12 个头各有一个从整个 768 维输入读进来的小投影”,每个头都能看到输入的全部信息,切分的位置本身没有任何讲究——按 64 维平均切只是为了把 12 个小矩阵拼成一个大矩阵、一次乘完。
那怎么保证各头看的方向不一样?没有任何机制保证。 12 套 \(W_Q^{(i)}, W_K^{(i)}, W_V^{(i)}\) 只是随机初始化得不同,训练时梯度各自更新;”一个看上一个词、一个看主语”是训练”顺便”分工出来的结果,不是设计出来的。事实上也常常没分工好——GPT-2 里有不少头几乎只看第一个 token(相当于什么都不看),剪掉一层里的大部分头对效果影响很小2。多头的价值不在”保证每头一个方向”,而在于给了模型同时学多种关系的容量,用不用得上由训练决定。形状变化在 《算法工程师的数学(01):向量、矩阵与形状》画过:
两件常被问的事:
- 多头的代价是什么? 打分与加权求和不增加算量:\(h\) 个 \([T, d_h]\) 的小乘法加起来与一个 \([T, d]\) 的大乘法 FLOPs 相同(\(h \times 2T^2 d_h = 2T^2 d\)),\(W_Q, W_K, W_V\) 的总大小也不变(12 个 \(768 \times 64\) 拼起来就是一个 \(768 \times 768\))。真正多出来的有两样:
- 参数:\(W_O\)。 \(W_O\) 与多头一起出现:它把 12 个头的 64 维结果混合起来,让每个输出维度都能看到所有头。单头时 \(W_V W_O\) 可以合并成一个矩阵,不需要单独的 \(W_O\)。GPT-2 small 每层的 \(W_O\) 有 \(768^2 \approx 0.59\)M 个参数,是 attention 子层的四分之一;每个 token 另需 \(2 \times 768^2 \approx 1.2\)M FLOPs。
- 中间结果:权重表多了 \(h\) 倍。 六步手算第 ⑤ 步 softmax 得到权重表 \(P\),形状是 \([T, T]\):一行对应一个 token,记录它分给其他 token 的注意力权重。
- 单头每层有一张 \([T, T]\) 表;多头每层有 \(h\) 张。
- GPT-2 small 有 12 层、12 个头。\(T = 1024\) 时,每层每个序列有 \(12 \times 1024 \times 1024 = 12{,}582{,}912\) 个权重。bf16(bfloat16)每个占 2 字节,合计 \(25{,}165{,}824\) 字节,约 25 MB(24 MiB);12 层合计 \(301{,}989{,}888\) 字节,约 0.30 GB(288 MiB)。
- 常规训练的反向计算要用这些权重,求 value \(V\) 和分数 \(S\) 的梯度。
- 《GPU Kernel 工程(08):Attention Kernel——FlashAttention 与 PagedAttention》中的 FlashAttention 不把完整的 \([T, T]\) 表写入 GPU 的 HBM(high bandwidth memory):它在片上 SRAM(static random-access memory)分块计算 softmax 与加权和,反向时再重算。这样不必物化随 \(T^2\) 增长的整张表,attention 的额外存储随 \(T\) 而不是 \(T^2\) 增长。
- KV(key–value)cache 不受影响:缓存的是 \(K, V\) 各 \([T, d]\),切成几个头都一样大。
- 为什么每头只有 64 维还够用? 每个头只需要判断一种关系,64 维足够表达”是不是名词”“是不是上一个词”这类问题;真实模型从 GPT-2 到 Llama-3 都保持 \(d_h = 64\)–\(128\),加大的是头数与层数。
7. 这一子层的参数
这一子层的参数就是前面几节一个个引出来的四个矩阵,把它们的来历、形状与数量放到一张表里:
| 矩阵 | 哪一节、为什么引入 | 形状(GPT-2 small) | 参数量(含 768 维 bias) |
|---|---|---|---|
| \(W_Q\) | 第 2 节:打分要有”我在找什么”的一方 | 768 × 768 | 590,592 |
| \(W_K\) | 第 2 节:被打分的一方”我是什么”,与 \(W_Q\) 分开才能不对称 | 768 × 768 | 590,592 |
| \(W_V\) | 第 2 节:被选中后传什么内容,与打分解耦 | 768 × 768 | 590,592 |
| \(W_O\) | 多头拼接后的输出投影,作用见第 6 节 | 768 × 768 | 590,592 |
| 合计 | 2,362,368 ≈ 2.36M |
打分需要 \(W_Q\)、\(W_K\);传递内容需要 \(W_V\);多头之后把各头结果合起来需要 \(W_O\),见第 6 节。
nanoGPT 把前三个矩阵拼成 c_attn(768 × 2304),把 \(W_O\) 叫作 c_proj;参数表见第十一章第 2 节。
Attention 本身没有参数:打分、softmax、加权求和都是固定运算,学习发生在四个投影矩阵里。
Llama 后续用 GQA(grouped-query attention)缩小 \(W_K, W_V\),并去掉 bias;四个矩阵的角色不变(现代 LLM 结构(04))。
八、FFN 子层:让每个 token 自己想一想
1. attention 之后为什么还要一步
回头看第七章第 3 节的第 ⑥ 步:输出是 \(V\) 的加权平均。加权平均是线性运算——不管权重多聪明,输出永远在输入向量张成的空间里,不能”算出”新东西。而且到这一步为止整个模型都是线性的(查表、加法、矩阵乘),《深度学习基础(03):反向传播——手推一个两层网络》说过:没有非线性,一百层等于一层。
所以每个 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(Gaussian error linear unit)是 ReLU(rectified linear unit)的平滑版本(《深度学习基础(03):反向传播——手推一个两层网络》),负半轴不是硬截到 0 而是缓缓压到 0,GPT-2 起成为标配;公式与导数的完整对照见《深度学习基础(04):激活函数——从 sigmoid 到 SwiGLU》。”4 倍”是原论文的经验选择,之后被沿用;Llama 换成三个矩阵的 SwiGLU(Swish-gated linear unit)、宽度改为约 2.7 倍(本系列第五篇《现代 LLM 结构(01):从 GPT-2 到 Llama——五处改动与参数量》讲 14336 怎么来的),但”放大 → 非线性 → 压回”的形状没变。
2. 它在一层里占了三分之二
一层的参数数一数:attention 四个矩阵 \(4d^2\),FFN 两个矩阵 \(8d^2\)——FFN 占一层参数的三分之二(GPT-2 small:4.72M 对 2.36M)。这个比例在 Llama 上更高(约 80%,本系列第五篇《现代 LLM 结构(01):从 GPT-2 到 Llama——五处改动与参数量》的表)。所以”大模型的参数主要在 attention 里”是个常见误解;attention 负责决定看谁,真正的”存储”在 FFN。
3. 知识存在哪
有一个有用的直觉:把 \(W_1\) 的 3072 列看成 3072 个”探测器”,每个探测器问输入向量一个问题(”这是不是在讲一种动物?”“前面是不是出现了 Paris?”),GELU 决定答”是”的强度,\(W_2\) 再把答”是”的探测器对应的”回答向量”加起来。可解释性研究(Geva 等,2021 起)确实在真实模型的 FFN 里找到了这种 key–value 结构:某些神经元专门在特定主题出现时激活,并把对应的词推向输出。这也是为什么”往模型里塞知识”(预训练)主要涨的是 FFN,而 MoE(mixture of experts,现代 LLM 结构(06))选择把 FFN 复制成多个专家而不是复制 attention。
九、残差连接与 LayerNorm:让几十层能训
1. 残差流
第七、八章的两个子层都不是”输入进去、输出出来”,而是把输出加回输入:
\[x \leftarrow x + \text{Attention}(\text{LN}(x)), \qquad x \leftarrow x + \text{FFN}(\text{LN}(x))\]这就是残差连接(《深度学习基础(05):训练为什么不稳定——初始化、归一化与残差》讲了它的数学:让梯度能沿着”+”直接传回去,深网络才训得动)。一个有用的读法:把那条从 embedding 一直通到 lm_head 的 \([T, d]\) 主干叫残差流(residual stream),每个子层都是从主干上读一份、算出一个”修正量”、再加回主干。12 层 = 24 次修正。GPT-2 small 的一个 token 从进到出,它的 768 维向量被修正了 24 次,每次修正量都比主干本身小得多——这也是为什么 Transformer 能到 100 层以上而 RNN(recurrent neural network)不行。
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(convolutional neural network)里 BatchNorm(batch normalization)的区别,也是它在变长序列上好用的原因。Llama 换成 RMSNorm(不减均值,只除均方根,省一次运算,本系列第五篇《现代 LLM 结构(01):从 GPT-2 到 Llama——五处改动与参数量》),作用相同。
3. pre-norm 与 post-norm
LayerNorm 放在子层之前(上面的公式,GPT-2 起的做法,叫 pre-norm)还是之后(原论文 2017 与 d2l(Dive into Deep Learning)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。
十、lm_head:把向量变成词表上的分数
1. 最后一次 LayerNorm 与 lm_head
最后一个 block 出来的 \([T, 768]\) 过 ln_f,再乘 lm_head(\(768 \times 50257\))得到每个位置在词表上的 50257 个分数(logits),softmax 之后就是”下一个 token 是谁”的概率。
2. 与 embedding 表共享权重
GPT-2 的 lm_head 直接复用 embedding 表的转置(tie weights):查表是”编号 → 向量”,lm_head 是”向量 → 每个编号的分数”,用同一张表做两件事既省了 3860 万参数,又让”输入端相近的词在输出端也相近”。大模型(Llama-3-70B)通常不共享,因为 embedding 那点参数相对总量已经不重要(本系列第五篇《现代 LLM 结构(01):从 GPT-2 到 Llama——五处改动与参数量》)。
十一、把它们叠起来
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]"]
这个图就是本系列第三篇《手搓 GPT(上)——nanoGPT model.py 逐行解析》里 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. 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。本系列第三篇《手搓 GPT(上)——nanoGPT model.py 逐行解析》会用 model.get_num_params() 把这个数打印出来(nanoGPT 默认不计 wpe,报 123.65M),本系列第五篇《现代 LLM 结构(01):从 GPT-2 到 Llama——五处改动与参数量》把同一套算法用到 Llama 上。
十二、本文小结
- 第一章先画出 GPT-2 small 的 decoder-only 总览;第二至四章回到为机器翻译设计的原始 encoder-decoder,说明 encoder、decoder、cross-attention 与三条路线的来历。
- GPT-2 是本系列拆解 decoder-only 的实践基线,不代表 Transformer 从一开始就是 decoder-only,也不表示 encoder-decoder 只能在 decoder 侧产生训练目标。
- 一个 decoder-only Transformer 只有六种部件:token embedding、位置 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 的统一生成目标与接口是常见的解释之一,不是唯一答案。
配套脚本 attention_by_hand.py(ai-learning-labs/transformer-and-llm)打印本文六步手算的每一个中间矩阵、对拍 PyTorch、做换序实验、测 \(\sqrt{d}\) 表;tools/gen_gpt2_attention_heatmap.py 生成第七章那张 GPT-2 真实 attention 图。
十三、自测
-
原始 Transformer decoder 的 cross-attention 中,Q、K、V 分别来自哪里?GPT-2 为什么没有这一子层?
答案
Q 来自 decoder 当前表示,K、V 来自 encoder 输出;GPT-2 没有单独的源序列 encoder,于是也没有 cross-attention,参考内容直接放进 causal self-attention 的输入上下文。
-
encoder 的双向 self-attention 与 decoder 的 causal self-attention 分别允许一个位置看到哪些 token?为什么生成目标序列时需要 causal mask?
答案
encoder 位置可以看源句中的前后位置;decoder 位置只能看目标序列中它左侧的已知 token。causal mask 避免训练预测位置时偷看未来目标 token,使生成时的可见信息与逐步解码一致。
-
一个 block 的输入是 \([T, d]\),输出是什么形状?为什么必须相同?
-
为什么 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 表:它的行数就是训练前定下的上下文长度(
n_positions= 1024),上限写死在参数里,第 1025 个位置没有训练过的向量,要加长只能扩表再训。Llama 用 RoPE,位置是一个角度而不是查表,任何位置都能算(能不能算得好是另一回事,《现代 LLM 结构(03):位置编码与外推》讲外推)。见第六章第 2 节。 -
原始 Transformer 的 decoder 有三个子层,GPT 只有两个,少了哪个?它的功能在 GPT 里由什么代替?
答案
少了 cross-attention(读 encoder 输出的那个)。GPT 把要参考的内容直接拼在输入前面(prompt),由 causal self-attention 去看——见第二章的 cross-attention 说明。
下一篇
本篇画的是静态的结构。下一篇《一个 token 的旅程:训练侧与推理侧》让数据流过这些方框:训练时一句话的 \(T\) 个位置怎么同时算出 \(T\) 个 loss、反向传播沿哪条路走回来;推理时 prefill 与 decode 有什么不同、KV cache 为什么能让每一步只算一个 token。
-
六种部件:embedding 查表(编号 → 向量,否则编号只是任意整数);位置信息(attention 是集合运算,不加它分不清词序);attention(唯一让 token 互相看的地方:\(\text{softmax}(QK^T/\sqrt d + M)V\),去掉它每个 token 只能看自己);FFN(逐 token 的非线性变换,去掉它整个模型是线性的、存不了知识);残差 + LayerNorm(去掉残差深了训不动,去掉 LayerNorm 残差流尺度失控);最后 lm_head 把向量变成词表上的分数。详见第五至第十章。 ↩
-
Michel, Levy & Neubig, Are Sixteen Heads Really Better than One? (NeurIPS 2019):训好的 BERT / 机器翻译模型里,很多层在推理时只留一个头,效果几乎不变;Voita 等人同年的工作把 Transformer 翻译模型 48 个 encoder 头剪到 10 个,BLEU(bilingual evaluation understudy)只掉 0.15。 ↩
系列 《Transformer 原理与实现:从论文到手搓 GPT-2》 第 1 / 5 篇 进阶
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 4 / 14 个系列 · AI 算法工程师学习地图 第 5 / 12 个系列
- Transformer 长什么样——从一句话到下一个 token
- 一个 token 的旅程——训练侧与推理侧
- 手搓 GPT(上)——nanoGPT model.py 逐行解析
- 手搓 GPT(下)——nanoGPT train.py 与训一个会续写的模型
- Transformer 原理与实现:系列总结与通关自测
上一个系列:PyTorch 深度实践下一篇:一个 token 的旅程——训练侧与推理侧
本文由 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 会按你的通知设置发邮件,不用守在这里。