Transformer: Attention Is All You Need

论文: Attention Is All You Need (NeurIPS 2017)
作者: Ashish Vaswani, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Łukasz Kaiser, Illia Polosukhin (Google Brain)
代码: tensor2tensor 官方实现


一、背景

把《Attention Is All You Need》放回 2017 年的语境,理解它当时要解决什么问题、采用了哪些关键设计,有助于更准确地把握后续架构细节。

1.1 2017 年的世界:Transformer 诞生时的 NLP 图景

2017 年 6 月这篇论文出现时,NLP 学术圈与工业界的图景是这样的:

  • 机器翻译是 NLP 的旗舰任务。Google Translate 在 2016 年 9 月把生产环境从短语统计 MT(PBMT)切到神经 MT(GNMT),翻译质量显著提升,推动行业转向 NMT,几乎所有 NLP 实验室都在做翻译。
  • LSTM 是事实标准。所有 seq2seq、对话模型、语言模型都是 LSTM(GRU 被视为轻量替代)。Schmidhuber 1997 年提出的 LSTM,20 年后才成为序列建模的主流。
  • attention 是 LSTM 的辅助机制。Bahdanau (2015)、Luong (2015) 的 attention 被定位成 RNN 的附加模块,RNN 仍是主体,attention 仅帮助 decoder 关注 encoder 的相关信息。
  • 训练慢是公认瓶颈。GNMT 训练要好几天到一周,因为 LSTM 的时间步必须串行。Facebook 的 ConvS2S(2017.5)开始用 CNN 做 seq2seq 来并行化,引起讨论。
  • 没有预训练范式。word2vec / GloVe 词嵌入是有的,但”先预训练大 LM 再 fine-tune 下游”的范式还没诞生——BERT 还要再等 17 个月。
  • “大模型”指 <1B 参数的东西。GPT 还没出现,GPT-2 要再等一年半。

在这个语境里,标题 Attention Is All You Need 略带挑衅意味,意在表明:去掉 RNN 同样能完成翻译,而且更快更好。

1.2 真正的动机:为「训练效率」而生,而非「容量」

论文表面是 Google Brain 的研究,背后还有一条隐藏脉络:Google Translate 团队的工程压力

GNMT 上线后,翻译团队要在生产环境维护一个每月迭代的庞大模型。真实痛点不是”翻译不够好”,而是:

  • 训练一次新模型要 1 周以上(LSTM 串行);
  • 实验迭代极慢,调一个超参要等几天;
  • LSTM 的长依赖在长句子上仍不行。

关键认知:Transformer 在诞生时是为”训练效率”而生的架构,不是为”容量”而生的架构。它的并行性是优化目标,attention 的全局连接是手段。今天我们说”Transformer 因 attention 能建模长依赖所以强大”——这种叙事其实是后人的总结。当年作者真正想要的,是把 N 个时间步的串行计算变成可并行的矩阵乘法。

这也是论文反复强调 “3.5 days on 8 P100 GPUs” 的原因:在当年的语境下,这一数字强调的是训练速度上的优势。

同时,2017 年 5 月 Facebook 的 ConvS2S 用 CNN 并行化做 seq2seq,WMT’14 EN-FR 达到 40.46 BLEU、超过 GNMT,引起广泛关注。Google 团队由此感受到竞争压力,论文实验里 ConvS2S 是核心 baseline。要回答的问题就是:能不能用 attention 同样实现并行化,而且效果更好? 答案是 Transformer big 在 EN-DE 28.4(ConvS2S 25.16)、EN-FR 41.8(ConvS2S 40.46),用更小模型、更短训练时间超过两者。

1.3 技术脉络:从 RNN+attention 到纯 self-attention

很多教程把 Bahdanau 2015 当作 self-attention 的祖先,这是不准确的。Bahdanau attention 是 decoder-encoder 之间的 cross-attention,不是 self-attention,且 RNN 仍是主体。

self-attention 真正的前身有几条线:

工作 年份 贡献
Cheng, Dong, Lapata 2016 让 LSTM 的某 step attend 到自己历史所有隐状态(”序列内部 attention”的早期想法)
Parikh et al.(Google) 2016 A Decomposable Attention Model,把 attention 用作两文本相似度建模的核心而非辅助(Niki Parmar 团队)
Lin et al. 2017.3 A Structured Self-Attentive Sentence Embedding,专门用 self-attention 做句子表示

Transformer 的创新不是”发明”了 self-attention,而是把它推到极端:整个网络都不要 RNN/CNN,纯靠 self-attention 堆叠。在 2017 年,”能不能完全没有 RNN”这个问题本身就大胆——多数同行认为这一思路过于激进。

1.4 八位作者与后续动向

论文署名八人,arXiv 顺序:Ashish Vaswani、Noam Shazeer、Niki Parmar、Jakob Uszkoreit、Llion Jones、Aidan N. Gomez(多伦多大学实习生)、Łukasz Kaiser、Illia Polosukhin。结尾一行 “Equal contribution”——八人贡献相等、顺序随机,反映这不是传统的主导模式,而是一群高水平工程师/研究员的平等合作。

几人后续都成了 AI 领域的重要人物,且几乎没人留在 Google 原岗位——这是 2018–2022 年间被媒体(Wired 等)称为 “Transformer Mafia” 现象的一部分:

  • Noam Shazeer:后做 Sparse MoE Transformer,创办 Character.AI(2021),2024 年又重返 Google,被广泛认为是核心作者之一。
  • Łukasz Kaiser:做 Tensor2Tensor(论文实验框架),后去 OpenAI 参与 GPT 系列。
  • Aidan Gomez:当时本科实习生,后创办 Cohere。
  • Illia Polosukhin:后做 NEAR Protocol。
  • Jakob Uszkoreit:后创办 Inceptive 做生物医药 AI。

1.5 论文真正说了什么——以及刻意没说什么

论文的真正贡献可归纳为五点:

  1. 完全去掉 RNN 和 CNN,仅用 attention + FFN 构造序列模型;
  2. Multi-head self-attention:把 attention 拆成多个并行 head;
  3. Positional encoding:用 sin/cos 给位置无关的 attention 注入位置信息;
  4. Encoder-decoder + cross-attention:保留 seq2seq 框架但内部全用 attention;
  5. 训练速度大幅提升:8 卡 P100、3.5 天达到 SOTA。

实验设定非常 minimal:

设定
数据 WMT’14 EN-DE (4.5M 句对)、EN-FR (36M 句对)
硬件 1 台 8×P100
训练 base 12 小时(100k step)、big 3.5 天(300k step)
指标 BLEU
结果 EN-DE base 27.3 / big 28.4(前 SOTA 26.03);EN-FR base 38.1 / big 41.8

⚠️ 注意论文里”没有”什么:没有预训练、没有 in-context learning、没有 chain-of-thought、没有 instruct-tuning。这些后来”让 Transformer 真正改变世界”的东西都不在原论文里。原论文做的事非常聚焦:在机器翻译这一个任务上,证明纯 attention 架构可行且训练更快。模型也小——base 65M、big 213M,按今天的标准看规模很小。

1.6 接收度与历史转折:从「翻译论文」到「大模型圣经」

论文在 NeurIPS 2017 只被接收为 poster(不是 oral),如今看来略显意外。当年的反应大致是:

  • 机器翻译圈:迅速接受,SOTA + 训练时间优势太明显,2018 年内几乎所有 NMT 系统都迁到 Transformer。
  • NLP 整体:观望,多数人觉得这是”翻译领域的论文”。
  • CV 圈:几乎未关注,当时少有人认为 attention 适用于 CV。

真正的爆发来自几个转折点:

  • 2018 BERT:证明 Transformer encoder 可预训练成通用语言表示,下游任务全面 SOTA,把整个 NLP 拉到 Transformer 旗帜下;
  • 2018 GPT-1 → 2019 GPT-2 → 2020 GPT-3:decoder-only 路线被验证,GPT-3 在 175B 涌现出 in-context learning;
  • 2022.11 ChatGPT:decoder-only + RLHF 引发广泛关注,奠定对话式 AI 的产品形态,decoder-only 从此成为 LLM 事实标准。

有意思的是:原论文展示的是 encoder-decoder,而今天主流大模型(GPT / LLaMA / Claude / Gemini / Qwen)几乎全是 decoder-only。从 encoder-decoder 简化到 decoder-only 只是”去掉一半”——如果 2017 年作者已知 decoder-only 这么强,可能根本不会讲 encoder-decoder。但他们当时面对的是翻译这个本质 seq2seq 任务,encoder-decoder 是默认选择。

到 2024 年,这篇论文 Google Scholar 引用超 13 万,是过去十年被引最多的 ML 论文之一。

1.7 广泛影响:从翻译到通用骨干

七年里,Transformer 的核心结构几乎没变,却从翻译模型发展为几乎所有现代 AI 的共同基础:

领域 代表工作 用法
NLP 大模型 BERT / GPT / T5 / LLaMA / Claude encoder-only / decoder-only / enc-dec 三大流派
视觉 ViT 把图块当 token,attention 建模全局
检测 DETR 用 attention 取代 NMS 后处理
自动驾驶 BEVFormer / DETR3D / 时空 attention 时序 + 多视角 BEV 特征建模、占据网络中的 attention
3D 高斯泼溅 Transformer 预测高斯参数 / 动态场景建模 把 3DGS 与序列/生成模型结合

核心结构七年不变,但实现细节被社区不断更新:位置编码改 RoPE / ALiBi、归一化改 RMSNorm、激活改 SwiGLU、attention 加 KV cache / FlashAttention 优化。这正是 Bitter Lesson 的又一例证——“少改动 + 多 scale + 好工程”击败了几乎所有精巧但局限于局部改进的变体(ConvS2S、Universal Transformer、Reformer、Performer、Linformer 等都没能成为主流)。

1.8 常见误解澄清

  • ❌「核心创新是 attention」——不准确。attention 2014 年就有,self-attention 也有前作。真正创新是”全部用 attention 不用 RNN” + multi-head + positional encoding 的组合
  • ❌「一发表就引爆 NLP」——错。当时只是 NMT 圈接受,全行业转向是 BERT 之后的事。
  • ❌「原论文是 decoder-only」——错。原论文是 encoder-decoder 用于翻译。
  • ❌「论文里有 GPT/BERT 雏形」——只能说有 encoder/decoder 模块,pretraining-finetuning 范式当时根本不存在。
  • ❌「Transformer 当年很大」——错。base 65M、big 213M 按今天的标准看规模都很小。
  • ❌「八位作者后来都留在 Google」——错。几乎所有人都离开了。

