Transformer 架构完全学习指南 · 程序员版

从矩阵乘法出发,逐步推导到 GPT / LLaMA:含维度追踪、真实数值验证、从零实现与现代变体

16 章 + 3 附录5 张矢量架构图 2 份可运行源码10 道折叠自测题单文件 · 离线可用

Transformer 架构完全学习指南(程序员版)

面向有编程基础、但没有 AI / 深度学习背景的读者。前置知识:会 Python、知道矩阵乘法。softmax 不熟也没关系,第 4 章会从零推导。不需要任何框架经验。

0. 如何使用这份文档

0.1 你会得到什么

这份文档不是论文摘要,也不是"三分钟看懂"式的类比合集。目标是让你读完后能够:

  1. 在白纸上默写出 Attention(Q,K,V) = softmax(QK^T/√d_k)V,并说清每个张量的形状。
  2. 独立推导"为什么除以 √d_k",以及"为什么自注意力必须配位置编码"。
  3. 从零写出一个能跑的 Encoder 层,遇到 shape 报错能自己定位。
  4. 看懂 GPT / LLaMA 的结构图,知道它们相对 2017 年原版改了哪些组件、各自解决什么问题。
  5. 理解 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.pyDecoder-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(数据)。一次查询是:

sql
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 加权求和,得到自己的新表示。

这解释了两个常见疑问:


2. 全局架构:先看森林

原始 Transformer:Encoder - Decoder(各 N=6 层)源序列 · token ids词嵌入 + 正弦位置编码Encoder Layer × 6多头自注意力(双向)残差 Add + LayerNorm前馈网络 FFN残差 Add + LayerNorm输入输出形状恒为 [n_src, d_model]编码器输出 M [n_src, d_model]目标序列(标签右移一位)词嵌入 + 正弦位置编码Decoder Layer × 6掩码多头自注意力(单向)Add + Norm交叉注意力 Q ← DecoderAdd + Norm前馈网络 FFNAdd + Norm每层额外一次「回看」编码器Linear → Softmax [n_tgt, V]输出概率 / 下一个 tokenK / VM 作为 K、V

2.1 原论文是 Encoder-Decoder 结构

2017 年《Attention Is All You Need》解决的是机器翻译,所以需要两半:

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-onlyBERT双向,全句可见理解、分类、检索、Embedding
Encoder-Decoder原始 Transformer、T5编码器双向 + 解码器单向翻译、摘要、条件生成
Decoder-onlyGPT、LLaMA、Qwen单向(因果)通用生成、对话、推理

Decoder-only 成为主流的原因:单一自回归目标可以覆盖几乎所有任务(把任务本身也写成文本),训练目标与推理形态完全一致,且不需要成对语料,工程上最简单、最容易规模化。

本指南第 3~6 章按"通用组件"讲,第 7 章单独讲 Decoder 特有的部分。 只看 Decoder-only 的读者也不会有障碍。

2.4 记住三层嵌套就够了

后面所有内容,都是把这三层的每一格拆开。


3. 输入层:从字符串到矩阵

3.1 分词 Tokenization

模型只认整数。分词器把文本切成 token 并映射为 id。主流用 BPE(Byte-Pair Encoding):从字符开始,反复合并最高频的相邻单元,直到词表达到设定大小。

"transformer"  ->  ["transform", "er"]  ->  [18421, 312]

要点:

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 行。"我吃苹果"和"苹果吃我"在注意力层眼里是同一批向量,只是摆放顺序不同。

注意 · 这不是 bug,是设计

注意力刻意不内置顺序,换取了完全并行和对任意长度的统一处理。代价就是位置信息必须由外部显式注入。所有"位置编码"方案都是在补这一课。

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 看成不同频率的时钟:

这正像一组连续的二进制编码:低位快变、高位慢变,组合起来给每个位置一个几乎唯一的指纹。而且它是确定性的、可外推到训练时没见过的更长序列(可学习的位置表做不到这点)。

另一个优美性质:对任意固定偏移 k,PE(pos+k) 可以表示为 PE(pos) 的线性变换(旋转矩阵),这让模型更容易学到"相对距离"而非"绝对位置"。后来的 RoPE 把这个思想直接做进了注意力打分里。

python
# 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 pe

3.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 为例:

步骤运算输入形状输出形状含义
1X @ W_Q[6,512] @ [512,64]Q [6,64]6 个查询向量
2X @ W_K同上K [6,64]6 个键向量
3X @ W_V同上V [6,64]6 个值向量
4Q @ K.T[6,64] @ [64,6]scores [6,6]每个 query 对每个 key 的原始分数
5/ √64[6,6][6,6]缩放,防止 softmax 饱和
6mask(可选)[6,6][6,6]屏蔽位置填 -inf
7softmax(dim=-1)[6,6]weights [6,6]按行归一化,每行和为 1
8weights @ V[6,6] @ [6,64]out [6,64]按权重混合所有 Value

三个必须记牢的细节:

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 后
42.0112.0001.006
648.0588.0001.007
51222.66922.6271.002

数值与推导完全吻合:缩放不是拍脑袋的技巧,是把方差归一化的必要步骤。

4.6 两个实现层面的坑

python
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 循环

多头注意力:一次投影 + reshape,不是循环 h 次X[n, 512]Q = X·W_QK = X·W_KV = X·W_V拆头 split[h, n, 64]Head 1Head 2Head 8 …Concat[n, 512]W_O线性融合输出[n,512]d_k = d_model / h = 512 / 8 = 64 · 每个头独立算 [n, n] 分数,参数总量与单头相同(4·d_model²)

这是新手最容易误解的实现细节:框架里不会循环 h 次。做法是先用一整个 [d_model, d_model] 矩阵一次算出所有头,再 reshape 拆分。

python
# 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 版用的正是这一套:

python
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 五个常见误区

  1. ~~多头 = 算 h 次注意力~~ → 一次大矩阵乘法 + reshape,完全并行。
  2. ~~每个头的维度是 d_model~~ → 是 d_model/h。
  3. ~~头越多越好~~ → h 增大则 d_k 减小,单头表达力下降;h=8/16/32/… 是经验折中。
  4. ~~不同头一定学到不同语言学规律~~ → 常被这样解释,但严格说只是"不同子空间",实际可视化后会发现部分头功能冗余,甚至可被剪枝。
  5. ~~多头注意力有 h 套缓存~~ → 推理时 KV Cache 按头分别存,所以才有了后面 GQA/MQA 的优化空间(第 13 章)。

6. 组装一个 Encoder 层

一个层:两个子层 + 两次残差 + 两次归一化输入 X [n, d_model]LayerNorm多头自注意力 / 掩码自注意力残差相加 X + Sub(X)LayerNorm前馈网络 FFN (d_ff = 4·d_model)残差相加 X + Sub(X)输出 [n, d_model](形状不变 → 可堆叠)X 原样直连X 原样直连Pre-LN 写法: X ← X + Sublayer( LayerNorm(X) ) (原版 Post-LN 把 LayerNorm 放在相加之后)

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)

三个要点:

一个有用的视角:把 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

维度BatchNormLayerNorm
归一化轴跨 batch 的同一特征维单个样本内部的所有特征维
依赖 batch 统计量是,训练/推理行为不同否,逐样本独立计算
变长序列 / padding难处理,不同长度统计量不可比天然适配
小 batch / 自回归生成失效(推理时 batch 常为 1)完全正常

一句话:BatchNorm 需要"一批样本同一位置"的统计,NLP 的变长序列和逐 token 生成让它无从下手;LayerNorm 只看一个 token 自己的 d_model 个数,任何时候都能算。

python
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) + beta

6.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 与三种掩码

三种注意力与掩码:行 = 查询方,列 = 被查询方Encoder 自注意力双向,全句可见scores [n_src, n_src]Decoder 掩码自注意力上三角被屏蔽,权重为 0scores [n_tgt, n_tgt]交叉注意力Q 来自译文,K/V 来自原文scores [n_tgt, n_src]蓝色 = 允许关注;灰色 = 屏蔽(分数置 -inf)。交叉注意力可以是矩形:输出行数跟随 Q。

7.1 因果掩码:训练并行与推理串行的和解

这里有个初看很矛盾的地方:

解决办法叫 Teacher Forcing + 因果掩码:训练时把正确的完整译文右移一位作为输入(不管模型上一步预测对不对,都喂标准答案),再用一个下三角 mask 把"未来位置"屏蔽掉,让每个位置在数学上看不到它不该看的内容。于是"并行训练"和"单向生成"同时成立。

python
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,断言已通过。

注意 · 屏蔽位必须填 -inf,不能填 0

若把 scores 上三角置 0,softmax 后 exp(0)=1 仍会分到权重,模型等于在训练时偷看答案;推理时却看不到,训练/推理分布不一致,生成会彻底崩坏。这是从零实现时最高频的 bug。

7.2 交叉注意力 Cross-Attention

