初识 Transformer

0 阅读33分钟

初识 Transformer

面向有工程背景的读者,按"RNN 困境 → 训练与推理 → 自注意力 → 多头与位置编码 → 一层三件套 → GPT/BERT 分化"的顺序逐层拆解 Transformer,直觉、公式与最小实现并重。

一、为什么需要 Transformer:RNN 的困境

1.1 RNN 是一个什么样的架构

RNN(循环神经网络)是专门处理序列数据(一句话、一段音频、一段时间序列)的网络。它的关键设计只有一个:一个带"记忆"的单元,沿时间轴反复复用

  • 每读入一个输入 x(t),单元就结合"上一刻的记忆 h(t-1)"算出"这一刻的记忆 h(t)",同时给出输出 o(t)
  • 记忆 h 就像一条传送带:每到一个新站点(时间步),把新包裹(当前输入)的处理结果合并到带上,再传给下一站
  • 左图是折叠画法(一个自循环的 Cell),右图是把循环沿时间展开后的真实计算过程——训练时实际按右图算

rnn-architecture.png

两个矩阵看懂全部计算(对应上图底部公式):

h(t) = tanh( W_xh · x(t) + W_hh · h(t-1) + b )   # 更新记忆:新输入 + 旧记忆
o(t) = softmax( W_ho · h(t) )                     # 由记忆产生输出

用 PyTorch 写一个等价的 Cell 只要几行,对照公式看非常直白:

import torch.nn as nn

class MyRNNCell(nn.Module):
    def __init__(self, x_dim, h_dim, o_dim):
        super().__init__()
        self.W_xh = nn.Linear(x_dim, h_dim)   # 处理当前输入
        self.W_hh = nn.Linear(h_dim, h_dim)   # 处理上一刻记忆
        self.W_ho = nn.Linear(h_dim, o_dim)   # 记忆 -> 输出

    def forward(self, x_t, h_prev):           # 一个时间步
        h_t = torch.tanh(self.W_xh(x_t) + self.W_hh(h_prev))
        o_t = self.W_ho(h_t)
        return o_t, h_t                       # h_t 传给下一时间步

1.2 RNN 的优点

优点说明
参数共享无论序列多长,只有一套 W_xh/W_hh/W_ho,参数量与序列长度无关
天然有序逐个时间步处理,顺序信息"免费"获得,不需要位置编码
任意长度理论上可处理不定长输入,按步展开即可
轻量模型小、单步计算量小,早年算力下友好

1.3 RNN 的缺点(Transformer 的出场动机)

缺点根因后果
无法并行h(t) 依赖 h(t-1),必须逐步串行计算训练慢,序列越长越慢,GPU 大量算力闲置
梯度消失/爆炸反向传播沿时间连乘同一 Jacobian(≈ W_hh 的 t 次幂)远距离的词元(token)学不到(LSTM/GRU 的门控只是缓解,没有根治)
长程依赖弱上述两者叠加句首的主语传到句尾早就"失真"了
信息瓶颈整个序列的信息都要塞进最后一个 hSeq2Seq 翻译里,长句的 encoder 最后状态装不下整句话

一句话总结:RNN 用"串行记忆"换来了顺序建模,但也因此被串行和遗忘锁死。1997-2017 年间的 LSTM/GRU 一直在给这条传送带打补丁,直到 Transformer 提出:干脆不要循环,用注意力让每个词直接"看"到句子里所有其他词——一步到位、全程并行。后文将逐层拆解这套机制。

二、训练与推理:并行读取与逐词生成

2.1 元问题:模型是矩阵的集合,训练是在更新这些矩阵

先弄清一个更底层的问题:大模型到底是什么、训练到底在更新什么

模型 ≈ 一堆数字(参数)的集合。一个 7B 模型的权重文件里:

参数形状(Llama-7B 量级)备注
Embedding 表(词嵌入)[32000 × 4096] ≈ 1.3 亿最大的单一矩阵,词表 → 向量
注意力 W_Q/W_K/W_V/W_O每层 4 个 [4096×4096]× 32 层
FFN(前馈网络)W_in/W_out每层 [11008×4096] × 2参数量大头
LayerNorm(层归一化)γ/β每层 2 个 4096 维向量少数"非矩阵"参数

严格说:主体是矩阵,混着少量向量和标量;"7B"就是所有这些数字的总个数(fp16 存约 14GB 的 float 文件)。

训练 = 让这些数字被数据"掰弯",流程就是右图那个闭环:tokenize(分词)→ 前向(一连串矩阵乘)→ 算 loss → 反向传播求梯度 → 优化器微调每个参数:

for x, y in dataloader:        # x: [B, L] token 序列,y: 真实下一词
    logits = model(x)          # 前向:几十层矩阵乘,用当前的 θ
    loss = cross_entropy(logits, y)
    loss.backward()            # 反向:算出每个参数的梯度 ∇L
    optimizer.step()           # θ ← θ − η·∇L,全部矩阵同时微调

