Jerry's Blog

Back

返回专题总目录 · 上一讲:Q / K / V 直觉

01 > 02 > [03] > 04 > 05 > 06 | 07 > 08 > 09 > 10 > 11 > 12

“如果你不能用一个 3 词的句子手推一遍 Attention,那你还没有真正理解它。“


这一讲要回答什么#

上一讲我们用”查资料”的类比理解了 Q / K / V 的角色。但类比终究是类比——模型真正跑的是矩阵乘法和 softmax。

这一讲只回答一个问题:给定一个短句,Attention 从输入到输出,数学上每一步到底是怎么算的?

目标是把下面这条公式链一次讲完整:

Q=XWQ,K=XWK,V=XWVQ = XW^Q,\quad K = XW^K,\quad V = XW^V Attention(Q,K,V)=softmax ⁣(QKdk)V\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V

我们不会只给公式——我们会用一个 3 词的短句,手推每一步的维度和数值。


先把维度搞清楚#

这是所有初学者最容易卡住的地方,所以我们先把维度全部写明白。

假设输入序列有 n=3n = 3 个词,每个词的嵌入维度 dmodel=4d_{\text{model}} = 4,注意力头的维度 dk=dv=3d_k = d_v = 3

交互演示:点击查看每个矩阵的维度
点击下方的矩阵名称,查看它的形状、含义和具体数值

用一张表总结所有维度:

矩阵形状含义
XX(n,dmodel)=(3,4)(n, d_{\text{model}}) = (3, 4)输入序列,3 个词各 4 维嵌入
WQW^Q(dmodel,dk)=(4,3)(d_{\text{model}}, d_k) = (4, 3)Query 投影矩阵
WKW^K(dmodel,dk)=(4,3)(d_{\text{model}}, d_k) = (4, 3)Key 投影矩阵
WVW^V(dmodel,dv)=(4,3)(d_{\text{model}}, d_v) = (4, 3)Value 投影矩阵
QQ(n,dk)=(3,3)(n, d_k) = (3, 3)Query 矩阵
KK(n,dk)=(3,3)(n, d_k) = (3, 3)Key 矩阵
VV(n,dv)=(3,3)(n, d_v) = (3, 3)Value 矩阵
QKQK^\top(n,n)=(3,3)(n, n) = (3, 3)原始注意力分数矩阵
α\alpha(n,n)=(3,3)(n, n) = (3, 3)softmax 后的注意力权重矩阵
Output(n,dv)=(3,3)(n, d_v) = (3, 3)融合上下文后的输出序列

维度的关键规律:不管序列多长,QKQK^\top 永远是 (n,n)(n, n)——这就是 Attention 时间复杂度 O(n2)O(n^2) 的来源。


第一步:输入矩阵 XX#

假设我们的句子是 [猫, 坐, 地毯],每个词已经通过 Embedding 层变成了 4 维向量:

X=[1.00.50.20.80.30.61.20.10.70.90.40.5]shape: (3,4)X = \begin{bmatrix} 1.0 & 0.5 & -0.2 & 0.8 \\ 0.3 & -0.6 & 1.2 & 0.1 \\ 0.7 & 0.9 & 0.4 & -0.5 \end{bmatrix} \quad \text{shape: } (3, 4)
  • 第 1 行:x1\mathbf{x}_1 =“猫”的嵌入
  • 第 2 行:x2\mathbf{x}_2 =“坐”的嵌入
  • 第 3 行:x3\mathbf{x}_3 =“地毯”的嵌入

第二步:线性投影得到 Q、K、V#

接下来,XX 分别乘以三个权重矩阵:

Q=XWQ,K=XWK,V=XWVQ = XW^Q, \quad K = XW^K, \quad V = XW^V
步进演示:从 X 到 Q / K / V 的投影
点击 Q / K / V 查看完整的矩阵乘法过程(支持键盘 ←→)
0 / 5

投影之后的结果(使用演示中的示例权重):

Q=[0.740.310.891.260.420.170.180.870.63],K=[0.620.850.130.511.100.780.940.220.41],V=[0.410.250.930.870.620.310.180.740.56]Q = \begin{bmatrix} 0.74 & 0.31 & 0.89 \\ 1.26 & -0.42 & -0.17 \\ 0.18 & 0.87 & 0.63 \end{bmatrix}, \quad K = \begin{bmatrix} 0.62 & 0.85 & -0.13 \\ -0.51 & 1.10 & 0.78 \\ 0.94 & 0.22 & 0.41 \end{bmatrix}, \quad V = \begin{bmatrix} 0.41 & -0.25 & 0.93 \\ 0.87 & 0.62 & -0.31 \\ -0.18 & 0.74 & 0.56 \end{bmatrix}

