第 18 章 · 序列与 Transformer

Transformer 的完整结构

注意力是发动机,但光有发动机还开不了车。要把注意力堆成又深又能训的网络, 还需要几个关键配件:残差连接、LayerNorm、前馈网络, 再加上让模型知道词序的位置编码。把它们组装起来,就是一块完整的 Transformer Block——今天所有大模型的基本积木。

路线图 · 你在这一段的哪一站

上一站(第 17 章)拆了注意力这颗发动机;本站把它和残差 / LayerNorm / 前馈 / 位置编码拼成一块可堆叠的 Transformer(全景图第 ③ 步);下一站(第 19 章)接上输入输出,做成会写字的语言模型。

读完这一章,你会明白

  • 残差连接为什么能让“很深的网络”也训得动;
  • LayerNorm 在归一化什么,为什么能稳住训练;
  • 前馈网络(FFN)在 attention 之后又补了什么;
  • 位置编码怎么把“词的顺序”告诉模型,以及 encoder 和 decoder 的区别;
  • 读懂那张最经典的 Transformer 全架构图:编码器、解码器,以及它们之间的 cross-attention;
  • self / masked / cross 三种注意力的区别,以及 GPT / BERT / T5 三种家族的由来;
  • 用一句诗走完 GPT 前向:整矩阵形状链(含多头 h=2) + 逐步手算,以及它和 MNIST 在“关联方式”上的同异。

1. 残差连接:给信息和梯度修一条高速公路

网络一深,就容易“训不动”:梯度反传到前面几层时已经衰减得几乎为 0(梯度消失)。 残差连接(residual connection)的解法简单得惊人—— 让每个子层的输出加上它自己的输入:

输出 = x + Sublayer(x) 不是用子层的输出替换 x,而是在 x 的基础上“加一个增量”

这里的 x 不是“整篇文章”那样的抽象输入,而是某个位置当前这一个 token 的隐藏向量。 如果整层输入是一条序列矩阵 X = [x1; x2; ...; xn], 那残差相加做的其实是:

Y = X + Sublayer(X) = [x1 + Δ1; x2 + Δ2; ...; xn + Δn] 序列长度和每个 token 的维度都不变,只是每个位置的表示被“加一笔修正”
典型形状它是什么
xt d_model t 个 token 当前那条隐藏向量
Δt = Sublayer(xt) d_model 子层(注意力或 FFN)给这个 token 算出来的“增量修正”
xt + Δt d_model 残差相加后的新表示,继续往下一层送

最该记住的一点:残差不会改变 token 个数、顺序或宽度,只是给每个 token 当前表示加上一笔“改进量”。

这条“x +”的捷径有两个好处:① 梯度可以原路直通回前面的层(高速公路), 深网络也不怕梯度消失;② 每个子层只需要学“在原有基础上改进多少”,比从零学一个新表示容易得多。

例如 attention 子层输出的不是“最后答案”,而是“这个 token 看完整句后该往哪边改一点”;FFN 子层输出的也不是“替换掉原表示的新向量”, 而是“这个 token 经过逐词加工后该补上的那一笔”。残差连接把这些“改一点”的结果稳稳叠回去,网络就能一层层细修,而不是每层都推翻重来。

src/deeplearning/transformer/transformer_block.cpp · 残差(精简)
last_residual_1_ = input;                          1
for (int i ...) for (int j ...)
  last_residual_1_[i][j] += last_attention_output_[i][j];   2
attention_norm_.Forward(last_residual_1_, last_norm_1_);    3
  1. 先把原始输入 input 留一份。
  2. 把注意力的输出加回到输入上——这就是 x + Attention(x)。
  3. 再交给 LayerNorm(下一节)。这种“子层 → 加残差 → 归一化”的三连,在 block 里会出现两次。
为什么这条“捷径”能让梯度直通

反向传播是把梯度从后往前连乘回去。对 y = x + F(x) 求导:∂y/∂x = 1 + ∂F/∂x。 关键是那个 “+1”——哪怕子层 F 的梯度 ∂F/∂x 很小甚至接近 0,梯度也能靠这个 1 原样传回前一层,不会被一路乘没。 没有残差时,深层梯度是一串小于 1 的数连乘,越乘越小(这就是梯度消失);有了残差,等于永远留了一条“直达车道”,几十上百层也传得回去。

2. LayerNorm:把每个词的特征“拉回正常范围”

训练中,各层数值的大小、波动会乱飘,拖慢甚至搞崩训练。LayerNorm(层归一化)每一个词向量单独做标准化:减去均值、除以标准差,把它拉成“均值 0、波动 1”的分布, 再用两个可学习参数 γ(scale)和 β(bias)做微调:

LN(x) = x − μ√(σ2 + ε) · γ + β μ、σ² 是这个词向量自己的均值和方差;ε 防止除以 0;γ、β 可学习

这里的 x 同样指某一个 token 当前那一条向量,不是整句一起混着算。 如果一层输入是一整条序列矩阵 X = [x1; ...; xn], 那 LayerNorm 做的是:

LN(X) = [LN(x1); LN(x2); ...; LN(xn)] 一行一行各自归一化;不同 token 之间互不影响
典型形状它到底是什么
xt d_model t 个 token 当前的隐藏向量
μt、σ²t 标量 只由 xt 自己这 d_model 个分量算出来的均值和方差
γβ d_model 全层共享的可学习缩放/平移参数,对每个 token 都按同样规则使用
LN(xt) d_model 归一化后送往下一步的 token 向量

LayerNorm 不改变序列长度,也不改变每个 token 的维度;它做的是“把每个 token 自己内部各维的尺度拉回稳定范围”。

src/deeplearning/transformer/layer_norm.cpp · Forward(精简)
double mean = 0;
for (double v : token) mean += v;
mean /= feature_dim_;                                  1

double variance = 0;
for (double v : token) variance += (v - mean) * (v - mean);
variance /= feature_dim_;                              2

double denom = std::sqrt(variance + epsilon_);
for (int i = 0; i < feature_dim_; i++)
  token[i] = ((token[i] - mean) / denom) * scale_[i] + bias_[i];  3
  1. 算这个词向量所有维度的均值 μ。
  2. 方差 σ²(各维度偏离均值的平方的平均)。
  3. 标准化(减均值、除标准差),再乘 scale(γ)、加 bias(β)。注意:它是对每个词自己归一化,和样本数无关,这正是它适合序列的原因。