理解"学习"的本质,有两个关键点:

  1. 学到的不是内容,而是规律。训练完成后,从参数里拿不出任何一句训练原文——模型不是数据库。数据塑造的是模型的行为(统计规律):哪些词倾向跟哪些词、什么结构对应什么语义。矩阵被数据"掰弯"成一个新的函数,而不是装数据的容器。
  2. 更新微小,但海量累积。每步只挪一点(学习率 η ≈ 1e-4 量级),万亿 token 的数据反复冲刷,几万步累积出质变。

一个深刻推论:参数量固定(7B),训练数据远超参数量(万亿 token)——模型想"记住"数据根本装不下,只能被迫压缩出规律。这正是泛化能力的来源,也是"背书"和"学会"的分界线。

model-matrices-training-loop.png

2.2 并行是怎么发生的:一次读完整句 + 因果掩码

RNN 的依赖链是 h(t) 需要 h(t-1),前向传播必须逐步串行。Transformer 没有这条链:整个序列 [batch, seq_len, d_model] 作为一个张量一次性进网络,所有位置的 Q/K/V、注意力、FFN 同时计算,吃满 GPU 的矩阵运算。

那"逐词生成"的语言模型怎么也能并行训练?两个关键设计:

  1. Teacher Forcing(教师强制):训练时每个位置的输入都用真实数据的前缀,而不是模型自己上一轮生成的结果——没有"等待自己输出"的递归依赖;
  2. 因果掩码(Causal Mask):在注意力分数矩阵上加一个下三角掩码,位置 i 对所有 j > i 的分数置为 -inf(softmax 后为 0)。效果上等价于"只能看过去",但计算上所有位置同时进行

transformer-training-parallel.png

于是训练一步就得到全部 L 个位置的 loss(next-token prediction 每个位置都是一道题)——这是 Transformer 相对 RNN 的训练效率根源:同样的序列,RNN 要 L 次串行前向,Transformer 只需 1 次并行前向。

2.3 并行的大小限制:上下文窗口(max_seq_len)

"一次读完"的"一次"是有额度的,限制来自三处:

限制来源说明量级感受
注意力 O(n²)计算量与显存随长度平方增长512→8192,注意力开销 ×256
位置编码长度训练时见过的位置范围,超出则外推崩坏(绝对位置编码尤甚)RoPE 等相对编码缓解
GPU 显存激活值 ≈ batch × L × d × 层数batch 和长度此消彼长

所以每个模型都有训练时的 max_seq_len:BERT 512、GPT-2 1024、GPT-3 2048、Llama 系 4K~8K。现代 128K+ 长上下文靠 FlashAttention(省显存)、RoPE 外推、滑动窗口/稀疏注意力等专门技巧换来的,不是免费午餐。

另外别忘了并行的另一半:batch 维度。GPU 真正吃满靠的是 [B, L, D] 三维张量——序列内并行(L)× 样本间并行(B)。

2.4 超过窗口怎么办:训练数据的切分

原始语料先分词成 token 流,超过 max_seq_len 的部分按策略切块:

策略做法适用
硬截断只取前 max_len 个 token,其余丢弃文本分类等"看开头就够"的任务
滑动窗口块大小 = max_len,步长 stride < max_len,相邻块重叠语言模型预训练、长文理解(主流)
结构化切分 + Packing按句子/段落边界切,多个短样本拼进同一个 max_len 块,attention mask 隔离提高块利用率,现代预训练标配

滑动窗口的直觉:stride 的重叠区(如 512 窗口、256 步长)保证跨块边界的信息在至少一个块里是完整的;代价是重叠部分被重复计算,是"信息完整 vs 计算量"的折中。Packing 解决的是另一头:大量短样本(比如聊天记录)各自成块太浪费——拼起来一次算,块内用掩码互不可见。

用 HuggingFace 分词器几行就能体会前两种策略(Packing 在数据预处理阶段完成):

from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("gpt2")

# 策略一:硬截断
tok(long_text, truncation=True, max_length=512)

# 策略二:滑动窗口(溢出部分自动成块,带重叠)
tok(long_text, max_length=512, stride=256,
    return_overflowing_tokens=True)   # 返回多个 512 的块

# 策略三:Packing——多个短样本拼进同一块,用 attention mask 隔离(数据预处理实现)

注意:切块是"训练数据预处理",块与块之间没有梯度流动——模型永远只在窗口内看上下文。跨块的长程关系,训练时就丢了。

2.5 推理:训练的另一面

2.5.1 自回归与两个阶段

训练一次看全句,推理则相反:逐 token 生成——每产出一个新 token,就把它拼回上下文再算下一个,直到遇到结束符。工程上把这一过程拆成两个特征完全不同的阶段:

阶段做什么计算特征
Prefill(预填充)整个 prompt 并行前向(与训练同款),一次性填满 KV cache算力密集,只跑一次;首字延迟主要来自这里
Decode(解码)每步只算新 token 的 q/k/v,与缓存的 K/V 做注意力访存密集,受显存带宽限制;每 token 重复一次

KV cache(键值缓存)在此登场:Decode 阶段若不缓存,每生成一个 token 都要对整个前缀重新做 Q/K/V 投影(O(n²) 的重复劳动);缓存之后每步只增量计算新 token 的部分。它的本质是——RNN 用固定大小的隐状态"压缩"历史,Transformer 把历史的 K/V 原样缓存、随取随用:记忆从"有损压缩"变成"无损缓存",换来了长程依赖,代价是显存随上下文线性膨胀(2.3 长度限制的另一半根源)。

inference-prefill-decode.png

推理成本 ≈(prefill 一次 + decode × 输出长度)。输出越长花钱越多——这正是思考模型要权衡的地方。

2.5.2 思考与非思考:输出结构的区别

非思考(standard)思考(reasoning)
输出结构问题 → 直接给答案问题 → 先输出推理链(如 <think>…</think>,几百到几千 token)→ 再给答案
能力来源预训练 + 指令对齐在此基础上用大规模强化学习把"先想后答"内化为行为,遇到难题会自发延长思考、试错回溯
擅长对话、抽取、改写、简单问答数学、代码、多步规划、复杂推理
代价快、便宜token 消耗数倍到数十倍、时延长、首答案更慢

两者不是模型规模的区别,而是行为模式的区别:思考模型把"思维链"从用户的提示技巧变成了模型自己的输出习惯。现代产品常做成可开关的"思考预算"——简单问题少想、复杂问题多想,在质量和成本之间动态取舍。

2.5.3 MoE 与非 MoE:激活范围的区别

非 MoE(dense 稠密)MoE(混合专家)
FFN 结构一整块 FFN,每个 token 过全部参数一层内放 N 个"专家"FFN + 一个路由器,每个 token 只激活 top-k 个专家(如 DeepSeek-V3:256 选 8)
参数与算力参数量 = 激活量总参数 ≫ 激活参数(671B 总 / 37B 激活):知识容量按总参数算,推理成本按激活参数算
优点结构简单、行为稳定同等算力下容量更大;训练和推理都更省
代价容量受预算硬限制全部专家都要驻显存;训练要做负载均衡(防 token 挤向少数专家);多卡通信开销

直觉理解 MoE:把 2.1 参数表里那块 FFN 大矩阵换成一组专家矩阵——每个 token 按需咨询其中几位专家,而不是每次都劳烦全体。它在空间维度省算力(每步激活少),与思考与否(时间维度:生成多长)完全正交,所以存在"思考型 MoE 模型"(如 DeepSeek-R1)——两种机制叠加。

thinking-vs-moe.png

三、核心机制一:自注意力 Self-Attention

3.1 为什么叫"自":序列自己看自己

注意力机制不是 Transformer 发明的——2014 年 Seq2Seq 翻译就有了:解码器生成每个词时,去"看"编码器输出的另一个序列(Q 来自解码器、K/V 来自编码器)。那是"看别人"。

Transformer 的创举是把注意力用在序列内部:一句话里的每个 token,自己去和同一句话的所有 token 计算关联——Q、K、V 全部由同一个序列投影而来,所以叫 self-attention(自注意力,"自己看自己")。跨序列的那种在 Transformer 里保留下来叫 cross-attention,只用在编码器→解码器的衔接处。

self-attention-intuition.png

看右图的经典例句:"The animal didn't cross the street because it was too tired"——it 指的是谁? 自注意力让 it 的 Query 与句内每个词的 Key 打分,学出来 it→animal 权重最大(换成 because it was too wide,it 就该看 street)。一个词的表示由它"注意到"的内容重新组成——这就是上下文相关的词表示,歧义消解的来源。

3.2 自注意力的作用:训练时与推理时

先看训练一侧:自注意力一次性解决了 RNN 的三大死穴。

RNN 的死穴自注意力的解法训练中的直接收益
串行(h(t) 等 h(t-1))所有 token 的 Q/K 同时算,注意力是几次大矩阵乘全并行,GPU 吃满(呼应第二章)
长程依赖弱(信息走 n 步传送带)任意两个 token 之间有一条直连边,路径长度 O(1)梯度沿注意力边直达远处的词,不再随距离衰减
固定连接、静态权重权重由内容动态决定(Q·K 相似度),每个位置、每个样本都不同同一个词在不同上下文得到不同表示——模型能学会"看情况"

三条里最本质的是第二条和第三条的组合:"连接谁"由数据学出来,"连上之后"信息一步直达。训练时反向传播沿着这些注意力边把梯度直接送到远距离的 token,RNN 里梯度要穿过几十步连乘衰减的问题,在这里结构性地不存在了。

