Transformer 从零到一

不是因为它更聪明——是因为它把整个句子当一张全连接图,一次算完。

1. RNN 为什么不行

RNN 的翻译流程:读第 1 个词 → 更新隐藏状态 → 读第 2 个词 → 更新隐藏状态 → … → 读最后一个词 → 开始解码。

"die katze schläft"  →  h1 → h2 → h3 → 解码 "the cat sleeps"

这里有一个根本问题:50 个词共用一条链。第 50 个词的梯度要穿过 49 层才能传到第 1 个词。LSTM 用三个门(遗忘门、输入门、输出门)延缓了梯度消失,但治标不治本。

用 Phase 1 LSTM 跑 IWSLT14 翻译,最佳 BLEU 只有 3.06。跑 5 个 epoch 就过拟合——梯度链太长,信息衰减太快,模型记不住长程依赖。

Transformer 的方案是:不串行了。整个句子一次性扔进去,让每个词同时关联所有其他词。

但这个方案不免费——代价是两方面的膨胀:

1. Hidden 膨胀。 LSTM 的 encoder 输出是 (num_layers, B, hidden_size),比如 2 层 256 维 = (2, B, 256),只存最终状态 512 个浮点数。Transformer 的 encoder 输出是 (B, S, d_model) ——每个位置都保留了完整的 d_model 维向量。句长 S=128、d_model=512 时,encoder 输出是 65536 个浮点数,是 LSTM hidden 的 128 倍

2. 空间复杂度 O(n²)。 Self-Attention 计算所有词对之间的分数,Attention 矩阵是 (B, heads, S, S)。S=128 时是 128² = 16384 个元素/头,S=512 时是 262144。相比之下 LSTM 的复杂度是 O(n)——每步只看当前输入和 hidden state。这就是为什么长序列(长文本、高像素图像)上原生 Attention 会撞显存墙。

代价之三是:需要位置编码。Attention 本身不认识顺序——而 RNN 天然有位置信息,因为词是一个一个按顺序灌进去的,位置即处理顺序,不需要额外标记。Transformer 一次性吞入整句,词的先后关系全部丢失,必须显式注入 sin/cos 位置信号补回来。常被误称为"时序信息",其实不是时序——是位置信息,跟时间无关,跟排在哪儿有关。

那代价花得值吗? 一个直接的反问:把 LSTM 的 hidden_size 膨胀到和 Transformer 同等参数量,能追上来吗?事实证明不能。LSTM BiLSTM + Attention 在 IWSLT14 上的上限是 BLEU 3.77,而同等参数量的 Transformer 是 11.49。

差距不是参数量——是信息流动方式。

改变这个架构,改变的是矩阵本身的形状。 LSTM 的梯度沿时间步串行累积——error signal 穿过 n 层 cell state 逐层回传,路径长度 O(n)。Transformer 的梯度通过 Attention 权重矩阵直连——第 i 个位置到第 j 个位置的梯度走一次 matmul 的链式法则就到,路径长度 O(1)。前者是串联电路,后者是全连接总线的平行电路。

这是一个数据结构层面的观察。 O(n) 和 O(1) 不是运算步数的差异——是矩阵的拓扑结构从链变成了图。RNN 的隐藏状态链是一个一维序列,梯度沿这条线逐站传递。Attention 的权重矩阵是一个二维邻接矩阵,任意两点直连,梯度在各位置间均匀分布。形状的改变先于算法的改变。 后面拆解的 Self-Attention、Multi-Head、LayerNorm,都是在"全连接邻接矩阵"这个数据结构上展开的具体计算规则。

LSTM (即使扩大) Transformer
信息瓶颈 单管滴管:整句逐个吸入 (num_layers, B, hidden_size) 一个定长向量,解码器只能从这根管里拆 (B, S, d_model) 按位置展开,解码器直接查源句任意位置
梯度路径 O(n):第 50 个词的梯度穿 49 层 RNN step 才到第 1 个词 O(1):任意两个位置通过 Attention 权重直连
并行性 串行:必须等上一步算完,GPU 大量空闲 全并行:所有位置一次 matmul 算完

