从0训练LLM · 现代大模型体系 阅读+实验 ≈ 25 分钟 页内实验 ×3
C3 注意力:一个 Token 怎么从别的 Token 那里取信息 第 4 课

训练时怎么防止模型偷看答案?

因果掩码:不许偷看答案
训练时整句一次喂进去,每个位置同时预测它的下一个 token。不加掩码,「给了」那一行有三成注意力直接落在答案「小王」上。因果掩码在 softmax 之前把右上三角置为 −∞:一次前向算完全部位置,每个位置又只看得见自己和左边。

本课一问:训练时怎么防止模型偷看答案?

这一课先不碰:权重拿去乘什么(第 5 课);生成时怎么避免重复计算(第 8 课)。

从已有出发

从已有出发 · 累计数据流 点已学环节看形状与参数
解码层

前三课里,「给了」一直在看「小王」。现在回头想想它是在什么场景下看的。

训练一个语言模型,目标是「预测下一个 token」。一句话「小明把苹果给了小王」其实是 4 道题:看到「小明」预测「把」,看到「小明把」预测「苹果」……看到「小明把苹果给了」预测「小王」。为了快,训练时整句一次喂进模型,每个位置同时输出一个预测,4 道题的 loss 一起算。这就带来一个问题:第 i 个位置在做题时,第 i+1 个 token(答案)也在输入里。

先自己设计:什么也不做

先按前三课的样子,让每个 token 都能看全句。每一行第 i+1 列标了绿框:那是这一行要预测的答案。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案不加掩码,每个 token 都能看全句

为什么 loss 会「低得虚假」。不加掩码时,位置 i 的输出可以直接由第 i+1 个 token 的 V 构成,而第 i+1 个 token 正是它的标签。存在一个几乎不用理解语言的解:把右边的 token 原样搬过来。这条捷径在训练集和验证集上都有效(验证时同样整句喂入、同样不加掩码),所以两条 loss 曲线都会好看;只有真正去生成时——第 i+1 个 token 还没生出来——它才暴露。项目里做了这个对照实验:同配置开、关掩码各训一次。看下面三处:关掉掩码那一组的 loss 曲线降到了哪里;同一组权重在评估时把掩码加回去(相当于生成时的处境),loss 又回到哪里,和「瞎猜」的 ln(词表大小)比一比;两组生成的文本各是什么样。

真训练 · 开 / 关因果掩码各训一次(项目真实日志)

改进:因果掩码

做法只有一步:在 softmax 之前,给分数矩阵加一个掩码矩阵 M——左下三角(含对角线)是 0,右上三角是 −∞。e−∞ = 0,所以 softmax 之后,每一行在自己右边的权重恰好是 0,左边的权重仍然和为 1 。

它带来一个很强的保证:位置 i 的输出只依赖位置 0…i 的 token,和「只把前 i+1 个 token 喂进模型」算出来的完全一样。于是训练时一次前向,就同时做完了 4 道「只看前缀」的题——如果真的逐个前缀喂,5 个 token 要做 5 次前向、处理 1 + 2 + … + 5 = 15 个 token 位置,而掩码只要 1 次、5 个位置。

掩码在哪一步加进去
A = softmax(S + M), Mij = −∞ (j > i), 否则 0
输入输出中间结果 渐变 = 分数(连续值) 留白 = 结构上恒为 0
边长按对数比例缩放,块边上的数字是真实值;同一维度符号的边在全图等长。
维度含义 数值
T 序列长度(贯穿例句) 5
分数画成渐变、权重画成实心;留白表示结构上恒为 0 或 −∞。掩码本身(斜线)在下面的矩阵组件里单独一个视图。

下面可以开关掩码、切换四个视图,并比较两种放掩码的位置。点任何一格,看它的完整算术。

矩阵视角 · 分数 → 掩码 → softmax → 权重(真算术)
🎯

试三件事:① 在「③ S + M」视图里开关掩码,看右上三角整片变成 −∞;切到「④ 权重」,留下的是下三角;② 在「④ 权重」里把掩码的位置切到「softmax 后清零」,看每行的和掉到多少,再切回来;③ 点「给了」对「小王」那一格,分别在开、关掩码时看它的算术:分数一样,权重一个是 32.5%、一个恰为 0。

⚠ 常见误解

⚠

「因果掩码就是把未来的词删掉。」不是删,是把分数置为 −∞。矩阵始终是完整的 T×T 方阵,所有位置用同一次矩阵乘并行算完——这正是 Transformer 训练快的前提。真删掉的话,每个位置的输入长度都不一样,就回到了「逐个前缀喂」。

⚠

「softmax 之后再把未来的权重清零也一样。」不一样。那样每一行剩下的权重之和小于 1,少掉多少取决于未来 token 的分数——未来仍然在影响现在;要补救就得再归一化一次,结果才和「softmax 之前置 −∞」相同。上面的组件里可以直接比较两种放法的行和。

注意力的演进,你在这里