下面把一个最小的“未归一化 token 向量”手算一遍。假设某个词在经过子层和残差相加后,当前表示变成了 [1, 2, 3]:三个维度整体偏正,均值不在 0 附近,尺度也还没被拉回稳定范围。 如果一层层传下去的向量总是这样忽大忽小、整体漂移,后面的 attention 和 FFN 就得不断适应输入分布的变化,训练会更不稳。 先设 γ=1、β=0,看看 LayerNorm 怎么把它拉回标准范围:

步骤算式结果
① 均值 μ(1 + 2 + 3) / 32
② 方差 σ²[(1−2)² + (2−2)² + (3−2)²] / 3≈ 0.667
③ 标准差√0.667≈ 0.816
④ 标准化(x − 2) / 0.816[−1.22, 0, 1.22]

这个原本整体偏正、尺度未对齐的向量,被拉成一组均值 0、波动 1 的数;γ、β 再在此基础上做可学习的微调(初始 γ=1、β=0 就原样输出)。

所以你可以把 LayerNorm 理解成“给每个 token 自己洗一遍数值”。它不负责让 token 之间互相交流, 也不负责提取新特征;它只是保证“这个 token 现在这条向量的各维数值别乱飘”,让后面的 attention 和 FFN 都在更稳定的尺度上工作。

为什么是“每个词各自归一化”,而不是按 batch

注意上面全程只用了这一个词自己的几个数——和同批里别的句子、同句里别的词统统无关。 这正是 LayerNorm 与 BatchNorm 的分水岭:BatchNorm 要跨一批样本统计,句子一变长、batch 一变小就很别扭; LayerNorm 只看“一个词的这几十/几百个特征”,于是不依赖 batch 大小、对变长序列天然友好、训练和推理表现一致——序列模型因此偏爱它。 好处很直接:每层拿到的输入分布都被拉回稳定范围,梯度不易忽大忽小,就能用更大的学习率、堆更深也不易训崩。

3. 前馈网络 FFN:逐个词再加工一次

注意力让每个词“看了全局”,但融合方式基本是线性加权。前馈网络(FFN) 紧随其后,对每个词独立地做一次“放大 → 非线性 → 压缩”:先用一个矩阵升到更高维度(比如 4 倍), 过 ReLU,再压回原维度。这给了模型额外的非线性加工能力。

FFN(x) = ReLU(x W1 + b1) W2 + b2 W₁ 把维度升上去,ReLU 加非线性,W₂ 再压回来

这里最容易误解的就是公式里的 x。它不是整句文本,也不是“所有 token 一起拼成的大矩阵”直接一次性揉成一个向量; 它指的是某一个位置当前那一个 token 的隐藏向量。如果 attention 之后整条序列的表示是 X = [x1; x2; ...; xn], 那 FFN 真正在做的是:

FFN(X) = [FFN(x1); FFN(x2); ...; FFN(xn)] 同一套 W₁/W₂ 对每个位置各算各的; token 与 token 之间不在 FFN 里交换信息
对象典型形状它到底是什么
xt d_model t 个 token 经过 attention 和归一化后的那一条向量
W1 d_model × d_ff 把单个 token 向量从 d_model 升到更宽的 d_ff
ReLU 后 hidden d_ff 这个 token 在更宽空间里的中间表示
W2 d_ff × d_model 把中间表示再压回原宽度,方便继续残差相加
整层输入 X seq_len × d_model 整条序列当前的隐藏状态矩阵,由很多个 xt 堆在一起
整层输出 X' seq_len × d_model 每个 token 各自过完 FFN 后重新拼回整条序列

一句话: attention 负责“词和词之间互相看”;FFN 负责“每个词自己再加工一次”。

所以如果一条句子有 128 个 token,FFN 不是把这 128 个词糊成一团一起做变换,而是把同一个两层小 MLP 对 128 个 token 各跑一遍。它看的是“某个词已经融合过上下文后的表示”,再把这个表示加工得更适合下一层使用。

src/deeplearning/transformer/transformer_block.cpp · FFN(精简)
for (int token_idx = 0; token_idx < norm_1.size(); token_idx++) {      1
  hidden = ApplyLinear(norm_1[token_idx], W1, b1);   // model_dim → ff_dim
  for (double &v : hidden) v = Relu(v);                                2
  ff_output[token_idx] = ApplyLinear(hidden, W2, b2); // ff_dim → model_dim 3
}
  1. 外层这个循环就是关键:FFN 是对每个 token 向量逐个处理,不是把整条序列先混在一起再做变换。
  2. 第一层线性 + ReLU:把某个 token 的向量从 model_dim 升到更大的 ff_dim,并加入非线性(第 2 章的老朋友)。
  3. 第二层线性:再压回 model_dim,方便接下来继续残差相加。所有 token 共用同一套 W₁/W₂,但彼此互不通信

把这一节压成两句话就是: attention 负责“跨词混合”,FFN 负责“逐词深加工”; 之所以要“先升维、再降维”,是因为把每个 token 摊到更宽的空间里,ReLU 才能切出更多“分段”、拟合更复杂的非线性变换, 加工完再压回原维度,好和残差相加、继续往下堆。

4. 把它们拼成一块 Block

一块标准 Transformer Block,就是“注意力 + 残差 + 归一化”和“FFN + 残差 + 归一化”这两段串起来:

输入序列(每个词一个向量) Self-Attention LayerNorm Feed-Forward (FFN) LayerNorm 输出序列(可送入下一块 Block) 残差(加回输入) 残差(加回输入)

一块 Transformer Block:Self-Attention → 加残差 → LayerNorm → FFN → 加残差 → LayerNorm。

关键在于:这块 Block 的输入和输出形状完全一样(都是“一串词向量”)。 所以它能像乐高一样一块叠一块:堆 6 块、12 块、96 块……越深,模型能力越强。 这正是“GPT-3 有 96 层”这类说法的含义。

5. 位置编码:告诉模型“谁在前谁在后”

