decoder-only 语言模型(GPT 系)能逐 token 生成,靠的是一个看似简陋的机制:位置 $i$ 只能 attend 到 $j\le i$。实现上就是给注意力 logits 加上一个下三角掩码。但这个"简单"操作里藏着几个常被搞混的问题:掩码到底加在哪一步?反向传播时 masked 位置的梯度去哪了?FlashAttention 里的 is_causal=True 和显式 masked_fill 等价吗?这篇一次说清。
数学对象:掩码矩阵 M
定义 $n\times n$ 掩码矩阵
$$ M_{ij} = \begin{cases} 0 & j \le i \quad\text{(可见)}\\ -\infty & j > i \quad\text{(不可见)}\end{cases} $$
因果注意力就是
$$ P = \mathrm{softmax}_{\text{row}}\Big(\frac{QK^\top}{\sqrt d} + M\Big). $$
加 $-\infty$ 的项在 softmax 的指数里变成 $e^{-\infty}=0$,对应的 $P_{ij}=0$——不是"很小的数",是严格的零。这意味着位置 $i$ 的输出 $o_i=\sum_j P_{ij}v_j$ 在数学上根本不依赖 $v_j$($j>i$),无论前向还是反向,信息通路被物理切断,而不只是被"压低"。
关键推论:$P$ 的第 $i$ 行只有 $i$ 个非零元。第 0 行(第一个 token)退化成 one-hot——它只能看自己,$o_0=v_0$。这也是为什么第一个 token 会演化成 attention sink:它被迫成为"无处可看"位置的信息垃圾场,模型干脆把它用成寄存器。
三种实现写法,同一个对象
PyTorch 生态里常见的三种写法:
# 1. 显式 masked_fill(教学与自定义 attention 最常见)
mask = torch.triu(torch.ones(n, n, dtype=torch.bool), diagonal=1) # 上三角为 True
S = Q @ K.T / sqrt(d)
S = S.masked_fill(mask, float('-inf'))
P = S.softmax(dim=-1)
# 2. 加性 mask(multi-head 广播版)
P = (S + mask_additive).softmax(dim=-1) # mask_additive ∈ {0, -inf} 或 {0, -1e9}
# 3. F.scaled_dot_product_attention 的 is_causal=True(fused kernel 路径)
out = F.scaled_dot_product_attention(Q, K, V, is_causal=True)
三者在数学上等价,数值和性能上有差别。masked_fill(-inf) 在纯 fp16 下有坑:-inf 与某些 kernel 的初始化值相加产生 NaN,工程上常用一个大的负数(-1e9)替代,softmax 后同样压到零,但避免了极值算术。is_causal=True 走 fused kernel(FlashAttention 路线),掩码不物化为矩阵,直接在分块计算里跳过 $j>i$ 的块——省的是显存带宽和 mask 张量本身的 $n^2$ 存储。
反向传播:掩码的梯度效应是免费的
反向看因果掩码如何作用于梯度。注意力反向链(第一章推过)中,$\bar S$($S$ 的梯度)在 softmax 段是
$$ \bar S = P\odot\big(\bar P - \mathrm{diag\_broadcast}(P\odot \bar P)\big). $$
$P$ 的上三角($j>i$)严格为零,逐元素乘 $\odot$ 之后 $\bar S$ 的上三角也自动为零——被掩位置的 logit 收不到任何梯度。不需要为掩码写任何反向代码,梯度流跟着 $P$ 的零结构走,泄漏在前向和反向两个方向同时被堵死。
这带来一个训练动力学的观察:上三角 logits $S_{ij}$($j>i$)在训练中完全不更新,它们从初始化值出发永远停在原地。反向验证时注意:如果用数值梯度检验 causal attention,$\partial \ell/\partial S$ 的上三角应当精确为零(autograd 会自动给出),手写实现里忘了掩 $\bar S$ 是最常见的 bug 来源。
掩码与 attention sink 的伴生关系
causal mask 制造了一个结构性不对称:越早的 token 被越多位置看见,位置 0 被所有 $n$ 个位置看见。这给模型一个免费的"全局寄存器"——把与当前内容无关的注意力质量(比如"我现在不知道该看什么"的默认质量)倾倒给位置 0 和少数几个早期 token。StreamingLLM 的 KV cache 压缩策略正是利用这一点:固定保留前几个 token 的 KV,其余按重要性淘汰,性能几乎不掉。掩码的设计选择(谁可见)在这里变成了推理时显存管理的红利。
小结
causal mask 的数学对象是加性矩阵 $M\in\{0,-\infty\}^{n\times n}$,效果是 $P$ 上三角严格为零,信息泄漏在前向被 $e^{-\infty}=0$ 物理切断,反向被 $P$ 的零结构自动继承——没有额外的梯度代码。三种实现写法(masked_fill、additive、is_causal)数学等价,数值与性能各有取舍。掩码还制造了注意力分布的结构性不对称(位置 0 被所有人看),这个"缺陷"后来成了 KV cache 压缩的抓手。