二、整体架构

这一章只做一件事:把 Transformer 的整体骨架讲清楚–主架构图长什么样、由哪些部件组成、每个部件负责什么、数据怎么从输入流到输出。具体每个部件的内部细节,留到第三~六章展开。

2.1 主架构图:论文 Figure 1

Transformer 的全貌就是论文 Figure 1 这张图(下图是清晰重绘版)。熟悉这张图后,后续内容都可视为在其基础上的局部展开:

骨架四句话:

  1. 左半边 Encoder(堆 $N=6$ 层):处理源序列(如待翻译的句子);
  2. 右半边 Decoder(堆 $N=6$ 层):自回归地生成目标序列;
  3. Cross-Attention 是两边唯一连接:encoder 的输出作为 decoder 的 K、V;
  4. Decoder 顶端 -> Linear -> Softmax -> 词表概率

💡 这张图是 2017 年为机器翻译设计的完整 Encoder-Decoder 架构。今天的 LLM(GPT/LLaMA 等)只用了右半边的 decoder-only;BERT 只用了左半边的 encoder-only。理解了完整图,就顺带理解了 BERT 与 GPT 的关系。

2.2 两大模块与职责

模块 职责 输入 输出
Encoder 把源序列编码成一组”上下文记忆” 源 token + 位置编码 记忆 mem(每层输出形状不变)
Decoder 自回归生成目标序列,并经 cross-attention 读取 encoder 的记忆 目标 token(shifted right)+ 位置编码 + encoder 的 mem 每个位置的隐表示 -> 词表概率
输入端 token id -> 向量 + 位置信息 token id $d_{model}$ 维序列表示
输出端 隐表示 -> 词表分布 decoder 最后一层输出 每位置一个词表概率分布

Encoder 与 Decoder 之间只有一个信息通道:cross-attention。encoder 算完一次,结果被 decoder 的每一层反复”查询”–这继承了 2014 年 Bahdanau RNN attention 的思想。

2.3 组件清单:每个组件负责什么

Transformer 由以下组件构成(标注 src/transformer.py 中已实现的对应代码):

组件 职责 实现 详见
Token Embedding token id -> $d_{model}$ 向量(×√d_model 缩放) TransformerEmbedding 第三章
Positional Encoding 注入位置信息(sin/cos),因模型本身无顺序感 PositionalEncoding 第三章
Multi-Head Self-Attention 序列内部建模依赖:encoder 双向、decoder 因果(mask) SelfAttention(单头版) 第四章
Cross-Attention decoder 查询 encoder,是两边唯一信息通道(Q来自decoder, K/V来自encoder) 待补 第五章
Feed-Forward (FFN) 非线性变换 + 记忆容量,两层线性夹 ReLU,宽度 4×d 待补 第四章
Add & Norm 残差连接 + LayerNorm,缓解梯度消失、稳定训练 待补 第四章
Linear + Softmax 投影到词表维度 + 归一化为概率 待补 第六章

几个关键认知(后面章节会展开):

  • 全程无递归、无卷积 -> 必须靠 Positional Encoding 显式注入位置信息;
  • FFN 占了约 2/3 参数量(每层 attention ~1.05M,FFN ~2.1M),这就是为什么 MoE 把 FFN 稀疏化能省大量参数;
  • 原论文用 Post-LN(Add 后再 Norm),今天大模型几乎都改 Pre-LN(Norm 在前),稳定性更好。

2.4 单层内部:Encoder 6 步 / Decoder 9 步

Encoder 一层做 6 件事(一个 sublayer 群 + 一个 FFN 群):

1
2
3
4
5
6
1. Multi-Head Self-Attention      -> Attn(x)
2. Add (残差) x + Attn(x)
3. LayerNorm
4. Feed-Forward (Linear->ReLU->Linear, 宽度 4d)
5. Add (残差) y + FFN(y)
6. LayerNorm

Decoder 一层多一个 cross-attention,共 9 步(三个 sublayer 群):

1
2
3
第一组 Masked Self-Attention:  1. Masked MHA (QKV 都来自 decoder 输入)  2. Add  3. LayerNorm
第二组 Cross-Attention: 4. Cross-Attn (Q 来自 decoder, K/V 来自 encoder 输出) 5. Add 6. LayerNorm
第三组 FFN: 7. Feed-Forward 8. Add 9. LayerNorm

三个关键差异:

  • 第一组 self-attention 必须带 causal mask(decoder 生成时不能提前看到未来 token);
  • 第二组 cross-attention 不需要 causal mask(源序列每个位置都可被完整看到);
  • 第二组的 K、V 来自 encoder 最后一层输出,是 encoder/decoder 唯一信息通道。

2.5 数据流:一个 token 的旅程

以翻译 "Hello world" -> "Bonjour le monde" 为例,架构层面的数据流:

1
2
3
4
5
6
7
8
9
10
11
12
13
源: "Hello world" --tokenize--> [15496, 995]
↓ Embedding + 位置编码
Encoder × 6 ──────────────┐
↓ 输出 mem (2×512) │ K,V

目标: "Bonjour le monde" --shift right--> [BOS, 10222, 333]
↓ Embedding + 位置编码
Decoder × 6 <── cross-attention 读取 mem
↓ (3×512)
Linear(512->vocab) + Softmax

每位置一个词表概率分布
(BOS->Bonjour, Bonjour->le, le->monde)

训练 vs 推理,同一套权重、两种执行方式

训练(teacher forcing) 推理(autoregressive)
decoder 输入 整个目标序列一次喂入 从 BOS 开始逐 token 生成
并行性 一次 forward 算出所有位置 loss(高度并行) 每生成一个 token 重新 forward(串行,用 KV cache 优化)
mask 作用 causal mask 保证位置 t 只看 0..t-1 保证只看已生成部分
复杂度 $O(1)$(不算 batch) $O(m)$ 步,m 为输出长度

这正是 Transformer 训练快、推理慢的根源:训练时整句并行,推理时必须自回归串行。所有”推理优化”(KV cache、speculative decoding 等)都在攻这个串行瓶颈。

2.6 三种架构变体

原论文是完整 Encoder-Decoder,2018 年后工业界把它拆成三种用法,对应这张图用哪一半:

维度 Encoder-only Decoder-only Encoder-Decoder
用图哪部分 左半边 右半边(去 cross-attn) 完整
Self-attention 双向 causal enc双向 / dec因果
训练目标 MLM(掩码预测) LM(下一 token) seq2seq
主要用途 理解(分类、NER、QA) 生成(对话、LM) 翻译、摘要
代表 BERT、RoBERTa GPT、LLaMA、Qwen 原版、T5、BART
时代 2018–2020 2020+ 主流 2017–2020

今天 LLM 几乎全是 decoder-only:参数效率高、数据简单(纯文本自回归)、任务可统一成”prompt -> text”、推理优化生态成熟。Encoder-only 主要留在向量检索/分类;Encoder-Decoder 主要在 T5 派研究里。


三、输入表示 (Input)

这一章围绕 src/transformer.pysrc/transformer.ipynb 中的实现,说明文本如何转换为模型可处理的张量,并解答几个常见疑问:编码器输入如何处理、变长序列如何对齐、mask 如何获得、长度限制、中英文输入关系、位置编码。

3.1 输入处理总览:完整链路

文本不能直接输入网络,要经过以下处理(对应代码中的执行顺序):

1
2
3
4
5
6
7
8
9
原始文本 (中英句对 clean_cmn.txt)

① 分词 Tokenization (BPE / SentencePiece) -> token id 列表 (变长)
② 对齐 Padding + 截断 (到 batch 内最长, ≤512) -> [batch, max_len] 整数矩阵
③ 生成 Padding Mask -> [batch, 1, 1, max_len]
④ 词嵌入 Token Embedding (×√d_model) -> [batch, max_len, d_model]
⑤ 加位置编码 Positional Encoding (+ dropout) -> [batch, max_len, d_model]

送入 Encoder / Decoder

编码器的输入是怎么处理的? 即上述 ①->⑤ 五步:前三步把文本变成对齐的 token id 矩阵 + mask,后两步把 id 矩阵变成带位置信息的连续向量。下面逐节展开。

3.2 分词:BPE 与 SentencePiece(中英联合词表)

为什么用 BPE

直接按”词”分词表会爆炸、按”字符”分序列太长且丢语义。BPE(Byte Pair Encoding)在两者间取平衡:

优势 说明
解决 OOV 只要词表含基础字符,就能用子词拼出任何生僻词,不再有 <unk>
语义对齐 子词常携带语义(词根/前缀),如 unhappily = un + happily
效率最优 序列长度适中,词表大小可控

BPE 原理(doc/Byte-Pair-Encoding.md 有完整演示):先 把词拆成字符 + 加 </w> 边界标记,再 反复统计最高频相邻对、合并成新 token,直到达到预设 vocab_size。所以高频词会变成单个 token,低频词被拆成子词。

代码实现:Joint BPE 训练中英共享词表

1
2
3
4
5
6
7
8
9
10
spm.SentencePieceTrainer.train(
input=cleaned_data_path, # clean_cmn.txt: 英文\t中文 句对
model_prefix="bpe_m",
vocab_size=8000, # 词表大小
model_type="bpe",
character_coverage=0.9995, # 覆盖 99.95% 字符,防 [UNK]
pad_id=0, unk_id=1, bos_id=2, eos_id=3,
user_defined_symbols=['<mask/>']
)
sp = spm.SentencePieceProcessor(model_file="bpe_m.model")

几个关键点:

  • Joint BPE(联合分词):中英文合并训练一个共享词表,不分开训练。这样一个 nn.Embedding 表就能同时表示中英文。
  • 特殊 token 占住前 4 个 idpad=0 / unk=1 / bos=2 / eos=3pad_id=0 是为后面 padding 直接用 0 填充做准备。
  • 编码sp.encode_as_ids("Hello world") -> [15496, 995] 这样的 id 列表。