LSTM 的 hidden state 本质是一个单管滴管——不管管子多粗(hidden_size=512、1024、2048),一次只能吸一个孔,50 个词的语义必须逐个挤过去。Transformer 是多通道移液器——多个吸头对准矩阵的一整行,一次操作同时处理所有位置。这才是根本差别。

RNN 不是废了。它在这里只是碰巧被拿来解决 NLP——NLP 需要全距离依赖,RNN 的链式拓扑先天吃亏。但在流式信号处理、实时控制、低延迟嵌入式推理里,时序本身就是信息,串行不是缺陷是特性,RNN 依然是英雄之刃。

怎么做到的?答案就是 Self-Attention。

2. Self-Attention —— Q、K、V

Self-Attention 的直觉:我是一个词,我拿三个问题去问整句话里的每个词。

组件 含义 直觉
Q (Query) “我想找什么?” 当前词的搜索意图
K (Key) “我有什么?” 每个词的标签/索引
V (Value) “我值多少?” 每个词的实际语义内容

Q、K、V 都来自同一个输入序列,不是来自词表。 输入张量 (B, T, d_model) 分别过三个不同的线性投影 W_qW_kW_v(都是 nn.Linear(d_model, d_model)),得到三个形状完全相同的张量。词表只在两处出现——入口的 Embedding(token → d_model,本质是一键查表)和出口的 Linear(d_model → vocab_size logits)。中间的 Q、K、V 全程在 d_model 空间运算,跟词表大小无关。

这些 W 参数本质是寄存器。 不管是 RNN 的 W_hh(h_{t-1}→h_t 的时间连接),还是 Transformer 的 W_q/W_k/W_v(位置 i→位置 j 的图连接),里面存的都是一个一个的标量参数——梯度反向传播时,值就积累在这些寄存器里。RNN 积的是串行时间量,Transformer 积的是全连接边权重。拓扑不同,寄存的逻辑相同。

这些寄存器之所以排列成矩阵,是为了适应物理世界的算力架构。 GPU 的并行单元按矩阵瓦片调度——寄存器排成矩阵,才能被一次 matmul 调起所有通道。不是"矩阵天然适合存参数",是"参数必须排成矩阵才能用硬件"。

流程:

1. 每个词过一个线性层 → 得到自己的 Q, K, V
2. Q 和所有 K 做点积 → 得到 "当前词和每个词的匹配分数"
3. 除以 √d_k → 缩放防梯度饱和(点积方差随维度增长)
4. softmax → 归一化成分数权重
5. 权重 × V → 加权求和 → 当前词的上下文表示

公式:

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

为什么除以 √d_k? 假设 Q 和 K 的每个元素独立同分布,均值为 0,方差为 1。点积 q·k 的方差是 d_k。d_k 大了之后,点积值可能很大,会让 softmax 落入梯度平坦区(极低或极高的 softmax 值梯度接近 0)。除以 √d_k 把方差压回 1,保持 softmax 在"敏感区"。

代码对照——model.py:59

scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)

2.1 Mask —— 边界与未来

让模型知道"哪句话在哪结束、哪个词不能偷看"。Encoder 屏蔽 padding,Decoder 用下三角屏蔽未来词。

# Encoder mask (model.py:115): padding 位置不能参与
src_mask = (src != 0).unsqueeze(1).unsqueeze(2)  # (B, 1, 1, S)

# Decoder mask (model.py:167): 未来词不能偷看
tgt_mask = torch.tril(torch.ones(1, 1, T, T, device=tgt.device))
# 比如 T=4:
# [[1, 0, 0, 0],
#  [1, 1, 0, 0],
#  [1, 1, 1, 0],
#  [1, 1, 1, 1]]

3. Multi-Head —— 8 个专家各自打分

一个 Attention Head 只从一种角度衡量"相似度"。Multi-Head 让 8 个头各自在低维子空间(d_k = d_model / 8)独立计算 Attention,然后拼接起来。

d_model = 512, num_heads = 8 → d_k = 64

不是 "把 512 维劈成 8 段" —— 
而是 "每个头都有权看全部 512 维,但只输出 64 维"

代码——model.py:46-65

# 四个线性投影
self.W_q = nn.Linear(d_model, d_model)   # 512 → 512(拆分到 8 头 × 64)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model)   # 拼接后投影回 512