还有一层训练视角的关键点:注意力权重是 softmax 归一化的概率分布(每行和为 1)——"把注意力分配给谁"不是硬选择而是加权平均,因此整个打分-加权过程处处可微,梯度能流过"注意什么"本身:模型不仅学怎么用注意力,也学该注意什么(W_Q/W_K 这两个矩阵就是"什么该看什么"的法则,见 3.3)。

训练时的角色一句话:自注意力是全并行、全连接、内容寻址的信息交换层——它把"顺序处理+固定记忆"换成了"互相看+按需取",训练效率和长程依赖同时解锁。

推理时:每生成一个 token,都在决定"从哪取材"

推理的两个阶段里,自注意力干的是两件事:

  • Prefill 阶段(与训练同款):prompt 内所有 token 并行互相注意,一次性建立起"带上下文"的表示——问题里的指代、约束、术语定义在这一步就被解析进每个位置的向量;
  • Decode 阶段(画风突变):注意力矩阵每次只增长一行——新 token 的 q 与 KV cache 中全部前缀的 k 打分,这一行 [1×L] 的分布就是新 token 的取材决策:从上文哪些位置取信息、各取多少,加权组成新词的表示。生成代码时引用上文定义的变量名、回答时引用问题里的细节,靠的都是这一行打分。

与训练对照着看最清楚:训练一次算满整个 [L×L](teacher forcing),推理则逐行生长——KV cache 省掉的正是"已经算过的行"的重复投影。由此还能理解两个现象:

  1. 长上下文的注意力稀释:一行 softmax 要在几千个 key 上分配权重,远处信息容易被摊薄——这是"聊久了忘记开头"现象的机制根源之一;
  2. 思考链的本质:思考模型生成的每个思考 token,都是一次"取材-重组"——每多想一步,就重新聚焦了一次上下文,相当于给了模型反复回看材料的机会。

3.3 Q/K/V 怎么算:打分与加权

先分清三个投影的角色

Q/K/V 不是三种不同的数据,而是同一个输入 X 的三个线性投影,在计算中扮演不同的数学角色:

向量来源角色
Q(Query)x · W_Q发起匹配:决定"以什么口径去给所有 token 打分"
K(Key)x · W_K被匹配:决定"以什么口径被别人打分"
V(Value)x · W_V传递内容:被打分加权后取回,参与组成别人的新表示

论文原文的表述(本身无比喻):注意力是把一个 query 和一组 key-value pairs 映射为输出——输出是 values 的加权和,每个 value 的权重由 query 与对应 key 的兼容性(compatibility)函数计算得到

自注意力 = 每个 token 用自己的 Q 与所有 token(含自己)的 K 算兼容性分数,按分数加权取回它们的 V,加权结果成为自己的新表示。

计算:六步管线

qkv-attention-pipeline.png

对照上图,每一步的形状变化:

步骤计算形状说明
① 输入X[L × d]L 个 token 的向量按行堆叠
② 三投影Q=X·W_Q, K=X·W_K, V=X·W_V各 [L × d_k]三个可学习的投影矩阵
③ 打分缩放S = Q·K^T ÷ √d_k[L × L]每对 token 的相似度
④ 掩码归一化A = softmax(S),未来位 = -inf[L × L]每行和为 1(因果掩码在此生效)
⑤ 加权A · V[L × d_k]按权重取回各 token 的 V
⑥ 输出逐行拼回[L × d_k]每个 token 的新表示

为什么需要三个投影,而不是直接拿 X 自己当 Q/K/V? 两个原因:其一,同一个向量既当"问题"又当"答案"太受限——投影让匹配口径(Q·K)和交付内容(V)在不同子空间里各司其职;其二,W_Q/W_K 是可学习的,模型通过训练学出"按什么维度匹配"(3.1 例句里 it 学会按"指代对象"维度去匹配 animal)。

为什么要除以 √d_k? 点积的方差随维度线性增长(q、k 各分量近似独立时 Var[q·k] ≈ d_k)。维度一大,点积动辄几十,softmax 直接被推进饱和区——输出接近 one-hot,梯度近乎为零,训练不动。除以 √d_k 把方差拉回 1,softmax 保持在"软"的工作区。一个 √ 拯救了整个注意力。

最小实现(对照公式逐行看)
import torch
import torch.nn.functional as F

def self_attention(x, Wq, Wk, Wv, mask=None):
    # x: [L, d]  一个序列的 token 向量
    Q, K, V = x @ Wq, x @ Wk, x @ Wv          # ② 三个投影 -> [L, dk]
    scores = Q @ K.T / (K.size(-1) ** 0.5)    # ③ 打分 + 缩放 -> [L, L]
    if mask is not None:                       # ④ 因果掩码(训练并行用,见 2.2)
        scores = scores.masked_fill(mask == 0, float("-inf"))
    A = F.softmax(scores, dim=-1)              #    每行和为 1
    return A @ V, A                            # ⑤ 加权取回 + 返回权重(可视化用)