英译汉中,英文和中文是同时输入的吗? 不是。词表是共享的(合并训练),但输入分两路:英文句子进 Encoder,中文句子(shifted right,首位加 BOS)进 Decoder。共享词表只是让两边能用同一张 embedding 表,而非把中英拼成一条序列输入。

3.3 变长处理:Padding 与长度限制

句子长度不一,无法组成矩阵,必须对齐到 batch 内最长

1
2
3
4
5
6
7
8
9
10
tokenized_data = [sp.encode_as_ids(s) for s in input_data]   # 每句变长 id 列表
limit = 512 # 长度上限
actual_max_len = max(len(t) for t in tokenized_data) # batch 内最长
max_len = min(actual_max_len, limit) # 不超过 512

padded_inputs = torch.full((batch_size, max_len), pad_idx, dtype=torch.long) # 先全填 0
for i, tokens in enumerate(tokenized_data):
content_len = min(len(tokens), limit)
padded_inputs[i, :content_len] = torch.tensor(tokens[:content_len]) # 前面填真 token
# 形状: [batch, max_len],不足的尾部是 0 (pad)

句子长度不一怎么处理? padding 到 batch 内最长(不足的尾部补 pad_id=0),超长的截断到 limit

原文输入长度限制多少? 代码中 limit = 512。原论文做 WMT 翻译时句子较短(且按子词计),实际设置视任务而定;如今 LLM 上下文窗口从 2k 到 128k+ 不等,但”截断到一个上限”的思路不变。

3.4 Padding Mask:让模型忽略填充位

padding 补的 0 不是真 token,不能让 attention 去关注这些位置,否则会污染结果。所以需要一个 mask 标出哪些是有效 token。

1
2
3
4
def GeneratePadMask(input_ids, pad_token_id=0):
# 1 表示有效 token,0 表示 padding
mask = (input_ids != pad_token_id).unsqueeze(1).unsqueeze(2)
return mask # [batch_size, 1, 1, seq_len]
  • input_ids != pad_token_id 得到 [batch, seq_len] 的布尔矩阵;
  • .unsqueeze(1).unsqueeze(2) 升到 [batch, 1, 1, seq_len]方便在多头注意力里广播[batch, heads, query, key] 的 key 维度。

在 SelfAttention 中的使用

1
2
3
4
if mask is not None:
# 把 mask==0 (padding) 位置的注意力分数设成极大负数
attention_scores = attention_scores.masked_fill(mask == 0, float("-1e9"))
attention_weights = F.softmax(attention_scores, dim=-1) # softmax 后这些位置权重 -> 0

把分数设成 -1e9 再 softmax,对应位置权重就接近 0–等于让 padding 位对任何 query 都不产生影响

变长句子的 mask 怎么获得?input_ids != pad_token_id,再 unsqueeze 到 4D 广播。Decoder 还多一个 causal mask(下三角,防止提前看到未来),两者按位相加/相乘合并(第四章细讲)。

3.5 词嵌入 Token Embedding

token id 是离散整数,要变成连续向量才能进网络:

1
2
3
4
5
6
7
class TransformerEmbedding(nn.Module):
def __init__(self, vocab_size, d_model):
super().__init__()
self.lut = nn.Embedding(vocab_size, d_model) # 查表: id -> d_model 向量
self.d_model = d_model
def forward(self, x):
return self.lut(x) * math.sqrt(self.d_model) # ★ 乘 √d_model
  • nn.Embedding(vocab_size, d_model):一张 [vocab, d_model] 的可学习查表,lut(x)[batch, seq] 的 id 变成 [batch, seq, d_model]
  • 为什么要 ×√d_model:后面要加位置编码(PE 的值在 [-1,1] 量级),如果不放大 embedding,加完 PE 后 embedding 信号会被 PE 淹没。乘 √d_model(d_model=512 时 ≈22.6)把 embedding 量级抬上去,两者才匹配。

3.6 位置编码 Positional Encoding ⭐

为什么必须有位置编码:self-attention 的排列等变性

一个常被忽视的硬事实:self-attention 是排列等变(permutation-equivariant)的–它对 token 的顺序无感知。把输入序列任意打乱,输出只是跟着重排,每个位置的内容完全不变。用一行公式写死:设 $P$ 是任意置换矩阵,$f(X)=\text{softmax}(QK^\top/\sqrt{d_k})V$ 是一次 self-attention,则

$$
f(PX) = P,f(X)
$$

证明很短:投影逐行做,故 $Q’=PQ,\ K’=PK,\ V’=PV$;分数矩阵 $S’=Q’K’^\top = PS P^\top$;softmax 逐行作用,$\text{softmax}(PSP^\top)=P,\text{softmax}(S),P^\top$;再乘 $V’$ 并用 $P^\top P=I$,即得 $P,f(X)$。

工程后果很直接:在没有位置信息的 self-attention 看来,”猫吃鱼”和”鱼吃猫”是不可区分的–模型不是学不会,而是从根本上接收不到两者有差别的信息。实测中,把 PE 替换成 torch.zeros_like,loss 在前几百步几乎不下降。

RNN/CNN 自带位置感:RNN 的位置信息隐含在”第 t 步只能依赖第 t-1 步”的递推里;CNN 的位置感来自感受野的局部性(哪些 token 一起做了卷积)。Transformer 放弃了这两种结构性偏置,换取长程依赖的全局建模能力,代价就是位置信息归零–除非显式注入。

正弦位置编码公式

$$
PE_{(pos,,2i)} = \sin!\left(\frac{pos}{10000^{2i/d_{model}}}\right),\quad
PE_{(pos,,2i+1)} = \cos!\left(\frac{pos}{10000^{2i/d_{model}}}\right)
$$

  • pos:token 在序列中的位置(0,1,2,…);
  • i:维度索引(偶数维用 sin,奇数维用 cos);
  • 每对维度 $(2i, 2i+1)$ 共用一个频率 $\omega_i = 10000^{-2i/d_{model}}$。

代码逐行解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class PositionalEncoding(nn.Module):
def __init__(self, max_len, d_model, dropout=0.1):
super().__init__()
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(max_len, d_model) # [max_len, d_model]
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) # [max_len, 1]
# div_term = 1 / 10000^(2i/d_model),用 exp+log 实现是为了数值稳定
div_term = torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term) # 偶数维 = sin
pe[:, 1::2] = torch.cos(position * div_term) # 奇数维 = cos
pe = pe.unsqueeze(0) # [1, max_len, d_model]
self.register_buffer('pe', pe) # ★ 不参与训练

def forward(self, x):
x = x + self.pe[:, :x.size(1)] # 按当前序列长度切片,逐元素相加
return self.dropout(x)

几个要点:

  • register_buffer:PE 是固定公式算出来的,不是可学习参数,存成 buffer(随模型一起保存、随 .to(device) 一起迁移,但不被优化器更新)。
  • div_termexp(-log(...)) 而非直接 1/10000^...:纯为数值稳定(避免大数幂运算溢出),数学上等价。
  • x + pe[:, :x.size(1)]:PE 预算到 max_len(代码中设为 5000),实际用时按当前序列长度切片相加。
  • dropout:加完 PE 后做 dropout,正则化的是”语义+位置”这个组合。

把它读成”一组不同频率的波”

$d_{model}=512$ 的 PE 摊开,是 256 对 (sin, cos) 通道,频率从最高($i=0,\ \omega=1$,周期约 6.28)平滑下降到最低($i=255,\ \omega=1/10000$,周期约 62832)。高频维度区分相邻位置的差异,低频维度记录”在序列的哪个大段落”,类似傅里叶级数把一个位置坐标同时投影到多个尺度的正弦波上,组合起来成为该位置的唯一指纹。

10000 是怎么来的

  • 数学层:$10000^{2i/d_{model}}$ 的最大值是 10000,最低频周期约 $2\pi\times10000\approx62832$,意味着能区分约六万个位置而不”绕回”相撞,对原论文几千的序列长度非常宽裕。
  • 工程层:作者在 §3.5 末尾说明,选 sinusoidal 是假设它”可能让模型外推到训练时未见过的更长序列”。10000 本身没有神奇理论依据,是”足够大、覆盖足够长尺度、无需调参”的经验选择。

d_model=4 的最小手算例子($\omega_0=1,\ \omega_1=0.01$):

pos PE 向量
0 [0, 1, 0, 1]
1 [0.841, 0.540, 0.010, 1.000]
2 [0.909, -0.416, 0.020, 1.000]
3 [0.141, -0.990, 0.030, 1.000]

前两维(高频)剧烈变化,后两维(低频)几乎不动;pos=0 是特殊的起点向量 $[0,1,0,1]$。

范数恒定:每个 pos 的 PE 范数都等于 $\sqrt{d_{model}/2}$(每对 (sin,cos) 平方和恒为 1)。这意味着位置信息全部在”方向”上而非”长度”上,对 LayerNorm 友好–LayerNorm 归一掉范数、保留方向,位置信息得以保留。若用”位置越远 PE 越大”的方案,LayerNorm 后位置信息会被显著抹掉。

关键性质:相对位置可线性表达 ⭐

这是原论文选 sin/cos 的真正理由。原话:对任意固定偏移 $k$,$PE_{pos+k}$ 可表示为 $PE_{pos}$ 的线性函数:

$$
PE(pos+k) = M_k, PE(pos)
$$

其中 $M_k$ 不依赖 pos。证明来自一组三角恒等式:

$$
\begin{pmatrix} \sin(\alpha+\beta) \ \cos(\alpha+\beta) \end{pmatrix}
= \begin{pmatrix} \cos\beta & \sin\beta \ -\sin\beta & \cos\beta \end{pmatrix}
\begin{pmatrix} \sin\alpha \ \cos\alpha \end{pmatrix}
$$

即”角度从 $\alpha$ 加到 $\alpha+\beta$”对 $(\sin\alpha,\cos\alpha)$ 是乘一个旋转矩阵 $R(\beta)$。回到 PE:第 $i$ 个频率块上,从 pos 到 pos+k 多出的角度是 $k\omega_i$,故

$$
\begin{pmatrix} \sin((pos{+}k)\omega_i) \ \cos((pos{+}k)\omega_i) \end{pmatrix}
= R(k\omega_i)\begin{pmatrix} \sin(pos\cdot\omega_i) \ \cos(pos\cdot\omega_i) \end{pmatrix}
$$

