Transformer

基于论文《Attention Is All You Need》(Vaswani et al., 2017)

配套代码:transformer_pytorch.py(可运行的 PyTorch 实现 + 序列反转演示任务)

1. 核心组件速览

Transformer 由这些核心组件堆叠而成:

  1. 位置编码(Positional Encoding)------ 给模型"顺序"信息
  2. 缩放点积注意力(Scaled Dot-Product Attention)------ 注意力计算的最小单元
  3. 多头注意力(Multi-Head Attention)------ 多组注意力并行
  4. 前馈网络(Feed-Forward Network)------ 每个位置的独立变换
  5. 残差连接 + 层归一化(Residual + LayerNorm)------ 让深层网络能训练
  6. 编码器 / 解码器(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,为什么需要它

pePositional Encoding(位置编码),给模型"告诉它每个词在第几位"的机制。

Transformer 没有 RNN/CNN,self-attention 是"并行看所有位置"的------它本身对词的先后顺序完全无感知。需要把"这是第 1 个词、第 2 个词..."的信息塞进去,否则"我爱你"和"你爱我"对模型来说没区别。

3.2. max_lend_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⁡ ( pos10000 2i/ dmodel ) ,PE(pos,2i+1)=cos⁡ ( pos10000 2i/ dmodel ) 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) 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)= cos⁡(kω) sin⁡(kω) −sin⁡(kω) cos⁡(kω) ⏟ 只含k,不含pos ⋅PE(pos)PE(pos+k) = \underbrace{\begin{bmatrix} \cos(k\omega) & \sin(k\omega) \\ -\sin(k\omega) & \cos(k\omega) \end{bmatrix}}_{只含 k,不含 pos} \cdot PE(pos) 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(R(m)x_q)^T (R(n)x_k) = x_q^T R(n-m) x_k (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⋅ln⁡aa^b = e^{b \cdot \ln a} ab=eb⋅lna

1100002i/d =10000−2i/d=e(−2i/d)⋅ln⁡10000=e2i⋅(−ln⁡10000/d) \frac{1}{10000^{2i/d}} = 10000^{-2i/d} = e^{(-2i/d)\cdot\ln 10000} = e^{2i \cdot (-\ln 10000 / d)} 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 ( QKT dk ) V\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V 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]

关键点:

  1. 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)。
  2. .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) 输出=x+F(x)

  • x 是输入,F(x) 是子层输出,两者相加
  • 直觉 :给信息留一条"抄近路"的通道。即使 F(x) 学得不好,原始 x 还在,信息不丢
  • 核心作用 :解决梯度消失。深层网络梯度层层衰减,残差的 x + F(x) 让梯度能"直达"流回,这是深层网络能训练的关键(来自 ResNet 2015)

7.2. LayerNorm(层归一化)

核心公式: x^= x−μ σ2+ϵ \hat{x} = \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} 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 = 2Transformer 类默认是 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-normnorm(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 多一个交叉注意力,共三个子层:

  1. 掩码自注意力:causal mask,只能看"当前及之前"的位置(自回归)
  2. 交叉注意力:Q 来自 decoder,K/V 来自 encoder 输出------这是 decoder "读取"输入信息的地方
  3. 前馈网络

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 的"隐性知识",都发生在你看不到的地方):

  1. model(...) 为什么等于调 forwardnn.Module 内部定义了一个 __call__,它会自动转调 self.forward(...)。所以 model(src, tgt_input, ...) 这一行,实际执行的就是上面 Transformer.forward 里的逻辑。
  2. enc_out 只算一次forwardencode 一次得到 enc_out,之后 2 层 decoder 的每一层都复用这同一份 enc_out(不是每层重新 encode)。
  3. 每层的 self_attn / cross_attn / ffn / norm 都是独立的 nn.Module :因为写在 self.xxx = ... 里,被 nn.Module 自动登记,所以 model.parameters() 能拿到它们、loss.backward() 能更新它们(参数数量见 9.3)。
  4. nn.ModuleListfor 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 L_{enc} = 4(d^2 + d) + (d \cdot d_{ff} + d_{ff}) + (d_{ff} \cdot d + d) + 4d Lenc=4(d2+d)+(d⋅dff+dff)+(dff⋅d+d)+4d

Ldec = Lenc +4(d2+d)+2d L_{dec} = L_{enc} + 4(d^2 + d) + 2d Ldec=Lenc+4(d2+d)+2d

总参数=2⋅V⋅d+N⋅ Lenc +N⋅ Ldec +(d⋅V+V)\text{总参数} = 2 \cdot V \cdot d + N \cdot L_{enc} + N \cdot L_{dec} + (d \cdot V + V) 总参数=2⋅V⋅d+N⋅Lenc+N⋅Ldec+(d⋅V+V)

代入 V=32, d=64, d_ff=128, N=2,即得 173600。

注意:pos_enc 里的位置编码 peregister_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,原因是:

  1. 训练目标简单统一------"下一个词预测"一个目标通吃,不需要像 BERT 那样设计掩码策略,也不需要像 encoder-decoder 那样准备平行语料
  2. 数据利用率极高------任意文本都是训练数据,可以塞进海量数据
  3. 扩展性好(Scaling Law)------架构简单,参数堆上去效果稳定提升,这是大模型"大力出奇迹"的基础
  4. 生成能力强------天然就是"生成下一个词",正好是对话、补全、写代码这些场景需要的
  5. 支持上下文学习(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)
相关推荐
逸Y 仙X1 小时前
MCP(模型控制协议)完全指南:从核心概念到实战开发
python·大模型·llm·ai编程·mcp
DigitalOcean2 小时前
DigitalOcean 推理路由器新增缓存感知能力:为什么最便宜的模型不一定最省钱
llm
众人皆醒我独醉2 小时前
调和循环:kubectl apply 之后发生了什么
面试·llm·gpu
KAIWEILIUCC2 小时前
Ray:高性能、易扩展的Python分布式计算框架
分布式·llm
众人皆醒我独醉2 小时前
从 Spec 到资源:Predictor/Transformer/Explainer 如何变成 Deployment
面试·llm·gpu
tachibana23 小时前
RAGAS 指标解读
数据库·人工智能·算法·机器学习·架构·大模型·llm
a187927218313 小时前
从一条直线到大模型输出一个token(五):Transformer Block 全景与层归一化
ai·大模型·llm·transformer·layernorm·deepseek·层归一化
谢白羽3 小时前
vLLM-Omni 部署 IndexTTS 2.5
llm·agent·tts·vllm·大模型部署
Komorebi_99994 小时前
RAG23-RAG28
llm·rag