基于论文《Attention Is All You Need》(Vaswani et al., 2017)
配套代码:transformer_pytorch.py(可运行的 PyTorch 实现 + 序列反转演示任务)
1. 核心组件速览
Transformer 由这些核心组件堆叠而成:
- 位置编码(Positional Encoding)------ 给模型"顺序"信息
- 缩放点积注意力(Scaled Dot-Product Attention)------ 注意力计算的最小单元
- 多头注意力(Multi-Head Attention)------ 多组注意力并行
- 前馈网络(Feed-Forward Network)------ 每个位置的独立变换
- 残差连接 + 层归一化(Residual + LayerNorm)------ 让深层网络能训练
- 编码器 / 解码器(Encoder / Decoder)------ 由上述组件堆叠成的整体架构
2. 先建立全局认知
在深入每个组件之前,先搞清楚三个贯穿全文的基础概念。
2.1. 特殊 token:<pad> / <bos> / <eos> / <unk>
NLP 里最基础的四个特殊标记,给模型"打暗号"标记序列边界:
| 符号 | 全称 | 项目里的 id | 作用 |
|---|---|---|---|
<pad> |
padding | 0 | 填充符:把长短不一的句子对齐 |
<bos> |
begin of sentence | 1 | 开始符:标记序列开头 |
<eos> |
end of sentence | 2 | 结束符:标记序列结尾 |
<unk> |
unknown | 3 | 未知词:词表里没有的词 |
<pad>:一个 batch 里句子长短不一,矩阵运算要求长度一致,短句后面补<pad>。算 loss 时用ignore_index=0忽略它们。<bos>:解码的"起点信号",模型看到它才开始生成第一个词。<eos>:模型吐出它就停止生成,否则会无限生成下去。<unk>:词表有限,遇到没见过的词用<unk>兜底。
2.2. 训练 vs 推理(两种完全不同的走法)
这是理解 Transformer 的关键。
训练时(teacher forcing):把目标序列整段喂给 decoder,一次性算出所有位置的预测,直接和标准答案算 loss。
python
# 训练:decoder 输入 = 目标去掉最后一个词(shift right),预测 = 目标去掉第一个词
tgt_input = tgt[:, :-1] # [batch, seq_len-1]
tgt_label = tgt[:, 1:] # [batch, seq_len-1]
logits = model(src, tgt_input, src_mask, tgt_mask)
推理时(自回归解码) :从 <bos> 开始,逐词生成,每次拿上一步生成的结果继续。
python
generated = [1] # 从 <bos>=1 开始
for _ in range(max_len):
logits = model.decode(enc_out, generated, ...)
next_token = logits[0, -1].argmax().item() # 取最后一个位置的 argmax
generated.append(next_token)
if next_token == 2: # 遇到 <eos> 停止
break
2.3. 经典坑:train/inference mismatch(训练推理不一致)
如果训练和推理时 decoder 的输入格式不一致,会导致模型训练时从没见过推理时的输入,推理第一步就乱猜。
典型症状:loss 已经降到接近 0,但预测结果整体错位一位(丢了开头一个词,结尾多重复一个词)。
根因通常是:训练时目标序列没加 <bos> ,decoder 第一步看到的是第一个真实词;而推理时 decoder 第一步看到的是 <bos>,模型从没见过这种开头。
修复 :训练时也给目标序列加 <bos>/<eos>,让训练和推理的输入格式严格一致。
3. 位置编码 (Positional Encoding)
论文 3.5 节
python
# Transformer 没有 RNN/CNN,本身对序列顺序无感知,
# 因此把「位置」信息以正弦/余弦函数的形式直接加到输入词向量上。
class PositionalEncoding(nn.Module):
def __init__(self, d_model: int, max_len: int = 5000, dropout: float = 0.1):
super().__init__()
self.dropout = nn.Dropout(dropout)
# pe: [max_len, d_model],第 pos 行 = 位置 pos 的编码向量
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1).float() # [max_len, 1]
# 论文公式:div_term = 1 / 10000^(2i / d_model)
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]
# register_buffer:不参与梯度,但会随模型一起 save/load、move 到 GPU
self.register_buffer("pe", pe)
def forward(self, x):
# x: [batch, seq_len, d_model]
x = x + self.pe[:, : x.size(1)]
return self.dropout(x)
3.1. 什么是 pe,为什么需要它
pe 是 Positional Encoding(位置编码),给模型"告诉它每个词在第几位"的机制。
Transformer 没有 RNN/CNN,self-attention 是"并行看所有位置"的------它本身对词的先后顺序完全无感知。需要把"这是第 1 个词、第 2 个词..."的信息塞进去,否则"我爱你"和"你爱我"对模型来说没区别。
3.2. max_len 和 d_model 是什么
| 名称 | 是什么 | 单位 |
|---|---|---|
max_len |
一句话里有多少个词(token 数) | 词的个数 |
d_model |
每个词用多少维的向量表示 | 向量的维度 |
举例:"I love you very much"(5 个词),每个词用一个 128 维向量表示:
max_len= 词数上限(这句话是 5,但上限通常设 5000)d_model= 128(每个词 128 个数字)
pe 的形状 [max_len, d_model]:5000 行(位置)× 128 列(每个位置向量维度)。
3.3. 为什么用 sin 和 cos
论文 3.5 节原版公式:
PE(pos,2i)=sin(100002i/dmodelpos),PE(pos,2i+1)=cos(100002i/dmodelpos)
代码里 0::2 是"从 0 开始步长 2"(偶数维),1::2 是奇数维。偶数维填 sin,奇数维填 cos。
3.3.1. 性质一:相对位置可以线性表示
把一对 (sin, cos) 看成单位圆上的"指针",位置 = 角度。从位置 pos 走到 pos+k,指针多转 k 格,就是一个旋转矩阵:
PE(pos+k)=只含k,不含pos cos(kω)−sin(kω)sin(kω)cos(kω)⋅PE(pos)
矩阵只跟相对距离 k 有关,跟绝对位置 pos 无关。所以模型学的是"相隔 k 的变换",而非"第 N 位长什么样"------这是一种可迁移的相对位置规则。
3.3.2. 性质二:固定、不需训练、可泛化任意长度
sin/cos 编码不是一个表,而是一个公式 ,给定任意 pos 代入公式就能算。训练时见过 50 词的句子,测试时 100 词照样能算(公式是连续函数)。相比之下,"可学习位置向量"被"训练时见过的最大长度"卡死。
register_buffer 标明它不参与梯度更新------位置信息是"白送的",模型把参数省下来学词义和注意力。
3.3.3. 对比
| sin/cos 位置编码 | 可学习位置向量 | |
|---|---|---|
| 相对位置 | ✅ 平移 = 旋转矩阵 | ❌ 每个位置独立参数 |
| 是否训练 | ❌ 固定 | ✅ 需要训练 |
| 泛化更长句子 | ✅ 任意长度 | ❌ 被训练长度卡死 |
3.4. 这是 Transformer 论文发明的吗
"用 sin/cos 编码位置"这个具体公式是 Transformer 论文(2017)提出并普及的,但"位置编码"思想有更早的前身:
arduino
RNN 时代 → 架构自带顺序,不需要位置编码
ConvS2S (2017) → 首次显式用"可学习位置向量"(比 Transformer 早几个月)
Transformer → 提出"sin/cos 正弦位置编码"
另外,那个"旋转矩阵/相对位置线性表示"的严格数学解读,其实不是论文自己证明的 ------论文原文只用 "we hypothesized"(我们假设)一句话带过。真正严格形式化并加以利用的是后来的 RoPE(旋转位置编码,2021)。
3.5. RoPE 是什么(位置编码的终极版)
RoPE 不再把位置信息加 到词向量上,而是直接把词向量旋转一个角度:
- 原始 sin/cos:
x + PE(pos)(加法) - RoPE:
R(pos·θ) · x(旋转,乘法)
核心性质:两个 token 的 attention 分数里,绝对位置互相抵消,只剩相对位置:
(R(m)xq)T(R(n)xk)=xqTR(n−m)xk
attention 分数天然只依赖相对距离,这正是原论文只是"假设"、RoPE 严格实现并证明的性质。现在 ChatGPT、Llama、Qwen、DeepSeek 等几乎所有大模型都用 RoPE。
3.6. div_term 公式推导
论文公式 div_term = 1 / 10000^(2i/d_model) 对应代码:
python
div_term = torch.exp(
torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)
)
核心恒等式: ab=eb⋅lna
100002i/d1=10000−2i/d=e(−2i/d)⋅ln10000=e2i⋅(−ln10000/d)
torch.arange(0, d_model, 2)生成[0, 2, 4, ...],这本身就是2i(步长 2 隐藏了"乘 2")- 用 exp+log 形式是因为数值更稳定、计算更快
3.7. unsqueeze 是什么
unsqueeze(dim) = 在第 dim 维插入一个大小为 1 的维度。
python
x = torch.tensor([1, 2, 3]) # [3]
x.unsqueeze(0) # [1, 3] ← 加 batch 维
x.unsqueeze(1) # [3, 1] ← 变列向量
position.unsqueeze(1):把[max_len]变[max_len, 1](列向量),好和div_term(行向量)广播成二维矩阵pe.unsqueeze(0):把[max_len, d_model]变[1, max_len, d_model],补 batch 维,好和输入x相加
4. 缩放点积注意力 (Scaled Dot-Product Attention)
论文 3.2.1 节
python
# Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V
#
# mask 的作用:把「不该被看到的位置」置为 -inf,softmax 后概率归零。
# 两种常见 mask:
# - padding mask:屏蔽 <pad> 填充位置
# - causal mask (下三角):解码时只能看到「当前及之前」的位置(自回归)
def scaled_dot_product_attention(q, k, v, mask=None, dropout=None):
"""
q: [batch, heads, q_len, d_k]
k: [batch, heads, k_len, d_k]
v: [batch, heads, v_len, d_v]
mask: [batch, heads, q_len, k_len] 或可广播形状,True 表示「被屏蔽」
"""
d_k = q.size(-1)
# scores: [batch, heads, q_len, k_len]
scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k)
if mask is not None:
# masked_fill:mask 为 True 的地方填一个极小值(-1e9),softmax 后变成 0
scores = scores.masked_fill(mask, -1e9)
attn = F.softmax(scores, dim=-1)
if dropout is not None:
attn = dropout(attn)
output = torch.matmul(attn, v) # [batch, heads, q_len, d_v]
return output, attn
完整公式:
Attention(Q,K,V)=softmax(dk QKT)V
4.1. 逐行拆解
| 代码 | 公式 | 含义 |
|---|---|---|
q.size(-1) |
d_k | 取 q 最后一维 = 每个头的维度 |
k.transpose(-2, -1) |
Kᵀ | 转置最后两维,让 d_k 维度对齐 |
torch.matmul(...) |
QKᵀ | 点积 = 算 query 和每个 key 的相似度分数 |
/ math.sqrt(d_k) |
÷√d_k | 缩放,防止分数太大 |
masked_fill(mask, -1e9) |
屏蔽 | 不该看的位置置 -inf |
F.softmax(...) |
softmax | 分数归一化成注意力权重 |
torch.matmul(attn, v) |
×V | 加权求和得到输出 |
4.2. 为什么除以 √d_k
点积结果会随维度 d_k 增大而增大。d_k 很大时(如 512),点积值很大,softmax 进入"饱和区",梯度趋近 0。除以 √d_k 把分数拉回正常范围。论文原话:
We suspect that for large values of d_k, the dot products grow large in magnitude, pushing the softmax function into regions where it has extremely small gradients. To counteract this effect, we scale the dot products by 1/√d_k.
4.3. 两种 mask
- padding mask :屏蔽
<pad>填充位置(make_pad_mask) - causal mask(下三角) :解码时只能看"当前及之前"的位置,自回归(
make_causal_mask)
mask 为 True 的位置填 -1e9,softmax 后变成 0,实现"完全屏蔽"。
4.4. 补充:matmul 是什么
torch.matmul(a, b) = 矩阵乘法(行×列,相乘再求和),和逐元素 * 是两码事。要求"左矩阵列数 = 右矩阵行数":
python
A = [[1, 2], B = [[5, 6],
[3, 4]] [7, 8]]
matmul(A, B) = [[1*5+2*7, 1*6+2*8], = [[19, 22],
[3*5+4*7, 3*6+4*8]] [43, 50]]
5. 多头注意力 (Multi-Head Attention)
论文 3.2.2 节
python
# 把 Q/K/V 投影到 h 个不同的子空间,并行做注意力,再拼接。
# 好处:让模型同时关注不同位置、不同表示子空间的信息。
class MultiHeadAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int, dropout: float = 0.1):
super().__init__()
assert d_model % num_heads == 0, "d_model 必须能被 num_heads 整除"
self.num_heads = num_heads
self.d_k = d_model // num_heads
# Q/K/V/O 四组可学习线性变换
self.w_q = nn.Linear(d_model, d_model)
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)
self.dropout = nn.Dropout(dropout)
def forward(self, q, k, v, mask=None):
batch = q.size(0)
# 1) 线性投影,然后按 head 切分:
# [batch, q_len, d_model] -> [batch, q_len, heads, d_k] -> [batch, heads, q_len, d_k]
q = self.w_q(q).view(batch, -1, self.num_heads, self.d_k).transpose(1, 2)
k = self.w_k(k).view(batch, -1, self.num_heads, self.d_k).transpose(1, 2)
v = self.w_v(v).view(batch, -1, self.num_heads, self.d_k).transpose(1, 2)
# 2) 缩放点积注意力
out, _ = scaled_dot_product_attention(q, k, v, mask, self.dropout)
# 3) 拼接多个 head,再过一次线性变换
# [batch, heads, q_len, d_k] -> [batch, q_len, d_model]
out = out.transpose(1, 2).contiguous().view(batch, -1, self.num_heads * self.d_k)
return self.w_o(out)
5.1. 核心思想:把一个"大头"切成多个"小头"
一个词向量 128 维,切成 8 个 16 维的小向量,就是 8 个"头"。每个头用不同的 w_q 投影,关注不同的侧重点------有的头关注相邻词的语法,有的头关注远处词的指代。
5.2. "分头"三步(用具体数字走一遍)
假设 batch=2, q_len=5, d_model=128, num_heads=8, d_k=16:
| 步骤 | 操作 | 形状 |
|---|---|---|
| 输入 | q 原始 | [2, 5, 128] |
| 投影 | w_q(q) |
[2, 5, 128] |
| 切分 | .view(2, -1, 8, 16) |
[2, 5, 8, 16] |
| 换位 | .transpose(1, 2) |
[2, 8, 5, 16] |
关键点:
view(batch, -1, num_heads, d_k):把 128 维切成 8×16。-1是"自动推断",因为2×8×16=256,而总元素2×5×128=1280,所以-1 = 1280÷256 = 5(即 q_len)。.transpose(1, 2):把 heads 维度提到前面,让 8 个头能并行独立计算。
5.3. 算完后拼回来
8 个头的结果 transpose + view 拼回 128 维,再过一次 w_o 线性变换。
6. 前馈网络 (Position-wise Feed-Forward Network)
论文 3.3 节
python
# FFN(x) = max(0, xW1 + b1) W2 + b2
# 每个位置独立作用(position-wise),中间维度 d_ff 通常是 d_model 的 4 倍。
class PositionwiseFeedForward(nn.Module):
def __init__(self, d_model: int, d_ff: int, dropout: float = 0.1):
super().__init__()
self.w_1 = nn.Linear(d_model, d_ff)
self.w_2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
return self.w_2(self.dropout(F.relu(self.w_1(x))))
公式:FFN(x) = max(0, xW1 + b1)W2 + b2
关键点:
- position-wise :对每个位置独立做同样的变换,位置之间不交互(位置间的交互由注意力层负责)
- 先升维再降维 :
d_model → d_ff → d_model,中间d_ff通常是d_model的 4 倍(如 512 → 2048 → 512) - 作用:给模型加入非线性变换能力。注意力层本质是"加权求和"(线性),光靠注意力表达不了复杂函数,FFN 补上非线性
7. 残差连接 + 层归一化
论文里的每个子层都是"子层 → 加残差 → LayerNorm":
python
x = self.norm1(x + self.dropout(self.self_attn(x, x, x, mask))) # 注意力 + 残差 + LN
x = self.norm2(x + self.dropout(self.ffn(x))) # FFN + 残差 + LN
7.1. 残差连接(Residual Connection)
核心公式: 输出=x+F(x)
x是输入,F(x)是子层输出,两者相加- 直觉 :给信息留一条"抄近路"的通道。即使
F(x)学得不好,原始x还在,信息不丢 - 核心作用 :解决梯度消失。深层网络梯度层层衰减,残差的
x + F(x)让梯度能"直达"流回,这是深层网络能训练的关键(来自 ResNet 2015)
7.2. LayerNorm(层归一化)
核心公式: x^=σ2+ϵ x−μ,再乘 γ 加 β
- 把每个词向量内部归一化,均值 0、方差 1
- 作用:稳定数值、加速收敛
7.2.1. 为什么用 LayerNorm 而不是 BatchNorm
| BatchNorm | LayerNorm | |
|---|---|---|
| 归一化维度 | 同一特征、跨一批样本 | 单个样本的整条向量 |
| 依赖 batch | 依赖,batch 小不准 | 不依赖 batch |
| 适合 | CNN(图像) | RNN / Transformer(序列) |
Transformer 选 LayerNorm 的原因:不依赖 batch 大小、每个位置独立归一化、适配变长序列。
8. 编码器 / 解码器
在深入每一层之前,先看整体架构和层内部结构。下面的图以配套代码 transformer_pytorch.py 演示任务的超参为准:num_layers = 2(Transformer 类默认是 num_layers = 6)。
整体结构:encoder 堆 2 层 + decoder 堆 2 层,中间通过 cross-attention 连接。
text
src 序列 tgt 序列
| |
v v
Encoder Embedding Decoder Embedding
(x sqrt(d_model)) (x sqrt(d_model))
| |
v v
+ Positional Encoding + Positional Encoding
| |
v |
+------------------------+ |
| Encoder Layer x 2 | |
| (num_layers = 2) | |
+------------------------+ |
| enc_out |
| +----------------------+
v v (cross_attn 用 enc_out 作为 K/V)
+------------------------+
| Decoder Layer x 2 |
| (num_layers = 2) |
+------------------------+
|
v
Generator (Linear) -> 词表概率 -> softmax
单个 Encoder Layer 内部(2 个子层,各带「残差 + LayerNorm」):
text
x -----------------------------+
| | 残差
v |
self_attn (Q=K=V=x) |
| |
v |
(+) <---------------------------+
|
v
norm1 (LayerNorm) <- 顺序:self_attn -> 加残差 -> norm(post-norm)
|
+-----------------------------+
| | 残差
v |
FFN (w_1 -> ReLU -> w_2) |
| |
v |
(+) <---------------------------+
|
v
norm2 (LayerNorm)
|
v
输出
单个 Decoder Layer 内部(3 个子层,比 encoder 多一个 cross-attn):
text
x -----------------------------+
v | 残差
masked self_attn (causal mask) |
v |
(+) <---------------------------+
v
norm1
+-----------------------------+
v | 残差
cross_attn (Q=dec, K/V=enc) |
v |
(+) <---------------------------+
v
norm2
+-----------------------------+
v | 残差
FFN |
v |
(+) <---------------------------+
v
norm3
v
输出
各层包含的参数 (对照导出的 code-transformer/model_params.txt 可找到对应张量):
| 模块 | 层数 | 每层含的参数 |
|---|---|---|
| encoder | 2 层 | self_attn(w_q / w_k / w_v / w_o) + ffn(w_1 / w_2) + norm1 / norm2 |
| decoder | 2 层 | self_attn + cross_attn + ffn + norm1 / norm2 / norm3 |
注意:本实现用的是论文原版的 post-norm (
norm(x + Sublayer(x)))。后来更主流的 Pre-LN 是x + Sublayer(norm(x))------两者只是 norm 放在残差前后位置不同,本代码是 post-norm。
8.1. 编码器层(Encoder Layer)
python
# 每个 Encoder 层 = 多头自注意力 + 前馈网络,各自带残差连接 + LayerNorm。
# 这里用论文原版的「后归一化 (post-norm)」:x + Sublayer(x),再接 LayerNorm。
class EncoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
super().__init__()
self.self_attn = MultiHeadAttention(d_model, num_heads, dropout)
self.ffn = PositionwiseFeedForward(d_model, d_ff, dropout)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask):
# 自注意力:Q=K=V=x(每个词看所有词,双向)
x = self.norm1(x + self.dropout(self.self_attn(x, x, x, mask)))
x = self.norm2(x + self.dropout(self.ffn(x)))
return x
特点 :Q=K=V=x,双向注意力------每个位置能看所有位置(用 padding mask,无 causal mask)。
8.2. 解码器层(Decoder Layer)
python
# 每个 Decoder 层比 Encoder 多一个「交叉注意力」:
# - masked self-attention:只能看已生成的位置(causal mask)
# - cross-attention:Q 来自 decoder,K/V 来自 encoder 输出
class DecoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
super().__init__()
self.self_attn = MultiHeadAttention(d_model, num_heads, dropout)
self.cross_attn = MultiHeadAttention(d_model, num_heads, dropout)
self.ffn = PositionwiseFeedForward(d_model, d_ff, dropout)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.norm3 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, enc_out, src_mask, tgt_mask):
# 1) 掩码自注意力:只能看已生成的位置(causal mask)
x = self.norm1(x + self.dropout(self.self_attn(x, x, x, tgt_mask)))
# 2) 交叉注意力:Q 来自 decoder,K/V 来自 encoder 输出
x = self.norm2(x + self.dropout(self.cross_attn(x, enc_out, enc_out, src_mask)))
# 3) 前馈
x = self.norm3(x + self.dropout(self.ffn(x)))
return x
比 encoder 多一个交叉注意力,共三个子层:
- 掩码自注意力:causal mask,只能看"当前及之前"的位置(自回归)
- 交叉注意力:Q 来自 decoder,K/V 来自 encoder 输出------这是 decoder "读取"输入信息的地方
- 前馈网络
8.3. 自注意力 vs 交叉注意力
| 自注意力 | 交叉注意力 | |
|---|---|---|
| Q/K/V 来源 | 都来自同一序列 | Q 来自 decoder,K/V 来自 encoder |
| 作用 | 序列内部的位置关系 | 输出"读取"输入信息 |
| mask | encoder 用 padding / decoder 用 causal | 用 src 的 padding mask |
9. 完整 Transformer + mask
9.1. 完整模型
python
class Transformer(nn.Module):
def __init__(self, src_vocab, tgt_vocab, d_model=512, num_heads=8,
num_layers=6, d_ff=2048, dropout=0.1, max_len=5000):
super().__init__()
# 词嵌入 + 位置编码
self.encoder_embed = nn.Embedding(src_vocab, d_model)
self.decoder_embed = nn.Embedding(tgt_vocab, d_model)
self.pos_enc = PositionalEncoding(d_model, max_len, dropout)
# N 层堆叠
self.encoder = nn.ModuleList([
EncoderLayer(d_model, num_heads, d_ff, dropout) for _ in range(num_layers)
])
self.decoder = nn.ModuleList([
DecoderLayer(d_model, num_heads, d_ff, dropout) for _ in range(num_layers)
])
# 输出投影:d_model -> 词表大小,softmax 后得到每个词的预测概率
self.generator = nn.Linear(d_model, tgt_vocab)
def encode(self, src, src_mask):
x = self.encoder_embed(src) * math.sqrt(self.d_model)
x = self.pos_enc(x)
for layer in self.encoder:
x = layer(x, src_mask)
return x
def decode(self, enc_out, tgt, src_mask, tgt_mask):
x = self.decoder_embed(tgt) * math.sqrt(self.d_model)
x = self.pos_enc(x)
for layer in self.decoder:
x = layer(x, enc_out, src_mask, tgt_mask)
return self.generator(x) # [batch, tgt_len, tgt_vocab]
上面的代码定义的是静态结构(有哪些层、层之间怎么连)。数据实际是怎么在层之间流动的?下面这张图画出了从训练循环到最底层 sub-layer 的完整调用链(和第 8 章的结构图互补:第 8 章讲"有什么组件",这里讲"数据怎么一步步流")。
text
train_demo() # 最外层:训练循环
│ for step in range(1, steps+1):
│ src, tgt = make_reverse_batch(...)
│ logits = model(src, tgt_input, src_mask, tgt_mask) ← 写 model(...) 实际自动调 forward
▼
Transformer.forward(src, tgt, src_mask, tgt_mask)
│
├──▶ enc_out = self.encode(src, src_mask)
│ x = encoder_embed(src) × √d_model ← 查词向量表
│ x = pos_enc(x) ← 加位置编码
│ ┌─ for layer in self.encoder (×2): ─┐
│ │ EncoderLayer.forward(x, src_mask) │
│ │ ├─ self_attn(x, x, x, src_mask) │ ← Q=K=V=x 双向自注意力
│ │ ├─ x = x + dropout(...) │ ← 残差
│ │ ├─ x = norm1(x) │ ← LayerNorm
│ │ ├─ ffn(x) │ ← 前馈
│ │ ├─ x = x + dropout(...) │ ← 残差
│ │ └─ x = norm2(x) │ ← LayerNorm
│ └────────────────────────────────────┘
│ return x ← 这个 x 就是 enc_out
│
└──▶ return self.decode(enc_out, tgt, src_mask, tgt_mask)
x = decoder_embed(tgt) × √d_model
x = pos_enc(x)
┌─ for layer in self.decoder (×2): ──────────────────────────┐
│ DecoderLayer.forward(x, enc_out, src_mask, tgt_mask) │
│ ├─ self_attn(x, x, x, tgt_mask) ← 掩码自注意力 │
│ ├─ x = x + dropout(...) ← 残差 │
│ ├─ x = norm1(x) ← norm │
│ ├─ cross_attn(x, enc_out, enc_out, src_mask) ← 交叉注意力│
│ ├─ x = x + dropout(...) ← 残差 │
│ ├─ x = norm2(x) ← norm │
│ ├─ ffn(x) ← 前馈 │
│ ├─ x = x + dropout(...) ← 残差 │
│ └─ x = norm3(x) ← norm │
└────────────────────────────────────────────────────────────┘
return generator(x) ← 投影到词表
▼
logits = [batch, tgt_len, vocab] # 每个位置、每个词的分数
loss = CrossEntropyLoss(logits, tgt_label)
loss.backward() # 反向传播,更新所有可学习参数
理解这张图的几个关键点(PyTorch 的"隐性知识",都发生在你看不到的地方):
- 写
model(...)为什么等于调forward:nn.Module内部定义了一个__call__,它会自动转调self.forward(...)。所以model(src, tgt_input, ...)这一行,实际执行的就是上面Transformer.forward里的逻辑。 enc_out只算一次 :forward先encode一次得到enc_out,之后 2 层 decoder 的每一层都复用这同一份enc_out(不是每层重新 encode)。- 每层的
self_attn/cross_attn/ffn/norm都是独立的nn.Module:因为写在self.xxx = ...里,被nn.Module自动登记,所以model.parameters()能拿到它们、loss.backward()能更新它们(参数数量见 9.3)。 nn.ModuleList让for layer in self.encoder里的层被正确登记 :普通 Python list 装的层不会被框架管理(参数拿不到、不更新),必须用nn.ModuleList。
9.2. mask 构造
python
def make_pad_mask(seq, pad_idx):
# padding mask:位置为 <pad> 的置 True(屏蔽)
# [batch, 1, 1, seq_len]
return (seq == pad_idx).unsqueeze(1).unsqueeze(2)
def make_causal_mask(seq_len):
# causal mask:下三角,屏蔽「未来」位置
# [1, 1, seq_len, seq_len]
mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool()
return mask.unsqueeze(0).unsqueeze(0)
为什么 mask 是 4 维? 因为注意力分数 scores 是 4 维 [batch, heads, q_len, k_len],mask 要跟它做 masked_fill,所以也必须能被广播成 4 维。
make_pad_mask 形状 [batch, 1, 1, seq_len] 怎么来的 (假设 batch=2, seq_len=3,0 是 pad):
text
seq [[3,5,0],[4,0,0]] [2, 3]
(seq == 0) [[F,F,T],[F,T,T]] [2, 3] 和 seq 同形状
.unsqueeze(1) [2, 1, 3]
.unsqueeze(2) [2, 1, 1, 3] = [batch, 1, 1, seq_len]
中间两个 1 是"占位"用来广播:
- 第 1 个
1(heads 位):每个头都一样,不用为每个头重复存 - 第 2 个
1(q_len 位):pad 屏蔽跟 query 位置无关,任何 query 看某个 pad 都该屏蔽
text
mask: [batch, 1, 1, seq_len] [2, 1, 1, 3]
scores: [batch, heads, q_len, k_len] [2, 4, q, 3]
↓ 广播规则(维度为 1 的可扩展成任意值)
[2, 4, q, 3] ← 自动变成和 scores 一样大,可做 masked_fill
make_causal_mask 形状 [1, 1, seq_len, seq_len] 怎么来的 (seq_len=3):
text
torch.ones(3, 3) [[1,1,1],[1,1,1],[1,1,1]] [3, 3]
torch.triu(..., diagonal=1) [[0,1,1],[0,0,1],[0,0,0]] [3, 3] 上三角(不含主对角线)
.bool().unsqueeze(0).unsqueeze(0) [1, 1, 3, 3]
下三角 mask 的含义(行 = query 位置,列 = key 位置,True = 屏蔽):
text
看位置0 看位置1 看位置2
位置0 0 1 1 ← 位置0 只能看自己(还没生成 1、2)
位置1 0 0 1 ← 位置1 能看 0、1(还没生成 2)
位置2 0 0 0 ← 位置2 能看全部(都生成了)
这就是自回归 :生成第 i 个词时只能看已生成的 0~i 个词,不能偷看未来。前两维 [1, 1] 是因为因果 mask 对所有 batch、所有 head 都完全一样(下三角固定,跟数据无关),所以用 1 占位广播到任意大小。
9.3. 参数量计算
模型一共有多少可学习参数?可以用三条基础公式从结构逐层累加出来。下面以演示任务超参(vocab=32, d_model=64, num_heads=4, num_layers=2, d_ff=128)为例算一遍,结果就是配套代码导出的 code-transformer/model_params.txt 里的 173600。
三条基础公式:
| 层类型 | 参数公式 | 说明 |
|---|---|---|
nn.Embedding(vocab, d) |
vocab × d |
无 bias |
nn.Linear(d_in, d_out) |
d_in × d_out + d_out |
weight + bias |
nn.LayerNorm(d) |
d + d = 2d |
weight + bias |
分组件计算:
text
词嵌入(2 个):
encoder_embed = 32 × 64 = 2048
decoder_embed = 32 × 64 = 2048
一个多头注意力(4 个 Linear):
w_q / w_k / w_v / w_o 各 = 64×64 + 64 = 4160
MultiHeadAttention = 4 × 4160 = 16640
一个 FFN(2 个 Linear):
w_1 = 64×128 + 128 = 8320
w_2 = 128×64 + 64 = 8256
FFN = 8320 + 8256 = 16576
一个 LayerNorm = 64 + 64 = 128
逐层汇总:
text
一个 EncoderLayer = self_attn(16640) + ffn(16576) + norm1(128) + norm2(128)
= 33472
一个 DecoderLayer = self_attn(16640) + cross_attn(16640) + ffn(16576) + 3×norm(384)
= 50240
最终求和:
text
总参数 = encoder_embed(2048)
+ decoder_embed(2048)
+ encoder × 2 层 = 2 × 33472 = 66944
+ decoder × 2 层 = 2 × 50240 = 100480
+ generator(64×32 + 32 = 2080)
────────────────────────────────────────
= 2048 + 2048 + 66944 + 100480 + 2080
= 173600
| 组件 | 数量 | 单个参数 | 小计 |
|---|---|---|---|
| encoder_embed | 1 | 2048 | 2048 |
| decoder_embed | 1 | 2048 | 2048 |
| EncoderLayer | 2 | 33472 | 66944 |
| DecoderLayer | 2 | 50240 | 100480 |
| generator | 1 | 2080 | 2080 |
| 合计 | 173600 |
通用公式 (任意超参都适用,V=词表大小、d=d_model、d_ff=FFN 中间维、N=层数):
Lenc=4(d2+d)+(d⋅dff+dff)+(dff⋅d+d)+4d
Ldec=Lenc+4(d2+d)+2d
总参数=2⋅V⋅d+N⋅Lenc+N⋅Ldec+(d⋅V+V)
代入 V=32, d=64, d_ff=128, N=2,即得 173600。
注意:
pos_enc里的位置编码pe是register_buffer(固定 sin/cos 生成),不参与梯度,所以不算 可学习参数(贡献 0)。如果你看到model_params.txt里「非学习 buffer 数量 = 1」,指的就是它。
10. 演示任务:序列反转 + 训练要点
transformer_pytorch.py 的演示任务是序列反转(reverse):输入一个随机序列,输出它的反转。这是验证模型结构正确的 smoke test。
10.1. warmup 学习率(Transformer 训练头号关键)
Transformer 论文强调用 Noam warmup(学习率先升后降),而不是固定学习率:
python
lr = d_model ** (-0.5) * min(step ** (-0.5), step * warmup_steps ** (-1.5))
- 前
warmup_steps步:lr 从 0 线性上升(避免一开始 lr 太大导致震荡/发散) - 之后:lr 按
1/√step衰减
同时论文用 Adam(betas=(0.9, 0.98), eps=1e-9)(区别于 PyTorch 默认的 (0.9, 0.999))。
调参经验(针对反转任务"预测不好"):
| 优先级 | 调整 | 原因 |
|---|---|---|
| ⭐⭐⭐ | 加 warmup | 头号关键,没 warmup 易训不好 |
| ⭐⭐⭐ | 初始 lr | warmup 下能承受更大 lr |
| ⭐⭐ | d_model / num_heads | 容量更大,收敛更快 |
| ⭐⭐ | num_layers / d_ff | 更深更强 |
| ⭐ | batch_size | 梯度更稳 |
| ⭐ | steps | 给足训练时间 |
10.2. 诊断:看 loss 卡在哪
- loss 还很高(>1.0)且不降 → 学习率问题,缺 warmup 或 lr 不合适
- loss 缓慢降但没到 0 → 训练不够或容量不够
- loss 接近 0 但预测不对 → 是 bug(如 train/inference mismatch、mask 用错),不是参数问题
10.3. model.train() 和 model.eval() 是什么
两者都不会调用 forward,只是「切换模式」的开关。
| 调用 | 作用 | 会执行 forward 吗 |
|---|---|---|
model(x) |
真正做前向传播 | ✅ 是(自动调 forward) |
model.train() |
切到"训练模式" | ❌ 否 |
model.eval() |
切到"推理/评估模式" | ❌ 否 |
它们切换的是那些"训练和推理行为不同"的层------在这个 Transformer 里最典型的是 Dropout:
python
model.train() # Dropout 生效:每层随机把 10% 的神经元置 0(防过拟合)
# 训练循环里多次 model(x) 调 forward,此时 dropout 在随机丢弃
model.eval() # Dropout 关闭:所有神经元全用上,不丢弃
with torch.no_grad(): # 同时关掉梯度计算(推理不需要反向传播)
output = model(x) # 调 forward,但这次 dropout 不再丢弃
对照 train_demo 里两处就很清楚:
python
# 训练阶段(train_demo 开头)
model.train() # 切训练模式,dropout 生效
for step in range(1, steps + 1):
logits = model(...) # 这里才真正调 forward
loss.backward()
# 推理阶段(train_demo 末尾)
model.eval() # 切推理模式,dropout 关闭
with torch.no_grad(): # 关梯度
output = model.decode(...) # 调 forward,但行为变了
完整流程 :先 model.train() 切模式 → 再多次 model(x) 训练 → 训练完 model.eval() 切模式 → model(x) 推理。
11. 三种架构:encoder-only / encoder-decoder / decoder-only
Transformer 论文给出的是 encoder-decoder 架构,但它可以"拆开用",衍生出三种主流架构,分别对应不同的任务类型和代表作:
| 架构 | 代表作 | 注意力方向 | 典型任务 |
|---|---|---|---|
| Encoder-only | BERT | 双向 | 理解:分类、标注、检索 |
| Encoder-Decoder | 原始 Transformer、T5、BART | 双向 + 自回归 | 翻译、摘要 |
| Decoder-only | GPT、Llama、Qwen、DeepSeek | 单向(因果) | 生成:对话、补全 |
11.1. 什么时候能不用 decoder(encoder-only)
输出长度未知的任务(翻译、摘要、对话)必须用 decoder 自回归生成,因为输出长度不固定。
但输出长度固定 = 输入长度 的任务(反转、复制、序列标注),可以不用 decoder,改用纯 encoder + 每个位置分类器(非自回归):
python
class EncoderOnly(nn.Module):
def forward(self, src, mask):
x = self.embed(src) * math.sqrt(self.d_model)
x = self.pos_enc(x)
for layer in self.encoder:
x = layer(x, mask)
return self.classifier(x) # [batch, seq_len, vocab],一次性输出全部位置
11.2. 三种架构对比
| Encoder-Only | Encoder-Decoder | Decoder-Only | |
|---|---|---|---|
| 组成 | 只有 encoder | encoder + decoder | 只有 decoder |
| 注意力方向 | 双向(看全部) | 双向 + 因果 | 单向因果(只看前面) |
| 输出方式 | 一次性并行 | 逐词自回归 | 逐词自回归 |
| 输出长度 | 必须 = 输入长度 | 任意 | 任意 |
| 训练目标 | 掩码语言模型(MLM) | 翻译/重建 | 下一个词预测(next token) |
| 适合任务 | 理解(分类/标注) | 翻译、摘要 | 生成(对话/补全) |
| 代表作 | BERT | 原始 Transformer、T5 | GPT、Llama、Qwen |
11.3. decoder-only:现在大语言模型的主流
11.3.1. 什么是 decoder-only
只用 Transformer 的 decoder 部分堆叠,没有 encoder,也没有 cross-attention。本质上是"阉割掉交叉注意力的 decoder":
python
class DecoderOnly(nn.Module):
def forward(self, x, mask):
x = self.embed(x) * math.sqrt(self.d_model)
x = self.pos_enc(x)
for layer in self.layers:
# 只有 causal self-attention,没有 cross-attention
x = layer(x, mask)
return self.lm_head(x) # 预测下一个 token
关键差异:原始 Transformer 的 decoder 有三层(掩码自注意力 + 交叉注意力 + FFN),decoder-only 去掉了交叉注意力(因为没有 encoder 可"读"),只剩掩码自注意力 + FFN。
11.3.2. 训练目标:下一个词预测(Next Token Prediction)
decoder-only 的训练目标极其简单统一:给定前面的词,预测下一个词。
arduino
输入:"今天天气很"
预测下一个词:"好"
这被称为 自回归语言模型(Autoregressive Language Model)。它不需要精心构造输入输出对------任何文本天然就是训练数据(前 n-1 个词 → 第 n 个词),所以能从海量互联网文本里无监督学习。
11.3.3. 为什么大模型都选 decoder-only
现在几乎所有大语言模型(GPT、ChatGPT、Llama、Qwen、通义千问、DeepSeek、Gemini 等)都是 decoder-only,原因是:
- 训练目标简单统一------"下一个词预测"一个目标通吃,不需要像 BERT 那样设计掩码策略,也不需要像 encoder-decoder 那样准备平行语料
- 数据利用率极高------任意文本都是训练数据,可以塞进海量数据
- 扩展性好(Scaling Law)------架构简单,参数堆上去效果稳定提升,这是大模型"大力出奇迹"的基础
- 生成能力强------天然就是"生成下一个词",正好是对话、补全、写代码这些场景需要的
- 支持上下文学习(In-Context Learning)------把指令和示例拼在 prompt 里,不用微调就能做新任务
11.3.4. BERT 为什么退出了生成舞台
BERT 是 encoder-only(双向注意力 + 掩码语言模型),它"看得到后面",所以:
- 适合理解类任务(分类、问答、命名实体识别)
- 但不能自然地生成------因为它训练时是"填空"(遮住中间词预测),不是"从左到右生成"
所以 BERT 火过一阵后,生成式任务(对话、写作)完全被 decoder-only 的 GPT 系列接管。现在 BERT 主要用于需要"理解全文"的场景(如搜索引擎、文本分类)。
11.4. 一个坑:别手动 flip
如果做纯 encoder 反转,不能 在外面 torch.flip(output)------那是用代码硬编码了"反转",模型只学会了复制。必须是"encoder 位置 i 的输出自己学会等于输入第 n-1-i 位",靠双向注意力真正学会从后往前读。
12. 常见问题速查
| 问题 | 答案要点 |
|---|---|
| Transformer 相比 RNN 的优势? | ① 可并行训练 ② 长距离依赖建模强(self-attention O(1) 距离 vs RNN O(n))③ 位置编码泛化好 |
| 为什么除以 √d_k? | 防止点积过大导致 softmax 饱和、梯度消失 |
| 为什么用多头? | 让模型关注不同表示子空间,捕捉多样化关系 |
| 为什么 LayerNorm 不用 BatchNorm? | 不依赖 batch 大小,适配变长序列 |
| 位置编码为什么用 sin/cos? | 相对位置可线性表示(旋转矩阵)+ 固定不需训练 + 可泛化任意长度 |
| 残差连接作用? | 解决梯度消失,让深层网络能训练 |
| mask 有几种? | padding mask(屏蔽 pad)+ causal mask(自回归屏蔽未来) |
| self-attention vs cross-attention? | 前者同序列内部,后者 Q 来自 decoder / K·V 来自 encoder |
| teacher forcing 是什么? | 训练时用真实目标序列(而非上一步预测)喂给 decoder |
| warmup 作用? | 学习率先升后降,避免初期震荡 |
| 为什么能并行训练? | 训练时整段输入一次过(teacher forcing),不像 RNN 要逐步 |
| BERT 和 GPT 的区别? | BERT=encoder-only 双向(理解/填空),GPT=decoder-only 单向(生成/下一个词预测) |
| 为什么大模型都用 decoder-only? | 训练目标简单统一(下一个词预测)+ 数据利用率高 + 扩展性好 + 生成能力强 |
| 三种架构分别适合什么任务? | encoder-only 理解、encoder-decoder 翻译摘要、decoder-only 生成对话 |
| 什么是下一个词预测? | 给定前面所有词预测下一个词,是 decoder-only 的统一训练目标 |
| 什么是上下文学习(ICL)? | 把指令和示例拼在 prompt 里,不用微调就能做新任务 |
13. 附:关键术语中英对照
| 中文 | 英文 |
|---|---|
| 位置编码 | Positional Encoding |
| 缩放点积注意力 | Scaled Dot-Product Attention |
| 多头注意力 | Multi-Head Attention |
| 前馈网络 | Feed-Forward Network |
| 残差连接 | Residual Connection |
| 层归一化 | Layer Normalization |
| 自注意力 | Self-Attention |
| 交叉注意力 | Cross-Attention |
| 自回归 | Autoregressive |
| 非自回归 | Non-autoregressive |
| 教师强制 | Teacher Forcing |
| 束搜索 | Beam Search |
| 贪心解码 | Greedy Decoding |
| 编码器 | Encoder |
| 解码器 | Decoder |
| 仅编码器 | Encoder-Only |
| 仅解码器 | Decoder-Only |
| 下一个词预测 | Next Token Prediction |
| 掩码语言模型 | Masked Language Model (MLM) |
| 上下文学习 | In-Context Learning (ICL) |