# 示例:4 个 token 的序列,观察 A 矩阵每行在看谁
L, d, dk = 4, 8, 8
x = torch.randn(L, d)
Wq, Wk, Wv = (torch.randn(d, dk) for _ in range(3))
out, A = self_attention(x, Wq, Wk, Wv)
print(A.shape)   # [4, 4] —— 这就是注意力矩阵,O(n²) 复杂度的本体

三个工程备注:

  • A 就是那个 [L × L] 注意力矩阵——2.3 节说的 O(n²) 复杂度、显存爆炸的本体就是它;可视化注意力(看模型在看谁)画的也是它
  • 每行是一个概率分布(看谁),每列不归一——"谁被看"没有约束,一个热门 token 可以被所有位置同时重仓
  • 推理时的 KV cache 缓存的就是 ②算出的 K、V——新 token 只需算自己的 q,再和缓存的 K/V 走 ③④⑤

3.4 延伸:W_Q/W_K 是权重吗?打分函数的语义从哪来?

两个常见问题,把 3.3 的地基夯实:

① W_Q、W_K 确实是权重(可学习参数)。它们就是 2.1 节那张"7B 参数表"里的成员——初始化为随机数,训练中每一步被 θ ← θ − η·∇L 更新(W_V 同理)。

② "兼容性"的语义不是人为规定的,是从数据里训出来的。把打分公式展开:

score(i, j) = q_i · k_j = x_i · W_Q · (W_K)^T · x_j^T

一个由参数矩阵 W_Q·(W_K)^T 决定的双线性打分函数——它的"语义"完全来自训练:

阶段W_Q/W_K 状态打分函数行为
训练前随机初始化分数无意义,注意力"乱看"
训练中每步被梯度微调没有人工规则,纯粹是"预测下一词"的 loss 在施压
训练后被数据"掰弯"成语义匹配器it 的 q 与 animal 的 k 点积自然变大

以 3.1 的例句说明"施压"怎么发生:在无数含 it 的句子里,只有当 it 把注意力放对位置(指向 animal 而非 street),后续词的预测才准、loss 才低——梯度因此持续朝"让该看的 token 打高分"的方向修正 W_Q/W_K。没有任何人告诉模型"it 应该看名词",这个规律是从数据里被 loss 逼出来的。

一句话总结:注意力"在看什么"没有任何先验规定,它完全是 W_Q·(W_K)^T 这个双线性函数被万亿语料训练后的产物——模型学到的所有"匹配规律",都藏在这两个矩阵的乘积里。

四、核心机制二:多头注意力

4.1 是什么:把维度预算切成 h 份视角

先看单头的天花板:softmax 之后,每个 token 的注意力是唯一的一个分布——it 在一次注意力里只能按"一种模式"分配它的关注(要么主要看 animal,要么主要看 street,无法同时既按指代看又按句法看)。而 token 之间的关系本来就是多重的:指代、修饰、依存、邻近……���个分布装不下。

多头注意力的解法干脆:把 d_model 切成 h 份(论文:512 维切 8 头,每头 64 维),每头拿自己的 W_Q^i/W_K^i/W_V^i(都是 512×64 的投影,把输入投到 64 维子空间),独立跑一遍完整的 3.3 注意力,最后把 h 个 [L×64] 输出拼回 [L×512],过一个输出投影 W_O 融合:

MultiHead(X) = Concat(head_1, …, head_h) · W_O
head_i       = Attention(X·W_Q^i,  X·W_K^i,  X·V^i)

multi-head-attention.png

最重要的账:8 头 × 64 维 = 512 维——总维度、总计算量与一个 512 维的单头几乎相同。多头不是加预算,是同样的预算买了 8 套独立的打分函数(8 组 W_Q·W_K^T),每套可以在自己的子空间里学一种"看的方式"。

4.2 作用:训练时与推理时

作用机制训练中的体现
同时学多种关系规律每头有独立的 W_Q/W_K(独立兼容性函数)不同头自发分工:可视化研究发现有的头专攻指代、有的盯邻近词、有的抓句法依存——没有任何显式监督指定分工,是梯度在降低同一 loss 的过程中自然分化(涌现分化)的
突破"单分布"表达瓶颈h 个头 = h 套并行的注意力分配单头里指代和句法两种模式会互相稀释(一个分布里此消彼长);多头让它们各占一头互不干扰
冗余与鲁棒多套视角覆盖同一信息头剪枝研究:剪掉相当比例的头模型性能几乎不掉——重要头集中,其余是冗余备份
可组合的融合Concat 后过 W_OW_O 也是被训练的:学出"哪个头的判断在什么情况下更可信"