注意力有个“缺陷”:它对所有位置一视同仁,本身并不知道词的先后顺序 ——把“狗咬人”和“人咬狗”打乱了它也分不清。解决办法是给每个位置造一个独特的“位置指纹”, 加到词向量上。经典做法用不同频率的正弦和余弦:

src/deeplearning/transformer/positional_encoding.cpp(精简)
double angle = pos / std::pow(10000.0, (double)i / model_dim);  1
encoding[pos][i]     = std::sin(angle);                         2
encoding[pos][i + 1] = std::cos(angle);
// 之后: sequence[i][j] += encoding[i][j];                      3
  1. 每个位置 pos、每个维度 i 对应一个角度;不同维度用不同频率(10000 的幂)。
  2. 偶数维用 sin,奇数维用 cos——这样每个位置都得到一串独一无二、且“相邻位置相近”的编码。
  3. 把位置编码到词向量上,词向量从此既带“是什么词”也带“在第几位”的信息。
直觉:像时钟的指针,也像二进制计数器

为什么要用好几个不同频率的 sin/cos,而不是一个数字?想想时钟:秒针快、分针中、时针慢—— 单看一根针说不清“几点”,三根合起来就能唯一锁定一个时刻。位置编码同理: 高频维度变化快,负责“精确到第几个词”;低频维度变化慢,负责“大致在句子前段还是后段”。 多个频率叠在一起,每个位置就拿到一串独一无二又有规律的指纹。

这和二进制计数是一个道理——低位翻得快、高位翻得慢,合起来给每个数一个唯一编码:

位置高位(慢)中位低位(快)
0000
1001
2010
3011
4100
5101
6110
7111

低位每 1 步就翻一次(高频),高位每 4 步才翻一次(低频)。位置编码就是它的“连续版”:把 0/1 换成不同频率的 sin/cos。

把公式真代进去,给一个 4 维位置编码算前几个位置(d=4,偶数维用 sin、奇数维用 cos):

位置 pos维0 = sin(pos)维1 = cos(pos)维2 = sin(pos/100)维3 = cos(pos/100)
00.001.000.001.00
10.840.540.011.00
20.91−0.420.021.00
30.14−0.990.031.00

左两维(高频)相邻位置就明显不同——管“精确位次”;右两维(低频)几乎没动——管“大致段落”。每行合起来各不相同,就是该位置的指纹。

两个常见疑问

· 为什么是“加”到词向量上,而不是拼接? 相加不增加维度、不多花参数,模型能在同一组数里同时读到“是什么词 + 在第几位”。(也有模型改用可学习的位置向量,思路一样,只是编码从公式改成训练得到。)
· sin/cos 还有个隐藏好处: 位置 pos+k 的编码可以由位置 pos 的编码线性组合出来。于是模型很容易学会“相对位置”(比如“往前第三个词”),而不只是死记绝对位次。

6. Encoder 与 Decoder

同样的 Block,按“能看到哪些词”分成两种用法:

本项目两种都支持

配套的 MiniTransformerLM 用一个 backbone 配置在 encoder / decoder 之间切换。 下一章我们要做“预测下一个字”,用的就是带 causal mask 的 decoder 思路。

7. 合体:那张最经典的 Transformer 全图

把前面所有零件拼齐,就得到了 2017 年论文 Attention Is All You Need 里那张最经典的架构图。 很多人第一次看它就发怵,其实图里每一个方块你都已经学过了。我们照着把它画出来,再一块块讲清楚“它到底在说什么”。

编码器 解码器 编码器输出 → K, V Inputs 输入 Input Embedding 输入嵌入 位置编码 Multi-Head Attention 自注意力 Add & Norm Feed Forward 前馈网络 Add & Norm Outputs 输出(右移一位) Output Embedding 输出嵌入 位置编码 Masked Multi-Head Attention 掩码自注意力 Add & Norm Multi-Head Attention 交叉注意力 cross Add & Norm Feed Forward 前馈网络 Add & Norm Linear Softmax Output Probabilities 输出概率

经典 Transformer:左塔编码器读懂输入,右塔解码器一个词一个词生成输出,中间那条蓝线是交叉注意力(编码器输出当 K、V)。绿色虚线是各处的残差——Add & Norm 里的 Add,就是第 1 节讲的残差相加;Norm 就是第 2 节的 LayerNorm(对每个词的特征做缩放平移,拉回稳定范围)。

顺着数据流走一遍

左边这座塔是编码器(Encoder),负责“读懂输入”:

  1. 输入嵌入 + 位置编码:把输入每个词查成向量(第 19 章),⊕ 上位置编码(第 5 节),让它既知道“是什么词”又知道“在第几位”。
  2. N× 编码器块:每块 = 自注意力 → Add & Norm → 前馈网络 → Add & Norm(第 1–4 节);堆 N 层(原论文 N=6),越堆理解越深。
  3. 编码器输出:一串“看过全句上下文”的向量,原样送给右边的解码器备用。

右边这座塔是解码器(Decoder),负责“一个词一个词生成输出”:

  1. 输出嵌入 + 位置编码:把“到目前为止已生成的词”嵌入 + 位置编码。注意底部写着 Outputs(shifted right,右移一位)——预测第 t 个词时,喂进去的是第 1…t−1 个词,这正是第 19 章的 teacher forcing。
  2. 掩码自注意力:带 causal mask 的自注意力,保证生成时只能看前文、不能偷看未来(第 17 章)。
  3. 交叉注意力(cross-attention):关键一环——它一边看解码器自己的状态,一边回头查阅编码器的输出(下面细讲)。
  4. 前馈网络,再 Add & Norm;同样堆 N 层。
  5. Linear + Softmax:最顶上把解码器输出投影到词表大小、过 softmax,得到“下一个词的概率”——这就是第 19 章的 LM Head。

全图的“三种注意力”,一次看懂

很多人卡在“怎么突然冒出三个 Multi-Head Attention?”其实它们是同一套机制(第 17 章),只是Q、K、V 来自哪里不同。 这张表,是读懂整张图的钥匙:

注意力图中位置Q 来自K、V 来自mask作用
编码器自注意力左塔输入序列输入序列无(双向)让输入每个词读懂全句
掩码自注意力右塔 · 底部已生成序列已生成序列有(causal)生成时只看前文
交叉注意力右塔 · 中部解码器编码器输出解码时“查阅原文”