把所有 $d_{model}/2$ 个频率块的旋转矩阵堆成块对角矩阵,就是完整的 $M_k$。

意义:模型若想识别”离我 k 步的 token”,只需学一个 $M_k$(等价于让 $W_Q, W_K$ 学到合适形状),而不必在数据里见过每对 $(pos, pos+k)$。这种”相对位置一次学习、全 pos 复用”是 sinusoidal 相比可学习位置的关键优势。

⚠️ 注意:线性可表达只是”免费的下限”,不保证模型一定学到这条性质。后续可解释性研究发现原版 sinusoidal 在远端位置上 attention 仍有漂移–这正是 RoPE 的动机:把旋转直接”焊”进 Q/K 投影,把下限推成结构上的保证。

为什么是相加,不是拼接

原论文从头到尾是相加(常被误读为拼接):

$$
x = \text{embed}(token) + PE(pos)
$$

相加看似会污染语义,但 embedding 空间是高维的($d_{model}=512$),模型能在不同子空间分别承载语义与位置。更技术地看,相加让 PE 与 embedding 共享同一组 $W_Q, W_K$,attention 打分 $(W_Q(e+p))(W_K(e+p))^\top$ 展开后产生 $e!\cdot!e$、$e!\cdot!p$、$p!\cdot!e$、$p!\cdot!p$ 四项,模型可同时利用语义-语义、语义-位置、位置-位置三种交互。

拼接(concat)不被采用的原因:①参数效率低(要么挤占语义维度,要么扩大 $d_{model}$ 增加计算量);②强制语义与位置不混合,限制表达;③实验上无稳定优势。

这也呼应 3.5 的 ×√d_model:相加前先把 token embedding 放大到与 PE 可比的量级,避免固定的 PE 在训练初期过早主导输入–本质是尺度校准

其他位置编码方式对比

按”位置信息从哪进入 attention”分为三类:

类别 方式 形式 外推 代表
绝对位置(加在输入) 正弦 固定 sin/cos 中等 原版 Transformer
可学习 PE (max_len, d_model) 查表 几乎为零 BERT、GPT-2、ViT
相对位置(改打分公式) 相对位置 bias 在 Q·Kᵀ 加 (i-j) 偏置 T5、Shaw 2018
旋转/衰减 RoPE 旋转矩阵把位置编进 Q/K LLaMA、Mistral、Qwen
ALiBi attention score 减 m·|i-j| -

可学习 PE 的外推问题:它是一张有限查表,训练长度外的位置没有梯度。训练 512、推理 2048 时,前 512 位能查表,后面的位置要么不存在、要么未被充分训练。这正是大模型时代主流转向函数型(sinusoidal/RoPE/ALiBi)的原因–函数型在任何 pos 都有定义且服从同一规律。

选型没有”正确答案”,只有”约束下最合适”:固定长度任务(如 NER、分类)用可学习 PE 完全够用;需要相对距离敏感的翻译任务,sinusoidal 或 T5 bias 不错;长上下文对话/文档理解,RoPE/ALiBi 是默认。

常见误解

  • ❌「位置编码是可选模块,去掉也能训」–对纯 self-attention 不成立,去掉后”猫吃鱼”与”鱼吃猫”不可分(除非是集合建模任务,或 decoder 的 causal mask 提供了部分顺序偏置)。
  • ❌「sinusoidal 是拼接到 embedding 上的」–原论文是相加,且要求 PE 维度 = $d_{model}$。
  • ❌「10000 是某种神秘最优值」–是”足够大、覆盖足够长尺度、无需调参”的经验选择,RoPE 沿用 10000,长上下文场景会调到 1e6。
  • ❌「可学习位置一定更强,因为能学」–能学的代价是参数量、长度上限、外推能力差;长上下文场景函数型更占优。
  • ❌「sinusoidal 的相对位置性质保证模型一定用得上」–只保证”可线性表达”,不保证”一定学到”;这正是 RoPE 把旋转焊进 Q/K 的动机。

今天主流 LLM 基本用 RoPE;原论文的 sin/cos 是起点,理解了它再看 RoPE,就是”把位置从加在输入上,改成融入 Q/K 中”,并从”免费下限”升级为”结构保证”。

3.7 组合与中英分流:Encoder / Decoder 各吃什么

最终输入 = Embedding + PE,代码中的完整调用链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ①②③ 文本 -> token id -> padding -> mask
padded_inputs = ... # [batch, max_len]
pad_mask_4d = (padded_inputs != 0).unsqueeze(1).unsqueeze(2)

# ④ 词嵌入
emb_layer = TransformerEmbedding(vocab_size, d_model)
embedded_output = emb_layer(padded_inputs) # [batch, max_len, d_model]

# ⑤ 加位置编码
pos_enc = PositionalEncoding(max_len=5000, d_model=d_model)
enc_input = pos_enc(embedded_output) # [batch, max_len, d_model] ★ 最终输入

# 送入 self-attention (带 pad mask)
sa_out = SelfAttention(d_model)(enc_input, mask=pad_mask_4d)

英译汉任务中,两路输入分别是什么

输入内容 处理
Encoder 输入 英文句子(源语言) tokenize -> pad -> mask -> emb -> +PE
Decoder 输入 中文句子 shifted right(首位加 BOS) 同上 5 步,再加 causal mask
Decoder 目标 中文句子(要预测的,即把 BOS 去掉、末位接 EOS) 算 loss 用

训练时 encoder 和 decoder 分别处理各自的输入(英文一路、中文错位一路),通过 cross-attention 连接;并非把中英拼成一条序列输入。共享的只是 BPE 词表和 embedding 表。


四、编码器 Encoder

Encoder 由”自注意力 + FFN + 残差归一化”组成,重复 N 层。这一章以你 src/transformer.py 里的 SelfAttention 为核心讲清单层内部,再回答一个关键问题:既然一层就能看全局,为什么还要堆 6 层? 你代码尚未实现的多头、FFN、Add&Norm、Encoder Block 给出标准写法(可直接拼进工程)。

4.1 自注意力 Self-Attention ⭐⭐

自注意力的本质:序列中每个位置都去”查询”所有位置,按相似度加权求和,得到融合了全局上下文的新表示。核心是 Q(Query)、K(Key)、V(Value)三个量。

你的代码(逐行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class SelfAttention(nn.Module):
def __init__(self, embed_size):
super(SelfAttention, self).__init__()
self.embed_size = embed_size
self.values = nn.Linear(embed_size, embed_size, bias=False) # W_V
self.keys = nn.Linear(embed_size, embed_size, bias=False) # W_K
self.queries = nn.Linear(embed_size, embed_size, bias=False) # W_Q

def forward(self, x, mask=None):
# 1. 由输入 x 线性投影出 Q, K, V(三个不同参数的线性层)
V = self.values(x) # [batch, seq, d]
K = self.keys(x) # [batch, seq, d]
Q = self.queries(x) # [batch, seq, d]

# 2. 注意力分数 = Q @ K^T(每个 query 对每个 key 的相似度)
energy = torch.matmul(Q, K.transpose(-1, -2)) # [batch, seq, seq]

# 3. 缩放:除以 √d_k
scaling = self.embed_size ** (1/2) # √d_model
attention_scores = energy / scaling

# 4. pad masking(可选,针对变长序列,见 3.4)
if mask is not None:
attention_scores = attention_scores.masked_fill(mask == 0, float("-1e9"))

# 5. softmax 归一化成权重(每个 query 对所有 key 的权重和为 1)
attention_weights = F.softmax(attention_scores, dim=-1) # [batch, seq, seq]

# 6. 用权重对 V 加权求和,得到最终输出
out = torch.matmul(attention_weights, V) # [batch, seq, d]
return out

对应公式(Scaled Dot-Product Attention):

$$
\text{Attention}(Q,K,V) = \text{softmax}!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V
$$

三个关键”为什么”

① Q/K/V 为什么是三个不同投影?
同一个输入 $x$ 经三组不同参数($W_Q, W_K, W_V$)投影,让模型学到”用什么去查(Q)、用什么被查(K)、查出信息取什么(V)”三种角色。若三者相同,attention 退化为对 $x$ 自身加权平均,表达力大幅下降。

② 为什么除以 $\sqrt{d_k}$?
$d_k$ 较大时 $Q\cdot K^\top$ 数值会很大,把 softmax 推入饱和区(最大值趋近 1、其余趋近 0),梯度几乎为 0,训练停滞。除以 $\sqrt{d_k}$ 把方差控制在 1 附近,保持梯度健康。你的代码用 embed_size ** (1/2),单头时 $d_k=d_{model}$,与论文一致。

③ 维度怎么变?

  • 特征维度不变:Q/K/V 与输出都是 [batch, seq, d_model]
  • 产生 seq×seq 矩阵Q @ K^T[seq, d] 变成 [seq, seq] 的注意力分数矩阵,这是核心中间量,不是特征升维;
  • 真正的”升维”在后面的 FFN($d_{model}\to 4d_{model}\to d_{model}$),见 4.3。

Encoder 的 self-attention 是双向的

Encoder 的 self-attention 没有 causal mask(那是 Decoder 的事),位置 $i$ 的 query 可以看见所有位置的 key,包括左边和右边。从第一层起每个 token 就是双向的–这正是 Encoder 适合”理解”任务的结构原因。

上下文化表示:以 "The animal didn't cross the street because it was tired." 为例,it 进入第一层前只是个通用代词向量;经过 self-attention,它的 query 与全句 key 匹配后,输出向量已融合了”指向 animal”的语义信息。attention 决定”从哪里拿信息”,把每个 token 改写成”在这句话里的含义”。

4.2 多头注意力 Multi-Head Attention ⭐

你的 SelfAttention单头版。多头把它扩展:把 $d_{model}$ 切成 $h$ 份(论文 $h=8$,每头 $d_k=d_{model}/h=64$),每头独立做一次 attention,最后 concat 再投影。

为什么要多头:单头只用一组 Q/K/V,关注一种”视角”;多头让不同头关注不同子空间/不同模式(局部、长程、语法关系等),表达力更强。

各头如何并行:靠 reshape 把头维度并进 batch 维,一次矩阵乘法同时算完所有头,无需 for 循环:

1
2
3
4
5
6
7
8
9
# x: [batch, seq, d_model]
Q = self.W_Q(x).view(batch, seq, h, d_k).transpose(1, 2) # [batch, h, seq, d_k]
K = self.W_K(x).view(batch, seq, h, d_k).transpose(1, 2)
V = self.W_V(x).view(batch, seq, h, d_k).transpose(1, 2)
scores = Q @ K.transpose(-2, -1) / math.sqrt(d_k) # [batch, h, seq, seq]
attn = F.softmax(scores, dim=-1)
out = attn @ V # [batch, h, seq, d_k]
out = out.transpose(1, 2).reshape(batch, seq, d_model) # concat 回 [batch, seq, d_model]
out = self.W_O(out) # 最后线性投影

计算量与单头相当:单头投影是 $d_{model}\times d_{model}$,多头是 $h\times(d_{model}\times d_k)=d_{model}\times d_{model}$,参数量与 FLOPs 基本相同,只是换成了多视角。

4.3 位置前馈网络 Position-wise FFN

你的代码尚未实现 FFN。它是每个 attention 子层后的两层全连接,对每个位置独立施加(token 间交互已由 attention 完成):

$$
\text{FFN}(x) = \max(0,\ xW_1+b_1),W_2+b_2
$$

1
2
3
4
5
6
7
8
9
10
11
class FeedForward(nn.Module):
def __init__(self, d_model, d_ff=2048, dropout=0.1):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_model, d_ff), # 512 -> 2048 ★ 升维 4 倍
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(d_ff, d_model), # 2048 -> 512 降回
)
def forward(self, x):
return self.net(x) # [batch, seq, d_model] -> [batch, seq, d_model]