训练视角还有两个细节值得记住:

  1. 所有头同时并行训练,没有先后和主次。分工不是设计出来的,是随机初始化打破对称后,梯度沿各自最省力的方向演化出来的;
  2. 每头有自己的 [L×L] 注意力矩阵——8 头就是 8 个注意力矩阵并存,可视化"模型在看什么"时看到的都是具体某个头的视角。
最小实现(看清"拆头-算注意力-拼回"三步)
import torch
import torch.nn as nn

class MultiHeadAttention(nn.Module):
    def __init__(self, d=512, h=8):
        super().__init__()
        self.h, self.dk = h, d // h          # 8 头,每头 64 维
        self.Wq, self.Wk, self.Wv = (nn.Linear(d, d) for _ in range(3))
        self.Wo = nn.Linear(d, d)

    def forward(self, x):                     # x: [L, d]
        L, d = x.shape
        Q, K, V = self.Wq(x), self.Wk(x), self.Wv(x)        # [L, d] 一次算全部头
        # 拆头:[L, d] -> [h, L, dk],每头住进自己的 64 维子空间
        Q, K, V = (t.view(L, self.h, self.dk).transpose(0, 1) for t in (Q, K, V))
        A = (Q @ K.transpose(-2, -1) / self.dk ** 0.5).softmax(-1)  # [h, L, L] 每头一个注意力矩阵
        out = A @ V                                        # [h, L, dk]
        out = out.transpose(0, 1).reshape(L, d)            # 拼接回 [L, d]
        return self.Wo(out)                                # 融合投影

三个投影用 Linear(d, d) 一次算出再 reshape 拆头,而不是每头单独一层——工程上等价、GPU 上更高效,这也解释了为什么参数表里 W_Q 是一个 [512×512] 而不是 8 个 [512×64]。

推理时:h 个视角同时各做一次取材决策

结合 3.2 的"逐行生长":Decode 阶段每生成一个新 token,不是做一次取材决策,而是同时做 h 次——每个头在自己的子空间里独立打分、独立加权,h 份结果由 W_O 融合。生成过程中多头的分工比训练时更直观:

  • 有的头负责指代一致性——答案里的"它 / 该方案"要接住上文的正确对象;
  • 有的头负责句法搭配——下一个词要与已生成部分语法连贯;
  • 有的头负责邻近复制——把上文的专有名词、代码变量名原样引用(对代码续写尤其关键,即经典的 induction / copying head 现象)。

工程上还有一个关键关联:KV cache 是按头缓存的——形状约为 [层数, h, L, d_k],每个头各存各的 K/V,头数越多 cache 越大。现代推理优化的 MQA / GQA(多查询 / 分组查询注意力,Llama-2-70B 采用 GQA)正是把多个头的 K/V 合并共享,直接压缩 cache 显存、提升 Decode 吞吐——用"牺牲部分头的视角独立性"换"更便宜的推理"。

4.3 位置编码:顺序信息从哪来

问题:注意力天生"无序"

自注意力对输入是置换等变的:把 token 顺序打乱,输出跟着同样打乱。换个说法——在注意力眼里,输入是"词的集合"而不是"词的序列"。"我爱她"和"她爱我"送进去,每个词得到的表示完全相同,模型分不出差别。

这不是 bug,是并行的必然:2.2 节里所有 token 同时进网络、地位完全对称,没有任何机制天然携带"谁在前谁在后"。

解法:把位置"加进"输入

给每个位置 i 生成一个位置向量 PE(i),逐元素加到 token 的 embedding 上:

x_i = Embedding(token_i) + PE(i)        # 词义 + 位置,一个向量两种信息

原论文用正弦位置编码:每个维度一对 sin/cos,频率从 2π 到 10000·2π 按几何级数排开——像一排由慢到快的多进制计数器,任意位置的编码模式唯一。它有个漂亮性质:PE(i+k) 可以写成 PE(i) 的线性变换,相对位置信息天然可提取

positional-encoding.png

后续演化一行看完:GPT/BERT 改用可学习的位置嵌入(训练学出来,但长度不能外推);Llama 系用 RoPE(把位置作为旋转角乘进 Q/K,相对位置直接体现在打分里,外推性好);ALiBi 干脆给注意力分数加个距离惩罚。万变不离其宗:都是把顺序以某种形式注入"无序"的注意力

与训练并行计算的关系

位置编码与训练并行之间存在直接的因果关系——位置编码正是并行化的配套代价:

RNNTransformer
计算方式按时间步串行整句并行(2.2)
顺序信息来源免费——先算谁后算谁本身就是顺序必须手动注入——位置编码
代价训练慢多一套编码机制 + 长度外推难题(见 2.3)

完整的顺序语义由两件事拼成:因果掩码负责"不能看未来",位置编码负责"知道自己在哪"——合起来才等价于 RNN 逐词处理时隐含的全部顺序信息。顺序没有消失,只是从"计算图的拓扑"(串行依赖)变成了"输入的特征"(位置向量)。