三处注意力,同一套算法,区别只在 Q / K / V 的来源。

交叉注意力 = 一边写译文,一边回头看原文

想象你在做翻译:每写下一个译词(解码器),都会回头扫一眼原文(编码器输出),看看该对齐到哪里。 落到机制上就是:Query 来自解码器(我正要写什么),Key、Value 来自编码器(原文里有什么)。 这就是图中那条从左塔顶伸到右塔中部的蓝线——原始 Transformer 本来就是为“机器翻译”设计的,cross-attention 正是连接“原文”和“译文”的那座桥。

三种家族:后来的模型只取半张图

这张全图是“编码器 + 解码器”的完整版。后来的模型常常只取其中一半:

为什么如今大模型多是 decoder-only?

因为“预测下一个 token”这一个目标,既逼它学会理解、又逼它学会生成,而且训练数据全是现成的文本(自监督,第 20 章)。 于是最简洁的 decoder-only 反而最能吃满规模红利。你在第 19 章亲手搭的那个字符级模型,正是一个 decoder-only 的迷你版。

8. GPT 这种模型:把右塔堆很多层,再接一个 LM Head

既然如今大模型大多是 decoder-only,那把第 7 节那张“完整 Transformer 全图”裁开后, GPT 这类模型到底还剩什么?答案很干脆:只留右塔,把掩码自注意力的 Block 反复堆叠, 最顶上再接一个把隐藏状态投影到词表大小的输出层(LM Head)。训练目标始终不变: 预测下一个 token

Prompt / 已生成前缀 Embedding + Position Decoder Block Masked Self-Attention 本层独立参数, 只看左边前文 Add & Norm Feed Forward (MLP) 逐 token 再加工一次 Add & Norm Final LayerNorm LM Head (Linear) 训练 全部位置的 logits → CrossEntropy 生成 最后位置的 logits → Softmax → greedy / 采样 → 下一个 token id

GPT / ChatGPT 这类模型常用的 decoder-only 架构:主干只有“Embedding → N× Decoder Block → LM Head”。训练时,所有位置的 logits 都拿去算交叉熵;生成时,只取最后一个位置的 logits 做 softmax 和采样,再把新 token 接回输入继续自回归。

顺着这张图走一遍

  1. Prompt tokens → Embedding + Position:先把输入提示切成 token,查表变向量,再加上位置信息。到这一步,模型拿到的是“一串带顺序的词向量”。
  2. N× Decoder Block:整座主干就是很多层一模一样的 decoder block。每一层都只有两段:带 causal mask 的自注意力,再加一个逐 token 的 FFN。它没有 encoder,也没有 cross-attention,因为它不需要“回头看原文”那条支路。更重要的是: 每一层都有自己的一套 attention 参数,不是全网共用同一套 Q/K/V 投影矩阵。
  3. Final LayerNorm + LM Head:最顶上把最后一层隐藏状态做一次归一化,再投影到词表大小,得到的是每个位置的 logits,还不是最终选出来的 token。
  4. 训练:拿所有位置的 logits 和“正确的下一个 token”去算交叉熵;这就是大模型预训练时那条最核心的 loss 链路。
  5. 生成:只取最后一个位置的 logits,过 softmax 后 greedy 或采样选一个新 token,把它追加到序列末尾。这个循环就是自回归生成

层和层之间,到底传的是什么?

很多人第一次看 GPT 架构图会有个困惑:这些方块之间到底在传什么?其实从头到尾,主角几乎只有两类东西: 一串 token id,和它们对应的一串向量。从 embedding 往上到 Transformer 主干内部, 每一层传来传去的基本都是同一种对象:一张 序列长度 × model_dim 的矩阵——也就是 “这句话里每个位置,当前各自的一份隐藏表示”。

阶段传进去的是什么这一层做什么传出来的是什么
输入 一串 token id
[t₁, t₂, …, tₙ]
只是编号,还没有语义 仍是一串 id
Embedding + Position token id 序列 查词向量表,再把位置编码加上去 一串向量
X ∈ R^(n × d_model)
Masked Self-Attention 一串隐藏向量 X 当前这一层用自己的一套 WQ/WK/WVX 投影成 Q/K/V,再只和左边前文做注意力加权 一串新的向量
A ∈ R^(n × d_model)
Add & Norm 旧表示 X 和注意力输出 A 先做残差相加,再归一化 一串更新后的向量
H₁ ∈ R^(n × d_model)
FFN 一串向量 H₁ 对每个位置各自做一次小 MLP(升维 → 激活 → 降维) 一串新的向量
F ∈ R^(n × d_model)
Add & Norm H₁F 再做一次残差相加 + 归一化 一串更高层的隐藏向量
H₂ ∈ R^(n × d_model)
N 层堆叠后 上一层的一串隐藏向量 下一层拿这份新表示重新生成一套新的 Q/K/V;层与层之间不共享同一套 attention 投影矩阵 最后一层隐藏状态
H_final ∈ R^(n × d_model)
LM Head 最后一层隐藏状态 H_final 把每个位置的向量投影到词表大小 每个位置一份分数
logits ∈ R^(n × vocab_size)
Softmax + 取样 最后一个位置的 logits
logits[n]
变成概率,再 greedy 或采样选一个 下一个 token id

最关键的一点:从 embedding 往上,层和层之间传的几乎始终都是“整条序列当前的隐藏表示”。每一层都在改写同一份草稿,而不是把 token 变成别的奇怪对象。

带着数字走完前向:整矩阵 + 逐步手算

上表讲的是阶段语义。下面先给整矩阵形状链(每一步的矩阵乘与形状), 再用第 16 章那句诗的开头 「床前明」把具体数字也走通,看它怎么猜出下一个字 。真实 GPT 会堆几十上百层、维度也大得多,但运算类型完全一样

配置与目标

符号本例取值含义
输入文本床前明3 个 token,要猜下一个字
seq_len3序列长度 n
d_model2每个 token 的向量宽度 d
d_ff4FFN 中间层宽度 f(本例取 2d;真模型常 4d)
vocab_size5词表大小 V(床/前/明/月/山)
Block 层数1只演示一层;真实模型是 N×
注意力头数1(下文先单头) / 2(多头见「多头矩阵版」)d_h = d / h

