Ch09 Transformer修订于 二〇二六年七月二日

9.10 动手实现 Self-Attention

9.10.1 PyTorch 实现:缩放点积注意力

import torch
import torch.nn as nn
import torch.nn.functional as F
import math

def scaled_dot_product_attention(Q, K, V, mask=None):
    """
    缩放点积注意力的完整实现

    参数:
        Q: (batch_size, n_heads, seq_len, d_k)  -- 查询
        K: (batch_size, n_heads, seq_len, d_k)  -- 键
        V: (batch_size, n_heads, seq_len, d_v)  -- 值
        mask: (batch_size, 1, seq_len, seq_len) 或 None
              True 的位置表示"不能关注"(会被设为 -inf)

    返回:
        output: (batch_size, n_heads, seq_len, d_v)  -- 注意力输出
        attn_weights: (batch_size, n_heads, seq_len, seq_len)  -- 注意力权重
    """
    d_k = Q.size(-1)

    # 第 1 步:计算注意力分数 (batch, n_heads, seq_len, seq_len)
    scores = torch.matmul(Q, K.transpose(-2, -1))

    # 第 2 步:缩放(除以 √d_k)
    scores = scores / math.sqrt(d_k)

    # 第 3 步:应用掩码(如果有)
    if mask is not None:
        # mask 中 True 的位置设为 -inf,softmax 后变为 0
        scores = scores.masked_fill(mask == 0, float('-inf'))

    # 第 4 步:Softmax 归一化(沿最后一维,即 key 维度)
    attn_weights = F.softmax(scores, dim=-1)

    # 第 5 步:加权求和
    output = torch.matmul(attn_weights, V)

    return output, attn_weights

9.10.2 从头实现 Mini-Transformer 的步骤

完整的 Transformer 解码器块的实现:

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        assert d_model % n_heads == 0
        self.d_model = d_model
        self.n_heads = n_heads
        self.d_k = d_model // n_heads

        # 合并 Q、K、V 投影为一个矩阵乘法(实践中更高效)
        self.W_qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.W_o = nn.Linear(d_model, d_model, bias=False)

    def forward(self, x, mask=None):
        B, N, D = x.shape  # batch, seq_len, d_model

        # 一次性投影 Q、K、V
        qkv = self.W_qkv(x)  # (B, N, 3*D)
        qkv = qkv.reshape(B, N, 3, self.n_heads, self.d_k)
        qkv = qkv.permute(2, 0, 3, 1, 4)  # (3, B, n_heads, N, d_k)
        Q, K, V = qkv[0], qkv[1], qkv[2]

        # 缩放点积注意力
        attn_out, _ = scaled_dot_product_attention(Q, K, V, mask)

        # 拼接多头并投影
        attn_out = attn_out.transpose(1, 2).contiguous()  # (B, N, n_heads, d_k)
        attn_out = attn_out.reshape(B, N, D)  # (B, N, d_model)
        return self.W_o(attn_out)


class FeedForward(nn.Module):
    def __init__(self, d_model, d_ff, activation='gelu'):
        super().__init__()
        self.W1 = nn.Linear(d_model, d_ff, bias=False)
        self.W2 = nn.Linear(d_ff, d_model, bias=False)
        self.activation = nn.GELU() if activation == 'gelu' else nn.ReLU()

    def forward(self, x):
        return self.W2(self.activation(self.W1(x)))


class DecoderBlock(nn.Module):
    def __init__(self, d_model, n_heads, d_ff):
        super().__init__()
        self.attn = MultiHeadAttention(d_model, n_heads)
        self.ffn = FeedForward(d_model, d_ff)
        self.ln1 = nn.RMSNorm(d_model)  # Pre-LN
        self.ln2 = nn.RMSNorm(d_model)

    def forward(self, x, mask=None):
        # Pre-LN: 先归一化,再计算,最后残差
        x = x + self.attn(self.ln1(x), mask)
        x = x + self.ffn(self.ln2(x))
        return x


class MiniTransformer(nn.Module):
    def __init__(self, vocab_size, d_model, n_heads, n_layers, d_ff, max_seq_len):
        super().__init__()
        self.token_embed = nn.Embedding(vocab_size, d_model)
        self.pos_embed = nn.Embedding(max_seq_len, d_model)  # 可学习位置编码
        self.layers = nn.ModuleList([
            DecoderBlock(d_model, n_heads, d_ff) for _ in range(n_layers)
        ])
        self.ln_final = nn.RMSNorm(d_model)
        self.lm_head = nn.Linear(d_model, vocab_size, bias=False)

    def forward(self, x):
        B, N = x.shape
        positions = torch.arange(0, N, device=x.device).unsqueeze(0)
        x = self.token_embed(x) + self.pos_embed(positions)

        # 因果掩码:位置 i 不能看到位置 j > i
        causal_mask = torch.tril(torch.ones(N, N, device=x.device)).unsqueeze(0).unsqueeze(0)

        for layer in self.layers:
            x = layer(x, causal_mask)

        x = self.ln_final(x)
        return self.lm_head(x)

9.10.3 关键调试技巧

(1)注意力权重检查。 前向传播后,验证每行注意力权重之和是否为 1(允许浮点误差)。如果和不是 1,说明 softmax 实现有问题或掩码未正确应用:

assert torch.allclose(attn_weights.sum(dim=-1), torch.ones(1, device=attn_weights.device), atol=1e-6)

(2)因果掩码验证。 确保掩码正确阻止了未来信息的泄露。对于位置 i,所有 j > i 的注意力权重必须为 0:

assert (attn_weights[:, :, 0, :].triu(diagonal=1) == 0).all()

(3)梯度范数监控。 训练初期,每层的梯度范数应该在合理范围(0.001 到 10 之间)。如果某层梯度为 0(梯度消失)或超过 1000(梯度爆炸),检查:

  • 是否使用了 Pre-LN(而非 Post-LN)
  • 残差连接是否正确实现(x + layer(x),而非 x + layer(x) 被额外归一化)
  • 学习率是否过大

(4)NaN 排查。 如果注意力权重或输出出现 NaN:

  • 检查 √d_k 缩放是否正确——遗漏缩放是最常见原因
  • 检查掩码中 -inf 是否被正确处理——softmax(-inf) = 0,但 softmax(NaN) = NaN
  • 检查 embedding 初始化——过大的初始值会导致点积爆炸

(5)位置编码贡献。 验证位置编码没有被 token embedding 淹没。打印 token embedding 和位置编码的 L2 范数,两者应该在同数量级。如果位置编码的范数远小于 token embedding,位置信息将无法有效传递。

(6)数值精度。 对于大模型训练(d_model > 4096),使用 float32 做 softmax 计算(即使其他部分使用 bfloat16)。bfloat16 的精度不足以处理 softmax 中的指数运算:

scores = scores.float()  # 临时提升到 float32
attn_weights = F.softmax(scores, dim=-1).to(Q.dtype)  # 回到原始精度

← 上一节:9.9 2026 Transformer 进化 | 下一节:章首 →

关联4

知识图谱

局部图谱 · 点节点跳转