上一篇讲架构时,解码器里有一个子层叫“掩码多头自注意力”,我只用一句话带过。这句简单的描述藏着整个训练方式的关键,今天要把它想清楚。

那个看似矛盾的场面

训练语言模型时,我们把“今天 天气 不错”整句同时送进网络,一次前向计算就得到三个位置的向量,各自负责一个预测:位置 0 预测“天气”,位置 1 预测“不错”,位置 2 预测结束符。一句话一次算完三个训练样本,这就是 Transformer 训练效率的来源。

问题是:位置 0 做注意力时,“天气”和“不错”就在输入里,它凭什么看不见?

如果真让它看见,模型很容易学会“后面是什么就照着报什么”,训练损失迅速接近零;但真到生成时,第一个词的位置后面什么也没有,模型立刻失效。这和拿训练集当测试集是同一种自欺。

所以我们要的是一对看似矛盾的要求:整句并行读入,每个位置却只能看见自己和前面的词。 掩码(mask)做的就是这件事。

把分数改成负无穷,而不是事后把权重清零

自注意力的最后一步是:每个位置对所有位置打出分数,softmax 把分数变成一组加起来等于 1 的权重,再用权重加权求和 Value 向量。

softmax 可以理解成“按分数分配一个固定预算”。要屏蔽某个位置,就把它的分数改成 −∞-\infty:因为 e−∞=0e^{-\infty}=0,它分到的权重正好是 0;而 softmax 只对剩下的项做归一化,于是剩下的位置自动把预算重新分完,不需要再补一步归一化。

具体看位置 1 对三个位置打出的分数 [1.0, 2.0, 3.0][1.0,\ 2.0,\ 3.0]。不掩码时,e1=2.718e^{1}=2.718、e2=7.389e^{2}=7.389、e3=20.09e^{3}=20.09,和为 30.20,权重是 [0.090, 0.245, 0.665][0.090,\ 0.245,\ 0.665],未来的“不错”拿走了 66% 的注意力。掩码后分数变成 [1.0, 2.0, −∞][1.0,\ 2.0,\ -\infty],和为 10.11,权重是 [0.269, 0.731, 0][0.269,\ 0.731,\ 0]。那 0.665 并没有消失,而是按 1:2 被前两个位置分掉了。

反过来做不行:先算完整 softmax,再把未来位置的权重清零,得到 [0.090, 0.245, 0][0.090,\ 0.245,\ 0],和只有 0.335,输出的量级就错了。若再除以 0.335 校正,数学上确实等价,但要额外遍历一次、做一次除法。所以标准做法是在 softmax 之前加上那张表。

这张表就是下三角

“位置 ii 只能看 j≤ij \le i”写成矩阵就是下三角(下面用 1 表示允许),对角线要保留,因为预测下一个词时,当前这个位置本身也是输入:

11 0 0 0
21 1 0 0
31 1 1 0
41 1 1 1

对应的代码也短:

1allowed = torch.tril(torch.ones(T, T, dtype=torch.bool))   # 下三角,含对角线
2scores  = q @ k.transpose(-2, -1) / math.sqrt(d_k)          # (T, T)
3scores  = scores.masked_fill(~allowed, torch.finfo(scores.dtype).min)
4weights = scores.softmax(dim=-1)                            # 每行和为 1

实现里通常不写真的 −∞-\infty,而用该浮点类型能表示的最小有限值(如 finfo(dtype).min),这样在整行都被屏蔽的情况下,softmax 内部也不会算出 NaN。

因果性怎么在几十层之间保住

有人会追问:第一层只让位置 1 看到 0 和 1,堆到 96 层以后,信息会不会从别处绕回来?

不会。位置 1 在第一层的输出只用到位置 0、1 的 Value;接下来的残差相加和层归一化是逐元素的,前馈网络是逐位置独立处理的,都不会混进别的词的信息。第二层再做因果注意力时,输入里仍然只含前两个位置的信息。一层层推下去,第 96 层位置 1 的表示依然只依赖前两个输入。

“整句并行”和“不偷看”能同时成立,理由就是这两条:唯一的跨位置运算只有注意力,而它被掩码限制成只看过去;其余计算全都逐位置进行。

第二种掩码:padding

真实训练要把长短不一的句子凑成一个 batch,短的补 padding 到最长那句的长度。padding 是填充物,真实词若从它身上取信息,同一句话的输出就会随“这一批最长的那句有多长”而变。

两种掩码看着像一回事,作用的维度不同:

  • 因果掩码每一行都不一样,是否允许取决于行号和列号谁大,是 (T,T)(T,T) 的表,与 batch 无关。
  • padding 掩码只看列:这一列是不是填充物,对所有 query 都一样,形状是 (B,T)(B,T),广播成 (B,1,T,T)(B,1,T,T)。

合并规则是“两个都允许才允许”,也就是逻辑与;写成加法时恰好也是 0+0=00+0=0、任何数加 −∞-\infty 仍是 −∞-\infty,所以一张加性掩码表装得下两条规则。

一个容易想错的边界:右 padding 时,每个真实词都在 padding 左边,也就是 padding 处在“未来”,因果掩码本来就不让看,所以真实位置的输出不受影响;漏写 padding 掩码的问题往往只在批量评测、或改成左 padding 时才暴露。另外,padding 行“不计分”是另一件事,要靠 loss mask(如交叉熵的 ignore_index)单独处理:注意力掩码决定谁看谁,loss mask 决定哪些位置算分。

掩码的全部内容,就是往分数上加了一张表。但没有这一行加法,“整句并行训练”就等于让模型抄答案,decoder-only 架构根本训不出来。下一篇继续:最后一层的向量怎么变成词表上的概率,模型又怎么一个词一个词地生成。