注意力的演进:每一代都被上一代的某个真数字逼出来(本课所在的一代已高亮;另一条线可展开)
① 训练时:一个 token 怎么从别的 token 那里取信息
  1. 起点 RNN:一个接一个地读 注意力之前的主流
    解决了能读任意长的序列,参数与长度无关;手里只带一个固定大小的状态 h。
    卡在ht 要等 ht−1 算完:T = 4,096 时每层 4,096 步不能并行,第 1 个 token 的信息要经过 4,095 次更新才到最后;翻译时整句还得挤进一个定长向量——Bahdanau 2014 说的瓶颈。
    展开岔路 B1
  2. 第一代 Bahdanau 加性注意力 Bahdanau 2014
    解决了解码器每一步回头给源句的每个位置打分、加权取回,不再只靠那一个定长向量:score = vT·tanh(W1q + W2k)。
    卡在每一对都要单独过 tanh,交不给矩阵乘单元;朴素实现要先落一个 T×T×da 的中间张量:T = 4,096、da = 256 时单头单层 8.0 GiB(BF16),点积只留 T×T 个分数,32 MiB。它还长在 RNN 翻译模型里,串行没解决。
    展开第 2 课
  3. 第二代 ① 自注意力:Q/K/V 三种投影 + 点积 Vaswani 2017 §3.2
    解决了直接比 embedding 时「给了」最像的是它自己(余弦 1.000),小明、苹果、小王排在最后;投影成 Q、K 再点积,它自己排到最后、前三名是苹果、小王、小明,全部配对一次矩阵乘 QKT;取回用单独的 V——两个人名的 K 余弦 0.995,取回 K 分不清是谁。
    卡在不缩放:锚点每头 256 维,点积标准差 √256 = 16,同一行分差十几二十是常态(e20 ≈ 4.9 亿),softmax 几乎独占,梯度传不回 WQ、WK。
  4. 第二代 ② softmax 与 ÷√d:缩放点积 Vaswani 2017 式 (1)
    解决了softmax 把分数变成正的、和为 1 的权重,只看分差(按占比分,对话样本的 10、3、−4 会给「汽车」−44.4%);除以 √dk = 16,点积方差回到 1。
    卡在训练时整句一次喂进去:不加掩码,「给了」一行有 32.5% 的注意力直接落在它要预测的答案「小王」上,loss 低得虚假。
    展开第 3 课
  5. 第二代 ③ 因果掩码 Vaswani 2017 §3.2.3 你在这里
    解决了softmax 之前把右上三角置为 −∞:未来的权重恰为 0,一次前向做完 4 道「只看前缀」的题(逐个前缀喂要 5 次前向、15 个位置)。
    卡在一次 softmax 只有一份 100%:「给了」要同时找「谁给的」和「给了什么」,只能在苹果 56.5% 与小明 39.4% 之间劈开,取回两个 V 的混合。
    展开第 4 课
  6. 回扣 C2 RoPE 只转 Q、K Su 2021;锚点部分旋转
    解决了旋转后 q·k 只依赖距离 j − i;V 不参与打分,不转(转了会把绝对位置混进取回的内容)。锚点每头 256 维里只转 64 维。
    卡在旋转矩阵随位置变,夹在展开矩阵和缓存之间就没法事先并进 Q:MLA 因此把 RoPE 单独拆出 64 维另存(DeepSeek-V2 config,第 10 课)。
  7. 第二代 ④ 多头注意力 Vaswani 2017 §3.2.2
    解决了同一组投影按列切成几个头,各做各的 softmax,参数与乘法次数不变:头 A 把 89.5% 给「小明」、头 B 把 77.0% 给「苹果」;锚点 24 个头 × 256 维。
    卡在生成时一次只出一个 token:每一步把整段重新喂进去,提示 2 个、再生成 3 个就要算 9 份 K/V,其中 5 份是上一步刚算过的;K/V 次数随长度平方增长。
    展开第 7 课
  8. 旁支 attention sink 与它的出口 Xiao 2023 → Gated Attention 2025、DeepSeek-V4
    解决了标准 softmax 逼每行分完 100%:「苹果」一行 3 个分数只差零点几(0.04、0.36、0.00),照样分成 30% / 41% / 29%;分母加可学习的 sink logit,或在输出上乘门,这个头就能「这次不取」——锚点开着输出门(attn_output_gate = true)。
    卡在出口的效果只有实验证据:Gated Attention 原文说门控「缓解」sink,不是消除;DeepSeek-V4 没给 sink logit 的单独消融。
    展开岔路 B4