# 正弦位置编码的最小实现
import torch, math

def pe(pos, d):                     # 位置 pos、维度 d 的编码向量
    v = torch.zeros(d)
    for k in range(0, d, 2):
        v[k]   = math.sin(pos / 10000 ** (k / d))
        v[k+1] = math.cos(pos / 10000 ** (k / d))
    return v

# 使用:x = token_embedding + pe(i, d_model),逐位置相加
与推理的关系:长度外推与 cache 中的位置

位置编码与推理的关联比表面看起来深:

  1. 逐 token 的位置递增:Decode 时每个新 token 拿到递增的位置编号。绝对编码(正弦 / 可学习)在输入层一次性加进 embedding,随向量进入所有层;RoPE 则是每层给 q/k 按位置旋转——相对距离直接进入打分,"离得远打分自然衰减"。
  2. 长度外推(最直接的关联):训练只见过 [0, max_len) 的位置,推理超出这个范围,绝对位置编码的性能会崩塌;RoPE 配合缩放技巧(NTK / YaRN 等)能把窗口外推到训练长度的数十倍——这正是 2.3 提到的 128K 长上下文产品的位置编码基础。
  3. cache 里也有位置:KV cache 存的每个 K 都带着它生成时刻的位置信息(RoPE 模型通常缓存已旋转的 K),新 token 与它们的相对距离在打分时逐一生效——位置没有存在别处,就藏在 cache 的每个向量里。

五、FFN、残差与 LayerNorm:一层的另外三件套

注意力层之外,每个 Transformer 层还有三个部件。它们常被一笔带过,但缺了任何一个,几十层的网络都训不起来。

5.1 FFN:逐位置深加工,知识的主要仓库

结构极简——两层线性夹一个激活,先升维 4 倍再降回来

FFN(x) = W_out · act( W_in · x )      # 512 → 2048 → 512
# act:原论文 ReLU;现代模型多为 SwiGLU(带门控,Llama 系采用)

三个关键认识:

  1. 它逐位置独立(position-wise):每个 token 各自过同一套 FFN,不含任何 token 间交互。信息交换全部由注意力完成——所以 FFN 必须和注意力交替,二者是"开会收集信息"与"回去消化整理"的关系;
  2. 为什么要升维 4 倍:中间层是模型的"工作台"。研究表明 FFN 类似键值记忆——升维后的每个中间神经元像一个模式检测器(检测"这像不像某个语言模式"),检测命中后由 W_out 把相关知识写回表示。工作台越大,能同时检测的模式越多。这也解释了 2.1 参数表里 FFN 为什么是参数量大头——模型的事实性知识主要存在 FFN 的两个大矩阵里(MoE 替换的正是 FFN,呼应 2.5);
  3. 推理时的算力重心:Decode 阶段注意力的"新行"计算量很小,FFN 反而成为单 token 延迟的主要来源之一——所以推理加速(量化、MoE、专家裁剪)大多冲着 FFN 去。

5.2 残差连接:信息高速公路

每个子层的输出不是替换输入,而是加回输入

output = x + Sublayer(x)        # Sublayer = 注意力 或 FFN

为什么这个"+"至关重要:

  • 梯度视角:反向传播时 ∂(x+f(x))/∂x = 1 + ∂f/∂x——那个"1"给梯度修了一条不衰减的直达通道,无论网络多深,梯度都能一路畅通回到浅层。没有它,几十层连乘的梯度会像 RNN 沿时间那样消失(这里是沿深度),深网络根本训不动(ResNet 2015 的核心发现);
  • 学习视角:每层只学增量修正——"在现有表示上补什么",而不是从零重建表示。学"改哪里"远比学"重新表达一切"容易;
  • 信息视角:x_final = x₀ + f₁ + f₂ + … + f_N——各层贡献可加性地叠在初始 embedding 上,第 0 层的信息永远"在场",最后一层读到的永远是"原始信息 + 全部修正"。

5.3 LayerNorm:每层的稳压器

每个 token 自己的特征向量做标准化(均值 0、方差 1),再用可学习的 γ/β 缩放平移——正是 2.1 参数表里那对"非矩阵"参数。作用是把各层激活值拉回稳定区间,防止深网络内部数值尺度漂移导致训练崩溃。

为什么是 Layer 而不是 Batch:NLP 的序列长短不一,按 batch 维做统计会被 padding 污染、且依赖 batch 大小(推理时 batch=1 就没法算);LayerNorm 对单样本、单位置独立归一化,训练推理行为完全一致。

位置与演化:原论文 Post-LN(LN(x+f(x)))深层难训;现代模型一律 Pre-LN(x + f(LN(x)))——归一化放在子层入口,训练稳定得多;Llama 系进一步简化为 RMSNorm(去掉均值中心化只保留缩放,少一次统计计算)。