FFN 决定”拿回来之后怎么改写”:attention 让 token 带上上下文,但只是线性混合的结果;FFN 把每个位置的隐藏状态投到更高维空间、经 ReLU 引入非线性、再压回来,做逐位置的重写。升维 4 倍是模型”记忆容量”的主要来源–FFN 占每层约 2/3 参数量($\approx2.1M$ vs attention 的 $\approx1.05M$),这也是 MoE 把 FFN 稀疏化能省大量参数的原因。

4.4 残差连接与层归一化 (Add & Norm)

每个子层都包一层残差 + 归一化:

$$
\text{output} = \text{LayerNorm}(x + \text{Sublayer}(x))\quad\text{(Post-LN,原论文)}
$$

为什么需要 Add & Norm:残差(Add)让网络学残差 $F(x)=H(x)-x$ 而非直接学变换,梯度可经 $+x$ 直通浅层,缓解梯度消失,使堆叠多层成为可能;归一化(Norm)稳定每层输入分布,加速收敛、降低对初始化的敏感。两者让”重写”不是硬覆盖,而是在旧表示上做小修正。

为什么用 LayerNorm 而不是 BatchNorm

BatchNorm LayerNorm
归一化方向 沿 batch 维(每个特征跨样本统计) 沿 特征维(每个样本自身统计)
对 batch 大小 ✅ 强敏感(小 batch 统计不稳) ❌ 不敏感
变长序列 ❌ 难处理(样本长度不同) ✅ 每位置独立归一
训练/推理差异 需维护 running mean/var

NLP 序列任务 batch 小、序列变长,LayerNorm 更合适;BatchNorm 更适合 CV(固定尺寸图像、大 batch)。

Pre-LN vs Post-LN:原论文用 Post-LN(LayerNorm(x+Sublayer(x))),深层训练不稳定、对 warmup 敏感;现代大模型多用 Pre-LN(x+Sublayer(LayerNorm(x)))。直觉差别:Post-LN 每走一层都要先过一次归一化”闸门”;Pre-LN 主路径基本直通,归一化只作用在旁路变换的输入上,残差更像一条真正的直通高速路。

4.5 6 层堆叠到底在做什么 ⭐

这是理解 Encoder 的核心问题:既然第一层每个 token 已经能看全句,为什么还要堆 6 层甚至 24 层?

答案不是”增加感受野”–Transformer 从第一层起感受野就是全局的。堆叠增加的是表示的抽象层级与迭代修正能力

  • 第一层看到的是”原始词之间的关系”:词性、近距离依存、常见搭配、指代/主谓/修饰等浅层模式;
  • 更深层重写的是”已经上下文化过的表示”:第二层拿到的不再是裸 embedding,而是第一层改写后的表示,于是能看到”哪些位置已被标成主语/宾语/实体/否定词””这是问句/对比/条件句”等更抽象的关系;
  • 类比:像反复读一句复杂句子–第一遍只知字面意思,第二遍抓主干,第三遍才意识到让步、转折、因果。多层堆叠就是这种”反复读、反复重写”。

经验观察(非严格定律但相当稳定):底层头偏局部语法与位置模式,中层偏句法依赖与实体关系,高层偏任务相关语义。

6 层只是 2017 年的折中(WMT 翻译、P100 算力下的合理点),不是神奇数字。后来 BERT-base 用 12 层、BERT-large 24 层、ViT/DeBERTa 动辄 24 层起步。真正不变的是”重复同一种 block,靠深度反复 refinement“这条设计。

Encoder 的输出是什么

设最后一层输出 $H^{(L)}=[h_1,h_2,\ldots,h_n]$,每个 $h_i\in\mathbb{R}^{d_{model}}$。这些向量不是概率、不是标签,而是把”这个 token 在整句里的意义”压缩进来的连续表示–一组上下文化的 token 表示

  • 形状全程不变:输入输出都是 [batch, seq, d_model],Encoder 不是在改变张量外形,而是在反复重写同一组 token 表示(类似迭代 refinement,区别于 RNN 的时间深、CNN 的感受野扩大)。
  • 适合”读完再判断”的任务:分类(用 [CLS] 或池化)、Token 分类(NER/POS)、抽取式问答、检索/匹配、视觉/多模态编码。
  • 不擅长直接生成:没有 causal mask、训练目标不是预测下一 token、输出是表示而非生成状态。Encoder 像个”读者”而非”说话者”。

三大架构家族的分化正源于此(详见 2.6 对照表):Encoder-only(BERT)做理解、Decoder-only(GPT)做生成、Encoder-Decoder(T5)做翻译/摘要。BERT 之所以只要 Encoder,正是抓住了它”双向表示学习”最强的一点。

4.6 完整 Encoder Block

把上述零件组装成一个 Encoder Block(你的代码可在此基础上扩展):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class EncoderBlock(nn.Module):
def __init__(self, d_model, heads, d_ff, dropout=0.1):
super().__init__()
self.attn = MultiHeadAttention(d_model, heads) # 4.2(你的 SelfAttention 的多头版)
self.ffn = FeedForward(d_model, d_ff, dropout) # 4.3
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.drop = nn.Dropout(dropout)

def forward(self, x, mask=None):
# 子层 1: Multi-Head Self-Attention + Add & Norm
attn_out = self.attn(x, x, x, mask)
x = self.norm1(x + self.drop(attn_out)) # Post-LN
# 子层 2: FFN + Add & Norm
ffn_out = self.ffn(x)
x = self.norm2(x + self.drop(ffn_out))
return x # [batch, seq, d_model],形状不变
  • 单个 Block 内部 6 步(回扣 2.4):MHA -> Add -> LayerNorm -> FFN -> Add -> LayerNorm;
  • 堆叠 N=6 层:上层输出作为下层输入,逐层提取更高层表示;
  • 最后一层输出送入 Decoder 的 cross-attention 作为 K、V(见第五章)。

4.7 常见误解

  • ❌「一层已看全局,所以多层是浪费」–全局可见 ≠ 全局理解。第一层看原始 embedding 的全局关系,更深层看多轮改写后的高阶表示;深度带来的是表示变换与迭代推理,不是更大感受野。
  • ❌「Encoder 只是 Decoder 的配角」–只在翻译图里像配角;到 BERT、ViT、CLIP 里它是主角,是整条表示学习路线的核心骨架。
  • ❌「Encoder 的输出就是一句话的向量」–不对。默认输出是一整串 token 表示,句向量需额外构造([CLS]、mean pooling、attention pooling)。
  • ❌「Encoder 不建模顺序」–没有位置编码时成立;加上 PE 后它就能利用顺序,只是通过 attention + 位置信号建模,而非递归。
  • ❌「Encoder-only 一定比 Decoder-only 更懂语义」–在理解任务上有结构优势,但现代 decoder-only LLM 经大规模自回归训练也能学到很强语义。真正区别是训练目标与推理方式。

你当前的实现进度:SelfAttention(单头)✅ -> 多头/FFN/Add&Norm/EncoderBlock 待补。把 4.2~4.6 的标准实现拼进 transformer.py,即可得到一个完整的 Encoder。


五、解码器 Decoder

Decoder 像个”写作者”:在每个时刻回答”看到前文与外部条件后,下一个 token 该是什么”。这一章用 Homework1_Question.ipynb 里的 DecoderLayer / Decoder / mask 代码为主线,讲清三块子层、训练与推理的差异,以及为什么 Decoder 天生适合生成。

5.1 概览:比 Encoder 多一块 cross-attention

Encoder 一层有两块(self-attention + FFN),Decoder 一层多一个中间的 cross-attention,共三块子层,每块都包 Add & Norm:

原论文 Post-LN 形式:

$$
\begin{aligned}
h_1 &= \text{LayerNorm}(x + \text{MaskedMHA}(x)) \
h_2 &= \text{LayerNorm}(h_1 + \text{CrossAttn}(h_1, M)) \
y &= \text{LayerNorm}(h_2 + \text{FFN}(h_2))
\end{aligned}
$$

其中 $M$ 是 Encoder 最后一层输出的 memory。

子层顺序及其原因(不是随便排的):

  1. 先 masked self-attention:整理目标序列内部的历史,构造”我已经说到哪儿了”的状态;
  2. 再 cross-attention:拿着这个状态去 encoder memory 里查外部信息,决定对齐输入里的什么内容;
  3. 最后 FFN:对融合了历史与外部信息的表示做非线性重写。

若把 cross-attention 放到 masked self-attention 之前,”要查什么外部信息”就缺少明确的历史状态作目标,语义上不合理。

5.2 解码器的输入与两种执行方式 ⭐