特意让 n=3、d=2、f=4、V=5 四个数字互不相等,这样每一步矩阵形状都独一无二,一眼就能看出“谁在变”。

整矩阵流水线:从输入到输出的形状链

下面把同一句「床前明」从头到尾写成矩阵版。 约定:每一行是一个 token,列是向量各维;矩阵乘法和第 1 章第 17 章一致——行向量左乘权重表。

符号一览(本例 n=3, d=2, f=4, V=5)

ids · 长度 n 的整数 · XHA · n×d 隐藏矩阵 · W_Q,W_K,W_V,W_O · 各 d×d · W1 · d×f · W2 · f×d · W_lm · V×d · SM · n×n 注意力分数 / 权重

ids [n]                                          [3]
  →  EmbeddingTable[ids]           X₀  [n × d]     [3 × 2]
  →  X₀ + PE                       X   [n × d]     [3 × 2]   ← 进 Block 的输入
  →  Masked Self-Attention         A   [n × d]     [3 × 2]   (中间 S/M 是 [3 × 3])
  →  LayerNorm(X + A)              H₁  [n × d]     [3 × 2]
  →  FFN(每行独立,共用 W1/W2)      F   [n × d]     [3 × 2]   (中间升到 [3 × 4])
  →  LayerNorm(H₁ + F)             H₂  [n × d]     [3 × 2]   ← 1 层时即 H_final
  →  H₂ · W_lm + b_lm              L   [n × V]     [3 × 5]   ← 每个位置一份 logits
  →  softmax(最后一行)             p   [V]         [5]       ← 生成时只取最后一个位置
公式(矩阵版)输入形状输出形状
① 查表 X₀ = Embed(ids) [n] = [3] [n, d] = [3, 2]
② 加位置 X = X₀ + PE (逐元素加) [3, 2] [3, 2]
③ 投影 Q/K/V Q = X·W_Q · K = X·W_K · V = X·W_V [3, 2]·[2, 2] [3, 2]
④~⑦ 注意力 单头:S=QKᵀ/√d→mask→MM·V·W_O · 多头:按列切 h 段,各算一遍再 Concat→W_O [3, 2] [3, 2] (中间 S/M[3, 3])
⑧ 残差+Norm H₁ = LayerNorm(X + A) (逐行) [3, 2] [3, 2]
⑨ FFN Z = ReLU(H₁·W₁ + b₁) · F = Z·W₂ + b₂ [3, 2]·[2, 4] [3, 2] (中间 Z[3, 4])
⑩ 残差+Norm H₂ = LayerNorm(H₁ + F) [3, 2] [3, 2]
⑪ LM Head L = H₂·W_lm + b_lm [3, 2]·[2, 5] [3, 5]
⑫ 生成 p = softmax(L[n-1]) [5] [5] 概率

从 ② 到 ⑩,主矩阵形状始终是 n×d = 3×2。④~⑦ 单头时中间出现一张 3×3(S/M),⑨ FFN 中间升到 3×4,⑪ 输出 3×5;多头时 ④~⑦ 出现 h 张 3×3,拼回后仍 3×2。详见「多头矩阵版」。

各步展开:矩阵怎么乘

①② Embedding + 位置 — 先把 3 个 id 查成 3 行向量,再逐行加上位置指纹:

X₀ = Embed([0,1,2]) → 3 行 × 2 列 第 0 行=床 · 第 1 行=前 · 第 2 行=明
X = X₀ + PE · PE 也是 3×2,按行相加 形状不变;只是每行多了一层“第几位”的信息

示意(数字仅作形状演示,不是真训练权重):

       维0    维1
床  [  0.2,  -0.5 ]   ← X₀ 第 0 行
前  [  0.9,   0.1 ]
明  [ -0.3,   0.8 ]   ← +PE 后第 2 行变成 [0.61, 0.38]

形状: X₀ 是 3×2 → +PE(3×2) → X 仍是 3×2

③~⑦ Masked Self-Attention(单头示范, h=1) — 整表一次算完,不必逐 token 循环。真模型几乎总是 h > 1,见下节「多头矩阵版」:

Q = X·W_Q · K = X·W_K · V = X·W_V 三个都是 [3×2]·[2×2] = [3×2] — 每行仍是 2 维,但已是 Q/K/V 空间
S = Q·Kᵀ / √d [3×2]·[2×3] = [3×3] · S[i,j]=第 i 个词对第 j 个词的“相关分数” · 形状从 3×2 变成了 3×3!

因果 mask 后,S 的上三角(未来列)作废;对每一行做 softmax 得权重矩阵 M(3×3 下三角,每行和为 1):

        床    前    明          ← 被看的列 j
床  [ 1.00,  0.0,  0.0 ]      ← 第 0 行只能看自己
前  [ 0.32, 0.68,  0.0 ]      ← 第 1 行看床/前
明  [ 0.21, 0.34, 0.45 ]      ← 第 2 行可看床/前/明三列

形状: Q·Kᵀ = [3×2]·[2×3] = 3×3(注意力矩阵是「词×词」,和 d 无关)
à = M·V · A = ÷W_O [3×3]·[3×2]=[3×2] 再右乘 W_O(2×2) · 输出 A 又回到 3×2

直觉:M第 i 行是“第 i 个词该听谁的”;乘 V 就是把各词的 Value 行按这排权重混成新第 i 行。 注意这里形状先从 3×2 撑成 3×3(打分),又被 M·V 收回 3×2——这就是第 17 章手算的矩阵批量版。

多头矩阵版:把 ③~⑦ 拆成 h 条并行路(h=2)

上一段为了形状最简,设 h=1(单头)。真实 GPT 会用多个头—— 第 17 章第 8 节讲过: 把每个词的 d 维向量切成 h 段,每段各算一套注意力,最后再拼回去。 仍用本例 d=2, n=3,取 h=2,则每头宽度 d_h = d / h = 1(每头只分到 1 维,虽然极端,但正好看清“切分—各算—拼回”的形状流转)。

和单头比,什么变、什么不变