5.4 串起来:一层的完整数据流

transformer-block-ffn-residual-ln.png

class Block(nn.Module):
    def __init__(self, d=512, h=8):
        super().__init__()
        self.ln1 = nn.LayerNorm(d)
        self.attn = MultiHeadAttention(d, h)      # 4.2 的实现
        self.ln2 = nn.LayerNorm(d)
        self.ffn = nn.Sequential(                 # 升 4 倍再降回
            nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))

    def forward(self, x):                         # Pre-LN 残差结构
        x = x + self.attn(self.ln1(x))            # 搬运信息 + 高速公路
        x = x + self.ffn(self.ln2(x))             # 深加工 + 高速公路
        return x

# model = nn.Sequential(Embedding+PE, *[Block(d,h) for _ in range(N)], ln_f, lm_head)

至此一张拼图完成:LN 稳住数值 → 注意力跨 token 搬运 → 残差保底信息流 → FFN 逐位置深加工——这个 Block 堆 N 次(GPT-3 是 96 次),就是 2.1 那张参数表的全部由来。推理时每生成一个 token,都要把这 N 个 Block 完整走一遍。

六、从 Transformer 到 GPT / BERT:为什么拆开用

原始 Transformer 是为翻译设计的编码器-解码器结构;GPT 和 BERT 各拆走一半,走向两条路线。

encoder-decoder-gpt-bert.png

6.1 原始设计:编码器管理解,解码器管生成

翻译的自然流程是"先完整理解源句,再逐词生成目标句",两个部件正好各司其职:

  • 编码器(Encoder):双向注意力——每个词同时看左右全文,产出"理解后的表示"。理解时下文已经存在,不用白不用;
  • 解码器(Decoder):因果单向注意力——只能看已生成的部分;每层另有一个 cross-attention(3.1 提过的"看别人":Q 来自解码器、K/V 来自编码器输出),逐词从"理解结果"里取料生成。

分工的全部理由是生成与理解的不对称:生成时未来还不存在——训练语料里下一个词是现成的,若不加因果掩码,位置 i 就能直接看到要预测的答案,等于抄答案;推理时未来更是无从看起。理解则没有这个约束。

6.2 GPT:只用解码器——生成路线

砍掉编码器和 cross-attention,只留因果解码器堆,训练目标只有一个:预测下一个 token

优势来自"训练与使用完全一致":训练怎么练(看前文猜下一个词),使用就怎么用(看前文生成下一个词),因此可以无缝自回归生成任意长度文本;而任何任务都能表述成"续写"——对话、代码、问答皆然。这个统一让 GPT 路线成为规模化的主角:任务通用性和 in-context learning(不微调、靠上文示例学会新任务)都由此而来。

6.3 BERT:只用编码器——理解路线

砍掉解码器,纯双向编码器,训练目标换成完形填空(MLM,Masked Language Model)——随机遮住约 15% 的词让模型猜,外加下一句预测。

优势在理解:双向注意力让每个位置同时利用上下文两侧信息,做分类、命名实体识别、语义匹配(检索粗排)这类"输入整句、输出标签"的任务全面占优,是 2018-2020 年的 SOTA 主力。代价是不能自然生成——没有因果结构就无法自回归;且下游任务通常要加输出头微调。

6.4 对比与合流

原始(编 + 解)GPT(仅解码器)BERT(仅编码器)
注意力方向编码器双向 / 解码器单向仅单向(因果)仅双向
训练目标重建目标句(翻译)预测下一 token完形填空 + 下一句预测
擅长翻译、摘要等"输入→输出"生成、对话、代码、通用任务分类、抽取、语义匹配
短板结构重、两段式理解仅靠上下文内单向信息不能生成、需任务微调

后续演化:T5 / BART 保留编-解结构(翻译、摘要类任务仍然合适);现代 LLM 几乎清一色 decoder-only(GPT 路���)——"生成即通用"叠加规模效应;BERT 的双向理解血脉则活在检索系统的 embedding 模型里(RAG 向量化那一端)。

从 2017 年为翻译而生的模型,到今天驱动各类产品的 LLM——变的是规模、数据与训练方式,不变的是这些零件和它们背后的取舍。

延伸阅读

附:原论文总架构(中文重绘)

把前文的全部零件放回原始结构——左侧编码器(双向理解),右侧解码器(单向生成 + Cross 取料),自下而上走一遍,即第六章描述的完整翻译流程:

图中「残差相加 & 层归一化」即原论文的 Add & Norm——Add 指残差连接(5.2),Norm 指层归一化(5.3),合并标注为一个组件。

两个容易混淆的"归一化":Softmax 是归一化指数函数——把注意力打分变成概率分布(3.3 第④步);层归一化(LayerNorm)——把特征向量的数值尺度标准化(5.3)。前者作用于一组打分,后者作用于一个向量,是完全不同的两回事。

original-architecture-zh.png