Decoder 的输入是目标序列 shifted right(首位加 BOS)。训练与推理执行方式截然不同:

1
2
decoder 输入 : [BOS, y1, y2, ..., y_{T-1}]   ← shifted right
训练标签 : [y1, y2, y3, ..., y_T]

训练(teacher forcing,并行):已知整条目标序列,一次性喂入 decoder,靠 causal mask 保证位置 $t$ 计算时看不见未来,所有位置在同一次前向里并行算出。例如目标 [BOS, 我, 喜欢, 机器, 学习],位置 0/1/2/3 分别预测 我/喜欢/机器/学习,一次 forward 完成。

推理(autoregressive,串行):模型不知道未来,只能逐步生成:

  1. 输入 [BOS] -> 预测
  2. 输入 [BOS, 我] -> 预测 喜欢
  3. 输入 [BOS, 我, 喜欢] -> 预测 机器;……

训练并行、推理串行,不是矛盾,而是同一套自回归约束在”已知/未知未来”两种信息条件下的自然结果。推理串行正是 LLM 推理慢的根源,KV cache 等优化都在攻它。

⚠️ exposure bias:训练时 decoder 总看到真实前缀,推理时看到的是自己生成、可能带误差的前缀,两者分布不完全一致。scheduled sampling、RLHF 的 rollout 等都在缓解此问题。

5.3 掩码自注意力 Masked Self-Attention

Decoder 的核心约束:不能偷看未来。若不加 mask,训练时位置 $t$ 预测第 $t$ 个 token 时能直接看到答案 $y_t$ 及之后的 $y_{t+1},…$,loss 很好看但模型学到的是作弊。

解决:加一个下三角 causal mask

$$
S = \frac{QK^\top}{\sqrt{d_k}} + \text{Mask},\quad
\text{Mask}_{ij} = \begin{cases} 0, & j \leq i \ -\infty, & j > i \end{cases}
$$

softmax 后所有未来位置权重为 0。

你的代码:mask 怎么生成(pad mask 与 causal mask 合并)

回扣 3.4 的 pad mask,Decoder 还需 causal mask,两者按位与合并。Homework1_Question.ipynbTransformer 类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
def get_pad_mask(self, x, pad_idx):
return (x != pad_idx).unsqueeze(-2) # [batch, 1, seq_len]

def get_subsequent_mask(self, x):
seq_len = x.size(1)
# np.triu(k=1) 取上三角(不含对角线)为 1,==0 后上三角变 False
subsequent_mask = np.triu(np.ones((1, seq_len, seq_len)), k=1).astype(np.int8)
subsequent_mask = (torch.from_numpy(subsequent_mask) == 0).to(x.device)
return subsequent_mask # [batch, seq, seq],下三角+对角为 True

# 源序列只需 pad mask;目标序列需 pad & causal 两种合并
src_mask = self.get_pad_mask(src, self.pad_idx)
tgt_mask = self.get_pad_mask(tgt, self.pad_idx) & self.get_subsequent_mask(tgt)

tgt_mask = pad_mask & subsequent_mask:同时屏蔽 padding 位与未来位置。这就是第三章预告的”pad mask 与 causal mask 按位合并”的真实写法。

与 Encoder 的差别只在 mask:公式相同,但 Encoder 无 causal mask(双向,适合理解),Decoder 有 causal mask(只看过去,适合生成)。Causal mask 不是小补丁,而是 Decoder 路线的结构分水岭–没有它就没有 GPT 式自回归语言模型。

5.4 交叉注意力 Cross-Attention ⭐

Cross-attention 是 Decoder 读取 Encoder 的唯一通道。与 self-attention 的区别不在公式,而在 Q/K/V 的来源:

  • Query:来自 Decoder 当前层的隐藏状态;
  • Key、Value:来自 Encoder 最后一层输出(memory)。

$$
\text{CrossAttn}(Q_{\text{dec}}, K_{\text{enc}}, V_{\text{enc}}) = \text{softmax}!\left(\frac{Q_{\text{dec}} K_{\text{enc}}^\top}{\sqrt{d_k}}\right) V_{\text{enc}}
$$

你的代码

DecoderLayer 里第二块子层,Q 来自 decoder、K/V 来自 encoder memory:

1
2
3
4
# memory 是 encoder 最后一层输出
x1, attn = self.enc_attn_layer(x, memory, memory, src_mask)
# x1: [batch, tgt_len, d_model]
# attn:[batch, n_heads, tgt_len, src_len] ← 每个 decoder 位置对每个 encoder 位置的注意力

这一步是”条件生成”真正发生的地方。翻译时 decoder 决定下一个 token,masked self-attention 提供”前面已写了什么”,cross-attention 提供”源句里还有什么待对齐”,两者结合才能既符合目标语言历史又忠实于输入。

没有 encoder 时,这一块整块删掉–这就是 GPT 把”带 cross-attention 的 decoder”裁成”decoder-only”的过程。纯语言建模只需左侧历史,cross-attention 可去掉,Decoder layer 退化成 masked self-attention + FFN。

5.5 FFN + Add & Norm

第三块 FFN 与 Encoder 的 FFN 公式相同,但吃到的输入语义不同:Encoder 的 FFN 看到的是”融合了全句上下文的表示”,Decoder 的 FFN 看到的是”融合了历史前缀 + encoder memory 的表示”,作用是把当前生成位置的综合状态压成适合下一个 token 分类的隐藏向量。

Decoder 一层有 3 个 Add & Norm(masked-attn 后、cross-attn 后、FFN 后),结构同 Encoder,只是多一组。

5.6 完整 DecoderLayer 代码与堆叠

Homework1_Question.ipynb 的完整 DecoderLayer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class DecoderLayer(nn.Module):
def __init__(self, d_model, n_heads, d_ff, dropout_rate=0.1):
super().__init__()
self.attn_layer = MultiHeadAttention(d_model, n_heads, dropout_rate) # masked self-attn
self.attn_layer_norm = nn.LayerNorm(d_model, eps=1e-6)
self.enc_attn_layer = MultiHeadAttention(d_model, n_heads, dropout_rate) # cross-attn
self.enc_attn_layer_norm = nn.LayerNorm(d_model, eps=1e-6)
self.ff_layer = PositionwiseFeedForward(d_model, d_ff, dropout_rate)
self.ff_layer_norm = nn.LayerNorm(d_model, eps=1e-6)
self.dropout = nn.Dropout(dropout_rate)

def forward(self, x, memory, src_mask, tgt_mask):
# ① masked self-attention (Q=K=V=x, tgt_mask)
x1, _ = self.attn_layer(x, x, x, tgt_mask)
x = self.attn_layer_norm(x + self.dropout(x1))
# ② cross-attention (Q=x, K=V=memory, src_mask)
x1, attn = self.enc_attn_layer(x, memory, memory, src_mask)
x = self.enc_attn_layer_norm(x + self.dropout(x1))
# ③ FFN
x1 = self.ff_layer(x)
x = self.ff_layer_norm(x + self.dropout(x1))
return x, attn # attn: [batch, n_heads, tgt_len, src_len]

Decoder 在此基础上做 embedding + 位置编码,堆叠 N 层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Decoder(nn.Module):
def __init__(self, vocab_size, d_model, n_layers, n_heads, d_ff, pad_idx, ...):
self.tok_embedding = nn.Embedding(vocab_size, d_model, padding_idx=pad_idx)
self.pos_embedding = PositionalEncoding(d_model, dropout_rate, max_len)
self.layers = nn.ModuleList([DecoderLayer(...) for _ in range(n_layers)])
self.layer_norm = nn.LayerNorm(d_model, eps=1e-6)

def forward(self, x, memory, src_mask, tgt_mask):
x = self.tok_embedding(x) # 目标 token -> embedding
x = self.pos_embedding(x) # + 位置编码
for layer in self.layers:
x, attn = layer(x, memory, src_mask, tgt_mask) # 逐层堆叠
x = self.layer_norm(x)
return x, attn
  • 单层内部 9 步(回扣 2.4):MaskedMHA -> Add -> LN -> CrossAttn -> Add -> LN -> FFN -> Add -> LN;
  • 堆叠 N=6 层,每层都读取同一个 encoder memory;
  • 输出送入 Generator(Linear + softmax/log_softmax)得到词表分布(见第六章)。

5.7 为什么 Decoder 天生适合生成

  • 训练目标与推理动作一致:每个位置学的是 $p(y_t \mid y_{<t}, x)$,与生成时”给历史前缀预测下一 token”完全对齐;结构、目标、推理三者一致。
  • 历史状态累积在 residual stream:随 token 逐个生成,每层残差主路累积”到目前为止已说了什么”,效果类似 RNN 的 hidden state,但通过 attention 读取整段历史而非压缩向量。
  • 对外部条件友好:翻译/摘要/问答/多模态生成只需加回 cross-attention,就能在保留自回归生成能力的同时读取外部输入。

这也是为什么 2017 年至今 Decoder 路线从未消失,只是从”完整 encoder-decoder 的右半边”演化成”GPT 式 decoder-only 主干”。互联网文本天然适合 next-token 目标、统一接口极简(一切任务转成”给定前缀续写”)、少了 cross-attention 后结构与部署更干净–这是 decoder-only 成为主流的工程合力。

5.8 常见误解

  • ❌「Decoder 就是 Encoder 多了个 mask」–不止。它还多了”自回归目标”这整个训练-推理闭环;没有闭环,mask 只是矩阵操作,Decoder 才成为生成模型。
  • ❌「cross-attention 是可有可无的附加模块」–对 decoder-only 可去;对翻译/摘要/条件生成,它是读取输入条件的唯一主通道,删掉就失去对齐输入的能力。
  • ❌「训练时 Decoder 不能并行,所以慢」–错。训练有 teacher forcing + causal mask,所有位置一次性并行算出;真正串行的是推理。
  • ❌「decoder-only 一定比 encoder-decoder 更先进」–它只是更适合大规模统一预训练与通用生成;翻译、摘要、语音识别等输入输出天然两段的任务里,encoder-decoder 仍很有竞争力。
  • ❌「Decoder 只能做文本生成」–代码、音频 token、图像 token、动作序列都可自回归生成;关键是”序列化的下一步预测”,不是”文本”。

六、输出 (Output)

