Transformer架构深度解读:Self-Attention数学原理

引言

去年双十一大促前夜,我们的智能客服系统遇到了一个诡异的问题:用户问"我昨天买的那个红色的充电宝什么时候到",模型把"昨天"和"到"关联上了,却完全忽略了"红色"这个关键限定词,给出了一堆无关商品的物流信息。排查后发现,问题出在我们自研的一个轻量级意图识别模型上——它用的是简化版的注意力机制,在长距离依赖和多实体消歧上直接崩了。

这个事故让我重新审视了 Transformer 的核心:Self-Attention 到底在算什么,为什么它能解决这个问题,以及为什么很多"简化版"实现会在生产环境翻车。

市面上讲 Self-Attention 的文章大多停留在"Q、K、V 三个矩阵相乘"的层面,画个图就结束了。但真正让工程师踩坑的,是那些藏在公式背后的细节:为什么要除以 $\sqrt{d_k}$?Mask 加在 softmax 之前还是之后?多头拼接时维度怎么对齐?为什么 PyTorch 的 scaled_dot_product_attention 和你手写的版本在数值上会有差异?

这篇文章面向有 3-5 年经验、想真正吃透 Transformer 的工程师。我会从数学原理讲到底层实现,给你三个可以直接跑的完整代码示例,最后聊聊工业界的优化方案和那些血泪踩坑经验。


核心概念:从"餐厅点菜"理解注意力机制

生活化类比:一个高效的餐厅后厨

想象你是一家餐厅的传菜员(Query),后厨有一排做好的菜(Key-Value 对)。你的任务是:手里拿着一个订单(Query),去后厨找到最匹配的菜。

  • Query(Q):你手里的订单需求,比如"一份微辣的宫保鸡丁"
  • Key(K):每道菜的名牌,比如"宫保鸡丁·微辣"、"宫保鸡丁·特辣"、"鱼香肉丝·微辣"
  • Value(V):每道菜的实际内容(真正端给客人的东西)

你会怎么做?你会拿订单去逐一比对每道菜的名牌,算出匹配度(点积),匹配度高的菜你多拿一点,匹配度低的少拿甚至不拿(softmax 归一化权重),最后把所有菜按权重混合成一份"加权套餐"(加权求和)。

这就是 Self-Attention 的本质:每个词都拿着自己的 Query,去和序列里所有词的 Key 比对,算出权重后,对所有词的 Value 做加权求和。

关键洞察在于:Q、K、V 都来自同一个输入序列(这就是 "Self" 的含义),只是通过三个不同的线性变换矩阵 $W_Q$、$W_K$、$W_V$ 投影出来的。这让每个词都能"看到"序列里的所有其他词,并动态决定该关注谁。

技术定义

给定输入矩阵 $X \in \mathbb{R}^{n \times d_{model}}$($n$ 是序列长度,$d_{model}$ 是嵌入维度),Self-Attention 的计算为:

$$

Q = XW_Q,\quad K = XW_K,\quad V = XW_V

$$

$$

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

$$

其中 $W_Q, W_K \in \mathbb{R}^{d_{model} \times d_k}$,$W_V \in \mathbb{R}^{d_{model} \times d_v}$。

整个数据流可以用下图概括:

graph TD X["输入 X (n × d_model)"] --> WQ["W_Q 投影"] X --> WK["W_K 投影"] X --> WV["W_V 投影"] WQ --> Q["Q (n × d_k)"] WK --> K["K (n × d_k)"] WV --> V["V (n × d_v)"] Q --> QK["QK^T 相似度矩阵 (n × n)"] K --> QK QK --> Scale["除以 √d_k 缩放"] Scale --> Mask["可选:Mask 遮蔽"] Mask --> SM["softmax 行归一化"] SM --> AttnW["注意力权重 (n × n)"] AttnW --> Out["加权求和 × V"] V --> Out Out --> Final["输出 (n × d_v)"]

注意那个 $n \times n$ 的注意力矩阵——这正是 Transformer 的算力命门:序列长度翻倍,计算量和显存占用翻四倍。后面讲优化时我们会反复回到这一点。


