5. 注意力:模型怎样看上下文
注意力是 Transformer 里最重要的结构。它解决的问题是:
当前位置在理解这个词时,应该参考前面哪些位置?
例如读到“法国的首都是”时,模型需要把“法国”与“首都”联系起来;读到“它”时,需要知道“它”指代前文的哪个对象。注意力就是这种信息查找机制。
用查字典理解 Q、K、V
每个位置的向量会投影成三组:
| 名称 | 含义 | 直觉 |
|---|---|---|
| Query(Q) | 当前词元想找什么 | 我拿着一个查询去匹配 |
| Key(K) | 每个词元提供什么标签 | 字典里每个条目的索引 |
| Value(V) | 每个词元实际携带的内容 | 条目对应的解释 |
注意力先用 Q 和所有 K 计算相似度,得到一组权重,再用权重对 V 加权求和。
这不是严格的“字典查询”,但足够帮助你理解为什么需要三组不同的向量,而不是直接拿原始向量互相比。
计算步骤
对一个头、一个序列,核心公式是:
scores = Q Kᵀ / √D
scores = 掩码后的 scores
weights = softmax(scores)
输出 = weights V2
3
4
其中 D 是每个头的维度。除以 √D 是为了避免维度变大后点积过大,让 softmax 过于极端。
nanochat 的实现还有几个细节:
- Q 和 K 先经过旋转位置编码(RoPE)。
- 然后做 QK Norm,把每个头的 Q/K 向量归一化。
- 再分别乘以 1.2,让注意力分布更集中。
- 实际计算由 Flash Attention 3 或 PyTorch SDPA 完成。
nanochat/gpt.py第 84–128 行↗多头注意力
如果只做一组 Q/K/V,模型只能学习一种“关注方式”。多头注意力让模型并行学习多组关注方式:
C = 1280,D = 128,则头数 H = 10每个头独立计算注意力,最后把 10 个头的输出拼接起来,再投影回 C 维。
可以把每个头理解成一种不同的观察角度:有的头可能关注相邻词,有的头可能关注句法关系,有的头可能关注重复出现的名字。模型不会事先规定每个头做什么,这些角色是训练中自己形成的。
形状变化
假设:
B = 1(一条样本)
T = 4(四个词元)
C = 512(隐藏维度)
H = 4(四个查询头)
D = 128(每个头的维度)2
3
4
5
训练时的形状
输入 x: (B, T, C) = (1, 4, 512)
Q: (B, T, H, D) = (1, 4, 4, 128)
K、V: (B, T, H, D) = (1, 4, 4, 128)
每个头的分数: (B, H, T, T) = (1, 4, 4, 4)
注意力输出: (B, T, H, D) = (1, 4, 4, 128)
投影回 C: (B, T, C) = (1, 4, 512)2
3
4
5
6
T × T 的分数矩阵表示“每个当前位置对每个历史位置的关注程度”。序列越长,这个矩阵越大,计算量也增长得很快。
因果掩码
训练时,整段文本一次性送进模型。如果不加限制,位置 0 就能看到位置 3 的词元,等于提前偷看答案。
因果掩码把未来位置遮住:
被看的位置
0 1 2 3
当前位置 0 ✓ ✗ ✗ ✗
1 ✓ ✓ ✗ ✗
2 ✓ ✓ ✓ ✗
3 ✓ ✓ ✓ ✓2
3
4
5
6
对角线及左下方可见,右上方不可见。位置 t 只能关注 0..t,不能关注 t+1 及之后。
注意:训练时 inputs 仍然包含后面的词元,因为它们分别是各自位置的输入。掩码保证的是“预测第 t 个位置时,不能读取第 t+1 个位置的信息”。目标右移一位,配合因果掩码,才是完整的“预测下一个词元”。
滑动窗口
完整注意力的计算量随序列长度平方增长。nanochat 对部分层使用滑动窗口:每个位置只回看前面固定数量的词元。
读滑动窗口配置nanochat/gpt.py第 287–314 行↗window_pattern 是一个由 S 和 L 组成的字符串:
S:短窗口,约为上下文长度的四分之一。代码会把窗口大小对齐到 128 的倍数。L:完整上下文。
模式会在层之间重复。例如 SSSL 表示前三层使用短窗口,第四层使用完整上下文,然后继续重复。最后一层始终使用完整上下文。
短窗口层看得少、算得快;完整上下文层负责整合远距离信息。这是一种在效果和速度之间折中的设计。
GQA:减少键值缓存
GQA(Grouped-Query Attention)让多个 Query 头共享一组 Key 和 Value 头。
查询头: Q0 Q1 Q2 Q3
键值头: K0 K1
↑ ↑
Q0,Q1 Q2,Q32
3
4
这样做不会减少 Query 的头数,但会显著减少 K/V 的参数量和推理时需要保存的键值缓存。nanochat 的模型代码支持 GQA;默认训练脚本把 n_kv_head 设为和 n_head 相同,因此默认配置没有启用分组共享。理解这个开关,对读引擎和显存计算很重要。
nanochat/gpt.py第 67–90 行↗Flash Attention 与回退路径
nanochat 不直接写注意力矩阵乘法,而是调用统一的 flash_attn 接口:
- 在支持且精度合适的 CUDA 显卡上,使用 Flash Attention 3。
- 在其他环境(CPU、MPS、较旧的 GPU 或非 bf16 精度)自动回退到 PyTorch 的 SDPA。
两条路径的计算结果在数学上等价,区别主要是速度和显存占用。回退路径也能正确处理因果掩码、滑动窗口和键值缓存。
读统一注意力接口nanochat/flash_attention.py第 115–185 行↗第一遍不需要读 Flash Attention 的内核实现。知道“模型调用的是一个可替换的注意力后端”就够了。
没有注意力的模型会怎样
如果没有注意力,每个位置只能独立处理自己的词元,模型无法理解指代、语序和长距离关系。多层感知机只能变换单个位置,不能把“法国”的信息带到“首都”的位置上。
注意力是模型把上下文组织起来的机制。键值缓存、GQA、滑动窗口这些优化,都是围绕注意力的计算和存储展开的。
一个可以观察的实验
训练一个很小的模型后,用同一个提示、不同的 --max-seq-len 或 --window-pattern 比较验证损失。你会看到:
- 窗口越短,训练越快,但长距离依赖能力越弱。
- 完整上下文更慢,但需要更多显存。
- 序列长度增加时,注意力的
T × T分数矩阵会迅速变大。
这比单纯背“注意力复杂度是 O(T²)”更能建立直觉。
本章要记住的三件事
- Q 表示“我要找什么”,K 表示“我有什么标签”,V 表示“我携带什么内容”。
- 因果掩码保证当前位置只能看过去,不能看未来。
- 滑动窗口和 GQA 都是在不改变基本注意力思想的前提下,降低计算量或缓存大小。
下一章看模型怎样把“分数”变成可训练的损失:训练目标。