对应的 PyTorch 代码

import torch
import torch.nn as nn

# 参数
n = 3          # 序列长度
d_model = 4    # 嵌入维度
d_k = 3        # 注意力头维度

# 输入:3 个词,每个 4 维
X = torch.tensor([
    [1.0, 0.5, -0.2, 0.8],   # 猫
    [0.3, -0.6, 1.2, 0.1],   # 坐
    [0.7, 0.9, 0.4, -0.5],   # 地毯
])  # shape: (3, 4)

# 三个投影矩阵(实际训练中是可学习参数)
W_Q = nn.Linear(d_model, d_k, bias=False)
W_K = nn.Linear(d_model, d_k, bias=False)
W_V = nn.Linear(d_model, d_k, bias=False)

# 投影
Q = W_Q(X)    # (3, 4) @ (4, 3) → (3, 3)
K = W_K(X)    # (3, 4) @ (4, 3) → (3, 3)
V = W_V(X)    # (3, 4) @ (4, 3) → (3, 3)

print(f"Q shape: {Q.shape}")  # torch.Size([3, 3])
print(f"K shape: {K.shape}")  # torch.Size([3, 3])
print(f"V shape: {V.shape}")  # torch.Size([3, 3])
python

第三步:计算注意力分数 QKQK^\top#

这是 Attention 最核心的一步——让每个 Query 和所有 Key 做点积,得到一个”分数矩阵”

S=QKshape: (n,n)=(3,3)S = QK^\top \quad \text{shape: } (n, n) = (3, 3)

矩阵中第 ii 行第 jj 列的值 Sij=qikjS_{ij} = \mathbf{q}_i \cdot \mathbf{k}_j,表示位置 ii 对位置 jj 的原始相关性分数

交互演示:QKT — 每个分数是怎么来的
点击分数矩阵中的任意格子,查看这个分数的点积计算过程
← 点击矩阵格子查看计算细节

为什么是 QKQK^\top 而不是 QKQK

QQ 的形状是 (3,3)(3, 3)KK 的形状也是 (3,3)(3, 3)。如果直接做 QKQK,那是 (3,3)×(3,3)(3, 3) \times (3, 3)——虽然维度上可行,但含义不对。

我们需要的是让 QQ 的每一行(一个 Query)和 KK 的每一行(一个 Key)做点积。转置 KK 之后:

QK=(3,3)×(3,3)=(3,3dk)×(3dk,3)=(3,3)QK^\top = (3, 3) \times (3, 3)^\top = (3, \underbrace{3}_{d_k}) \times (\underbrace{3}_{d_k}, 3) = (3, 3)

结果中第 ii 行第 jj 列 = qi\mathbf{q}_ikj\mathbf{k}_j 的点积。这正是我们想要的。

对应的 PyTorch 代码

# 计算原始注意力分数
scores = Q @ K.transpose(-2, -1)  # (3, 3) @ (3, 3)^T → (3, 3)
print(scores)
# 每个 scores[i][j] = Q 第 i 行 · K 第 j 行 = q_i · k_j
python

第四步:除以 dk\sqrt{d_k} ——缩放#

Sscaled=QKdkS_{\text{scaled}} = \frac{QK^\top}{\sqrt{d_k}}

为什么要除以 dk\sqrt{d_k}?这不是可选的 trick,而是必要的数值稳定措施

直觉解释:当 dkd_k 很大时,qk=i=1dkqiki\mathbf{q} \cdot \mathbf{k} = \sum_{i=1}^{d_k} q_i k_i 的结果也会很大(因为更多项求和)。大的分数值传入 softmax 后会导致梯度消失——输出接近 one-hot,模型很难学到”分散注意力”的模式。

交互演示:为什么需要 √d_k 缩放
拖动滑块改变 d_k 的大小,观察有无缩放时 softmax 输出的差异

数学上:假设 qi,kiq_i, k_i 独立、均值为 0、方差为 1,那么:

Var(qk)=i=1dkVar(qiki)=dk\text{Var}(\mathbf{q} \cdot \mathbf{k}) = \sum_{i=1}^{d_k} \text{Var}(q_i k_i) = d_k

除以 dk\sqrt{d_k} 后方差变为 1,分数不会因为维度增大而膨胀。

对应的 PyTorch 代码