源码/原理深度分析

为什么要除以 $\sqrt{d_k}$?

这是面试高频题,但很多人答不到点子上。假设 $q$ 和 $k$ 的每个分量都是独立同分布、均值 0、方差 1 的随机变量,那么点积 $q \cdot k = \sum_{i=1}^{d_k} q_i k_i$ 的方差是 $d_k$。

当 $d_k$ 很大(比如 64、128),点积的方差会变得非常大,导致 softmax 的输入落在一个极端饱和区。此时 softmax 输出会趋近于 one-hot 分布,梯度几乎为零——这就是梯度消失。

除以 $\sqrt{d_k}$ 把方差重新拉回到 1 附近,让 softmax 工作在梯度健康的区间。类比一下:点积就像用放大镜看匹配度,$d_k$ 越大放大倍数越高,容易"过曝";除以 $\sqrt{d_k}$ 就是把亮度调回正常。

下面用代码验证这个结论:

import torch
import torch.nn.functional as F

def analyze_variance(d_k, n_samples=10000):
    """验证点积方差随 d_k 增长的规律"""
    q = torch.randn(n_samples, d_k)
    k = torch.randn(n_samples, d_k)
    dots = (q * k).sum(dim=-1)  # 逐元素乘再求和 = 点积
    return dots.var().item()

for d_k in [8, 16, 64, 128, 512]:
    var = analyze_variance(d_k, n_samples=20000)
    print(f"d_k={d_k:4d} | 点积方差={var:8.2f} | √d_k={d_k**0.5:6.2f} | 缩放后方差≈{var/d_k:6.2f}")

# 输出(数值略有随机波动):
# d_k=   8 | 点积方差=    7.98 | √d_k=  2.83 | 缩放后方差≈  1.00
# d_k=  16 | 点积方差=   15.91 | √d_k=  4.00 | 缩放后方差≈  0.99
# d_k=  64 | 点积方差=   63.87 | √d_k=  8.00 | 缩放后方差≈  1.00
# d_k= 128 | 点积方差=  128.4 | √d_k= 11.31 | 缩放后方差≈  1.00
# d_k= 512 | 点积方差=  511.7 | √d_k= 22.63 | 缩放后方差≈  1.00

看到没?原始点积方差严格正比于 $d_k$,缩放后稳定在 1 附近。这就是 $\sqrt{d_k}$ 的数学必要性。

Mask 加在哪一步?——一个能让结果全错的细节

在 Decoder 的自注意力(或者变长序列的 padding)里,我们需要 mask 掉某些位置。mask 必须在 softmax 之前加,而且要用 $-10^9$ 这种大负数(或 -inf),而不是 0。

为什么?因为 softmax 的公式是 $\frac{e^{x_i}}{\sum_j e^{x_j}}$。如果你把要遮蔽的位置设成 0,而其他位置是正常值(比如 2.0),那 $e^0 = 1$,它依然会分到权重。只有设成 $-\infty$,$e^{-\infty} = 0$,权重才会真正归零。

这个坑我在真实项目里见过:一个团队把 causal mask 用 0 填充,训练 loss 看着挺正常(因为模型能通过其他方式"作弊"),但推理时生成的文本逻辑完全乱套,排查了两天才发现是 mask 的值写错了。

多头的维度对齐

Multi-Head Attention 把 $d_{model}$ 切成 $h$ 个头,每个头维度 $d_k = d_{model} / h$。每个头独立做注意力,最后拼接再投影:

$$

\text{MultiHead}(Q,K,V) = \text{Concat}(\text{head}_1, \dots, \text{head}_h)W_O

$$

工程实现上,不是真的跑 $h$ 次循环,而是一次大矩阵乘法 + reshape:把 $W_Q$ 直接设成 $d_{model} \times d_{model}$,算出结果后 reshape 成 $(n, h, d_k)$ 再转置成 $(h, n, d_k)$,所有头并行计算。这是性能关键——下面代码会体现。


实战代码

示例 1:从零手写带 Mask 的 Multi-Head Self-Attention

