transformer你不学?08|Decoder 块:掩码注意力与自回归生成
作 者:吴佳浩(Alben)
微某信公某众号:全栈架构师笔记
导读
ChatGPT 为什么能一个字一个字往外蹦?为什么训练时它能"看到答案"却不作弊?答案都在一个三角形矩阵里——因果掩码(Causal Mask)。
本篇讲透 GPT 与 BERT 的根本分野:Decoder 的掩码注意力与自回归生成循环。
本篇你会学到什么
- 🔸 因果掩码的构造原理与数值实现;
- 🔸 为什么训练能并行、生成必须串行;
- 🔸 自回归循环的真实代码实现。
一、为什么要学这个
Encoder(BERT 系)和 Decoder(GPT 系)的核心差异只有一条:
- 🔸Encoder:双向注意力,每个词看全句——适合理解(分类、抽取);
- 🔸Decoder:单向注意力,每个词只准看左边——适合生成(写作、对话)。
为什么生成必须单向?因为生成的本质是预测下一个词:训练时如果让位置 i 看到 i+1 的词,等于把答案抄给模型(信息泄露),loss 会假性归零。因果掩码就是"考试防作弊"机制。
二、核心理论:一个上三角矩阵
掩码构造只有一行:
mask[i][j] = True 当 j > i # 位置 i 不许看未来的 j数值实现两步:未来位置填-inf→ softmax 后变 0 权重。-inf而不是 0 的原因:softmax 是指数运算,直接填 0 仍有微小权重,填-inf才是彻底屏蔽。
训练并行、生成串行的矛盾统一:
- 🔸 训练时一次喂整句,注意力矩阵一次性算完(并行),掩码保证位置 i 只用到 1…i 的信息(等价于逐词预测);
- 🔸 生成时只有前缀可用,必须逐 token 循环(串行),每次把新 token 拼回输入再前向——这就是你观察到大模型"打字机效果"的原因。
三、流程图
四、真实代码:因果掩码注意力
# 因果掩码:让每个位置只看得到过去importtorchimporttorch.nnasnnimporttorch.nn.functionalasFimportmath torch.manual_seed(42)B,T,C=1,5,16x=torch.randn(B,T,C)Wq=nn.Linear(C,C);Wk=nn.Linear(C,C);Wv=nn.Linear(C,C)Q,K,V=Wq(x),Wk(x),Wv(x)# 相似度矩阵att=Q @ K.transpose(-2,-1)/math.sqrt(C)# (B, T, T)# 构造上三角掩码:j > i 的位置全部屏蔽mask=torch.triu(torch.ones(T,T,dtype=torch.bool),diagonal=1)print("掩码矩阵 (True=禁止查看):")print(mask.int().tolist())# 转 list 打印更干净# 填 -inf 再 softmax,未来位置权重精确为 0att=att.masked_fill(mask,float("-inf"))att=F.softmax(att,dim=-1)print(f"注意力第一行:{att[0,0].tolist()}")print(f"注意力最后一行:{[round(v,2)forvinatt[0,-1].tolist()]}")out=att @ Vprint(f"输出 shape:{out.shape}")输出结果怎么看(真实运行输出):
掩码矩阵 (True=禁止查看): [[0, 1, 1, 1, 1], [0, 0, 1, 1, 1], [0, 0, 0, 1, 1], [0, 0, 0, 0, 1], [0, 0, 0, 0, 0]] 注意力第一行: [1.0, 0.0, 0.0, 0.0, 0.0] 注意力最后一行: [0.24, 0.33, 0.12, 0.09, 0.21]- 🔸 掩码是严格上三角:第 i 行恰好允许看 0…i 共 i+1 个位置;
- 🔸 第一行
[1,0,0,0,0]:序列开头没有"过去",只能看自己——模型对第一个 token 的理解天然受限,这也是为什么对话系统常拼 system prompt; - 🔸 最后一行权重和为 1 且分布到全部 5 个位置:生成最后一个词时,前文全部可见。
五、真实代码:自回归生成循环
# 自回归生成:逐 token 循环(第 9 篇 的迷你 Transformer 预演)importtorchimporttorch.nnasnn torch.manual_seed(42)classTinyLM(nn.Module):"""最小语言模型:Embedding + GRU + 输出头"""def__init__(self,vocab=20,d=16):super().__init__()self.emb=nn.Embedding(vocab,d)self.gru=nn.GRU(d,d,batch_first=True)self.head=nn.Linear(d,vocab)defforward(self,idx):# idx: (B, T)h,_=self.gru(self.emb(idx))returnself.head(h[:,-1])# 只要最后位置的 logits (B, vocab)m=TinyLM();m.eval()# 自回归循环:预测 → 拼接 → 再预测ctx=torch.tensor([[1,2,3]])# 初始前缀withtorch.no_grad():forstepinrange(5):logits=m(ctx)# 只用已有前缀,绝不偷看未来next_tok=logits.argmax(-1,keepdim=True)ctx=torch.cat([ctx,next_tok],dim=1)print(f"step{step}: 生成 token{next_tok.item()}, 序列长度{ctx.shape[1]}")print(f"最终序列:{ctx.tolist()}")输出结果怎么看(真实运行输出):
step 0: 生成 token 14, 序列长度 4 step 1: 生成 token 18, 序列长度 5 step 2: 生成 token 18, 序列长度 6 step 3: 生成 token 11, 序列长度 7 step 4: 生成 token 11, 序列长度 8 最终序列: [[1, 2, 3, 14, 18, 18, 11, 11]]- 🔸 每轮循环长度 +1:生成是严格串行的——这就是大模型推理慢的根源,第 13 篇 的 KV cache 就是冲着这个来的;
- 🔸 随机初始化下 token 重复出现(12→7→12→7)是正常现象,训练后才会生成连贯文本;
- 🔸 循环里永远只
argmax最后一个位置的 logits——前面的位置已经生成过了,不再需要。
六、常见错误与排查
- 🔸diagonal 参数错:
triu(..., diagonal=1)屏蔽严格未来;写成diagonal=0会把自己也屏蔽掉(第一行全 -inf,softmax 出 NaN); - 🔸忘记 masked_fill 直接 softmax:模型能训练但生成时 train/test 不一致,loss 正常、文本胡说;
- 🔸训练用掩码、推理忘掩码(或反之):两阶段注意力模式必须严格一致;
- 🔸生成循环不设 max_tokens:遇不到终止符就无限循环——生产代码永远带长度上限。
七、练习题
- 把
diagonal=1改成 0,观察 softmax 是否产生 NaN,解释原因; - 打印掩码后注意力矩阵的每一行和,验证全部为 1(掩码改变了分布形状,不破坏归一化);
- 统计自回归生成 N 个 token 需要几次前向传播;推导"有 KV cache 时需要几次",写出两者计算量比值。
一句话总结:因果掩码用上三角
-inf屏蔽未来,让"并行训练"与"逐词预测"等价;自回归循环把"预测下一个词"重复一万次——GPT 的全部生成能力就这两件事。
下一篇:transformer你不学?09|完整 Transformer:从零手搓迷你版。