import math

d_k = Q.size(-1)  # = 3
scores_scaled = scores / math.sqrt(d_k)
# 等价于:scores / (3 ** 0.5) ≈ scores / 1.732
python

第五步:Softmax → 注意力权重#

α=softmax ⁣(QKdk)\alpha = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)

Softmax 对分数矩阵的每一行独立操作,将分数转化为概率分布:

αij=eSijk=1neSik\alpha_{ij} = \frac{e^{S_{ij}}}{\sum_{k=1}^{n} e^{S_{ik}}}

每一行加起来等于 1——这就是”注意力权重”的含义。

交互演示:Softmax 逐行转化
点击行号,查看该行从原始分数到 softmax 权重的转化过程
缩放后分数
注意力权重 α

对应的 PyTorch 代码

import torch.nn.functional as F

# softmax 在最后一个维度上(即每一行独立做 softmax)
attn_weights = F.softmax(scores_scaled, dim=-1)  # (3, 3)

print(attn_weights)
# 每行之和 == 1
print(attn_weights.sum(dim=-1))  # tensor([1., 1., 1.])
python

第六步:加权求和 → 输出#

Output=αV\text{Output} = \alpha V

用注意力权重矩阵 α\alpha 左乘 Value 矩阵 VV,得到最终输出:

(n,n)×(n,dv)=(3,3)×(3,3)=(3,3)(n, n) \times (n, d_v) = (3, 3) \times (3, 3) = (3, 3)

输出的第 ii是所有 Value 向量按第 ii 行权重的加权平均:

oi=j=1nαijvj\mathbf{o}_i = \sum_{j=1}^{n} \alpha_{ij} \mathbf{v}_j
步进演示:从权重到输出的完整计算
逐步查看每个位置如何用注意力权重聚合 Value,得到最终输出(支持键盘 ←→)
0 / 4

对应的 PyTorch 代码

# 加权求和
output = attn_weights @ V  # (3, 3) @ (3, 3) → (3, 3)
print(f"Output shape: {output.shape}")  # torch.Size([3, 3])

# output[0] = α[0,0]*V[0] + α[0,1]*V[1] + α[0,2]*V[2]
# "猫"位置的输出 = 融合了"猫""坐""地毯"信息的新表示
python

把所有步骤串起来:完整的 Attention 公式#

Attention(Q,K,V)=softmax ⁣(QKdk)V\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V
全流程动画:从 X 到 Output 的完整 Attention
自动播放 / 手动步进,看整个计算管线如何流动(支持键盘 ←→)
0 / 6

完整的 PyTorch 实现#

把上面所有步骤合在一起,一个完整的单头注意力就是这么简短:

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