不变:投影仍是一次 Q=X·W_Q 等,三个表仍是 d×d;Block 进出仍是 [n×d]=[3×2];参数仍是 W_Q,W_K,W_V,W_O 四张表。
:打分 / softmax / 加权求和要在 h 个子空间里各做一遍,缩放除数从 √d 变成 √d_h;中间短暂出现 h 张 n×n 的权重表 M^(1), M^(2)

Q, K, V  =  X·W_Q , X·W_K , X·W_V          各 [n × d] = [3 × 2]

头 1 取各行的前 d_h=1 列 →  Q₁,K₁,V₁  各 [n × d_h] = [3 × 1]
  S₁ = Q₁·K₁ᵀ / √d_h                              [3 × 3]
  M₁ = softmax(S₁, 按行) + causal mask
  O₁ = M₁·V₁                                      [3 × 1]

头 2 取各行的后 d_h=1 列 →  Q₂,K₂,V₂  各 [3 × 1]
  S₂ = Q₂·K₂ᵀ / √d_h                              [3 × 3]
  M₂ = softmax(S₂, 按行) + causal mask
  O₂ = M₂·V₂                                      [3 × 1]

拼回  O_cat = [ O₁ | O₂ ]   按列拼接              [3 × 2]
输出  A = O_cat·W_O                                 [3 × 2]
步(多头)公式形状说明
③ 投影 Q=X·W_Q · K=X·W_K · V=X·W_V [3, 2] 与单头完全相同,先整表投影
③′ 按头切片 Q_t = Q[:, t·d_h : (t+1)·d_h] (K/V 同理) [3, 1] 头 1 用列 0 · 头 2 用列 1
④ 打分 S_t = Q_t·K_tᵀ / √d_h [3, 3] 每个头一张「词对词」分数表([3×1]·[1×3])
⑤ mask 上三角置极小,与单头相同 [3, 3] 每个头各自 mask,规则一样
⑥ 权重 M_t = softmax(S_t, 按行) [3, 3] 两头可学到不同的关注模式
⑦ 混 Value O_t = M_t·V_t [3, 1] 只在当前头的子空间里加权([3×3]·[3×1])
⑦′ 拼接 O_cat = Concat(O₁,…,O_h) [3, 2] 把 h 段 d_h 维拼回整行 d
⑦″ 输出投影 A = O_cat·W_O [3, 2] W_O 仍是 d×d = 2×2,融合各头结论

拼完 + 乘 W_O 之后,Attention 输出仍是 [n×d]=[3×2],后面 ⑧ FFN 不用改形状。

切片长什么样? — 以「明」那一行(第 2 行)为例,投影后 Q[2] 是 2 个数,切成两段(每段 1 个数):

Q[2] = [ q0 | q1 ]     ← 整行 2 维
          头1  头2
Q₁[2] = [ q0 ]          ← 只在维 0 上打分、做注意力
Q₂[2] = [ q1 ]          ← 只在维 1 上,可能盯完全不同的词

代码里对应 head_start = head * head_dim_,内层循环只在 [head_start … head_start+head_dim_) 那段维上做内积 (第 17 章第 9 节源码)。

两个头,两张不同的 M — 单头只有一张 3×3 权重表;多头各有自己的表(数字仅示意):

头 1  M₁ (可能更盯相邻词)          头 2  M₂ (可能更盯语义相关)
     床   前   明                       床   前   明
明 [ .15 .30 .55 ]                  明 [ .40 .12 .48 ]
     ↑ 头1 对「明」行的分配                 ↑ 头2 对同一行,另一套分配

同一行「明」,两个头可以分给「前」「明」不同的比重;拼回 O_cat[2] 后,再经 W_O 合成最终第 2 行输出。

多头一句话:Split(Q,K,V) → h 次 (mask+softmax+混V) → Concat → ·W_O 进去 [n×d]=[3×2] · 出来还是 [3×2] · 参数量仍约 4d²(四张 d×d 表)

本仓库默认 demo 常设 head_num=2model_dim=64head_dim=32。 上面 d=2, h=2 只为手算形状(每头 d_h=1);换成 d=64, h=8 时,每头 d_h=8,流程完全相同,只是数字更大。

⑧ Add & Norm — 矩阵逐元素加,再每一行单独做 LayerNorm(不是整表一个大矩阵乘):

H₁ = LayerNorm(X + A) [3×2]+[3×2]=[3×2] · 对每行:减均值、除标准差、再乘 γ 加 β

⑨ FFN — 整句写成矩阵,但token 之间仍不通信(每行只和自己的列做乘加):

Z = ReLU(H₁·W₁ + b₁) · F = Z·W₂ + b₂ [3×2]·[2×4]=[3×4] → ReLU → [3×4]·[4×2]=[3×2] · 先撑到 3×4 再收回 3×2

形状链:

H₁  [n × d ]  =  [3 × 2]
         · W₁ [d × f]  =  [2 × 4]  →  Z  [3 × 4]   ← 每行从 2 维升到 4 维
    ReLU(逐元素,形状不变)          [3 × 4]
         · W₂ [f × d]  =  [4 × 2]  →  F  [3 × 2]   ← 再压回 2 维

第 3 节 FFN一致:同一套 W₁/W₂ 对 3 行各算一遍,等价于代码里 for (token_idx…) 循环。

⑩ Block 出口:

H₂ = LayerNorm(H₁ + F)H_final = H₂ (本例只有 1 层) 仍是 3×2 · 第 2 行 H_final[2] 是“看完床前明”的表示

⑪⑫ LM Head + Softmax — 把每个位置的 2 维向量投影到 5 个字的分数:

L = H₂·W_lm + b_lm · L[3×5] [3×2]·[2×5]=[3×5] · 训练时 3 行都算 loss · 生成时只取最后一行 L[2] → softmax → 选「月」
H₂[2]  (1×2)  ·  W_lm (2×5)  +  b_lm  →  logits (1×5)
                                              →  softmax  →  p(月) 最高