Decoder 层比 Encoder 层多夹了一个子层,因此是三个子层:

  1. Masked Self-Attention:Q/K/V 全来自 Decoder 输入,带因果掩码。
  2. Cross-Attention:Q 来自 Decoder 上一子层的输出,而 K、V 来自 Encoder 的最终输出 M。
  3. 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源句 Xpadding[n_src, n_src]双向理解
Decoder 掩码自注意力译文 Y译文 Y因果 + padding[n_tgt, n_tgt]保证自回归
交叉注意力译文 Y编码器输出 Mpadding (对 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、多数现代实现也这么做:

python
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 版的玩具数据直接体现了这一点:

python
seq = (start + torch.arange(seq_len)) % vocab_size
return seq[:, :-1], seq[:, 1:]        # x 去掉最后一位,y 去掉第一位

因果掩码保证位置 t 看不到 t+1 及之后,因此"输入里已经含有答案"并不会造成信息泄漏。这两件事是配套的:mask 负责屏蔽未来,shift 负责对齐标签。

8.4 交叉熵与 padding

python
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
Dropout0.1(注意力权重、子层输出、嵌入和)正则
层数 / 维度N=6, d_model=512, d_ff=2048, h=8base;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 是必须懂的工程优化

KV Cache:历史 K/V 不变,每步只算 1 个新 token朴素生成:每步把整个前缀重新前向一遍step 1step 2step 3step 4整段前缀都被重算(含历史 K/V、FFN) · 单步 t × tKV Cache:缓存复用 + 增量计算step 1→ 写入缓存step 2→ 写入缓存step 3→ 写入缓存step 4→ 写入缓存历史 K/V 从缓存读取,每步只算 1 个新 token · 单步 1 × t深色格 = 当前预测位。累计注意力计算由 O(n³·d) 降到 O(n²·d),代价是 K/V 按层按头常驻显存。

10.1 朴素生成为什么慢

自回归生成第 t 个 token 时,若把长度为 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 Tsoftmax(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() 里有两处容易写错:

  1. 位置索引要续上:第 t 步新 token 的位置是 T0 + t - 1,不能每次都从 0 开始。
  2. 因果掩码要对齐:启用缓存后 T=1 而 K 的长度是 S,掩码必须取 causal_mask[:, :, S-T:S, :S],即完整矩阵的最后一行。直接套 [:, :, :T, :T] 会错位。
python
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_encoding3.4不同维度的频率"时钟"
scaled_dot_product_attention4.3每一步的 shape 断言、softmax 行和为 1
演示 14.43 个 token 的完整手算结果
演示 27.1因果掩码后上三角严格为 0
演示 34.5√d_k 缩放的方差验证
演示 43.3无位置编码时的置换等变性
_split_heads / 演示 55.2reshape 前后的维度轨迹
EncoderLayer / 演示 66.x堆叠后形状不变、数值有限
演示 712.2参数量逐项核算

运行方式:

bash
python transformer_numpy.py
# 结尾应输出: ALL CHECKS PASSED —— 全部形状断言与数值断言通过

这个版本故意不带 batch 维、不带自动微分。所有张量都是 2D/3D,print 出来一眼能看懂。建议逐行改数值(比如把 √d_k 删掉、把 mask 的 -1e9 改成 0),观察断言在哪里炸——这是最快的理解方式。

11.2 PyTorch 版(带上完整工程要素)

模块对应概念与 NumPy 版的差别
CausalSelfAttention4、5、7、10多 B 维;qkv 一次投影出三份;内置因果 mask buffer;支持 kv_cache
Block6Pre-LN、GELU FFN、返回新缓存
TinyGPT2、3、8可学习位置嵌入、权重绑定、cross_entropy 损失
generate10逐 token 增量解码 + 采样
make_batch / 训练循环8、9标签右移、AdamW、反向传播

玩具任务设计成"预测循环整数序列的下一个数",几百步内 loss 就能降到接近 0,next-token 准确率 可作为"实现是否正确"的强校验。如果你的实现 loss 卡在 2.7 附近不下降,十有八九是 mask 或标签对齐写错了。

11.3 五个建议动手做的改造实验

  1. 删掉 / math.sqrt(self.d_k),观察训练收敛速度变化。
  2. 把因果 mask 去掉,看模型在"预测下一个 token"任务上准确率是否虚高到 100%(因为它偷看了答案)——这是验证 mask 正确性的经典手法。
  3. 把 h 从 4 改成 8,打印 d_k 与参数量,确认参数量不变。
  4. 关掉残差连接(x = x + ... 改成 x = ...),加深到 6 层,观察梯度与 loss。
  5. 在 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 与 AV2 · 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)

拆解:

一个直观结论: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 B7B 全参微调可达 100 GB 以上
激活值(训练)B × n × d × L随 batch 和长度线性增长
KV Cache(推理)第 10.3 节公式长上下文下可超过权重本身

13. 从 2017 到今天:现代 LLM 改了什么

原论文的骨架(注意力 + FFN + 残差 + 归一化)今天基本没变,变的是每个零件的具体实现。看懂这张表,就能读懂绝大多数模型卡。

组件原版 2017现代主流解决的问题
归一化Post-LN + LayerNormPre-LN + RMSNorm深层训练稳定性;RMSNorm 去掉均值/方差中的均值项,更省算力
激活函数ReLUGELU / SwiGLU更平滑的梯度;SwiGLU 用门控提升表达力
位置信息正弦绝对 PE(输入层相加)RoPE(作用在 Q/K 上)显式建模相对位置,便于长度外推
注意力头MHA(每头独立 K/V)GQA / MQA(共享 K/V)大幅压缩 KV Cache 显存与访存带宽
注意力实现显式构造 n×n 矩阵FlashAttention避免把 n×n 矩阵写进 HBM
整体架构Encoder-DecoderDecoder-only目标统一、易规模化
FFN全部参数每次都激活MoE 稀疏专家参数量与计算量解耦
数值精度fp32bf16 / 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
MHAhh基准
GQAhg(如 h/4 或 h/8)降为 g/h
MQAh1最小,但质量损失较明显

多个 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

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 十二条高频误区

  1. "注意力有参数" —— 加权平均这一步没有;参数在 W_Q/W_K/W_V/W_O。
  2. "多头要循环 h 次" —— 一次大矩阵乘法 + reshape。
  3. "softmax 对列归一化" —— 必须对最后一维(对 key)归一化,行和为 1。
  4. "mask 填 0 就行" —— 必须填负无穷,否则 exp(0)=1 照样分权重。
  5. "位置编码可有可无" —— 没有它模型完全不知道词序(演示 4)。
  6. "除以 √d_k 是为了防止数值溢出" —— 主因是方差归一化、防止 softmax 饱和梯度消失;数值稳定靠减 max。
  7. "FFN 是逐 token 的所以没用" —— 它是非线性与大部分参数的所在。
  8. "LayerNorm 和 BatchNorm 差不多换一下就行" —— 归一化轴不同,变长序列与自回归推理下 BN 不可用。
  9. "Encoder 和 Decoder 层结构一样" —— Decoder 多一个交叉注意力,且自注意力带因果掩码。
  10. "KV Cache 是近似优化,会降低质量" —— 数学上完全等价,只是缓存了不变的量。
  11. "FlashAttention 是稀疏近似" —— 精确计算,优化的是 IO 分块。
  12. "参数量就是显存占用" —— 训练要算梯度+优化器状态+激活,推理还要加 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 × L

14.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 1arXiv: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 / cachegithub.com/huggingface/transformers
说明 · 一个务实建议

不要一上来就读论文原文或源码。先用配套 NumPy 脚本把 [6,6] 的权重矩阵打印出来、改动几个数字看它怎么变,建立"这些都是矩阵运算"的实感。有了这个锚点,再读任何论文都会快很多。


附录 A 符号表

符号含义原版 base 取值
n序列长度(token 数)变长
Bbatch size—
V词表大小37000
d_model模型主维度 / 隐藏维度512
d_k, d_v每个头的 K、V 维度64
d_ffFFN 中间层维度2048
h注意力头数8
NEncoder / Decoder 层数6
Q, K, V查询、键、值矩阵—
W_O多头输出融合矩阵[512,512]
L现代模型层数(第 10 章用)32(举例)

附录 B 中英术语对照

中文英文一句话
自注意力Self-AttentionQ/K/V 同源的注意力
缩放点积注意力Scaled Dot-Product Attentionsoftmax(QK^T/√d_k)V
多头注意力Multi-Head Attention / MHA多子空间并行注意力
分组查询注意力Grouped-Query Attention / GQA多 query 头共享 KV 头
交叉注意力Cross-AttentionQ 与 K/V 不同源
因果掩码Causal Mask下三角,屏蔽未来
位置编码Positional Encoding注入顺序信息
旋转位置编码RoPE在 Q/K 上做位置旋转
残差连接Residual Connectionx + 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。