class SingleHeadAttention(nn.Module):
    """单头 Scaled Dot-Product Attention"""

    def __init__(self, d_model: int, d_k: int):
        super().__init__()
        self.d_k = d_k
        self.W_Q = nn.Linear(d_model, d_k, bias=False)
        self.W_K = nn.Linear(d_model, d_k, bias=False)
        self.W_V = nn.Linear(d_model, d_k, bias=False)

    def forward(self, X: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        """
        Args:
            X: (n, d_model) — 输入序列
        Returns:
            output: (n, d_k)  — 融合上下文后的表示
            attn:   (n, n)    — 注意力权重矩阵
        """
        Q = self.W_Q(X)                              # (n, d_k)
        K = self.W_K(X)                              # (n, d_k)
        V = self.W_V(X)                              # (n, d_k)

        scores = Q @ K.transpose(-2, -1)             # (n, n)
        scores_scaled = scores / math.sqrt(self.d_k) # (n, n)
        attn = F.softmax(scores_scaled, dim=-1)      # (n, n)
        output = attn @ V                            # (n, d_k)

        return output, attn

# ---- 使用 ----
attn_layer = SingleHeadAttention(d_model=4, d_k=3)

X = torch.tensor([
    [1.0, 0.5, -0.2, 0.8],
    [0.3, -0.6, 1.2, 0.1],
    [0.7, 0.9, 0.4, -0.5],
])

output, weights = attn_layer(X)
print(f"Output:\n{output}")
print(f"\nAttention weights:\n{weights}")
print(f"Rows sum to 1: {weights.sum(dim=-1)}")
python

运行这段代码,你会得到:

  • output: (3,3)(3, 3) 矩阵——每个词融合上下文后的新表示
  • weights: (3,3)(3, 3) 矩阵——每个词对其他词的注意力分配

逐行对照:公式 ↔ 代码#

公式代码维度变化
Q=XWQQ = XW^QQ = self.W_Q(X)(n,dm)(n,dk)(n, d_m) \to (n, d_k)
K=XWKK = XW^KK = self.W_K(X)(n,dm)(n,dk)(n, d_m) \to (n, d_k)
V=XWVV = XW^VV = self.W_V(X)(n,dm)(n,dv)(n, d_m) \to (n, d_v)
QKQK^\topQ @ K.transpose(-2, -1)(n,dk)×(dk,n)(n,n)(n, d_k) \times (d_k, n) \to (n, n)
/dk/ \sqrt{d_k}/ math.sqrt(self.d_k)(n,n)(n, n) 不变
softmax()\text{softmax}(\cdot)F.softmax(…, dim=-1)(n,n)(n, n) 每行归一化
αV\alpha Vattn @ V(n,n)×(n,dv)(n,dv)(n, n) \times (n, d_v) \to (n, d_v)

四个容易搞混的问题#

1. 为什么 QKQK^\top 是”词与词的关系矩阵”?#

Sij=qikjS_{ij} = \mathbf{q}_i \cdot \mathbf{k}_j 是 Query ii 和 Key jj点积。点积衡量两个向量的方向相似性——方向越接近,值越大。

所以 SijS_{ij} 大 ⟹ 位置 ii 认为位置 jj 和自己”相关”——注意这里的”相关”是模型通过训练学到的,不是固定的语言学标签。

2. 为什么 softmax 要对每一行做?#

因为我们在为每个 Query 位置生成一个完整的注意力分布。第 ii 行表示”位置 ii 如何把注意力分配给所有位置”。

如果对列做 softmax,含义就变了——变成”位置 jj 被各个 Query 竞争的程度”,这不是我们想要的。

3. 为什么最后输出还是和输入一样的”一个词一个向量”?#

因为 αV\alpha V 的结果是 (n,dv)(n, d_v)——仍然是 nn 个向量。但每个向量的含义变了:

  • 输入 xi\mathbf{x}_i:词 ii 的静态嵌入,不知道上下文
  • 输出 oi\mathbf{o}_i:词 ii 融合了整个序列上下文后的动态表示

形式相同,但信息量完全不同。

4. dkd_kdmodeld_{\text{model}} 什么关系?#

在多头注意力中,通常 dk=dmodel/hd_k = d_{\text{model}} / h,其中 hh 是头数。比如 dmodel=512,h=8d_{\text{model}} = 512, h = 8 时,dk=64d_k = 64

但在单头情况下,dkd_k 可以自由设定。关键约束只有一个:dkd_k 越大,QKQK^\top 的方差越大,所以必须除以 dk\sqrt{d_k}


一张图总结完整流程#

XWQQXWKKXWVVQKdksoftmaxα×VOutput\boxed{ X \xrightarrow{W^Q} Q \quad X \xrightarrow{W^K} K \quad X \xrightarrow{W^V} V \quad \longrightarrow \quad \frac{QK^\top}{\sqrt{d_k}} \xrightarrow{\text{softmax}} \alpha \xrightarrow{\times V} \text{Output} }

每一步的形状变化:

(3,4)(3,3)(3,3)(3,3)(3,3)(3,3)(3,3)(3,4) \to (3,3) \to (3,3) \to (3,3) \to (3,3) \to (3,3) \to (3,3)

试着想一想#

  1. 如果把 dk\sqrt{d_k} 换成 dkd_k(不取根号),softmax 的输出会有什么变化?
  2. QKQK^\top 的结果中,对角线 SiiS_{ii} 表示什么?它一定最大吗?
  3. 如果 n=1000n = 1000QKQK^\top 有多少个元素?这就是为什么长序列计算量大的原因。
  4. 修改上面的 SingleHeadAttention 类,给它加一个 causal mask 参数,让位置 ii 只能看到 jij \leq i 的位置。(提示:在 softmax 之前把未来位置的分数设为 -\infty。)
  5. 运行代码,比较 output[0]V[0]——输出和”猫”自己的 Value 差多少?这个差距就是上下文信息的融合效果。

下一讲#

下一讲我们深入 QKQK^\top,回答一个更本质的问题:

为什么偏偏是点积来衡量词与词的关系?为什么不是别的形式?


Attention 03:单头注意力的完整计算流程
https://jerry609.github.io/blog/attention-03-single-head-math
Author Jerry
Published at March 16, 2026
Comment seems to stuck. Try to refresh?✨