若堆 N 层 Block,把上面 ③~⑩ 原样重复 N 次:每层输入输出都是 [n×d]=[3×2], 但每层有自己的一套 W_Q/W_K/W_V/W_O/W₁/W₂(第 17 章第 10 节)。 下面「逐步手算」把同一流水线拆成 9 步,并补上具体数字。第 19 章 §4.4 用另一组维度(n=2, d=4 的「床前」)把同一条流水线再走一遍——两处维度特意不同,对照着看最能分清“哪个数是 n(几个词)、哪个是 d(每个词多宽)”。

逐步手算(同一流水线的数字版)

  1. 1
    Tokenizer 文本 → 一串 id 床前明 → [0, 1, 2] · 形状 [3] 的整数,还没有向量
  2. 2
    Embedding 查表 每个 id → 一条 d_model 维向量 E = EmbeddingTable[[0,1,2]] → 矩阵 X₀ ∈ R^(3×2) 示意: 床→[0.2,−0.5] · 前→[0.9,0.1] · 明→[−0.3,0.8] · 形状 3×2
  3. 3
    + 位置编码 逐行相加,标上“第几位” X = X₀ + PE · 仍 3×2(第 5 节) 例: 明(位置 2) 的行 = [−0.3,0.8] + [0.91,−0.42] = [0.61,0.38] · 形状不变 3×2
  4. 4
    Masked Self-Attention 整表投影 Q/K/V,再按位置加权融合 Q = X·WQ · K = X·WK · V = X·WV · 各 [3×2]·[2×2]=3×2 打分 S = Q·Kᵀ 形状 [3×2]·[2×3]=3×3;对位置 i: 分数ij = Qi·Kj/√d (只看 j≤i) → softmax → 输出i = Σj≤i 权重ij·Vj “明”(i=2) 只能看 床/前/明;算完得新行 A[2] = [0.31, 0.55] · 全序列输出 A ∈ R^(3×2)(中间 S/M3×3)
  5. 5
    Add & Norm 残差相加,再 LayerNorm H₁ = LayerNorm(X + A) · 仍 3×2 “明”行: 先 [0.61,0.38]+[0.31,0.55]=[0.92,0.93],再对该行减均值除标准差 → H₁[2] ≈ [−1.0, 1.0](2 维标准化后必是一正一负)
  6. 6
    FFN(逐 token) 每个位置独立跑同一个小 MLP F[t] = W₂·ReLU(W₁·H₁[t] + b₁) + b₂ · 2→4→2 维(中间升到 d_ff=4) “明”行: H₁[2](2 维)→ 升到 4 维 → ReLU → 降回 2 维 → F[2] = [0.08, 0.62] · token 之间不通信
  7. 7
    Add & Norm Block 出口 H₂ = LayerNorm(H₁ + F) → H_final ∈ R^(3×2) 最后一行 h = H_final[2] = [0.18, 0.92] = “看完床前明后,这个位置对下一字的判断”
  8. 8
    LM Head 最后一行 → 词表上每个字的分数 logits = h · W_lmᵀ + b · [1×2]·[2×5]=1×5logits ∈ R^5 示意 logits: 床−0.3 · 前−0.1 · 明0.2 · 月 1.1(最高) · 山0.0
  9. 9
    Softmax 分数 → 概率 p = softmax(logits) · 生成时取 argmax 或采样 → 下一 token 示意概率: 月 ≈ 0.42(最高) · 明 0.21 · 前 0.18 · …

把注意力单独放大:第 4 步里,“明”这一行的新表示,本质上就是第 17 章手算那套 打分 → softmax → 对 Value 加权求和,只不过这里是对床/前/明 三个位置各算一组权重,再融合三个 Value 行。 训练时第 8 步会对三个位置都算 logits(每个位置都猜“自己的下一个字”),生成时只取最后一行 (第 19 章 4.3)。

步骤输入形状核心运算(矩阵一句话)输出形状
① id3 个字查词表编号[3]
②③ 嵌入+位置[3]X₀=Embed · X=X₀+PE[3, 2]
④ 注意力[3, 2]单头:Q,K,V=X·W · S→M→M·V·W_O · 多头:按列切 h 段各算再 Concat[3, 2](中间 S/M[3, 3])
⑤⑦ Add&Norm[3, 2]LayerNorm(残差相加) 逐行[3, 2]
⑥ FFN[3, 2]ReLU(H₁·W₁+b₁)·W₂+b₂ 每行独立[3, 2](中间 [3, 4])
⑧⑨ LM Head[3, 2]H₂·W_lm+b_lm · 取最后一行 softmax[3, 5] → 末行 [5] 概率

与上文「整矩阵流水线」表一致:从 ② 到 ⑩ 主形状始终是 seq_len × d_model = 3×2;变的是矩阵里每个位置的数值,以及中间几步短暂撑出的 3×3(注意力)、3×4(FFN)、3×5(LM Head)。

和 MNIST 比一比:都在做加权求和,关联方式不同

如果你从MNIST 手写数字一路读过来,可能会问: 语言模型是不是也在用权重 W 做加权求和?和 MNIST 到底差在哪? 答案是:都在做加权求和,差别不在“用不用 W”,而在关联是固定的还是随输入现算的

MNIST(MLP)GPT(语言模型)
输入是什么 一张图摊平成 一个 784 维向量 一句话变成 一串 向量 [n, d_model]
基本运算 h = σ(W·x + b) 矩阵乘 + 激活 同样是矩阵乘、点积、加权求和
“关联”靠谁 固定的 W:像素 i 永远通过 W[j,i] 连到隐藏神经元 j,换一张图接线不变 Attention:权重 = softmax(Q·K),每次前向、每个位置现算,换一句话就变
有没有“谁看谁” 没有 token 概念;整图一次性压进一层 有;“月”该多看“明”还是“床”,由内容决定
FFN / 隐藏层 每层都是全连接 MLP FFN 也是固定 W 的小 MLP,但对每个 token 各跑一遍,token 之间不混
输出层 64 维 hidden → 10 类 logits → softmax d_model 维 hidden → vocab_size 个 logits → softmax(LM Head)
训练更新 新值 = 旧值 − lr × 梯度 完全一样;只是参数种类多了嵌入表、注意力矩阵等

一句话:MNIST = 静态全连接;语言模型 = Attention 动态关联 + 逐位置的固定 MLP(FFN)

把两种网络想成两种“混合信息”的方式