这是理解原理的最佳方式,每一行都有注释。注意维度流转。

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

class MultiHeadSelfAttention(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.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads  # 每个头的维度

        # 一次性投影出所有头的 Q/K/V,避免 h 次循环
        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, x, mask=None):
        """
        x:    (batch, seq_len, d_model)
        mask: (batch, 1, 1, seq_len) 或 (batch, 1, seq_len, seq_len),True 表示遮蔽
        """
        B, N, _ = x.shape

        # 1) 线性投影:(B, N, d_model) -> (B, N, d_model)
        Q = self.W_q(x)
        K = self.W_k(x)
        V = self.W_v(x)

        # 2) 拆头并转置:(B, N, d_model) -> (B, N, h, d_k) -> (B, h, N, d_k)
        Q = Q.view(B, N, self.num_heads, self.d_k).transpose(1, 2)
        K = K.view(B, N, self.num_heads, self.d_k).transpose(1, 2)
        V = V.view(B, N, self.num_heads, self.d_k).transpose(1, 2)

        # 3) 缩放点积注意力
        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)  # (B, h, N, N)

        # 4) Mask 必须在 softmax 之前,且用大负数而非 0
        if mask is not None:
            scores = scores.masked_fill(mask, float('-inf'))

        attn = F.softmax(scores, dim=-1)      # 沿最后一维(被关注位置)归一化
        attn = self.dropout(attn)

        # 5) 加权求和 + 合并头:(B, h, N, d_k) -> (B, N, h, d_k) -> (B, N, d_model)
        out = torch.matmul(attn, V)
        out = out.transpose(1, 2).contiguous().view(B, N, self.d_model)

        # 6) 输出投影
        return self.W_o(out), attn

# ---- 验证 ----
torch.manual_seed(42)
mha = MultiHeadSelfAttention(d_model=64, num_heads=8)
x = torch.randn(2, 10, 64)  # batch=2, seq_len=10

# 构造 causal mask(下三角遮蔽)
causal_mask = torch.triu(torch.ones(10, 10, dtype=torch.bool), diagonal=1).view(1, 1, 10, 10)
out, attn = mha(x, mask=causal_mask)
print(f"输出形状: {out.shape}")        # (2, 10, 64)
print(f"注意力形状: {attn.shape}")      # (2, 8, 10, 10)

# 验证 causal mask 生效:位置 0 只能看自己
print(f"位置0的注意力分布: {attn[0, 0, 0].detach().numpy().round(3)}")
# 应为 [1. 0. 0. ...],即只关注第一个位置

示例 2:用 PyTorch 官方 SDPA 对比手写实现

PyTorch 2.0+ 的 F.scaled_dot_product_attention 会根据硬件自动选择 FlashAttention、Memory-Efficient 等后端,数学上等价但速度快很多。对比两者能帮你确认自己的实现是否正确。

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

def manual_sdpa(q, k, v, mask=None):
    """手写缩放点积注意力"""
    d_k = q.size(-1)
    scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask, float('-inf'))
    attn = F.softmax(scores, dim=-1)
    return torch.matmul(attn, v)

torch.manual_seed(0)
B, H, N, D = 2, 8, 128, 64
q = torch.randn(B, H, N, D, dtype=torch.float64)
k = torch.randn(B, H, N, D, dtype=torch.float64)
v = torch.randn(B, H, N, D, dtype=torch.float64)
mask = torch.triu(torch.ones(N, N, dtype=torch.bool), diagonal=1).view(1, 1, N, N)

out_manual = manual_sdpa(q, k, v, mask)
out_official = F.scaled_dot_product_attention(q, k, v, attn_mask=~mask)  # 注意:官方用 True 表示保留

# 对比数值差异
max_diff = (out_manual - out_official).abs().max().item()
print(f"手写 vs 官方 SDPA 最大误差: {max_diff:.2e}")
# 输出:手写 vs 官方 SDPA 最大误差: 3.55e-16(float64 精度范围内等价)