Decoder 最后一层输出的是每个位置的隐表示,要转成”下一个 token 在词表上的概率分布”才能用于计算 loss 或生成。这一章用 Homework1_Question.ipynbGenerator 代码讲清输出头、训练目标与解码策略。

6.1 输出头:Linear + Softmax

Generator 把 decoder 输出从 $d_{model}$ 维投影到词表大小,再过 softmax 得到概率分布:

1
2
3
4
5
6
7
8
9
10
class Generator(nn.Module):
def __init__(self, d_model, vocab_size):
super().__init__()
self.proj = nn.Linear(d_model, vocab_size)

def forward(self, x):
# x: [batch, tgt_len, d_model]
x = self.proj(x) # [batch, tgt_len, vocab_size]
output = F.log_softmax(x, dim=-1) # log 概率(配合 NLLLoss)
return output

log_softmax 而非 softmax 是为数值稳定,且直接配合 NLLLoss(负对数似然)使用;若用 CrossEntropyLoss 则内部已含 softmax,不需再 log_softmax。

Weight tying(原论文 §3.4):encoder 输入 embedding、decoder 输入 embedding、decoder 输出投影 $W_{out}$ 三者共享同一张 [vocab, d_model] 矩阵。省参数(词表 37k、d=512 时省下 ~38M)且有正则化效果。现代大模型因词表巨大(50k+)多不再共享。

6.2 训练目标:交叉熵 + Label Smoothing

训练用 teacher forcing:decoder 输入是目标序列去掉最后一个 tokentarget[:, :-1]),监督标签是去掉 BOStarget[:, 1:]):

1
2
3
4
5
6
7
8
9
# train_fn 核心
output, _ = model(source, target[:, :-1]) # 预测,[batch, tgt_len-1, vocab]
loss = criterion(
output.view(-1, output.size(-1)), # 展平到 [N, vocab]
target[:, 1:].contiguous().view(-1) # 展平到 [N]
)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), clip) # 梯度裁剪防爆炸
optimizer.step()

Label Smoothing(ε=0.1):把 one-hot 目标软化–目标词概率从 1 变 0.9,剩 0.1 平均分给所有其它词:

$$
q_i = (1-\varepsilon)\cdot\mathbf{1}[i=\text{target}] + \varepsilon/V
$$

反直觉但有效:label smoothing 让训练 PPL 变差(最优分布被人为软化),但 BLEU 变好–因为分布更柔和时 beam search 能保留更多候选路径,找到更优解。现代 LLM(自回归生成、不用 beam search)基本不再用,但翻译任务仍是标配。

6.3 推理解码策略

推理时没有真实标签,decoder 必须用自己生成的 token 作下一步输入。三种主流策略:

策略 做法 特点
Greedy 每步选概率最高的 token 快,但易被早期局部最优锁死
Beam Search 保留 k 条候选路径(原论文 beam=4) 翻译/摘要等”有标准答案”任务主流
Sampling 按概率采样(top-k / top-p) 现代 LLM 对话生成主流,多样性好

Beam search 的长度偏置:每生成一个 token 乘一个 $P<1$,句子越长总概率越低,beam search 倾向输出短句。原论文用 length penalty 抵消:

$$
\text{score}(y) = \frac{\log P(y)}{lp(|y|)},\quad lp(|y|)=\left(\frac{5+|y|}{6}\right)^\alpha,\quad \alpha=0.6
$$

现代 LLM 对话生成几乎不用 beam search(输出”过于安全”、多样性差、长生成算力开销大),改用 temperature sampling + top-k/top-p;但翻译、摘要这类有标准答案的任务 beam search 仍占主流。


七、训练与优化细节

“结构对了”不等于”能训出来”–同一架构配方稍动可能差几个 BLEU。这一章用 NoamOptim / train_fn 代码 + 原论文配方,讲清优化器、学习率调度、正则化、batching 等关键细节。

原论文完整配方一览:

1
2
3
4
5
6
7
8
9
10
11
模型:    Transformer base / big
数据集: WMT 2014 En-De (4.5M 句对) / En-Fr (36M 句对)
硬件: 8 × NVIDIA P100
训练时长:base 12 小时 (100k 步) / big 3.5 天 (300k 步)
batch: 每步约 25000 source token + 25000 target token
优化器: Adam, β₁=0.9, β₂=0.98, ε=1e-9
学习率: lr = d^(-0.5) · min(step^(-0.5), step · warmup^(-1.5))
warmup: 4000 步
正则化: dropout 0.1(embedding 后、每个子层输出)/ label smoothing 0.1
推理: beam search, beam=4, length penalty α=0.6
结果: En-De BLEU 27.3 (base) / 28.4 (big);En-Fr 38.1 / 41.8

7.1 优化器:Adam 的微调

Adam 维护一阶矩 $m$(动量)和二阶矩 $v$(梯度平方移动平均),自适应调节各参数步长。原论文对默认值做了两处关键调整:

  • β₂ = 0.98(不是默认 0.999):β₂ 控制 $v$ 的记忆长度。0.999 对应”记忆 1000 步”,对百万步训练合适但对 100k 步太长;0.98 对应”记忆 50 步”,让 $v$ 更快跟上学习率变化,适合短训练。
  • ε = 1e-9(不是默认 1e-8):影响梯度平方接近 0 时的步长,复现时保持一致即可。
  • 无 weight decay:当时靠 dropout + label smoothing 已提供充分正则;现代大模型改用 AdamW + weight decay 0.1。

7.2 学习率调度:warmup + 1/√step ⭐

原论文的学习率公式(你的 NoamOptim 即此实现):

$$
lr = d_{model}^{-0.5} \cdot \min!\left(step^{-0.5},\ step \cdot warmup_steps^{-1.5}\right)
$$

1
2
3
4
5
6
class NoamOptim(object):
def get_lr(self):
return self.factor * (
self.d_model ** (-0.5)
* min(self.n_steps ** (-0.5), self.n_steps * self.n_warmup_steps ** (-1.5))
)

min 里两支函数:A = step·warmup⁻¹·⁵(线性增长)、B = step⁻⁰·⁵(平方根衰减),交点在 step = warmup_steps。所以曲线先线性升、再 1/√step 衰减

  • step < 4000:lr 沿 A 线性升到峰值 $lr_{peak}=d^{-0.5}\cdot warmup^{-0.5}\approx7\times10^{-4}$(d=512);
  • step > 4000:lr 沿 B 按 1/√step 慢慢降,100k 步时降到约 1/5 峰值。

为什么 warmup:训练初期参数随机初始化、梯度方向嘈杂,Adam 的二阶矩 $v$ 需积累几千步才能给出靠谱的方差估计。若一开始就用大 lr,参数会被嘈杂梯度推到离解空间很远的”坏 basin”,后续难以恢复。warmup 给优化器几千步缓冲,让 $v$ 稳定后再放开 lr。

warmup=4000 不是魔法常数:是原论文那套模型规模/batch/硬件下的经验折中。太短训练不稳,太长前期 lr 偏低、收敛慢。现代 LLM 仍有 warmup(GPT-3 报 375M tokens、LLaMA-2 报 2000 steps),只是按各自尺度重选。

d_model⁻⁰·⁵:让学习率随模型宽度自动适配–模型越宽、矩阵乘输出方差越大,lr 要随宽度降下来(类似 Glorot/He 初始化的 1/√d)。big 模型(d=1024)峰值自动比 base(d=512)低约 30%。

7.3 正则化:Dropout + Label Smoothing

Dropout(P=0.1) 加在两处:

  1. Embedding + 位置编码相加之后;
  2. 每个子层(attention、FFN)的输出,在加到残差之前。
1
2
x' = x + dropout(Attention(x))   # 子层输出 dropout
y = x' + dropout(FFN(x'))

为什么 0.1 而非经典 CV 的 0.5:Transformer 已有 LN、残差、label smoothing、weight tying 多重正则,再加重 dropout 反而让训练不稳定。0.1 是”轻量正则”。现代 LLM(数据量千亿 token)过拟合非主要矛盾,dropout 降到 0。

Label Smoothing(ε=0.1):见 6.2,PPL 变差但 BLEU 变好。

7.4 Batching by Tokens

原论文每个 batch 含约 25000 source token + 25000 target token,即按 token 数而非句对数组 batch。

为什么不用固定 batch_size:翻译句子长度差异大(5 词到 100 词)。固定 64 句时,短句 batch(512 token)GPU 利用率极低,长句 batch(5120 token)可能爆显存;且显存由 batch 内最长句决定,短句 padding 白占位。按 token 数 batch 则短句 batch 句数多、长句 batch 句数少,显存占用稳定、利用率最大化。

实现:按长度分桶(bucket),每桶内长度相近,凑够 25000 token 提交一个 batch。

7.5 训练循环

train_fn 的核心流程(teacher forcing + 梯度裁剪):

1
2
3
4
5
6
7
8
9
10
11
12
def train_fn(model, iterator, optimizer, criterion, clip=1.0):
model.train()
for batch in iterator:
source, _ = batch.src
target, _ = batch.trg
optimizer.zero_grad()
output, _ = model(source, target[:, :-1]) # teacher forcing
loss = criterion(output.view(-1, output.size(-1)),
target[:, 1:].contiguous().view(-1))
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), clip) # 梯度裁剪防爆炸
optimizer.step()
  • 梯度裁剪(clip=1.0):防止偶发大梯度把训练弄崩,原论文未明确但 fairseq 默认开启,现代 LLM 普遍保留。
  • 8 卡数据并行:每卡一份 batch 子集,前向反向后 all-reduce 梯度,再各自更新(参数相同)。单机 8 卡纯数据并行即可,千卡规模才需 3D 并行。

7.6 现代大模型 vs 2017 配方

配方项 Transformer base (2017) GPT-3 175B (2020) LLaMA-2 70B (2023)
优化器 Adam Adam AdamW
β₂ 0.98 0.95 0.95
weight decay 0 0.1 0.1
LR schedule inverse √step cosine cosine
LR peak ~7e-4 (d=512) 6e-5 1.5e-4
dropout 0.1 0 0
label smoothing 0.1 0 0
batch (tokens) 25k+25k 3.2M 4M
训练 token ~3B 300B 2T
精度 FP32 FP16+loss scale BF16
梯度 clip 1.0 1.0