MNIST 像一张焊死的电路板:784 个输入脚到 128 个中间脚,每根线的电阻(W)训练好后就不动了,来什么图都走同一条路。
语言模型 像一场圆桌讨论:每个词先发言(embedding),Attention 让每人按内容决定“多听谁的”(动态权重),FFN 再让每人独自消化一遍(固定 MLP,互不插嘴),最后由 LM Head 读出“下一个字该是谁”。

所以前面那句“MNIST 用 W 求和关联、LLM 用 attention 关联”方向对,但要补半句: LLM 的 FFN、LM Head 仍然在用固定的 W做加权求和;真正和 MNIST 不一样的,是跨词混合那一步——Attention 的权重不是写死在参数里的,而是读句子时当场算出来的。 注意力权重本身不是参数(不存盘、不训练),被训练的是生成 Q/K/V 的 WQ/WK/WV (第 17 章)。

几个最容易混的点

容易混的点正确理解
每层是不是“有一套 Q/K/V”? 参数是每层自己的 WQ/WK/WV/WO; Q/K/V 是前向时用当前隐藏状态现算出来的中间结果。同层内所有位置共享这套参数,层与层之间不共享。
context_size / model_dim / head_num 各管什么? context_size 管一次最多看多少个 token;model_dim 管每个 token 的隐藏向量有多宽;head_num 管把这份宽度切成几个头并行去看,所以 head_dim = model_dim / head_num
训练和生成时主干里传的东西一样吗? 本质一样,传的都是“整条前缀当前的隐藏状态序列”。区别只是:训练通常对所有位置一起算 loss;生成只取最后一个位置的 logits,选一个新 token 再接回去。
KV cache 缓存的是什么? 缓存的是某一层里旧 token 已算好的 K/V 结果,目的是少做重复计算;不是整网只剩一套 K/V,也不是把上下文窗口凭空变大。

把这四条分清,就不容易再把“参数”“中间结果”“窗口长度”“隐藏宽度”这些概念拧在一起。

你也可以把隐藏状态想成一份“整句草稿”。最开始,每个 token 只有一个比较粗糙的“词向量草稿”;过一层 attention 后, 每个位置都读了一遍左边上文,草稿里多了“它和前文是什么关系”;再过一层 FFN,这份草稿又被逐词加工一遍。 所以层和层之间真正传递的,不是“一个个单词轮流往上送”,而是整条序列在当前时刻的一整版草稿

真正的 GPT 家族会换配件,但骨架不变。 上图画的是最稳定的共通骨架。真实模型会在细节上换代: 位置编码可能从论文里的 sin/cos 换成可学习位置向量RoPE;LayerNorm 可能换成 RMSNorm;FFN 里的激活可能从 ReLU / GELU 变成 SwiGLU。但它们都没改那件根本事实: 这是一个 decoder-only Transformer,目标仍是预测下一个 token

小结

  • 残差连接(x + Sublayer(x)):给梯度修高速公路,让深网络训得动,每层只学“增量”。
  • LayerNorm:对每个词向量减均值除标准差再缩放,稳住数值、加速收敛。
  • FFN:对每个词独立做“升维 → ReLU → 降维”,补充非线性加工。
  • 一块 Block = 注意力+残差+Norm,再 FFN+残差+Norm;输入输出同形,可无限堆叠。
  • 位置编码用 sin/cos 把词序加进向量;encoder 双向看全句,decoder 带 causal mask 只看前文。
  • 别混三条轴:context window 决定“看多长”,model_dim 决定“每个 token 多厚”,head_num 决定“把这份厚度切成几个头”。
  • 完整架构 = 编码器塔 + 解码器塔;图中三处注意力(自注意力 / 掩码自注意力 / 交叉注意力)是同一套机制,只是 Q/K/V 来源不同。
  • 交叉注意力让解码器“查阅”编码器输出(Q 来自解码器,K/V 来自编码器);GPT=decoder-only,BERT=encoder-only,T5/翻译=encoder-decoder。
  • GPT 架构 = Prompt tokens → Embedding + Position → N× decoder block → Final Norm → LM Head → 下一个 token 概率,再把生成结果接回去做自回归。
  • 前向时形状几乎始终是 seq_len × d_model;Attention 用 Q·K 现算跨词权重,FFN 用固定 W逐 token 加工;LM Head 与 MNIST 输出层同构。
  • 与 MNIST 的本质差别:关联是静态全连接,还是 Attention 的内容相关动态混合;训练更新规则相同。

动手与思考

问题 1:残差连接 x + Sublayer(x) 为什么有助于训练很深的网络?

那条“+ x”的捷径让梯度在反向传播时能几乎无损地直通回前面的层,缓解梯度消失;同时每个子层只需学习“在输入基础上的增量”,优化更容易。

问题 2:为什么注意力机制需要额外的“位置编码”?

因为注意力对所有位置对称,本身分不清词的先后顺序。位置编码给每个位置一个独特指纹并加到词向量上,模型才能区分“狗咬人”和“人咬狗”。

问题 3:做“预测下一个词”的语言模型,应该用 encoder 还是 decoder?为什么?

用 decoder。因为生成时只能依赖已经出现的前文,必须用 causal mask 屏蔽未来;encoder 能看全句,会“偷看答案”,不适合自回归生成。

问题 4:经典 Transformer 图里有三个注意力模块,它们到底有什么不同?

是同一套注意力机制,区别只在 Q/K/V 的来源:①编码器自注意力(Q/K/V 都来自输入,双向,无 mask);②解码器掩码自注意力(Q/K/V 来自已生成序列,带 causal mask,只看前文);③交叉注意力(Q 来自解码器,K/V 来自编码器输出),让解码时能“查阅原文”。

问题 5:GPT 这种 decoder-only 模型,相对“完整 Transformer 全图”删掉了哪些部分?

删掉了整个编码器塔,也删掉了解码器中部那层交叉注意力。保留下来的主干,就是很多层“带 causal mask 的自注意力 + FFN”反复堆叠,最顶上再接一个 LM Head 去预测下一个 token。

积木齐了!下一章我们把 embedding、Transformer、再加一个“输出层”接起来, 做成一个真正能一个字一个字写下去的字符级语言模型,并看看它怎么采样、怎么评估。