def forward(self, query, key, value, mask=None):
    B = query.size(0)
    Q = self.W_q(query).view(B, -1, self.num_heads, self.d_k).transpose(1, 2)
    K = self.W_k(key).view(B, -1, self.num_heads, self.d_k).transpose(1, 2)
    V = self.W_v(value).view(B, -1, self.num_heads, self.d_k).transpose(1, 2)
    # → (B, num_heads, T, d_k)

    scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_k)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, float("-inf"))
    attn = F.softmax(scores, dim=-1)
    # → (B, num_heads, T, d_k)

    out = attn @ V
    out = out.transpose(1, 2).contiguous().view(B, -1, d_model)
    # → (B, T, d_model)
    return self.W_o(out)

关键 viewtranspose

W_q 后: (B, T, 512)
view(B, T, 8, 64) → (B, T, 8, 64)
transpose(1, 2) → (B, 8, T, 64)   # 把 "头数" 当作 batch 维并行算

4. Positional Encoding —— 位置从哪来

Self-Attention 不分词序:“A 打了 B” 和 “B 打了 A” 在纯 Attention 里等价。需要注入位置信息。

Transformer 选 sin/cos 函数,每个维度用不同频率:

PE(pos, 2i)   = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
维度 0-1:频率最低(接近 DC)—— 编码"这句话有多长"
维度 510-511:频率最高 —— 编码"相邻词的关系"

代码——model.py:22-30

pe = torch.zeros(1, max_len, d_model)
pos = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[0, :, 0::2] = torch.sin(pos * div)
pe[0, :, 1::2] = torch.cos(pos * div)
self.register_buffer("pe", pe)  # 不是可训练参数!持久化但不求梯度

为什么选 sin/cos?因为 sin(a+b) 可以用 sin(a)cos(b) 线性组合表示——这让"相对位置"可以被 Attention 学习到,而不只是"绝对位置"。

5. FFN + Residual + LayerNorm

每个 Attention 层后面跟一个 Position-wise Feed-Forward Network对每个位置独立做相同的两层 MLP

FFN(x) = ReLU(xW1 + b1)W2 + b2
              512→2048   2048→512

代码——model.py:72-80

class FFN(nn.Module):
    def __init__(self, d_model, d_ff, dropout=0.1):
        self.fc1 = nn.Linear(d_model, d_ff)     # 升维
        self.fc2 = nn.Linear(d_ff, d_model)     # 降回来
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        return self.fc2(self.dropout(F.relu(self.fc1(x))))

Residual Connection 是 Transformer 能堆到 100 层的核心原因:

x = x + Sublayer(x)

梯度可以通过残差路径 x 直达浅层,不用穿过 Sublayer 的矩阵乘法——这就是"高速公路"。

LayerNorm 归一化每条样本的特征维度(d_model),消除层间的数值漂移:

$$\text{LayerNorm}(x) = \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} \cdot \gamma + \beta$$

本代码用 Pre-LN(现代惯例)——Norm 在前,Sublayer 在后:

原始论文 Vaswani 2017 (Post-LN):
x = LayerNorm(x + Sublayer(x))

现代实现 (Pre-LN, 更稳定):
x = x + Sublayer(LayerNorm(x))

区别:Post-LN 的残差梯度必须先穿过 LayerNorm 的归一化再流入浅层——随着层数变深,归一化累积把梯度压得太小,导致深层几乎不更新。Pre-LN 让梯度直接走残差路径,不经过 Norm——这就是为什么 Pre-LN 能训 100 层而 Post-LN 不行。

对应代码——model.py:97-99

# Pre-LN: norm 在里面,残差在外面
x = x + self.dropout1(self.self_attn(self.norm1(x), self.norm1(x), self.norm1(x), mask))
x = x + self.dropout2(self.ffn(self.norm2(x)))

上面把零件拆开讲完了——下面拼起来,看一整句数据怎么穿过 Encoder 和 Decoder。

6. Encoder-Decoder 全流程

                    ┌─ Encoder ─┐              ┌─ Decoder ──────────┐
