2.5 Transformer:把注意力堆成主干
2.4 给出了一个干净的算子:。它一次性解决了三个问题——历史不再被压缩、任意两点的路径长度为 、所有位置的计算可以并行。但一个算子还不是一个模型:单头注意力的表达能力有限,注意力只做「跨位置混合」,而每个位置内部还需要非线性变换;更麻烦的是,把这样的层堆几十层,梯度会像第 1 讲的深层网络一样出问题。
Transformer 的回答是「只靠注意力 + 前馈层,把工程件补齐」:多头注意力负责在多个子空间里并行检索,位置前馈层负责位置内的非线性,残差连接与层归一化负责让几十层能训得动。这一节的顺序就按这四个部件展开,然后做三件工程上最要紧的账:前向形状表、参数量估算、KV cache 显存。最后讨论一个经常被混淆的区别:训练时可以一次算完全部位置,推理时却只能逐 token 生成——这不是实现水平问题,而是自回归目标的结构性后果。
2.5.1 多头注意力:并行看多个子空间
设输入 ,头数 ,每头维度 。对第 个头用各自的投影得到
各自做一次缩放点积注意力,再拼接、投影融合:
批优先实现里的形状变化:。
注意力层输出的每个位置是 的一个凸组合。单头只能给出一组权重——「这个位置最该看哪里」只能有一个答案。但语言里的依赖是多重的:同一个位置可能同时需要(i)前一个词(局部语法)、(ii)主语(句法)、(iii)上一句话里的某个实体(指代)。多头就是把这件事拆成 个并行的小注意力:每个头在自己的 维子空间里独立决定「看哪里」,最后由 把 份结果线性混合。注意头与头之间没有交互(各自的 softmax 独立),交互只发生在拼接之后的 里。
设 。则多头注意力的可学习参数量为
与头数 无关——每头维度按 缩小,总参数量不变。但参数量相同不等于表达相同:多头改变的是归纳偏置(并行多组注意力 vs 一组注意力),而不是参数预算。
(1) 参数计数。 每头三个投影 ,单头参数量 ; 个头合计
输出投影 提供 。总参数 (加偏置 )。因为 是硬约束, 只影响「怎么切这块参数」,不影响总量。
(2) 与单头的关系。 在一种特殊情形下二者等价:若所有头的 相同、且 取特定的块结构,多头退化为单头。完整的多头表达力严格更强——因为每组 softmax 可以给出不同的权重模式,而这些模式在单头里被强制平均。这不是「多头一定会更好」的证明:更强的表达力也可能更容易过拟合,而且 太小会让每个头的内积空间变小、相似度估计变差。
(3) 头数的工程边界。 由 , 增大 ⇒ 减小 ⇒ 每个头能表达的关系变简单,且 reshape/转置带来的访存开销上升。反过来 时退化为单头。因此头数的选择是经验性的超参(本书立场:在 固定的前提下, 落在 这个区间是常见实践,具体取值依模型与硬件而变;不存在「头越多越好」的结论)。
∎把上面的结论翻译成实验语言:比较「、」与「、」时,两者的参数量完全相同,差别只在归纳偏置。多头之所以在实践中常胜,是因为(a)不同子空间可以各自锐化自己的相似度、不被平均掉;(b)多个头可以并行发展出不同功能(局部、指代、复制等)。但当你看到「多头显著更好」的论文结论时,务必确认它控制了什么:同参数量?同计算量?同训练步数?如果不控制参数量,单头只是「参数更少」而已,那就不足以说明多头结构的优越性。
2.5.2 前馈层:位置内的两层 MLP
对每个位置独立(共享同一套参数)应用同一个两层 MLP:
经典设定 ;现代实现常用门控变体(GLU 家族)把两个线性层并列:
此时三块矩阵的总参数按 计,为保持与 经典版的参数可比,常见做法是把 取为约 (工程惯例,源自 GLU 变体的宽度缩放实验)。
注意力做的是跨位置的事:把别处的内容取过来。但「取过来」之后还需要就地加工——判断特征组合、做非线性映射、把信息压回 维。FFN 就是干这个的:逐位置、同一套参数、两层 MLP。两者缺一不可:只有注意力,模型只能做凸组合(一层注意力的输出必然落在各位置 之间),表达力受限;只有 FFN,位置之间互不相通,序列结构无从利用。
再记住一个参数量事实:在经典配置 下,FFN 的参数是 ,注意力是 ——FFN 占了块内参数的三分之二。这解释了为什么 MoE(2.7)把专家结构放在 FFN 上:那里才是有参数可换的地方。
2.5.3 残差与归一化:Post-LN、Pre-LN 与 RMSNorm
设子层为 (自注意力或 FFN),则两种布局为
层归一化按最后一维(特征维)做标准化:
其中 是该位置在特征维上的均值与方差(与批大小无关——这一点与 BatchNorm 的关键区别见 1.7.4)。RMSNorm 去掉去均值与偏置、只做尺度归一化,计算更省。
残差的作用第 1 讲已经算过: 让反向传播中多出一条恒等路径 ,于是梯度不必只穿过子层那条连乘链,深堆才成为可能。LayerNorm 则负责把每个位置的激活尺度拉回可控范围,使学习率、初始化与深度解耦。Pre-LN 的额外好处是一眼看出的:主干上是纯粹的加法(),尺度随深度以温和方式累积;而 Post-LN 在每个子层之后都做一次归一化,反向时必须穿过这些归一化,层数越深数值行为越挑剔。
论文报告的结论(Xiong et al., 2020,arXiv:2002.04745):在初始化时,
- Post-LN:靠近输出层的参数梯度尺度较大(论文的分析给出形如 的量级项),因此需要学习率预热(warmup)才能稳定训练;
- Pre-LN:梯度尺度与网络深度基本无关,去掉预热也能正常训练。
代价与条件:Pre-LN 的主干是恒等累加,前向输出的尺度会随深度增长,因此它的最后一层表示需要额外归一化才能接输出头(这也是现代实现里都带一层 final norm 的原因)。此外,该结论是「训练稳定性」层面的,不是「下游效果一定更好」的承诺——架构选择仍取决于数据与规模。
不看完整论文分析,只做一个标量级的估算,就能理解两条路的差别。
(1) Post-LN 的链式因子。 Post-LN 里子层输出先加残差再归一化。设某层的归一化后输出为 ,则反向经过它时,梯度要乘上 ——这个雅可比含 与「去均值 + 去投影」两项。当层数变多时,每一层的输出尺度都被前一层归一化重新缩放,于是靠近输出的子层接收到的有效尺度与深度相关;参数梯度里因此累积出随深度增长的因子,必须用 warmup 把最初几步的步长压小。
(2) Pre-LN 的主干是加法。 Pre-LN 里 ,归一化被放在子层内部,主干上没有归一化。设 (这是 LN 的直接后果),则 ( 项近似无关的向量叠加,均方增长)。反向时主干上的恒等路径给出与深度无关的传输,子层的贡献被 LN 的尺度锁住——所以梯度尺度对深度不敏感。
(3) 一个必须说清的推论。 上面对 Pre-LN 的估算也解释了它的「副作用」: 随 增长,因此越深的模型,最后一层的激活范数越大,必须额外加一层归一化才能接输出头,否则 logits 尺度会随深度漂移。这条现象在开源实现里可以直接观察到(打印各层激活范数即可)。
∎把两条路并排看:Post-LN 表示更「规矩」(每层后都重新归一化,尺度不漂),但训练更挑学习率与预热;Pre-LN 训练更稳、允许更大的学习率与更少的调参,代价是主干尺度累积、需要收尾归一化,并且有研究指出在很深的设置下 Pre-LN 的有效深度利用不如更精细的布局(这类细节属于活跃研究方向,本书不展开)。实践中,2020 年以后的开源大模型几乎一律采用 Pre-LN(常配 RMSNorm)——这是工程惯例,不是理论最优的证明。
2.5.4 三种布局:编码器 / 解码器 / 编解码器
| 结构 | 自注意力 | 是否含交叉注意力 | 交叉注意力的 Q / K,V 来源 | 典型用途 |
|---|---|---|---|---|
| 编码器块 | 双向(无掩码,每个位置看全序列) | 否 | — | 理解整段输入:分类、句向量、检索嵌入 |
| 解码器块 | 因果掩码(只能看 ) | 否(GPT 类) | — | 自回归生成:语言模型、代码补全 |
| 编解码器块 | 因果掩码(目标侧) | 是 | Q 来自解码器,K / V 来自编码器输出 | 序列到序列:翻译、摘要、语音转写 |
三个要点:
- 掩码决定因果性:同一套注意力代码,加上因果掩码就是解码器,不加就是编码器;
- 交叉注意力的方向:查询来自「正在生成的一侧」(要知道自己缺什么),键值来自「被读取的一侧」(提供内容)。写反了模型仍能训练,但含义完全变了;
- decoder-only(仅解码器)成为默认:一方面它天然适配「把任务写成续写」的统一接口(指令、对话、代码),另一方面它便于用同一套因果实现做 KV cache 与流式推理。这是工程与生态层面的收敛,不代表「编码器—解码器结构没用」——在语音、翻译等输入输出都很长的场景里,编解码结构仍然常见。
2.5.5 前向形状表与参数量估算
取 、、、(故 )、,逐行写出一次前向的张量形状与参数量。
| 层 / 运算 | 输出形状 | 参数量 | 说明 |
|---|---|---|---|
| 词嵌入 | 为词表大小(权重可与输出层绑定) | ||
| 投影 | 投影到 个头后 reshape 成 4 维 | ||
| 分数 | 0 | 平方项: 个数 | |
| softmax(按键维) | 0 | 每行和为 1;掩码在 softmax 前加 | |
| 单头输出 | 0 | 加权求和得到 | |
| 拼接 + | 多头结果线性混合 | ||
| 残差 + 归一化 | LN 的 (RMSNorm 只要 ) | ||
| FFN: | 扩张 4 倍 | ||
| FFN: | 压回 | ||
| 残差 + 归一化 | — | ||
| 输出层(解码器) | logits; 时约 参数 |
读表要点:只有一行是「平方项」的产地(分数矩阵),它是 ——与 无关;其余各行的规模都由 决定。把这两类分开,长上下文与宽模型的开销来源就一目了然(2.6.6 会把它们算成显存与 FLOPs 的具体数字)。
用上面的配置计算: (1) 单个编码器块的参数量,并给出「注意力 / FFN / 归一化」三者的占比; (2) 12 层堆叠后的总参数量(不含词嵌入与输出层); (3) 若把词表 的嵌入与输出层也算进来(权重绑定只算一次),总量是多少?
(1) 单块参数量(忽略偏置或单独列出):
| 部件 | 算式 | 参数量 |
|---|---|---|
| 多头注意力() | ||
| 偏置(4 个 ) | ||
| FFN() | ||
| FFN 偏置 | ||
| 两个 LayerNorm | ||
| 合计 | — |
占比:注意力 ;FFN ;归一化约 。这就是「FFN 占三分之二参数」的来历——它与 2.7 里 MoE 只替换 FFN 的做法直接相关。
(2) 12 层:,即约 85M 参数( 可用来快速心算:每层 ,再乘 12)。
(3) 词嵌入与输出层:,权重绑定后只算一次,再含最后一层 LN():总量 ,即约 110M 参数。
三个可以记住的比例:① 每层参数 ( 注意力 + FFN);② FFN 占 ;③ 嵌入与输出层在 、 时约占总量 ——词表大时这一项不可忽略,这也是小模型常常共享嵌入与输出权重的动机。
∎2.5.6 训练与推理:一次前向 vs 逐 token
训练(teacher forcing):给定整段 token ,一次前向并行算出所有位置的 logits,损失为
其中「」由因果掩码在同一次前向里精确实现(2.4.5)。
推理(自回归):第 步没有「未来的答案」,只能采样出一个 token、把它接回输入、再算下一步。为免重复计算,把历史位置的键值存下来:
每一步只需为新 token 计算 ,再与缓存的 做注意力:增量计算量由 降为 。
训练时我们知道正确答案,于是可以用因果掩码在同一次矩阵乘里模拟出「每个位置只看自己左边」的效果—— 个位置同时算完,损失一次到位。推理时没有正确答案,第 个 token 必须先被生成才能作为第 步的输入,因此天然串行。这不是实现偷懒,而是自回归目标的定义: 的条件里包含模型自己的输出。三条常见推论——① 长上下文推理的瓶颈往往在显存带宽(每一步都要读整个 KV cache)而非算力;② batch 里不同序列长度不齐会造成「气泡」,因此系统层面要做连续批处理(continuous batching);③ KV cache 的显存随上下文线性增长,必须靠 GQA、量化、滑窗等办法压缩(2.6.8)。
每个 token 的 KV cache(FP16,每元素 2 字节)为
整段上下文的缓存为
其中 是层数、 批大小、 上下文长度、 是键值头数。
例算(、、、、、、FP16):
| 配置 | KV cache 大小 | 算式 | |
|---|---|---|---|
| MHA(每头独立) | 4.0 GiB | 字节 | |
| GQA(每 4 个查询头共享 1 组) | 1.0 GiB | 同上, 换成 8 | |
| MQA(全部共享) | GiB |
结论有三条:① KV cache 与 线性增长,与 线性增长——批推理时它是显存的主要消费者;② 把 从 降到 可把缓存降到 ,这是 GQA / MQA 的全部动机(质量损失通常很小,见 2.4.6);③ 与「权重显存」对比一下:、 的模型参数量约 (6.4B),FP16 权重约 12.8 GB——在 、batch 稍大时,KV cache 会迅速逼近甚至超过权重显存。
2.5.7 代码:从零搭一个 Pre-LN 块并验证增量解码
import torch, torch.nn as nn, torch.nn.functional as F, math
class RMSNorm(nn.Module):
def __init__(self, d, eps=1e-6):
super().__init__(); self.g = nn.Parameter(torch.ones(d)); self.eps = eps
def forward(self, x):
# 只按特征维做尺度归一化(无去均值、无偏置)
return self.g * x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
class MHA(nn.Module):
"""多头自注意力:同时支持整段前向与单步增量(KV cache)"""
def __init__(self, d, H):
super().__init__()
assert d % H == 0
self.H, self.d_h = H, d // H
self.Wq = nn.Linear(d, d, bias=False)
self.Wk = nn.Linear(d, d, bias=False)
self.Wv = nn.Linear(d, d, bias=False)
self.Wo = nn.Linear(d, d, bias=False)
def _split(self, x): # (B,T,d) -> (B,H,T,d_h)
B, T, _ = x.shape
return x.view(B, T, self.H, self.d_h).transpose(1, 2)
def forward(self, x, cache=None, causal=True):
B, T, _ = x.shape
q = self._split(self.Wq(x)) # (B,H,T,d_h)
k, v = self._split(self.Wk(x)), self._split(self.Wv(x))
if cache is not None: # 增量解码:把历史的 K,V 拼到当前步前面
k = torch.cat([cache[0], k], dim=2)
v = torch.cat([cache[1], v], dim=2)
new_cache = (k, v) # 本步结束后缓存更新
S = q @ k.transpose(-2, -1) / math.sqrt(self.d_h)
if causal and cache is None: # 整段前向时才需要掩码;增量解码天然只有 1 个查询
Tq, Tk = S.shape[-2], S.shape[-1]
keep = torch.ones(Tq, Tk, dtype=torch.bool, device=S.device).tril()
S = S.masked_fill(~keep, float("-inf"))
P = S.softmax(-1)
out = (P @ v).transpose(1, 2).reshape(B, T, self.H * self.d_h)
return self.Wo(out), new_cache
class PreLNBlock(nn.Module):
"""Pre-LN:主干是恒等加法,归一化放在子层内部"""
def __init__(self, d, H, d_ff=None):
super().__init__()
d_ff = d_ff or 4 * d
self.n1, self.n2 = RMSNorm(d), RMSNorm(d)
self.attn = MHA(d, H)
self.fc1, self.fc2 = nn.Linear(d, d_ff), nn.Linear(d_ff, d)
def forward(self, x, cache=None):
y, cache = self.attn(self.n1(x), cache=cache) # 注意力读「归一化后」的输入
x = x + y # 残差:主干上是纯加法
x = x + self.fc2(F.silu(self.fc1(self.n2(x)))) # FFN 同理
return x, cache
# ① 整段前向:一次算完全部位置(训练用)
torch.manual_seed(0)
B, T, d, H = 2, 16, 64, 8
blk = PreLNBlock(d, H).eval()
x = torch.randn(B, T, d)
full, _ = blk(x)
# ② 增量解码:逐 token 前向 + KV cache,输出应与整段前向逐位置一致
cache, outs = None, []
for t in range(T):
o, cache = blk(x[:, t:t+1], cache=cache) # 每次只喂一个位置
outs.append(o)
inc = torch.cat(outs, dim=1)
assert torch.allclose(full, inc, atol=1e-5), "增量解码与整段前向不一致"
print("KV cache 一致性检查通过;缓存形状:", tuple(cache[0].shape), tuple(cache[1].shape))
# ③ 观察 Pre-LN 的尺度累积:激活范数随深度温和增长
xs = x.clone()
for i, layer in enumerate([PreLNBlock(d, H).eval() for _ in range(12)]):
xs, _ = layer(xs)
if i in (0, 3, 7, 11):
print(f"第 {i+1:2d} 层后平均激活范数 = {xs.norm(dim=-1).mean().item():.3f}")
三段代码对应本节的三条主线:① 整段前向(训练时一次算完,靠因果掩码保证不偷看未来);② 增量解码(推理时逐 token、KV cache 拼接;断言保证两者数值一致——这是验证实现正确性的强力手段);③ 尺度观察(Pre-LN 主干尺度随深度累积,因此收尾必须有一层归一化,见 定理 2.5.2)。
- 「忘记因果掩码」:这是最隐蔽也最致命的 bug。缺掩码时训练损失会降得很漂亮(每个位置都能看到答案),但推理时没有未来可看,质量立刻崩坏。看到「训练完美、生成崩坏」,先查掩码。
- 「把 LN 的位置写反还说自己是 Pre-LN」:Pre-LN 是 ,归一化在子层内部、加法之前;Post-LN 是 。写反会让深层训练明显变差,而且症状(需要 warmup)与论文描述的一致,容易被误判为「学习率没调好」。
- 「前馈层只是装饰」:FFN 占块内约 的参数(例 2.5.1),且承担位置内的非线性变换;把它删掉模型会退化成「多层加权平均」结构。
- 「注意力的参数就是全部参数」:注意力只占 ;在小模型 + 大词表场景里,词嵌入与输出层还能占两成以上。
- 「训练能并行,推理也能并行」:推理必须逐 token(自回归的定义),能并行的只有「同一 batch 内不同序列」与「同一序列的不同采样」。把训练并行度当作推理吞吐的预期,会严重高估上线能力。
Q1 写出多头自注意力的公式,并说明:为什么头数 不改变参数量? 变大有什么代价?
Q2 一个 的 Transformer 块(经典 FFN),注意力与 FFN 各占多少参数?说出「FFN 占三分之二」的来处。
Q3 Pre-LN 与 Post-LN 的公式各是什么?为什么前者更容易训深?
Q4 、、、、、FP16:用 MHA()与 GQA()时 KV cache 各多大()?
参考要点
Q1 ,。因为 ,每头三个投影共 , 头合计 ,加 的 。 变大的代价:每个头的 变小(内积空间维度下降、单个头的表达能力变弱),reshape/转置等访存开销上升。
Q2 注意力 、FFN (),故 FFN 占 。这就是 MoE 把专家放在 FFN 的原因:那里才有足够多的参数可以做稀疏替换。
Q3 Post-LN:;Pre-LN:。Pre-LN 把归一化放进子层,主干成为恒等加法通路,梯度尺度与深度基本无关(论文报告:Post-LN 需 warmup、Pre-LN 不需要);代价是主干激活尺度随深度累积,需要收尾归一化。
Q4 公式 字节。MHA:;先算 ,乘 24 得 ,乘 2(K 与 V)得 ,再乘 2 字节 字节 ≈ 0.75 GiB。GQA():按比例降到 ,约 0.19 GiB。注意这两者都比权重显存小得多——KV cache 是随 与 增长的项,长上下文或大 batch 时才会成为瓶颈。
- Vaswani et al., Attention Is All You Need(2017, arXiv:1706.03762)——Transformer 原始论文:多头注意力、缩放点积注意力、位置编码与三种布局。
- He et al., Deep Residual Learning for Image Recognition(2015, arXiv:1512.03385)——残差连接的原始动机,第 2.5.3 节「梯度高速公路」直觉的来源。
- Ba, Kiros & Hinton, Layer Normalization(2016, arXiv:1607.06450)——LayerNorm 的定义与动机(与 BatchNorm 的维度差别)。
- Xiong et al., On Layer Normalization in the Transformer Architecture(2020, arXiv:2002.04745)——Pre-LN / Post-LN 的梯度尺度分析与 warmup 的必要性(定理 2.5.2 的依据)。
- Zhang & Sennrich, Root Mean Square Layer Normalization(2019, arXiv:1910.07467)——RMSNorm 的原始论文。
- Shazeer, GLU Variants Improve Transformer(2020, arXiv:2002.05202)——SwiGLU 与中间维缩放()的来源。
- Ainslie et al., GQA(2023, arXiv:2305.13245)与 Shazeer, Fast Transformer Decoding(2019, arXiv:1911.02150)——KV 头压缩的两种做法与动机。
本节小结:Transformer 块 = 多头注意力(跨位置混合,占 参数)+ 位置前馈层(位置内变换,占 参数)+ 残差与 Pre-LN(让几十层可训)。多头不改变参数量、只改变归纳偏置;Pre-LN 让梯度尺度与深度解耦,代价是主干尺度累积、需要收尾归一化。训练一次前向并行算完全部位置(因果掩码保证不偷看未来),推理必须逐 token 并用 KV cache 复用历史键值;KV cache 随 线性增长,是长上下文与大批量推理的主要显存开销。下一节补上最后一块拼图:位置信息,并系统处理长序列的三笔账。