② 生成时:K、V 怎么越存越少
  1. 第三代 KV cache 自回归生成的标准做法
    解决了因果掩码保证旧 token 的 K、V 永不再变,存下来反复用:同一个例子只算 4 份,输出逐位相同(项目测试核对)。
    卡在缓存随上下文线性增长:若 24 个 Q 头各存一份(MHA),锚点形状下每 token 16 层 × 2 × 24 × 256 × 2 B = 384 KiB,原生 262,144 上下文 96 GiB——一条请求,还没算权重。
    展开第 8 课
  2. 第四代 MQA:所有头共用一份 K、V Shazeer 2019
    解决了缓存直接除以头数:锚点形状下每 token 16 KiB,262,144 上下文 4 GiB。
    卡在质量会掉、训练可能不稳:GQA 论文表 1(T5-XXL,七个任务平均)MHA 47.2 → MQA 46.6。
    展开第 9 课
  3. 第五代 GQA:分组共用 K、V Ainslie 2023;锚点 4 组
    解决了24 个 Q 头分 4 组、每组 6 个共用 1 个 KV 头(组数 = KV 头数):每 token 64 KiB、262,144 上下文 16 GiB;T5-XXL 上 GQA-8 平均 47.1、推理 0.28 s,质量接近 MHA(47.2、1.51 s),速度接近 MQA(0.24 s)。
    卡在再减 KV 头就回到 MQA:DeepSeek LLM 67B 已是 GQA-8,每 token 仍要 389,120 B(每头一份的 MHA 是 3,112,960 B),1M token 单条请求 380 GiB。
    展开第 9 课
  4. 第六代 MLA:只存一个低秩潜向量 DeepSeek-V2 2024 → V3、GLM-5.3
    解决了不存 K、V,每层每 token 只存 512 维潜向量 + 64 维 RoPE = 576 个数,用时再展开:DeepSeek-V2(60 层)每 token 69,120 B、V3(61 层)70,272 B;相对 67B 按 BF16 降 82.2%,官方「−93.3%」还含约 6 bit 的 KV 量化。
    卡在仍随长度线性增长,打分仍是 T²:GLM-5.3 同是 576 个数、78 层,每 token 89,856 B,原生 1,048,576 上下文时单条请求只算潜向量就要 87.75 GiB。
  5. 交接 C11 线性注意力 / 稀疏注意力 锚点另外 48 层;DeepSeek-V4.1
    解决了换掉「按长度存」本身:锚点 64 层里 48 层不存 KV,只存固定大小的状态(FP32 共 144 MiB),与上下文长度无关(这些层是什么、怎么更新,C11 第 4 课揭晓);DeepSeek-V4.1 叠加压缩、稀疏与低精度,官方报告全局 KV 890 B/token。
    卡在固定状态装不下任意多对键值,锚点仍留 16 层全注意力;稀疏注意力每步只读挑出来的一部分,挑本身也有代价——各自省了什么、没省什么,C11 讲。
数字口径:锚点 Qwen3.8-27B 与 DeepSeek LLM 67B / V2 / V3、GLM-5.3 的字节数由 anchor-model.json / frontier-models.json 的官方 config 在构建时现算(BF16 每元素 2 字节);「给了」一行的余弦、权重、各头注意力与 sink 那一行,由本章 teaching-world.json 现算——那是手工构造的教学向量,不是模型学到的,与各课组件同一份数据;T = 4,096 是第 2 课与岔路 B1 取的示例长度;T5-XXL 分数出自 GQA 论文表 1,「−93.3%」出自 DeepSeek-V2 报告,890 B 出自 DeepSeek-V4.1 模型卡(均为官方自报)。

证据三问

  1. 它解决什么问题?让训练时整句并行计算,同时保证每个位置只用到它左边的信息,和生成时的处境一致。
  2. 数学上为什么有效?e−∞ = 0,且 softmax 的归一化只在可见位置之间进行,所以位置 i 的输出与右边的 token 无关 。
  3. 实验上呢?这一条不需要实验来选:它是「预测下一个 token」这个任务本身的要求。项目里的对照实验(上面)只是让你看到不加会怎样。

一句话

因果掩码在 softmax 之前把未来置为 −∞:训练时一次算完整句,每个位置又看不到自己的答案。

下一个缺口

权重现在干净了:「给了」只在 小明、把、苹果、给了 之间分配注意力。可前三课一直在说「取回信息」——按这组权重,到底取回什么?是 K 吗?→ 第 5 课:找到该关注谁之后,取回来的是什么?

你可能会问

这一课还没有收到真实提问;你的提问会记到 05-问答记录/C3-N1.md。

🔍 生成的时候还需要掩码吗?

要看怎么算。生成时每步只喂一个新 token,它的 q 本来就只能和已经存在的 k 打分,右边什么都没有,掩码是空的(第 8 课)。但处理 prompt 的那一步(prefill)是整段并行算的,仍然需要掩码;如果 prompt 分几块续写,掩码还要带上偏移——第 k 个新 token 能看见缓存里的全部旧 token 加上本块内它左边的新 token。项目代码 Attention.forward 里的 tril(diagonal=offset) 就是这个。

🔍 掩码本身是不是也泄露了位置信息?

是的。第 i 行能看 i+1 个 token,这个「能看多少」本身就和位置有关。所以即使完全不用位置编码(NoPE),带因果掩码的模型也能从中推断出一部分位置信息——这是 C2 第 5 课「NoPE 就是没有位置」那条误解的由来。