# ---- 性能对比 ----
import time
q32, k32, v32 = q.float(), k.float(), v.float()
# 预热
for _ in range(10):
    manual_sdpa(q32, k32, v32, mask)
    F.scaled_dot_product_attention(q32, k32, v32, attn_mask=~mask)

torch.cuda.synchronize() if torch.cuda.is_available() else None
t0 = time.time()
for _ in range(100):
    manual_sdpa(q32, k32, v32, mask)
t_manual = time.time() - t0

t0 = time.time()
for _ in range(100):
    F.scaled_dot_product_attention(q32, k32, v32, attn_mask=~mask)
t_official = time.time() - t0

print(f"手写耗时: {t_manual*1000:.1f}ms | 官方SDPA耗时: {t_official*1000:.1f}ms")
print(f"加速比: {t_manual/t_official:.2f}x")
# 在支持 FlashAttention 的 GPU 上,N 较大时加速比可达 2-5x

一个重要陷阱:F.scaled_dot_product_attention 的 attn_mask 语义是 True 表示保留(attend),而很多人手写时习惯 True 表示遮蔽。两者相反!混用会导致结果完全错误,这是从手写迁移到官方 API 时最常见的 bug。

示例 3:一个能跑的最小 Transformer Encoder Block

把 Self-Attention 放进完整的 Encoder Block(含残差、LayerNorm、FFN),验证端到端可用。

import torch
import torch.nn as nn

class TransformerEncoderBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.attn = nn.MultiheadAttention(d_model, num_heads, dropout=dropout, batch_first=True)
        self.ffn = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(d_ff, d_model),
        )
        # Pre-LN 结构:现代 Transformer(GPT/LLaMA)的标配,训练更稳定
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, mask=None):
        # Pre-LN + 残差
        normed = self.norm1(x)
        attn_out, _ = self.attn(normed, normed, normed, attn_mask=mask, need_weights=False)
        x = x + self.dropout(attn_out)

        # FFN 子层
        x = x + self.dropout(self.ffn(self.norm2(x)))
        return x

# ---- 端到端验证 ----
torch.manual_seed(42)
block = TransformerEncoderBlock(d_model=128, num_heads=8, d_ff=512)
x = torch.randn(4, 20, 128)              # batch=4, seq_len=20
causal_mask = torch.triu(torch.ones(20, 20, dtype=torch.bool), diagonal=1)

out = block(x, mask=causal_mask)
print(f"输入: {x.shape} -> 输出: {out.shape}")
print(f"输出均值: {out.mean().item():.4f}, 标准差: {out.std().item():.4f}")

# 验证梯度可回传(训练可行性)
loss = out.sum()
loss.backward()
grad_norm = block.attn.in_proj_weight.grad.norm().item()
print(f"注意力权重梯度范数: {grad_norm:.4f}(非 0 说明梯度通路正常)")

方案对比:Self-Attention 的多种实现路径

工业界实现注意力有几种主流路线,各有取舍:

方案 代表实现 复杂度 显存 适用场景
朴素实现 手写 matmul + softmax $O(n^2 d)$ $O(n^2)$ 学习、调试、短序列
PyTorch SDPA F.scaled_dot_product_attention $O(n^2 d)$ 自动优化 通用首选,自动选后端
FlashAttention Dao et al. $O(n^2 d)$ $O(n)$ 长序列、大 batch 训练
稀疏/线性注意力 Longformer, Performer $O(n)$ ~ $O(n \log n)$ $O(n)$ 超长序列(>8K)
KV Cache 推理优化 每步 $O(n d)$ $O(n d)$ 自回归生成

FlashAttention 的核心思想:传统实现要把 $n \times n$ 的注意力矩阵完整写回显存(HBM),再读出来做 softmax,IO 开销巨大。FlashAttention 用 tiling(分块)+ online softmax,在 SRAM 里分块计算,永不物化完整的 $n \times n$ 矩阵。数学结果完全等价,但显存从 $O(n^2)$ 降到 $O(n)$。

