Transformer 架构完全学习指南(程序员版)
面向有编程基础、但没有 AI / 深度学习背景的读者。前置知识:会 Python、知道矩阵乘法。softmax 不熟也没关系,第 4 章会从零推导。不需要任何框架经验。
0. 如何使用这份文档
0.1 你会得到什么
这份文档不是论文摘要,也不是"三分钟看懂"式的类比合集。目标是让你读完后能够:
- 在白纸上默写出
Attention(Q,K,V) = softmax(QK^T/√d_k)V,并说清每个张量的形状。 - 独立推导"为什么除以 √d_k",以及"为什么自注意力必须配位置编码"。
- 从零写出一个能跑的 Encoder 层,遇到 shape 报错能自己定位。
- 看懂 GPT / LLaMA 的结构图,知道它们相对 2017 年原版改了哪些组件、各自解决什么问题。
- 理解 KV Cache、FlashAttention、GQA 这些工程名词背后的具体计算,而不是只记住术语。
0.2 一条建议的阅读路线
| 阶段 | 目标 | 章节 | 可验证的产物 |
|---|---|---|---|
| 一:建立直觉 | 知道模型在算什么、为什么需要注意力 | 1、2 | 能用一句话说清"按内容寻址的软查询" |
| 二:吃透核心 | 逐元素理解自注意力 | 3、4、5 | 能手算 3 个 token 的注意力输出 |
| 三:拼装系统 | 理解层、堆叠、掩码、损失 | 6、7、8、9 | 能画出 Encoder 层与 Decoder 层的差异 |
| 四:工程视角 | 复杂度、推理优化、现代变体 | 10~14 | 能估算 KV Cache 显存与参数量 |
配套代码有两个版本,建议先读纯 NumPy 版:它没有自动微分这个黑盒,每一步都能 print 出来看。
0.3 三个必须先装进脑子的心智模型
- 模型就是一个可微分的函数。 输入是张量,输出是张量,中间全是矩阵乘法和逐元素运算。所谓"参数",就是这个函数里那堆待定的矩阵。不要把它想成"会思考的东西"。
- 训练 = 调参数;推理 = 固定参数反复调用同一个函数。 两者的计算图是同一套,区别只在要不要算梯度、要不要更新矩阵。
- 注意力是一种"软"的数据库查询。 这是全文最重要的类比,下一节展开。
0.4 配套文件
| 文件 | 作用 | 是否需要额外依赖 |
|---|---|---|
Transformer架构学习指南.html | 本文件的阅读增强版:目录导航、矢量图、代码高亮、可折叠自测题 | 浏览器即可,离线可用 |
guide.md | 纯文本源文件,便于做笔记、二次编辑、导入笔记软件 | 无 |
transformer_numpy.py | 从零实现 + 7 组可验证演示,本机已实际跑通 | numpy |
transformer_pytorch.py | Decoder-only 完整模型 + 训练循环 + KV Cache 生成 | torch |
本指南中所有"真实输出"均来自 python transformer_numpy.py 在本机(Python 3.13 / numpy 2.5.4)的实际运行结果,不是示意数字。PyTorch 版因本机未安装 torch 未执行,代码按标准 API 编写,供你自己的环境验证。
1. 先建立直觉:它到底在解决什么问题
1.1 一切从"预测下一个 token"开始
现代语言模型做的事,剥掉所有包装后只有一句:
给定已有的 token 序列,输出下一个 token 在整个词表上的概率分布。token 是文本被切分后的最小单位(可能是词、词的一部分、或一个标点)。词表大小 V 通常 3 万到 20 万。于是模型每一步的输出是一个长度为 V 的向量,softmax 之后每个分量是"下一个 token 是它"的概率。
"今天天气" -> [ "很":0.41, "不":0.22, "真":0.09, ",":0.05, ... ] -> 采样/取最大值 -> "很"生成一整句话,就是把预测出来的 token 拼回输入,再调用一次同样的函数。这个循环叫自回归(autoregressive)。
1.2 难点在于:词的含义由上下文决定
| 句子 | "苹果" 指的是 |
|---|---|
| 我买了一斤苹果 | 水果 |
| 苹果发布了新手机 | 公司 |
再比如"小王把书给了小李,他很开心"里的"他"指谁。一个静态的词向量表无法处理这件事——同一个 token id 查表永远得到同一个向量。
所以模型需要一种机制:让每个位置的表示,能够根据同一句话里其他位置的内容动态改变。这就是注意力的全部出发点。
1.3 前代方案卡在哪
| 方案 | 做法 | 瓶颈 |
|---|---|---|
| RNN / LSTM | 逐 token 串行,用隐状态传递历史 | 第 1 个词到第 n 个词的信息路径长度 O(n),长距离依赖被反复压缩后丢失;且无法并行,GPU 空转 |
| CNN | 堆叠卷积扩大感受野 | 感受野仍是局部的,覆盖全句需要堆很多层;路径长度 O(log n) 或 O(n/k) |
| Seq2Seq + Attention (2015) | 仍以 RNN 为骨架,只在解码时额外"回看"编码器 | 骨架依旧串行,注意力只是补丁 |
关键指标是任意两个位置之间的信息路径长度:RNN 是 O(n),注意力是 O(1)——一次矩阵乘法,所有位置两两直接通信。
1.4 Transformer 的核心赌注
既然要建模"所有 token 之间的关系",那就别绕路了:让每个位置一步之内直接看到所有位置,代价是两两比较带来的 O(n²) 计算量。
这个取舍非常"程序员":用可并行的、算力换距离的稠密计算,替代不可并行的、省算力但路径长的递归。GPU 擅长前者,于是规模一上来,效果就碾压了 RNN。
O(n²) 也是后来所有优化的靶子:长上下文、FlashAttention、稀疏注意力、线性注意力,本质上都在处理这张 n×n 的矩阵。
1.5 用数据库类比理解注意力(务必先吃透这一节)
假设有一张表,每行有 Key(索引键)和 Value(数据)。一次查询是:
SELECT Value FROM table WHERE Key MATCHES query;注意力做的事几乎一样,但有三点"软化":
| 数据库概念 | 注意力中的对应 | 说明 |
|---|---|---|
| 查询条件 query | 向量 Q | 当前这个 token 发出的"我想找什么" |
| 索引键 Key | 向量 K | 每个 token 挂出的"我是什么、可以被怎么检索" |
| 返回数据 Value | 向量 V | 真正被取回、用来更新表示的内容 |
| 匹配度 | Q · K^T 点积 | 向量越同向,分数越高 |
| 归一化 | softmax | 把匹配度变成一组和为 1 的权重 |
| 硬匹配一行 | 加权平均所有行 | 软查询:不是取一行,而是按权重混合所有 Value |
一句话总结:每个 token 拿自己的 Q 去和所有 token 的 K 打分,再按分数把所有 token 的 V 加权求和,得到自己的新表示。
这解释了两个常见疑问:
- 为什么 Q/K/V 要分开?因为"被用来检索的标签"和"被取回的内容"不必相同,分开投影让模型有更大自由度。
- 为什么叫"自"注意力?因为 Q、K、V 都来自同一组输入
X(自己查自己)。若 K/V 来自另一组输入,就叫交叉注意力。
2. 全局架构:先看森林
2.1 原论文是 Encoder-Decoder 结构
2017 年《Attention Is All You Need》解决的是机器翻译,所以需要两半:
- 左半(Encoder):读完整句原文。每个位置可以双向看全句,产出一组"上下文化"的表示,形状
[n_src, d_model]。 - 右半(Decoder):逐词生成译文。只能看已生成的 prefix(因果掩码),并通过交叉注意力去 Encoder 的输出里取信息。
- 两侧各堆叠
N=6个结构相同的层。
2.2 一次完整的数据流(把形状记住)
设源句 n_src=12、已生成译文 n_tgt=7、词表 V=37000、原版 base 配置 d_model=512:
| 阶段 | 张量 | 形状 | 说明 |
|---|---|---|---|
| 分词 | token ids | [12] | 整数,非浮点 |
| 嵌入 + 位置编码 | X | [12, 512] | 每个 token 一个 512 维向量 |
| Encoder 每层输入/输出 | X | [12, 512] | 形状不变,这是能堆叠 N 层的原因 |
| Encoder 最终输出 | M | [12, 512] | 供 Decoder 交叉注意力当 K/V |
| Decoder 自注意力分数 | scores | [7, 7] | 下三角有效 |
| Decoder 交叉注意力分数 | scores | [7, 12] | Q 长度 7,K/V 长度 12,允许矩形 |
| 输出头 | logits | [7, 37000] | 每个位置一个词表分布 |
| 损失 | 标量 | [] | 交叉熵,padding 位忽略 |
带 batch 维时,所有形状前面补一个 B:[B, n, d_model]。若看到 [B, h, n, d_k] 那是拆头之后的形状。遇到 bug,第一时间把实际 shape 打印出来对照这张表。
2.3 现代 LLM 只用右半边
| 架构 | 代表模型 | 注意力可见范围 | 典型用途 |
|---|---|---|---|
| Encoder-only | BERT | 双向,全句可见 | 理解、分类、检索、Embedding |
| Encoder-Decoder | 原始 Transformer、T5 | 编码器双向 + 解码器单向 | 翻译、摘要、条件生成 |
| Decoder-only | GPT、LLaMA、Qwen | 单向(因果) | 通用生成、对话、推理 |
Decoder-only 成为主流的原因:单一自回归目标可以覆盖几乎所有任务(把任务本身也写成文本),训练目标与推理形态完全一致,且不需要成对语料,工程上最简单、最容易规模化。
本指南第 3~6 章按"通用组件"讲,第 7 章单独讲 Decoder 特有的部分。 只看 Decoder-only 的读者也不会有障碍。
2.4 记住三层嵌套就够了
- 微观:缩放点积注意力 —— 一次加权平均,无参数(参数在前后投影里)。
- 中观:多头注意力 + 残差与归一化 + FFN = 一个层(Layer / Block)。
- 宏观:嵌入 → N 个层 → 输出头 = 完整模型。
后面所有内容,都是把这三层的每一格拆开。
3. 输入层:从字符串到矩阵
3.1 分词 Tokenization
模型只认整数。分词器把文本切成 token 并映射为 id。主流用 BPE(Byte-Pair Encoding):从字符开始,反复合并最高频的相邻单元,直到词表达到设定大小。
"transformer" -> ["transform", "er"] -> [18421, 312]要点:
- 词表大小固定,未登录词被拆成更小的片段,永远不会 OOV。
- 词表大小
V直接决定输出头和词嵌入的参数量(第 12 章会算)。 - 分词发生在模型之外,是独立的、不可微的预处理步骤。
3.2 词嵌入 Embedding
nn.Embedding(V, d_model) 本质上就是一张可学习的查表:矩阵 W_e 形状 [V, d_model],token id i 取出第 i 行。
X = W_e[token_ids] # [n] -> [n, d_model]训练时梯度回写到这张表,语义相近的词逐渐在向量空间中靠近。注意此时每个 token 的向量与上下文无关——1.2 节的问题还没解决,要靠后面的注意力层解决。
3.3 位置编码:为什么必须有
这是理解 Transformer 的第一道分水岭,也是最容易背下来却没真懂的点。
结论:纯自注意力把输入当作"集合",对顺序完全不敏感。 数学上,若把输入 X 的行按任意排列 P 打乱,输出也会严格地按同样方式换位(置换等变),不会多出任何区分信息。
配套脚本的「演示 4」用随机权重实测了这一点:
原始输出第 0 行 == 打乱后输出第 0 行 ? False
原始输出第 2 行 == 打乱后输出第 0 行 ? True把输入按 [2,0,1] 打乱后,输出的第 0 行恰好等于原来第 2 行。"我吃苹果"和"苹果吃我"在注意力层眼里是同一批向量,只是摆放顺序不同。
注意力刻意不内置顺序,换取了完全并行和对任意长度的统一处理。代价就是位置信息必须由外部显式注入。所有"位置编码"方案都是在补这一课。
3.4 正弦位置编码:公式与直觉
原论文用固定的(不可学习的)三角函数:
PE(pos, 2i) = sin( pos / 10000^(2i/d_model) )
PE(pos, 2i+1) = cos( pos / 10000^(2i/d_model) )
X = Embedding(tokens) + PE # 逐元素相加,形状仍是 [n, d_model]其中 pos 是位置下标,i 是维度对的下标(0 到 d_model/2-1)。
为什么这个设计成立? 把不同 i 看成不同频率的时钟:
i越小,分母越接近 1,波长越短,位置变化一点点数值就剧烈变化("秒针")。i越大,波长越长(10000·2π量级),跨越上千个位置才走完一个周期("时针")。
这正像一组连续的二进制编码:低位快变、高位慢变,组合起来给每个位置一个几乎唯一的指纹。而且它是确定性的、可外推到训练时没见过的更长序列(可学习的位置表做不到这点)。
另一个优美性质:对任意固定偏移 k,PE(pos+k) 可以表示为 PE(pos) 的线性变换(旋转矩阵),这让模型更容易学到"相对距离"而非"绝对位置"。后来的 RoPE 把这个思想直接做进了注意力打分里。
# transformer_numpy.py 中的实现(可直接对照)
def positional_encoding(n_pos, d_model):
pe = np.zeros((n_pos, d_model))
pos = np.arange(n_pos)[:, None] # [n_pos, 1]
i = np.arange(0, d_model, 2) # [d_model/2]
angle = pos / np.power(10000.0, i / d_model) # 广播 -> [n_pos, d_model/2]
pe[:, 0::2] = np.sin(angle)
pe[:, 1::2] = np.cos(angle)
return pe3.5 是相加还是拼接?
原版是相加。原因很实际:拼接会把维度变成 2·d_model,之后每一层的矩阵都得跟着改;相加保持 [n, d_model] 不变,且实验证明足够。直觉上的担心"位置信号会不会被词向量淹没"并不成立——高维空间里两个随机向量近似正交,信息可以共存。
3.6 位置方案的演进
| 方案 | 注入位置 | 外推性 | 代表 |
|---|---|---|---|
| 正弦绝对 PE | 输入层相加 | 较好(确定函数) | 原始 Transformer |
| 可学习绝对 PE | 输入层相加 | 差(超出训练长度无定义) | BERT、GPT-2 |
| RoPE 旋转位置编码 | 在注意力里对 Q/K 做旋转 | 好,配合插值可扩展 | LLaMA、Qwen、GLM |
| ALiBi | 在分数上直接加距离惩罚 | 好 | BLOOM、MPT |
共同点:都在打破注意力的置换不变性,只是注入点不同。
4. 核心:缩放点积注意力(全文最重要的一章)
4.1 一句话公式
Attention(Q, K, V) = softmax( Q · K^T / √d_k ) · V请把它当成一个函数签名来读:输入三个矩阵,输出一个矩阵。下面逐块拆。
4.2 Q、K、V 从哪来
对同一份输入 X([n, d_model])做三次独立的、可学习的线性投影:
Q = X · W_Q W_Q : [d_model, d_k]
K = X · W_K W_K : [d_model, d_k]
V = X · W_V W_V : [d_model, d_v]为什么要投影而不直接用 X?因为"我拿什么去检索"、"我挂什么标签供别人检索"、"我被取回时交付什么内容"是三种不同的语义角色。三个矩阵让模型自己学出这三种角色。这些 W 就是注意力子层的参数,训练时会被更新。
4.3 逐步维度追踪(程序员最需要的一张表)
以单头、n=6、d_model=512、d_k=d_v=64 为例:
| 步骤 | 运算 | 输入形状 | 输出形状 | 含义 |
|---|---|---|---|---|
| 1 | X @ W_Q | [6,512] @ [512,64] | Q [6,64] | 6 个查询向量 |
| 2 | X @ W_K | 同上 | K [6,64] | 6 个键向量 |
| 3 | X @ W_V | 同上 | V [6,64] | 6 个值向量 |
| 4 | Q @ K.T | [6,64] @ [64,6] | scores [6,6] | 每个 query 对每个 key 的原始分数 |
| 5 | / √64 | [6,6] | [6,6] | 缩放,防止 softmax 饱和 |
| 6 | mask(可选) | [6,6] | [6,6] | 屏蔽位置填 -inf |
| 7 | softmax(dim=-1) | [6,6] | weights [6,6] | 按行归一化,每行和为 1 |
| 8 | weights @ V | [6,6] @ [6,64] | out [6,64] | 按权重混合所有 Value |
三个必须记牢的细节:
scores[i][j]的含义是"第 i 个 token 应该多关注第 j 个 token"。行 = 查询方,列 = 被查询方。softmax在最后一个维度(对列 / 对 key)做归一化。写成softmax(scores, axis=-1)。若误写成axis=0就变成按列归一化,语义完全错了,而且形状仍然对、不会报错——这是最阴险的 bug 之一。- 输出的行数等于 Q 的行数,列数等于 V 的列数。K 的长度只影响中间那张 n×n 矩阵,这也是交叉注意力里 Q/K 长度可以不同的原因。
4.4 真实手算例子(可直接验算)
配套脚本「演示 1」用 3 个 token、d_k=2,刻意让第 0 个和第 2 个 key 同向。真实运行输出:
Q =
[[1. 0. ]
[0. 1. ]
[1. 0.1]]
QK^T / sqrt(d_k) =
[[0.7071 0. 0.7071]
[0. 0.7071 0. ]
[0.7071 0.0707 0.7071]]
注意力权重(每行和=1)=
[[0.4011 0.1978 0.4011]
[0.2483 0.5035 0.2483]
[0.3954 0.2092 0.3954]]
每行合计 = [1. 1. 1.]
输出 = weights @ V =
[[0.8022 0.3956]
[0.4965 1.007 ]
[0.7908 0.4185]]逐行验算第 0 行(V 的三行分别是 [1,0]、[0,2]、[1,0]):
scores[0] = [1,0]·[1,0]/√2 = 0.7071, [1,0]·[0,1]/√2 = 0, [1,0]·[1,0]/√2 = 0.7071
weights = softmax([0.7071, 0, 0.7071]) = [0.4011, 0.1978, 0.4011]
out[0] = 0.4011·[1,0] + 0.1978·[0,2] + 0.4011·[1,0]
= [0.4011+0.4011, 0.1978·2] = [0.8022, 0.3956] ✓ 与程序输出一致读法:第 0 个 token 发现"第 0 和第 2 个 key 跟我最像",于是各取 40% 权重;它俩的 Value 都是 [1,0],所以输出主要沿 [1,0] 方向,并掺入一点第 1 个 token 的 [0,2]。这就是"按内容相关性取回信息"的全部机制。 把这句话理解了,注意力就懂了。
4.5 为什么必须除以 √d_k(完整推导)
设 q, k ∈ R^{d_k},各分量独立同分布、均值 0、方差 1。点积
q·k = Σ(i=1..d_k) q_i · k_i每项 q_i·k_i 均值 0、方差 1(独立变量乘积的方差 = 方差之积),d_k 项相加:
E[q·k] = 0
Var(q·k) = d_k => 标准差 = √d_k也就是说,维度越高,点积的绝对量级越大。若不缩放,d_k=512 时分数标准差约 22.6;softmax 里有 exp(),最大的那个分数会彻底压过其他项,输出趋近 one-hot,梯度趋近 0(饱和),训练直接卡住。除以 √d_k 后方差归一到 1,softmax 保持在有梯度的区间。
配套脚本「演示 3」用 20000 组随机向量实测(真实输出):
| d_k | 未缩放点积标准差 | 理论 √d_k | 除以 √d_k 后 |
|---|---|---|---|
| 4 | 2.011 | 2.000 | 1.006 |
| 64 | 8.058 | 8.000 | 1.007 |
| 512 | 22.669 | 22.627 | 1.002 |
数值与推导完全吻合:缩放不是拍脑袋的技巧,是把方差归一化的必要步骤。
4.6 两个实现层面的坑
- softmax 数值稳定:实现里先减去每行最大值再
exp,否则exp(1000)直接溢出。配套脚本第一行就是shifted = x - np.max(x, axis=axis, keepdims=True)。 - mask 不要用 0:把屏蔽位的分数设成 0,
softmax后它仍会拿到exp(0)=1的权重,屏蔽完全失效。必须设成负无穷。工程上常用-1e9而不是-inf:若某一行被全部屏蔽,-inf会让该行分子分母全为 0 产生 NaN,而-1e9会退化成均匀分布,至少数值安全。
scores = np.where(mask, scores, -1e9) # 配套脚本的写法
weights = softmax(scores, axis=-1)4.7 注意力"没有"什么(避免误解)
| 没有 | 说明 |
|---|---|
| 没有内置顺序 | 置换等变,位置信息靠外部注入(第 3 章) |
| 没有非线性变换 | 除 softmax 外全是线性的加权平均;非线性由 FFN 提供 |
| 没有跨层记忆 | 单层只看输入;长距离推理靠堆叠 N 层 + 残差逐步累积 |
| 注意力本身无参数 | 参数在 W_Q/W_K/W_V/W_O 四个投影里,加权平均这一步是固定的 |
5. 多头注意力 Multi-Head Attention
5.1 为什么不只用一个头
单个头只有一套 W_Q/W_K,意味着只有一种"相似度"的定义。但一句话里同时存在多种关系:主谓、指代、相邻词、同位、位置……一个分布很难兼顾。
多头 = 把 d_model 维空间切成 h 个子空间,每个头有自己独立的投影矩阵,各自在不同的表示子空间里做一次注意力,最后把结果拼起来融合。
MultiHead(X) = Concat(head_1, ..., head_h) · W_O
head_i = Attention(X·W_Q_i, X·W_K_i, X·W_V_i)原版 base:h=8,d_model=512,于是 d_k = d_v = 512/8 = 64。
5.2 工程实现:一次投影 + reshape,不是 for 循环
这是新手最容易误解的实现细节:框架里不会循环 h 次。做法是先用一整个 [d_model, d_model] 矩阵一次算出所有头,再 reshape 拆分。
# transformer_numpy.py:拆头 / 合头,注意没有任何新参数
@staticmethod
def _split_heads(x, h, d_k):
"""[n, d_model] -> [h, n, d_k]"""
n = x.shape[0]
return x.reshape(n, h, d_k).transpose(1, 0, 2)
@staticmethod
def _merge_heads(x):
"""[h, n, d_k] -> [n, d_model]"""
h, n, d_k = x.shape
return x.transpose(1, 0, 2).reshape(n, h * d_k)
# 每个头的分数用一次 einsum 批量算完,等价于 h 个 Qh @ Kh.T
scores = np.einsum('hqd,hkd->hqk', Qh, Kh) / np.sqrt(self.d_k)配套脚本「演示 5」的真实输出:
X [5,8] -> Q [5,8] -> split heads (2, 5, 4) -> merge (5, 8)带 batch 时形状是 [B, n, d_model] → [B, h, n, d_k] → [B, n, d_model]。 PyTorch 版用的正是这一套:
q = q.view(B, T, self.n_heads, self.d_k).transpose(1, 2) # [B, h, T, d_k]
scores = (q @ k.transpose(-2, -1)) / math.sqrt(self.d_k) # [B, h, T, S]transpose 之后张量在内存里不再连续,拼接前必须 .contiguous() 才能 .view()。配套 PyTorch 代码里写的是 out.transpose(1, 2).contiguous().view(B, T, C),这行不是可有可无的。
5.3 参数量并没有变多
把 h 个 d_k 维的头合起来看:
每个头 4 个矩阵,各 d_model × d_k,合计 h · 4 · d_model · (d_model/h) = 4 · d_model²和"一个 d_model 维的大头"完全相同。多头是表达能力的重新分配,不是参数量的膨胀。 代价只在于中间那张 n×n 分数矩阵变成 h 份([B, h, n, n])。
5.4 W_O 是干什么的
Concat 只是把各头结果首尾接上,各子空间之间还没有交互。W_O([d_model, d_model])再做一次线性变换,让模型学习"如何综合各头的结论"。去掉它,多头就退化成 h 个互不通气的独立通道。
5.5 五个常见误区
- ~~多头 = 算 h 次注意力~~ → 一次大矩阵乘法 + reshape,完全并行。
- ~~每个头的维度是 d_model~~ → 是
d_model/h。 - ~~头越多越好~~ →
h增大则d_k减小,单头表达力下降;h=8/16/32/…是经验折中。 - ~~不同头一定学到不同语言学规律~~ → 常被这样解释,但严格说只是"不同子空间",实际可视化后会发现部分头功能冗余,甚至可被剪枝。
- ~~多头注意力有 h 套缓存~~ → 推理时 KV Cache 按头分别存,所以才有了后面 GQA/MQA 的优化空间(第 13 章)。
6. 组装一个 Encoder 层
6.1 一层 = 两个子层 + 两次残差 + 两次归一化
原论文采用 Post-LN:
X' = LayerNorm( X + MHA(X) )
X'' = LayerNorm( X' + FFN(X') )现代模型普遍改用 Pre-LN(配套代码用的就是这个):
X' = X + MHA( LayerNorm(X) )
X'' = X' + FFN( LayerNorm(X') )读法:每个子层的输出加回输入(残差),然后归一化。整层输入输出形状恒为 [n, d_model]。
6.2 FFN:注意力的非线性搭档
FFN(x) = W2 · activation(W1·x + b1) + b2
W1 : [d_model, d_ff] W2 : [d_ff, d_model] d_ff = 4·d_model (原版 2048)三个要点:
- 逐位置(position-wise):对序列里每个 token 独立施加同一个 FFN,token 之间在这里不通信。通信只发生在注意力子层。可以理解为"注意力负责横向交换信息,FFN 负责纵向加工每个位置"。
- 非线性从哪来:注意力除去 softmax 基本是线性加权平均;如果堆 N 层全是线性操作,整体等价于一个线性变换。FFN 里的 ReLU/GELU 是模型表达力的主要来源之一。
- 参数占比最大:
2·d_model·d_ff = 8·d_model²,是注意力子层4·d_model²的两倍(第 12 章细算)。
一个有用的视角:把 W1 的每一行看成一组可学习的 "key",W2 的对应列看成 "value",ReLU 充当匹配度。于是 FFN 近似一个与输入无关的静态键值记忆库,注意力则是动态的、由内容决定的检索。两者互补。
6.3 残差连接:为什么能堆到上百层
x + Sublayer(x) 提供了一条恒等路径,反向传播时梯度可以沿这条路径原封不动地回传(∂output/∂x 里恒有 1 这一项),不会随层数指数衰减。若没有残差,几十层的 Transformer 基本训不动。
工程直觉:初始化时把子层输出投影矩阵设得较小(如 std=0.02),让每层初始接近恒等映射,训练再从这里逐步偏离。配套 PyTorch 代码的 _init_weights 就是这么做的。
6.4 为什么是 LayerNorm 不是 BatchNorm
| 维度 | BatchNorm | LayerNorm |
|---|---|---|
| 归一化轴 | 跨 batch 的同一特征维 | 单个样本内部的所有特征维 |
| 依赖 batch 统计量 | 是,训练/推理行为不同 | 否,逐样本独立计算 |
| 变长序列 / padding | 难处理,不同长度统计量不可比 | 天然适配 |
| 小 batch / 自回归生成 | 失效(推理时 batch 常为 1) | 完全正常 |
一句话:BatchNorm 需要"一批样本同一位置"的统计,NLP 的变长序列和逐 token 生成让它无从下手;LayerNorm 只看一个 token 自己的 d_model 个数,任何时候都能算。
def layer_norm(x, gamma, beta, eps=1e-5):
mean = np.mean(x, axis=-1, keepdims=True) # [n, 1],注意是最后一维
var = np.var(x, axis=-1, keepdims=True)
return gamma * (x - mean) / np.sqrt(var + eps) + beta6.5 Pre-LN 还是 Post-LN
| 对比 | Post-LN(原版) | Pre-LN(现代主流) |
|---|---|---|
| 归一化位置 | 残差相加之后 | 送入子层之前 |
| 残差路径纯净性 | 梯度要穿过 LayerNorm | 残差路径上没有任何变换 |
| 训练稳定性 | 差,深层容易梯度爆炸/消失 | 好,可直接堆几十上百层 |
| 学习率 warmup | 必需,否则训崩 | 可省略或很短 |
| 最终效果(调到位时) | 略优 | 略逊但差距很小 |
结论很实际:原版靠 warmup 驯服 Post-LN,现代模型直接用 Pre-LN 换稳定性,省下的调参成本远比那点效果损失值钱。
6.6 Dropout 放在哪
原版 P_drop=0.1,位置包括:注意力权重(softmax 之后)、各子层输出(残差相加之前)、嵌入与位置编码之和。不要放在 softmax 之前——那会随机改掉相似度分数本身。
6.7 形状不变性 = 可堆叠
配套脚本「演示 6」堆叠 3 个 Encoder 层(n=6, d_model=32, h=4, d_ff=128),真实输出:
经过 EncoderLayer 0: shape=(6, 32), 均值=+0.0515, 标准差=0.1047
经过 EncoderLayer 1: shape=(6, 32), 均值=+0.0526, 标准差=0.1054
经过 EncoderLayer 2: shape=(6, 32), 均值=+0.0561, 标准差=0.1067输入 [n, d_model] 出来还是 [n, d_model],所以 for layer in stack: X = layer(X) 可以写任意多层。这是深度学习里"层"这个抽象最直接的体现。
7. Decoder 与三种掩码
7.1 因果掩码:训练并行与推理串行的和解
这里有个初看很矛盾的地方:
- 推理时只能一个一个往外吐词,第 3 个词生成时,第 4 个词还不存在。
- 训练时整句译文是已知的,我们希望能一次把整句喂进去并行算,不然 GPU 又白闲了。
解决办法叫 Teacher Forcing + 因果掩码:训练时把正确的完整译文右移一位作为输入(不管模型上一步预测对不对,都喂标准答案),再用一个下三角 mask 把"未来位置"屏蔽掉,让每个位置在数学上看不到它不该看的内容。于是"并行训练"和"单向生成"同时成立。
def causal_mask(n):
return np.tril(np.ones((n, n), dtype=bool)) # 下三角 True = 允许关注配套脚本「演示 2」的真实输出(n=4):
mask(True=允许)=
[[1 0 0 0]
[1 1 0 0]
[1 1 1 0]
[1 1 1 1]]
权重(上三角必须为 0)=
[[1. 0. 0. 0. ]
[0.7637 0.2363 0. 0. ]
[0.9559 0.0033 0.0408 0. ]
[0.3947 0.0491 0.3451 0.2112]]第 0 个位置只能看自己,权重恰为 [1, 0, 0, 0];第 3 个位置可以看全部 4 个位置。上三角严格为 0,断言已通过。
若把 scores 上三角置 0,softmax 后 exp(0)=1 仍会分到权重,模型等于在训练时偷看答案;推理时却看不到,训练/推理分布不一致,生成会彻底崩坏。这是从零实现时最高频的 bug。
7.2 交叉注意力 Cross-Attention
Decoder 层比 Encoder 层多夹了一个子层,因此是三个子层:
- Masked Self-Attention:Q/K/V 全来自 Decoder 输入,带因果掩码。
- Cross-Attention:
Q来自 Decoder 上一子层的输出,而K、V来自 Encoder 的最终输出M。 - FFN:同 Encoder。
关键区别是 Q 和 K/V 来源不同、长度也可以不同:
Q : [n_tgt, d_model] K,V : [n_src, d_model]
scores : [n_tgt, n_src] (矩形矩阵,不是方阵)
out : [n_tgt, d_model] (行数跟随 Q)直觉:翻译时,每生成一个中文词,解码器拿当前状态当查询,去原文所有位置"检索"该看哪里——这就是经典 seq2seq 里的 alignment(对齐),只不过全程可微、并行。
配套 NumPy 版 MultiHeadAttention.forward(X, Y=...) 用 Y 参数区分自注意力与交叉注意力;PyTorch 版是 Decoder-only,没有这一层。
7.3 Padding Mask
一个 batch 里句子长短不一,短句补的 <pad> 不应参与注意力。做法是构造 [B, 1, 1, n] 的布尔掩码,把所有 key 为 pad 的列屏蔽。它和因果掩码通常按位与合并后一起填 -inf。损失计算时也要用 ignore_index=-100 把这些位置的 loss 排除,否则模型会花力气去预测 padding。
7.4 三种注意力对比
| 类型 | Q 来自 | K/V 来自 | 掩码 | 分数形状 | 用途 |
|---|---|---|---|---|---|
| Encoder 自注意力 | 源句 X | 源句 X | padding | [n_src, n_src] | 双向理解 |
| Decoder 掩码自注意力 | 译文 Y | 译文 Y | 因果 + padding | [n_tgt, n_tgt] | 保证自回归 |
| 交叉注意力 | 译文 Y | 编码器输出 M | padding (对 key) | [n_tgt, n_src] | 对齐源句 |
8. 输出头、损失与标签平移
8.1 从隐藏向量回到词表
最后一个层输出 H : [n, d_model],接一个线性层投影到词表:
logits = H · W_head W_head : [d_model, V] -> logits : [n, V]
probs = softmax(logits, dim=-1)每个位置得到一个长度 V 的分布。注意 V 通常几万,W_head 是模型里最大的单块参数之一。
8.2 权重绑定 Weight Tying
词嵌入 W_e : [V, d_model] 和输出头 W_head : [d_model, V] 形状互为转置。原论文让二者共享同一份权重(再转置使用),GPT-2、多数现代实现也这么做:
self.head = nn.Linear(d_model, vocab_size, bias=False)
self.head.weight = self.tok.weight # 配套 PyTorch 版中的一行收益:省下 V·d_model 个参数(原版约 1900 万),且输入/输出共用同一套语义空间,通常还略提升效果。
8.3 标签右移(shift)
语言模型在位置 t 要预测的是 token[t+1],所以标签就是输入序列整体左移一位。不要错位。
| 输入 input | 我 | 今天 | 天气 | 很 | 好 |
|---|---|---|---|---|---|
| 标签 label | 今天 | 天气 | 很 | 好 | <eos> |
配套 PyTorch 版的玩具数据直接体现了这一点:
seq = (start + torch.arange(seq_len)) % vocab_size
return seq[:, :-1], seq[:, 1:] # x 去掉最后一位,y 去掉第一位因果掩码保证位置 t 看不到 t+1 及之后,因此"输入里已经含有答案"并不会造成信息泄漏。这两件事是配套的:mask 负责屏蔽未来,shift 负责对齐标签。
8.4 交叉熵与 padding
loss = F.cross_entropy(
logits.reshape(-1, V), targets.reshape(-1), ignore_index=-100
)把 [B, n, V] 展平成 [B·n, V],与展平后的标签逐位置算交叉熵;-100 是 PyTorch 约定中被忽略的标签值(对应 padding)。注意不要先 softmax 再算交叉熵,框架内部用 log-sum-exp 做了数值稳定处理。
8.5 Teacher Forcing 的副作用
训练时每一步都喂标准答案,推理时喂的是模型自己的输出。这种训练/推理分布不一致叫暴露偏差(exposure bias),是生成模型偶尔"一句之后越说越跑偏"的原因之一。缓解手段包括 scheduled sampling、强化学习 / RLHF、以及各类解码约束。实践中它通常不是首要问题,但值得知道这个名词指的是什么。
9. 训练配方:把原论文的设置抄一遍
| 项 | 原版 base 设置 | 为什么 |
|---|---|---|
| 优化器 | Adam,β1=0.9, β2=0.98, ε=1e-9 | 自适应学习率,β2 比默认 0.999 小,对稀疏梯度更稳 |
| 学习率 | Noam schedule,warmup_steps=4000 | 见下方公式 |
| 标签平滑 | ε_ls = 0.1 | 不让模型对预测过度自信,提升泛化与 BLEU |
| Dropout | 0.1(注意力权重、子层输出、嵌入和) | 正则 |
| 层数 / 维度 | N=6, d_model=512, d_ff=2048, h=8 | base;big 为 N=6, d_model=1024, d_ff=4096, h=16 |
| 推理 | beam size 4,长度惩罚 α=0.6 | 解码阶段,非训练 |
学习率公式:
lrate = d_model^(-0.5) · min( step^(-0.5), step · warmup_steps^(-1.5) )读法:前 4000 步线性升(第二项更小,随 step 增大),之后按 1/√step 衰减。为什么需要 warmup?训练初期参数是随机的、梯度方差大,且 Post-LN 结构对大学习率敏感;先用小步让模型进入稳定区域,再放大学习率,否则很容易训崩。这也解释了第 6.5 节中 Pre-LN 为什么能省掉 warmup。
学习率调度就像"服务启动时的预热":冷启动直接打满流量容易雪崩,先小流量把缓存和连接池养起来,再放开。这里预热的是参数的统计状态。
9.1 训练时显存花在哪
显存 ≠ 参数量。训练时要存:参数(fp32)、梯度、Adam 的两个动量状态、以及每一层的激活值用于反向传播。以 fp32 计,参数+梯度+两个动量约 4 × 参数字节数,混合精度下还要保留一份 fp32 主副本。激活值则随 B·n·d_model·层数 线性增长,并随 B·h·n² 二次增长。
这就是为什么"模型只有 7B 参数,却要几十 GB 显存才能训练",也是梯度检查点(activation checkpointing,用重算换显存)存在的原因。
10. 推理:KV Cache 是必须懂的工程优化
10.1 朴素生成为什么慢
自回归生成第 t 个 token 时,若把长度为 t 的整个前缀重新喂进模型:
- 前
t-1个 token 的 K、V 被重复计算了t次; - 前
t-1个 token 的 FFN、LayerNorm 也全部白算; - 单步要构造
t×t的注意力矩阵。
而因果掩码保证了:已生成 token 的表示不会因未来 token 而变化(第 i 行只看 0..i)。既然历史 K/V 是定值,就不该反复算。
10.2 KV Cache 的原理(一句话推导)
第 t 步只需要新 token 的查询 q_t。它与历史所有 key 的分数是 q_t · K_{0..t};历史 K_{0..t-1}、V_{0..t-1} 在上一步已经算过且永不改变。于是把每个层、每个头的 K/V 缓存下来,每步只追加新的一行:
第 0 步:输入完整 prompt -> 计算并缓存 K[:, 0:T], V[:, 0:T]
第 t 步:只输入 1 个新 token -> 算出 k_t, v_t,拼接进缓存
scores = q_t @ K_cache.T # [1, t+1],不再是 (t+1)×(t+1)复杂度对比(只列注意力部分,n 为最终长度):
| 口径 | 朴素生成 | KV Cache |
|---|---|---|
| 单步(长度为 t 时)的分数矩阵 | t × t,O(t²·d) | 1 × t,O(t·d) |
| 累计注意力计算 | O(n³·d) | O(n²·d) |
| 累计线性层 / FFN 计算 | O(n²·d²) | O(n·d²) |
也就是说,KV Cache 把"每步重算整段"变成"每步只算增量",这是所有推理框架(vLLM、TensorRT-LLM、llama.cpp)的基石。代价是显存换计算。
10.3 显存估算(这个数字面试和选型都常用)
KV Cache 字节数 = 2 × L × n_kv_heads × d_head × seq_len × bytes_per_element × batch
↑ K 和 V 两份 ↑ 层数 ↑ 头数 ↑ 每头维度以类 LLaMA-7B 配置(L=32, n_heads=32, d_head=128,即 d_model=4096,fp16 = 2 字节)计算:
2 × 32 × 32 × 128 × 2 = 524,288 字节 = 512 KiB / token(单序列)
上下文 4096 token = 512 KiB × 4096 = 2 GiB若采用 GQA 把 KV 头数降到 8:2 × 32 × 8 × 128 × 2 = 128 KiB/token,4096 上下文只需 512 MiB,直接省 4 倍。这就是第 13 章 GQA 的现实动机——长上下文服务里,KV Cache 往往比模型权重更吃显存。
batch 增大时这一项线性放大,所以高吞吐服务还要配合 PagedAttention(把缓存按页管理,减少碎片)。
10.4 解码策略
| 策略 | 做法 | 特点 |
|---|---|---|
| Greedy | 每步取 argmax | 确定性、稳定,但容易重复、单调 |
Temperature T | softmax(logits / T) | T<1 更尖锐保守,T>1 更平缓随机 |
| Top-k | 只保留概率最高的 k 个 | 截断长尾,避免采到极不合理 token |
| Top-p (nucleus) | 保留累计概率达 p 的最小集合 | 候选数随分布自适应,比 top-k 更常用 |
| Beam search | 维护 k 条候选序列 | 适合翻译等有明确参考答案的任务,对话中易产生无聊文本 |
10.5 增量解码的两个实现细节
配套 transformer_pytorch.py 的 generate() 里有两处容易写错:
- 位置索引要续上:第
t步新 token 的位置是T0 + t - 1,不能每次都从 0 开始。 - 因果掩码要对齐:启用缓存后
T=1而K的长度是S,掩码必须取causal_mask[:, :, S-T:S, :S],即完整矩阵的最后一行。直接套[:, :, :T, :T]会错位。
S = k.shape[2]
mask = self.causal_mask[:, :, S - T:S, :S]
scores = scores.masked_fill(mask == 0, float("-inf"))11. 从零实现:两份代码怎么读
11.1 纯 NumPy 版(推荐先读,本机已跑通)
| 脚本中的函数 / 演示 | 对应章节 | 你能观察到什么 |
|---|---|---|
positional_encoding | 3.4 | 不同维度的频率"时钟" |
scaled_dot_product_attention | 4.3 | 每一步的 shape 断言、softmax 行和为 1 |
| 演示 1 | 4.4 | 3 个 token 的完整手算结果 |
| 演示 2 | 7.1 | 因果掩码后上三角严格为 0 |
| 演示 3 | 4.5 | √d_k 缩放的方差验证 |
| 演示 4 | 3.3 | 无位置编码时的置换等变性 |
_split_heads / 演示 5 | 5.2 | reshape 前后的维度轨迹 |
EncoderLayer / 演示 6 | 6.x | 堆叠后形状不变、数值有限 |
| 演示 7 | 12.2 | 参数量逐项核算 |
运行方式:
python transformer_numpy.py
# 结尾应输出: ALL CHECKS PASSED —— 全部形状断言与数值断言通过这个版本故意不带 batch 维、不带自动微分。所有张量都是 2D/3D,print 出来一眼能看懂。建议逐行改数值(比如把 √d_k 删掉、把 mask 的 -1e9 改成 0),观察断言在哪里炸——这是最快的理解方式。
11.2 PyTorch 版(带上完整工程要素)
| 模块 | 对应概念 | 与 NumPy 版的差别 |
|---|---|---|
CausalSelfAttention | 4、5、7、10 | 多 B 维;qkv 一次投影出三份;内置因果 mask buffer;支持 kv_cache |
Block | 6 | Pre-LN、GELU FFN、返回新缓存 |
TinyGPT | 2、3、8 | 可学习位置嵌入、权重绑定、cross_entropy 损失 |
generate | 10 | 逐 token 增量解码 + 采样 |
make_batch / 训练循环 | 8、9 | 标签右移、AdamW、反向传播 |
玩具任务设计成"预测循环整数序列的下一个数",几百步内 loss 就能降到接近 0,next-token 准确率 可作为"实现是否正确"的强校验。如果你的实现 loss 卡在 2.7 附近不下降,十有八九是 mask 或标签对齐写错了。
11.3 五个建议动手做的改造实验
- 删掉
/ math.sqrt(self.d_k),观察训练收敛速度变化。 - 把因果 mask 去掉,看模型在"预测下一个 token"任务上准确率是否虚高到 100%(因为它偷看了答案)——这是验证 mask 正确性的经典手法。
- 把
h从 4 改成 8,打印d_k与参数量,确认参数量不变。 - 关掉残差连接(
x = x + ...改成x = ...),加深到 6 层,观察梯度与 loss。 - 在
generate里关闭 KV Cache,用一个长 prompt 计时对比(CPU 上也能看出差距)。
12. 复杂度与参数量:算得出来的账
12.1 单层时间复杂度
设序列长 n,d = d_model,d_ff = 4d:
| 运算 | FLOPs 量级 | 说明 |
|---|---|---|
| Q/K/V/O 四个投影 | 4 · n · d² | 矩阵乘 [n,d] @ [d,d] |
注意力分数 QK^T 与 AV | 2 · n² · d | 两次 [n,n] 与 [n,d] 相乘 |
| FFN 升维 + 降维 | 2 · n · d · d_ff = 8 · n · d² | 参数量最大项 |
| 合计 | O(n·d² + n²·d) | 短序列看 d²,长序列看 n² |
临界点:当 n ≪ d 时 FFN 主导;当 n ≫ d 时 n² 项主导。原版 d=512,短句里 FFN 是主要开销;今天动辄 32K~128K 上下文,注意力那张 n×n 矩阵才是瓶颈。这解释了为什么优化重心从"堆参数"转向"长上下文注意力"。
12.2 参数量逐项核算(真实输出)
配套脚本「演示 7」按原版 base(d_model=512, d_ff=2048, V=37000, N=6)实算:
MHA 4*d_model^2 = 1,048,576
FFN 2*d_model*d_ff = 2,097,152
单层合计 = 3,145,728
6 层 Encoder = 18,874,368
6 层 Decoder = 25,165,824 (自注意力 + 交叉注意力 + FFN)
共享词嵌入 V*d_model = 18,944,000
合计(不计偏置/LN) = 62.98 M (论文 base 报告 65 M)拆解:
- 一个注意力子层:
W_Q/W_K/W_V/W_O各[512,512]→4·512² = 1,048,576。 - 一个 FFN:
2·512·2048 = 2,097,152,正好是注意力子层的 2 倍。 - Decoder 层比 Encoder 层多一个交叉注意力子层,所以是
2 × 4d² + 2dd_ff。 - 论文报告 65 M,我们算出约 63 M,差额来自各层偏置、LayerNorm 的增益/偏置等小项——量级对得上,说明推导正确。
- LayerNorm 参数少到可以忽略:每层
2 × 2 × 512 = 2048,12 层共约 2.5 万,相对 6300 万完全不在一个量级。
一个直观结论:FFN 占了除词嵌入外约 2/3 的参数,这也是 MoE(第 13 章)选择把 FFN 稀疏化的原因。
12.3 上下文长度的真实代价
分数矩阵形状是 [B, h, n, n]。以 B=1, h=32, n=4096、fp16 为例,单层仅存 scores 就需要:
32 × 4096² × 2 bytes = 1,073,741,824 bytes = 1 GiB序列从 512 加长到 4096(8 倍),这张矩阵的量变为 64 倍。而且反向传播还要保留它用于求梯度。FlashAttention 的核心贡献就是根本不把这张矩阵写进显存。
12.4 参数显存 ≠ 激活显存
| 用途 | 规模决定因素 | 典型量级 |
|---|---|---|
| 模型权重(推理) | 参数量 × 每参数字节 | 7B × 2B (fp16) ≈ 14 GB |
| 优化器状态(训练) | 参数量 × 约 12~16 B | 7B 全参微调可达 100 GB 以上 |
| 激活值(训练) | B × n × d × L | 随 batch 和长度线性增长 |
| KV Cache(推理) | 第 10.3 节公式 | 长上下文下可超过权重本身 |
13. 从 2017 到今天:现代 LLM 改了什么
原论文的骨架(注意力 + FFN + 残差 + 归一化)今天基本没变,变的是每个零件的具体实现。看懂这张表,就能读懂绝大多数模型卡。
| 组件 | 原版 2017 | 现代主流 | 解决的问题 |
|---|---|---|---|
| 归一化 | Post-LN + LayerNorm | Pre-LN + RMSNorm | 深层训练稳定性;RMSNorm 去掉均值/方差中的均值项,更省算力 |
| 激活函数 | ReLU | GELU / SwiGLU | 更平滑的梯度;SwiGLU 用门控提升表达力 |
| 位置信息 | 正弦绝对 PE(输入层相加) | RoPE(作用在 Q/K 上) | 显式建模相对位置,便于长度外推 |
| 注意力头 | MHA(每头独立 K/V) | GQA / MQA(共享 K/V) | 大幅压缩 KV Cache 显存与访存带宽 |
| 注意力实现 | 显式构造 n×n 矩阵 | FlashAttention | 避免把 n×n 矩阵写进 HBM |
| 整体架构 | Encoder-Decoder | Decoder-only | 目标统一、易规模化 |
| FFN | 全部参数每次都激活 | MoE 稀疏专家 | 参数量与计算量解耦 |
| 数值精度 | fp32 | bf16 / fp16 混合精度 | 吞吐与显存 |
13.1 RMSNorm
LayerNorm 要算均值和方差并做中心化;RMSNorm 只按均方根缩放:
LayerNorm: (x - mean(x)) / sqrt(var(x) + ε) · γ + β
RMSNorm: x / sqrt( mean(x²) + ε ) · γ少一次均值与减法,实测效果相当,是当前 LLaMA / Qwen 系的默认选择。注意它通常仍配 Pre-LN 使用。
13.2 SwiGLU
原版: FFN(x) = W2 · ReLU(W1·x)
SwiGLU: FFN(x) = W2 · ( Swish(W1·x) ⊙ W3·x )多了一个门控投影 W3,逐元素相乘实现"动态开关"。因为多了一个矩阵,实践中把 d_ff 从 4d 调成约 8d/3 以保持总参数量相当,效果更好。
13.3 RoPE 旋转位置编码
不再把位置向量加到输入里,而是在注意力打分之前,按位置对 Q、K 做复数域的旋转:
q_m = R_m · q k_n = R_n · k
q_m · k_n 自然只依赖相对位置 (m - n)这样"两个 token 相距多远"直接内置在分数里,且旋转是确定函数,配合位置插值(NTK / YaRN)可以把上下文从 4K 扩到 128K 而无需从头训练。
13.4 MHA → GQA / MQA
| 方案 | Query 头数 | KV 头数 | KV Cache |
|---|---|---|---|
| MHA | h | h | 基准 |
| GQA | h | g(如 h/4 或 h/8) | 降为 g/h |
| MQA | h | 1 | 最小,但质量损失较明显 |
多个 query 头共享一组 K/V。因为 KV Cache 是按 KV 头存的(第 10.3 节公式里的 n_kv_heads),这个改动直接把长上下文推理的显存和带宽砍掉数倍,质量损失很小,已成为事实标准。
13.5 FlashAttention:不是近似,是 IO 优化
常见误解是"FlashAttention 用了稀疏/近似所以快"。它的输出与标准注意力数学等价。慢的根源不在算术,而在访存:标准实现要生成 [n,n] 的 scores 写进 HBM,再读出来做 softmax,再写权重,读写量是 O(n²)。
FlashAttention 把 Q/K/V 切成能放进 GPU 片上 SRAM 的小块,在片内完成打分、softmax、加权求和,用在线 softmax 技巧逐块累积结果,从不物化完整的 n×n 矩阵。显存从 O(n²) 降到 O(n),HBM 读写大幅减少,于是更快也更省内存。
13.6 MoE 与 Decoder-only
- MoE:把 FFN 换成多个"专家"FFN,每个 token 由路由器只激活 top-1/top-2 个。总参数量可以很大(如数百 B),但每个 token 的实际计算量接近一个小模型。注意它省的是 FLOPs,不省显存——所有专家权重仍要驻留。
- Decoder-only:把 Encoder 那半边删掉,只用带因果掩码的层堆叠。一个现代 Block 大致是:
x = x + Attention( RMSNorm(x) ) # QKV 投影 -> RoPE -> GQA -> FlashAttention -> W_O
x = x + SwiGLU_FFN( RMSNorm(x) ) # 或 MoE和你在第 6 章看到的 Encoder 层结构几乎一样,只是换了几个零件、加了一个 mask。这就是"学完原版就能看懂现代 LLM"的意思。
14. 常见误区与调试清单
14.1 十二条高频误区
- "注意力有参数" —— 加权平均这一步没有;参数在
W_Q/W_K/W_V/W_O。 - "多头要循环 h 次" —— 一次大矩阵乘法 + reshape。
- "softmax 对列归一化" —— 必须对最后一维(对 key)归一化,行和为 1。
- "mask 填 0 就行" —— 必须填负无穷,否则
exp(0)=1照样分权重。 - "位置编码可有可无" —— 没有它模型完全不知道词序(演示 4)。
- "除以 √d_k 是为了防止数值溢出" —— 主因是方差归一化、防止 softmax 饱和梯度消失;数值稳定靠减 max。
- "FFN 是逐 token 的所以没用" —— 它是非线性与大部分参数的所在。
- "LayerNorm 和 BatchNorm 差不多换一下就行" —— 归一化轴不同,变长序列与自回归推理下 BN 不可用。
- "Encoder 和 Decoder 层结构一样" —— Decoder 多一个交叉注意力,且自注意力带因果掩码。
- "KV Cache 是近似优化,会降低质量" —— 数学上完全等价,只是缓存了不变的量。
- "FlashAttention 是稀疏近似" —— 精确计算,优化的是 IO 分块。
- "参数量就是显存占用" —— 训练要算梯度+优化器状态+激活,推理还要加 KV Cache。
14.2 Shape 速查(贴在显示器上)
X [B, n, d_model]
W_Q/W_K/W_V/W_O [d_model, d_model] (多头时用一整个矩阵)
Q/K/V [B, n, d_model]
拆头后 [B, h, n, d_k] d_k = d_model / h
scores / weights [B, h, n_q, n_k] 自注意力 n_q = n_k = n
交叉注意力 n_q = n_tgt, n_k = n_src
FFN 中间 [B, n, d_ff]
logits [B, n, V]
KV Cache [B, n_kv_heads, S, d_k] × 2 × L14.3 症状 → 原因 → 排查
| 症状 | 最可能原因 | 怎么验证 |
|---|---|---|
| 训练 loss 很快到近 0,生成却乱码 | 因果 mask 失效 / 填了 0 | 打印 weights,检查上三角是否严格为 0 |
loss 卡在 ln(V) 附近不动 | 标签没右移,或在预测自己 | 打印 input[0][:5] 与 label[0][:5] 对比 |
| 注意力权重每行几乎均匀 | 忘了 /√d_k 的反向情况(分数过小)或初始化尺度不对 | 打印 scores 的最大值/标准差 |
| 注意力权重接近 one-hot 且梯度消失 | 忘了 /√d_k | 打印 scores 标准差,应约 1 |
| 出现 NaN | 整行被 mask 成 -inf;或 fp16 溢出 | 换 -1e9、检查 softmax 是否减了 max |
.view() 报"不连续" | transpose 后没 .contiguous() | 合头前加 .contiguous() |
| 推理越生成越慢、显存涨 | 没启用 KV Cache | 检查每步输入长度是否为 1 |
| 增量生成结果错乱 | 位置索引或 mask 切片错位 | 核对 S-T:S 与 pos = T0+t-1 |
| batch 内短句影响长句输出 | 漏了 padding mask 或 loss 没 ignore | 检查 ignore_index 与 key mask |
15. 学习路径与自测
15.1 四阶段路线(每阶段都有可验证产物)
| 阶段 | 做什么 | 验收标准 |
|---|---|---|
| 1 | 跑一遍 transformer_numpy.py,逐行读演示 1~4 | 能用手算复现 out[0] = [0.8022, 0.3956] |
| 2 | 不看代码,自己写出 scaled_dot_product_attention 和 MultiHeadAttention | 能过脚本里的 shape 断言 |
| 3 | 在 PyTorch 环境跑 transformer_pytorch.py,再做 11.3 的 5 个实验 | 训练后 next-token 准确率 > 95% |
| 4 | 找一篇 LLaMA / Qwen 的模型卡,对照第 13 章逐项标注 | 能说清每个配置项对应哪个组件 |
15.2 十道自测题
第 1 题:scores[i][j] 的语义是什么?点击查看答案
第 i 个 token(作为 query)对第 j 个 token(作为 key)的相关性分数。行是查询方,列是被查询方;softmax 后每行是一个关于所有 key 的概率分布。
第 2 题:为什么 softmax 要对最后一维,而不是第 0 维?点击查看答案
因为每个 query 要独立决定"把注意力预算(总和 1)如何分配给所有 key"。对最后一维归一化正是逐行归一化;对第 0 维归一化会变成"每个 key 被所有 query 如何分配",语义颠倒,且形状不变、不会报错,属于静默错误。
第 3 题:把 d_k 从 64 提到 256,不缩放会怎样?点击查看答案
点积标准差从 8 涨到 16(√d_k),softmax 更容易饱和成 one-hot,梯度趋近 0,训练变慢甚至停滞。这正是缩放因子随 d_k 自适应变化的原因。
第 4 题:为什么说纯自注意力是"置换等变"?这对建模意味着什么?点击查看答案
打乱输入行顺序,输出只会按同样方式换位,不产生任何新信息。意味着模型把输入当集合、无法区分"我吃苹果"与"苹果吃我",因此必须外部注入位置编码。
第 5 题:多头注意力的参数量为什么和单头一样?点击查看答案
h 个头各用 [d_model, d_k] 的矩阵,d_k = d_model/h,合计 h·4·d_model·(d_model/h) = 4·d_model²,与一个 d_model 维的大头相同。多头是表达能力的重新分配而非参数膨胀。
第 6 题:残差连接解决的是什么问题?点击查看答案
提供恒等梯度通路,反向传播时 ∂out/∂x 恒含 1 项,梯度不会随深度指数衰减,从而能堆几十上百层。配合小初始化,每层初始近似恒等映射。
第 7 题:交叉注意力的 scores 为什么可以是矩形?点击查看答案
Q 来自 Decoder(n_tgt 行),K/V 来自 Encoder(n_src 行),QK^T 自然是 [n_tgt, n_src]。输出行数跟随 Q,列数跟随 V,两者长度不必相等。
第 8 题:训练时为什么能一次喂完整句而不"偷看答案"?点击查看答案
Teacher Forcing 提供完整输入,因果掩码把每个位置的未来分数置为 -inf,softmax 后权重恰为 0。于是数学上等价于逐词生成,却能并行计算整句。
第 9 题:KV Cache 为什么成立?代价是什么?点击查看答案
因果掩码保证历史 token 的 K/V 不随未来变化,故可缓存复用,每步只算新 token 的增量。代价是显存:约 2·L·n_kv_heads·d_head·seq_len·bytes,长上下文下可能超过模型权重。
第 10 题:GQA 和 FlashAttention 分别在优化什么?点击查看答案
GQA 减少 KV 头数,压缩 KV Cache 的容量与带宽;FlashAttention 通过分块与在线 softmax 避免物化 n×n 矩阵,优化的是HBM 读写与训练/预填充显存,且是精确计算、非近似。两者作用在不同阶段,可叠加使用。
15.3 继续深入的资料
| 资料 | 适合 | 链接 |
|---|---|---|
| 原始论文 Attention Is All You Need | 读完本指南后对照原文,重点看 3.2 节与 Table 1 | arXiv:1706.03762 |
| The Illustrated Transformer (Jay Alammar) | 图形化补足直觉 | jalammar.github.io |
| The Annotated Transformer (Harvard NLP) | 逐行对照论文与 PyTorch 代码 | nlp.seas.harvard.edu |
| nanoGPT (Andrej Karpathy) | 极简可跑的 GPT 实现与配套视频 | github.com/karpathy/nanoGPT |
| FlashAttention 论文 | 理解 IO-aware 精确注意力 | arXiv:2205.14135 |
| Hugging Face Transformers 源码 | 看工业级实现如何组织 mask / cache | github.com/huggingface/transformers |
不要一上来就读论文原文或源码。先用配套 NumPy 脚本把 [6,6] 的权重矩阵打印出来、改动几个数字看它怎么变,建立"这些都是矩阵运算"的实感。有了这个锚点,再读任何论文都会快很多。
附录 A 符号表
| 符号 | 含义 | 原版 base 取值 |
|---|---|---|
n | 序列长度(token 数) | 变长 |
B | batch size | — |
V | 词表大小 | 37000 |
d_model | 模型主维度 / 隐藏维度 | 512 |
d_k, d_v | 每个头的 K、V 维度 | 64 |
d_ff | FFN 中间层维度 | 2048 |
h | 注意力头数 | 8 |
N | Encoder / Decoder 层数 | 6 |
Q, K, V | 查询、键、值矩阵 | — |
W_O | 多头输出融合矩阵 | [512,512] |
L | 现代模型层数(第 10 章用) | 32(举例) |
附录 B 中英术语对照
| 中文 | 英文 | 一句话 |
|---|---|---|
| 自注意力 | Self-Attention | Q/K/V 同源的注意力 |
| 缩放点积注意力 | Scaled Dot-Product Attention | softmax(QK^T/√d_k)V |
| 多头注意力 | Multi-Head Attention / MHA | 多子空间并行注意力 |
| 分组查询注意力 | Grouped-Query Attention / GQA | 多 query 头共享 KV 头 |
| 交叉注意力 | Cross-Attention | Q 与 K/V 不同源 |
| 因果掩码 | Causal Mask | 下三角,屏蔽未来 |
| 位置编码 | Positional Encoding | 注入顺序信息 |
| 旋转位置编码 | RoPE | 在 Q/K 上做位置旋转 |
| 残差连接 | Residual Connection | x + Sublayer(x) |
| 层归一化 | LayerNorm | 单样本内部归一化 |
| 前馈网络 | Feed-Forward Network / FFN | 逐位置非线性变换 |
| 教师强制 | Teacher Forcing | 训练时喂标准答案 |
| 自回归 | Autoregressive | 逐步生成、每步依赖前文 |
| 权重绑定 | Weight Tying | 输入嵌入与输出头共享权重 |
| 标签平滑 | Label Smoothing | 软化 one-hot 标签 |
| 暴露偏差 | Exposure Bias | 训练/推理输入分布不一致 |
| 混合专家 | Mixture of Experts / MoE | 稀疏激活的 FFN |
附录 C 一页公式总览
输入: X = Embedding(tokens) + PE [n, d_model]
注意力: Q = X·W_Q, K = X·W_K, V = X·W_V
Attention(Q,K,V) = softmax( Q·K^T / √d_k + mask ) · V
多头: head_i = Attention(X·W_Q_i, X·W_K_i, X·W_V_i)
MultiHead(X) = Concat(head_1..head_h) · W_O
FFN: FFN(x) = W2 · activation( W1·x + b1 ) + b2 d_ff = 4·d_model
Pre-LN 层: X' = X + MultiHead( LayerNorm(X) )
X'' = X' + FFN( LayerNorm(X') )
位置编码: PE(pos,2i) = sin( pos / 10000^(2i/d_model) )
PE(pos,2i+1) = cos( pos / 10000^(2i/d_model) )
输出: logits = H · W_head [n, V]
loss = CrossEntropy( logits[0..n-2], labels[1..n-1] )
学习率: lrate = d_model^(-0.5) · min( step^(-0.5), step · warmup^(-1.5) )
复杂度: 每层 O( n·d_model² + n²·d_model )
参数量: 注意力 4·d_model² FFN 2·d_model·d_ff (单层合计)
KV Cache: 2 · L · n_kv_heads · d_head · seq_len · bytes_per_element本指南的示例数值来自 transformer_numpy.py 的实际运行输出;如需修改内容,请编辑 guide.md 后重新生成 HTML。