src: "die katze"    Embed+PosEn               Embed+PosEn            tgt: "<sos> the cat"
       │              │                            │                      │
       ▼              ▼                            ▼                      ▼
  [2, 32000]     [2, 10, 512]                [2, 11, 512]          [2, 32000]
                     │                            │
                     ▼                            ▼
              N×EncoderLayer               N×DecoderLayer
              ┌ Self-Attn ─┐              ┌ Masked Self-Attn ─┐
              │   + FFN    │              │    Cross-Attn     │ ← Q 来自 decoder
              │   + FFN    │              │    + FFN          │ ← K/V 来自 encoder
              └────────────┘              └───────────────────┘
                     │                            │
                     ▼                            ▼
              Final LayerNorm              Final LayerNorm
                     │                            │
                     │                            ▼
   src_mask──────────┼─→ cross_attn ──→ Linear(d_model → vocab)
                     │                            │
                                            [2, 11, 32000]  ← logits

每一层的 tensor 形状变化(d_model=512, vocab=32000, 句长 src=10, tgt=11):

步骤 形状 说明
Embedding(src) (B, 10, 512) token → 512 维向量
×√512 + PositionalEncoding (B, 10, 512) 加位置信息
EncoderLayer × N (B, 10, 512) N 层 Self-Attn + FFN
Final LayerNorm (B, 10, 512) 归一化
— 交棒 —
Embedding(tgt) (B, 11, 512) 目标端也 embed
×√512 + PositionalEncoding (B, 11, 512)
DecoderLayer × N (B, 11, 512) Self-Attn(masked) + Cross-Attn + FFN
Final LayerNorm (B, 11, 512)
Linear(512 → 32000) (B, 11, 32000) 投影到词表

关键洞察:feature 维度全程不变。 从 Embedding 到 Final LayerNorm,每一层的输出都是 (B, T, d_model)。没有"先压缩到 hidden state 再解开"的过程——这就是 Transformer 和 LSTM 最本质的架构差异。

对比 LSTM(Phase 1/2 的 Encoder-Decoder):

                 LSTM                              Transformer
                 ────                              ───────────
Encoder:  src → [LSTM × N] → hidden         src → [Self-Attn × N] → enc_out
          (B,10,256) → (2, B, 256)          (B,10,512) → (B,10,512)   ← 保持!

Decoder:  hidden → [LSTM × N] → logits      tgt → [Self/Cross-Attn × N] → logits
          (2, B, 256) → (B, 11, 32000)      (B,11,512) → (B,11,512) → (B,11,32000)

LSTM 的 hidden(num_layers, B, hidden_size) ——一个固定大小的向量。50 个词的语义被压缩到 256 个浮点数里,解码器必须从这个压缩包里逐字拆出译文。

Transformer 的 enc_out(B, S, d_model) ——按位置展开的矩阵。解码器的 Cross-Attention 可以直接盯着源句子的每个位置查,不用从压缩包里猜。

这就是"关联"比"压缩"更有效的根本原因。

代码对照——model.py:189-191,整个流程被压缩成三行:

class Transformer(nn.Module):
    def forward(self, src, tgt, src_len):
        enc_out, src_mask = self.encoder(src, src_len)
        return self.decoder(tgt, enc_out, src_mask)

Cross-Attention 的关键(model.py:143-144)——Q 来自 decoder,K/V 来自 encoder:

# decoder 的 cross-attention:
x = x + self.dropout2(self.cross_attn(
    self.norm2(x),   # ← Q: decoder 的当前状态("我想翻译出什么")
    enc_out,         # ← K: encoder 的输出("源句子里有什么")
    enc_out,         # ← V: encoder 的输出("源句子的语义")
    src_mask         # ← 屏蔽源句子的 padding
))

以上是推理时的数据流——下面看训练时怎么让模型"学会"翻译。

7. 训练三件套

Teacher Forcing:训练时不喂自己的预测结果,而是喂正确答案的上一步。解码 "<sos> the cat" 时,第一步输入 <sos> 应该输出 the,第二步输入 the 应该输出 cat——但用的是真实的目标序列,不是模型自己生成的。

代码——train_wmt14.py:182

logits = model(src, tgt[:, :-1], src_len)
# tgt[:, :-1] = "<sos> the cat"  →  期望输出 = "the cat </s>"
# tgt[:, 1:]  = "the cat </s>"   →  损失计算目标