核心观察:scale 变了 1000 倍,但训练配方的”形状”基本没变–仍是 Adam 系、有 warmup、按 token batch、学习率峰值后衰减。变的细节:dropout/label smoothing 砍到 0、加了 weight decay、1/√step 换 cosine、FP32 换 BF16。梯度下降优化的几何性质并不随模型规模变化太多。

7.7 常见误解

  • ❌「warmup 可有可无,去掉只是慢一点」–错。warmup 缩过短或去掉,训练稳定性通常明显变差(Adam 二阶统计起步未站稳)。
  • ❌「Transformer 用 Adam 默认参数就行」–原论文用 β₂=0.98、ε=1e-9,非框架默认值;复现论文结果需先对齐这些超参。
  • ❌「label smoothing 让 PPL 变差所以是错误设计」–PPL 变差是预期内的,但 BLEU 变好,而翻译关心的是 BLEU。PPL 与 BLEU 不总同向。
  • ❌「beam size 越大越好」–beam=4 是原论文选择,继续加大不自动带来更好翻译,常只是把算力花在相近候选上。
  • ❌「现代 LLM 训练完全用了不一样配方」–部分对。变了很多细节,但”骨架”(Adam 系 + warmup + token batching + 峰值后衰减)是 2017 年定下的。

八、优缺点与对比

8.1 vs RNN/LSTM/CNN

维度 RNN/LSTM CNN (seq) Transformer
并行性 ❌ 时间步串行 ✅ 可并行 ✅ 全并行
长距离依赖 弱(梯度衰减) 中(需堆叠扩感受野) 强(一步全局)
复杂度 O(n) O(k·n)(k 为核宽) O(n²)
位置信息 隐含在递推顺序 隐含在感受野局部性 需显式位置编码

RNN/CNN 的位置感是”派生”的(递推顺序、感受野局部性),Transformer 放弃了这两种结构性偏置以换取全局建模,代价是位置信息归零(需 PE 注入)与 O(n²) 复杂度。

8.2 核心局限:O(n²) 复杂度 ⭐

Attention 的复杂度来自 $QK^\top$ 产生的 $n\times n$ 矩阵:

$$
S=\frac{QK^\top}{\sqrt{d}}\in\mathbb{R}^{n\times n}\to A=\text{softmax}(S)\to O=AV
$$

  • 计算复杂度 $O(n^2 d)$:$QK^\top$ 与 $AV$ 各约 $2n^2 d$ FLOPs;
  • 显存复杂度 $O(n^2)$:$S$ 和 $A$ 都是 $n\times n$,这才是真正卡死长上下文的瓶颈。

序列长度 $n$ $n^2$ 矩阵显存 (fp16, 单 head) 单 H100 (80GB) 能放几层
2k 8 MB 上万层
32k 2 GB ~30 层
128k 32 GB ~2 层(无法训练)
1M 2 TB 不可能

$n=128k$ 时单层单 head 的 attention 矩阵就 32GB,一张 80GB 的 H100 都装不下–没有 FlashAttention 的话 128k 上下文在硬件上根本不可能。

Attention vs FFN 谁是计算大头:FFN 计算量 $\approx 16nd^2$($O(nd^2)$),attention $\approx 4n^2d$($O(n^2d)$),比值 $\approx n/(4d)$。

  • $n<4d$ 时 FFN 主导(短上下文,attention 仅占小头,FFN 优化如量化/MoE 更值钱);
  • $n>4d$ 时 attention 主导(LLaMA-7B 的 $d=4096$,$n=16k$ 是临界点;$n=128k$ 时 attention 是 FFN 的 8 倍)。

KV cache(推理隐藏成本):推理时每生成一个 token,需存下所有历史 K、V。KV cache 大小 $=2\cdot n\cdot d\cdot L$。LLaMA-2-7B 每个 token 约 512KB;$n=128k$ 时 KV cache 达 64GB,甚至超过模型权重本身。这是 GQA、MLA、PagedAttention 等推理优化的攻击点。

8.3 降复杂度的五类方案

类别 思路 复杂度 代表 代价
滑窗 (local) 只看前后各 $w$ 位 $O(nw)$ Longformer、Mistral SWA 丢全局信息
稀疏 (sparse) 按规则稀疏化 attention $O(n\sqrt{n})$ Sparse Transformer、BigBird GPU 不友好、效果有差距
线性 (linear) 核分解 $\phi(Q)\phi(K)^\top V$ $O(n)$ Linformer、Performer 大规模上效果显著弱于 full
SSM/RNN 不走 attention,状态空间建模 $O(n)$ Mamba、RWKV、RetNet 长程检索仍弱、未到 70B+
FlashAttention IO-aware 分块,不物化 $n\times n$ 显存 $O(n)$、计算仍 $O(n^2)$ FlashAttention 1/2/3 精确非近似、已成事实标准

⚠️ 最常见的误解:FlashAttention 把 attention 降到 $O(n)$–。它把显存从 $O(n^2)$ 降到 $O(n)$,计算仍是 $O(n^2)$(每对 Q-K 仍要做内积)。加速来自减少 HBM↔SRAM 数据搬运(现代 GPU 瓶颈是带宽不是 FLOPs),而非减少计算量。

为什么主流仍用 full attention:①线性方案放大到 7B+ 时质量差距被放大;②FlashAttention 让显存不再是瓶颈,计算贵可用 GPU 数量解决;③长上下文真正瓶颈是 KV cache 而非 attention 计算;④稀疏方案 GPU 不友好;⑤混合架构(如 Jamba=Mamba+attention)兜底。所以 GPT-4/Claude/LLaMA/Qwen 仍是 full attention + FlashAttention + GQA/MLA。

8.4 优点 / 局限

优点 ✅

  1. 全并行:无递归,训练时所有位置一次算出,吞吐远高于 RNN;
  2. 全局建模:任意两位置一跳直达,长距离依赖强;
  3. 通用性强:同一架构覆盖 NLP/CV/语音/多模态,扩展到实例分割、关键点等;
  4. 可扩展性好:结构骨架 7 年不变,靠 scale + 局部改进持续受益。

局限 ❌

  1. O(n²) 复杂度:长序列显存/计算爆炸,需 FlashAttention、KV cache 等工程优化;
  2. 位置编码是补充:本身无顺序感,依赖 PE 注入,外推能力有限(sinusoidal 不够,需 RoPE/ALiBi);
  3. 推理串行:自回归生成必须逐 token,长生成 latency 高;
  4. 数据/算力 hungry:大规模预训练需千亿 token + 千卡,小数据场景未必优于 RNN。

8.5 常见误解

  • ❌「FlashAttention 把 attention 降到 $O(n)$」–是 $O(n)$ 显存,不是 $O(n)$ 计算。
  • ❌「线性 attention 已替代 full attention」–主流大模型仍是 full。
  • ❌「Mamba 比 Transformer 强」–中小模型上接近,长程检索仍弱。
  • ❌「attention 是模型计算量大头」–只在长序列成立,短序列时 FFN 才是大头。
  • ❌「降到 $O(n)$ 就能无限长上下文」–长上下文还有位置编码外推、训练数据、评测等多重瓶颈,复杂度只是表面问题。

九、变体与影响

9.1 三大架构流派

  • Encoder-only:BERT(理解任务)
  • Decoder-only:GPT 系列 / 现代 LLM(生成任务)
  • Encoder-Decoder:T5 / BART

9.2 跨模态

  • 视觉:ViT(把图块当 token)
  • 检测:DETR(目标检测去 NMS)
  • 语音/多模态

9.3 领域应用

  • 自动驾驶:BEVFormer / DETR3D / 时空注意力
  • 3D 高斯泼溅:Transformer 预测高斯参数、动态场景

9.4 后续改进方向

  • 位置编码:相对位置 / RoPE / ALiBi
  • 效率:Flash Attention、稀疏注意力、线性注意力
  • 长上下文、MoE 等

十、总结

  • 核心贡献:用纯注意力取代 RNN/CNN,实现并行 + 全局建模
  • 设计精髓:Q/K/V 自注意力、多头、位置编码、残差+LayerNorm、交叉注意力
  • 历史地位:现代大模型与多模态/自动驾驶/3D 场景的通用骨干

相关链接

  • 📋 论文原文: arxiv.org/abs/1706.03762
  • 📋 The Annotated Transformer (Harvard NLP): nlp.seas.harvard.edu/annotated-transformer
  • 📋 Jay Alammar 图解 Transformer: jalammar.github.io/illustrated-transformer
  • 📋 [[10.clippings/感知算法/骨干网络/Transformer/【Transformer 与注意力机制】19|《Attention Is All You Need》论文背景]] - 第一章背景的主要来源(含 2017 年时代图景、作者脉络、历史转折)
  • 📋 [[10.clippings/感知算法/骨干网络/Transformer/【Transformer 与注意力机制】21|位置编码:为什么需要它,为什么用正弦]] - 第三章 3.6 位置编码的主要来源(排列等变证明、相对位置线性可表达、相加vs拼接等)
  • 📋 [[10.clippings/感知算法/骨干网络/Transformer/【Transformer 与注意力机制】22|Encoder 详解:6 层堆叠到底在做什么]] - 第四章编码器的主要来源(6 层堆叠意义、encoder 输出性质、Post/Pre-LN)
  • 📋 [[10.clippings/感知算法/骨干网络/Transformer/【Transformer 与注意力机制】23|Decoder 详解:为什么它天生适合生成]] - 第五章解码器的主要来源(三子层、causal mask、cross-attention、训练/推理差异)
  • 📋 [[10.clippings/感知算法/骨干网络/Transformer/【Transformer 与注意力机制】27|原论文怎么训出来的:8 张 P100、12 小时、warmup 4000 步]] - 第七章训练优化的主要来源(Adam 微调、warmup 公式、label smoothing、batching by tokens、现代配方对照)
  • 📋 [[10.clippings/感知算法/骨干网络/Transformer/【Transformer 与注意力机制】18|注意力的复杂度问题]] - 第八章优缺点对比的主要来源(O(n²) 复杂度来源、显存瓶颈、五类降复杂度方案、FlashAttention 误解)
  • 🔗 待补:相关笔记(ViT / DETR / BERT / GPT 等建立后回链)
  • 📋 Transformer 系列原文(quant67): quant67.com/post/transformer/index.html