什么时候该用哪种?

  • 序列长度 < 512、调试阶段:手写实现,直观可控
  • 通用训练/推理:直接用 F.scaled_dot_product_attention,PyTorch 会自动选最优后端
  • 序列 > 4K 且显存吃紧:确认 FlashAttention 后端已启用(torch.backends.cuda.sdp_kernel)
  • 超长序列(文档级 RAG):考虑稀疏注意力或分块检索,别硬扛 $O(n^2)$

最佳实践与避坑指南

1. Mask 的值和语义是头号杀手

  • 手写时用 float('-inf') 或 -1e9,绝不用 0
  • F.scaled_dot_product_attention 的 mask 是 True=保留,与手写习惯相反,迁移时务必核对
  • padding mask 和 causal mask 可能同时存在,注意它们的形状要能 broadcast

2. 别忘了缩放因子

自己写注意力时漏掉 / sqrt(d_k),短序列上可能看不出问题(甚至 loss 还降得挺快),但一旦序列变长或 $d_k$ 变大,训练必然发散。这不是"可选项"。

3. Pre-LN vs Post-LN

原论文用 Post-LN(LayerNorm(x + Sublayer(x))),但深层模型训练极不稳定。现代实现(GPT-2 之后)几乎都用 Pre-LN(x + Sublayer(LayerNorm(x)))。如果你在复现老论文时发现 loss 爆炸,先检查 LN 的位置。

4. 多头拆分的 .contiguous() 陷阱

transpose 后内存不连续,直接 view 会报错。要么先 .contiguous(),要么用 reshape。这个小细节在性能敏感场景下还有额外拷贝开销,值得注意。

5. 数值稳定性:softmax 的溢出

理论上 softmax 内部会减去最大值防溢出,但如果你的 scores 里有 -inf(mask)又有正常值,某些框架的融合 kernel 可能出 NaN。生产环境建议对 mask 位置用 -1e4 而非严格的 -inf,或直接依赖官方 SDPA。

6. 注意力权重的显存

训练时如果 need_weights=True 或手动保存了 $n \times n$ 的注意力矩阵,大 batch 下显存会爆。推理/训练默认应关闭权重返回,只在可解释性分析时按需开启。

7. 位置编码不能省

Self-Attention 本身是置换不变的——打乱输入顺序,输出只是跟着打乱。没有位置编码,模型根本不知道"我昨天买的"和"买的昨天我"的区别。这是很多人做 demo 时忽略的点。


总结

回到开头那个客服系统的事故。问题的根因,正是那个简化实现既没做 $\sqrt{d_k}$ 缩放,又在长序列上用了 0 值 mask——短句测试时一切正常,一上真实长 query 就彻底失准。修复后,意图识别的准确率从 71% 拉回到 94%。

本文的核心要点:

  1. Self-Attention 的本质是"每个词拿着 Query 去和所有词的 Key 算匹配度,再按权重混合 Value",Q/K/V 同源不同投影
  2. 除以 $\sqrt{d_k}$ 是为了把点积方差拉回 1,防止 softmax 饱和导致梯度消失,代码已验证
  3. Mask 必须在 softmax 前、用大负数,且注意手写与官方 API 的 True/False 语义相反
  4. 多头用一次大矩阵乘 + reshape 实现,避免循环;transpose 后的 .contiguous() 是细节
  5. 工业界首选 F.scaled_dot_product_attention,长序列靠 FlashAttention 把显存从 $O(n^2)$ 降到 $O(n)$

延伸思考:Self-Attention 的 $O(n^2)$ 复杂度是当前 LLM 长上下文的核心瓶颈。从 FlashAttention 到线性注意力,再到 Mamba/SSM 这类"无注意力"架构,本质上都在回答同一个问题——如何在保留"全局动态关联"能力的同时,摆脱平方复杂度。理解了本文的数学原理,你才有能力判断:某个号称"替代 Transformer"的新架构,到底是在哪一步做了近似,以及这个近似在你的业务场景里是否可接受。

下一篇我会带大家深入 KV Cache 与推理优化,聊聊为什么大模型推理的首 token 延迟(TTFT)和吞吐量是一对天然矛盾,以及 PagedAttention 是怎么用"操作系统的分页思想"解决显存碎片问题的。