Label Smoothing:不要让模型对正确答案有 100% 的确信。把正确答案的概率从 1.0 降为 0.9,其他词瓜分 0.1。这防止模型过度自信,提升泛化。

criterion = torch.nn.CrossEntropyLoss(ignore_index=0, label_smoothing=0.1)

Warmup Scheduler:刚开始训练时学习率从 0 线性增长到目标值,然后衰减。前几步太大 → 梯度爆炸;太小 → 收敛慢。Warmup 给出了一个"缓慢启动"的安全缓冲区。

lr = d_model^(-0.5) * min(step_num^(-0.5), step_num * warmup^(-1.5))

8. 代码全图:model.py 逐块标注

前面分模块讲完——这里是压缩版速查,按执行流排列,每个模块对应的行号和关键 tensor 流。不是替代前文,是让你回头找代码的时候一眼定位。

model.py 总览 (191 行)
═══════════════════════════════════════

[L16-35]  PositionalEncoding
  pe[pos, 2i]   = sin(pos / 10000^(2i/d))
  pe[pos, 2i+1] = cos(pos / 10000^(2i/d))
  → register_buffer, 不学

[L41-65]  MultiHeadAttention
  W_q, W_k, W_v, W_o: 四个独立的线性投影
  view+transpose: (B, T, 512) → (B, 8, T, 64)
  Q·K^T / √64 → softmax → ×V → concat → W_o

[L72-80]  FFN
  fc1: 512→2048 (ReLU) → dropout → fc2: 2048→512
  每个位置独立,共享参数

[L87-100] EncoderLayer
  norm1 → Self-Attention → +residual (dropout)
  norm2 → FFN          → +residual (dropout)

[L103-120] Encoder
  Embedding → ×√d_model → +PositionalEncoding
  → EncoderLayer × N → Final LayerNorm
  → return (enc_out, src_mask)

[L127-147] DecoderLayer
  norm1 → Masked Self-Attention      → +residual
  norm2 → Cross-Attention(Q=dec,KV=enc) → +residual
  norm3 → FFN                         → +residual

[L150-176] Decoder
  Embedding → ×√d_model → +PositionalEncoding
  → DecoderLayer × N → Final LayerNorm
  → Linear(512→32000) → logits

[L183-191] Transformer
  enc_out, src_mask = encoder(src, src_len)
  return decoder(tgt, enc_out, src_mask)

9. 从 NMT 到 GPT

Encoder-Decoder 架构适合机器翻译——有明确的"源"和"目标"。但 LLM(GPT 系列)只用 Decoder。

Encoder-Decoder(翻译):
src → [Encoder: 全连接 Attention] → enc_out
tgt → [Decoder: masked Self-Attn + Cross-Attn(enc)] → logits

Decoder-only(GPT):
input → [Decoder: masked Self-Attention] → logits
        causal mask 就是全部
        (Cross-Attention 删了,因为没有 encoder)

只需要删掉 Cross-Attention,去掉 Encoder——Transformer 就退化成了 GPT。解码时逐 token 生成,每次用 causal mask 让当前位置后面的 token 不可见。

这就是 Transformer 的故事:从针对翻译的 Encoder-Decoder 设计,到成为所有 LLM 的通用骨架。不是因为"翻译"这个任务特殊——是因为 Self-Attention + Residual + LayerNorm 这三个东西的组合,恰好构成了一个能稳定堆叠到任意深度的通用序列处理器。

三句话带走:

  1. RNN 是单管滴管——梯度 O(n)、信息逐个挤过一根 hidden state 管;Transformer 是多通道移液器——O(1) 路径、全并行吸入一整行。
  2. 发动机是 Self-Attention(Q·K^T/√d × softmax × V),拼装上 Multi-HeadPre-LN Residualsin/cos Positional Encoding
  3. Encoder-Decoder 是翻译特化版——删掉 Cross-Attn 和 Encoder 就是 GPT。

License: GPLv3 本文《Transformer 从零到一》系列采用 GNU 通用公共许可证第三版 (GNU General Public License v3.0) 协议进行开源发布与分发。允许任何形式的复制、修改和分发,但必须继承相同的开源协议,承认在算力宇宙中所有的迭代与变异。