Transformer掩码完全指南:Padding Mask与Look-Ahead Mask的实战原理

📅 发布时间:2026/10/16 22:06:37
Transformer掩码完全指南:Padding Mask与Look-Ahead Mask的实战原理
你接触 Transformer 不久的话十有八九会被“Mask掩码”这个概念绕晕。注意力分数要加 Mask、损失函数要加 Mask、位置编码要配合 Mask看起来无处不在。但如果你愿意花半小时把它的来龙去脉捋清楚会发现 Mask 其实是个非常简单的东西——它就是一套规则告诉模型哪些信息该看哪些信息不该“偷看”。这篇我们就把 Transformer 掩码这件事彻底讲透结合三种最常见的 Mask 类型、它在训练与推理阶段的不同用法以及我实际调试过程里踩过的一堆坑。这篇文章适合刚学完 Self-Attention、转行做 NLP 或大模型方向的开发者也适合马上要面试、需要把 Transformer 细节串成体系的人。读完你不仅知道 Mask 长什么样还能搞懂为什么原论文要画三处 Mask、为什么训练和推理时 Mask 的表现形式不一样以及一个真实 Causal Language Model 里 Mask 是怎么贯穿数据管道到损失函数的。1. 为什么 Transformer 非加 Mask 不可——两个绕不开的场景想理解 Mask先在脑子里建立两个非常具体的场景否则后面所有代码都会变成“背公式”。1.1 场景一一个批次里句子长短不齐训练时我们会把一个批次里的很多句话拼成一个矩阵喂给模型。问题是句子长度天然不一样比如第一条 15 个词第二条只有 7 个词第三条 22 个词。你当然可以选择用最长的 22 做统一长度但剩下那几条短句子后面的 15 个位置根本不存在词。于是我们做了 padding也就是在短句子后面补一个特殊的填充符号填满到 22。计算注意力分数时这些占位符位置的计算完全是垃圾信息甚至可能干扰真正的语义。你要告诉注意力机制说“后面那一大串全是填充符别去看它们”最简单粗暴的做法就是把对应位置的注意力分数打成一个极大的负数让它经过 softmax 之后趋近于 0。这就是 Padding Mask填充掩码的出生原因。1.2 场景二解码时模型不能“偷看未来”语言模型在预测下一个词的时候逻辑上是逐词生成的看到“我爱”才能预测“北京”。训练时我们为了提速会把一整句话同时喂进去让模型并行预测每个位置的下一词。如果不做任何防护模型在第 1 个位置预测第 2 个词时已经能看到第 2 个位置的真实答案了——这不叫学习叫开卷考试。为了保持因果性我们要在解码器的自注意力里加一个上三角掩码把“当前时刻之后”的所有位置遮住。第 3 个位置的注意力只能看到前 3 个 Token含自己第 8 个位置只能看到前 8 个。这个玩意在论文里叫 Sequence Mask也叫 Look-Ahead Mask、Causal Mask或者直接叫 Attention Mask。这两个场景就是我常说的“Mask 的两大使命”一是挡住废话二是挡住未来。非 Transformer 架构里也有类似需求比如传统 RNN 靠单向传播天然满足因果性而 Transformer 噼里啪啦并行计算必须靠掩码主动维护规则。生活中可以这么理解你让一群人在一个房间里传纸条每个人同时把纸条上能看到的信息写下来。Padding Mask 是“房间里有一些空椅子没人坐就别传了”Look-Ahead Mask 是“只许往前传不许回头打听后面的信息”。就这两件事。2. 三种核心 Mask 逐一拆解在具体实现里Mask 通常有两种形状一种是(batch_size, seq_len)的二维矩阵一种是被广播过的四维矩阵(batch_size, 1, 1, seq_len)。我们平时看的代码往往用 bool 类型表示“哪些位置是 True要被遮住”或者用 float 类型提前填好大负数。逐个来看。2.1 Padding Mask挡住填充位置一个典型的 Padding Mask 长这样以中文翻译场景为例一个批次有两句话第一句我 爱 北 京 pad pad 第二句机 器 学 习 真 有 趣对应 mask 矩阵是第一句[False, False, False, False, True, True] 第二句[False, False, False, False, False, False]拿到了这个 mask 之后用一个masked_fill操作把注意力分数里为 True 的地方替换成-inf或者一个大负数比如 -1e9。注意这里的习惯很关键不同的框架和代码库对 True/False 的含义定义不一样有的库 True 表示“保留”有的库 True 表示“忽略”比如 PyTorch 官方学习率的 mask 常常是 True 表示“需要被 mask 掉”。换框架时第一件事就是确认这个语义不然代码能跑结果全反。你可能会想为什么必须用大负数去覆盖而不是直接把分数置零原因是注意力分数之后要进 softmaxsoftmax 的核心操作是exp(x)。你如果把分数改成 0exp(0) 1那些填充位置依然参与分母计算白白稀释了真实 Token 的注意力权重。只有让exp(一个超大的负数)趋近于 0才能彻底把它从分母里排除出去。由于一个批次里有样本被 mask 的位置较多时剩余真实 token 的注意力权重总和仍然是 1。你并不需要额外归一化。这个特性让 padding mask 在所有 batch 长度不一的训练里都很好用。2.2 Look-Ahead Mask挡住未来位置Look-Ahead Mask 用代码来理解最直观。假设序列长度是 4我们希望第 i 行只能看前 i 列[[False, True, True, True ], [False, False, True, True ], [False, False, False, True ], [False, False, False, False]]其中行表示“当前要预测的位置”列表示“可以看的历史位置”。对角线是 False 表示能看到自己右上角全是 True会被 mask 掉。PyTorch 里一行就能生成它import torch seq_len 4 mask torch.triu(torch.ones(seq_len, seq_len, dtypetorch.bool), diagonal1) # 这个 mask 里 True 的位置就是“未来”用来做 masked_filldiagonal1的意思是保留主对角线右上方的元素。如果你看到有的代码里写diagonal0那是对角线也一块“不允许看自己”这种情况常见于某些 GPT-like 场景的变体需要仔细分辨论文或项目的本意。值得多说一嘴的是Look-Ahead Mask 经常和 Padding Mask 会在解码器里同时出现。你不能只遮未来不遮 padding也不能只遮 padding 不遮未来否则模型要么学到非因果逻辑要么被一堆占位符干扰。所以 TensorFlow 的官方教程里直接用create_look_ahead_mask和create_padding_mask两个函数再tf.maximum把它们合并成一个 mask。PyTorch 实践里也很常见把两个 mask 逐位取或OR。2.3 Cross Attention 中的 Mask编码器输出里的填充位Decoder 的第二个注意力模块也就是 Cross AttentionQ 来自解码器K 和 V 来自编码器输出。这个时候遮挡逻辑就不一样了。未来信息的问题已经被第一个自注意力模块拦住了Cross Attention 里你只需要注意一件事K 的来源是编码器的输出而编码器输出里 padding 位置对应的向量应该被忽略。所以这里的 mask 不是seq_len_q x seq_len_k的上三角而是把编码器侧的 Padding Mask 广播到所有 query 上。比如解码器有 6 个 query编码器输出长度为 8其中后 3 个是 padding那 mask 就是6 x 8每一行都把第 6、7、8 列遮住。很多初学者把 Cross Attention 的 mask 和第一个自注意力模块的 mask 混在一起导致整个注意力张量形状对不上报一堆维度不一致的错误。记住一句话谁的 K/V 有 padding 问题就 mask 谁对应的 key 位置。Cross Attention 里 mask 只与编码器侧有关与解码器自身长度无关。3. 掩码在原版 Transformer 结构里的落地方式原论文里那张经典架构图画了三处 Mask。很多新手盯着那张图看半天也没搞明白哪根线通往哪。我把注入位置梳理一遍再用一张实际训练过程串起来。3.1 Encoder、Decoder、Cross Attention 三处配置原版 Transformer 的掩码配置可以概括为一个三行表模块Mask 类型作用对象说明Encoder 自注意力Padding Maskkey 的 padding 位置遮挡批次内填充符保留真实 Token 信息Decoder 第一个自注意力Padding Mask Look-Ahead Maskkey 的 padding 位置 未来位置合并成 Attention Mask保证并行解码时的因果性Decoder 第二个注意力Cross AttentionPadding Mask编码器输出的 key 的 padding 位置遮住编码器侧无效向量这里的顺序和形状关系很容易乱。我的方法是把 Attention 拆成三步score Q K.transpose(-2, -1) / sqrt(d_k)得到形状(batch, num_heads, seq_len_q, seq_len_k)的分数矩阵。score score.masked_fill(mask, float(-inf))mask 会被广播成和 score 一致的形状。weights softmax(score, dim-1)然后乘 V。最重要的一条实操经验是mask 要在 softmax 之前加不是之后。如果你把 mask 放在 softmax 之后再过一遍那你只是给已经归一化的权重简单置零分母里还残留填充位置的信息语义上等于是“假装看不见但偷偷参与计算”。3.2 从数据到 Loss一次完整训练过程中的 Mask 路径这里我用一个模拟的机器翻译任务来演示训练数据是(英文, 中文)句子对。假设你正在做 decoder 部分的训练也就是 Teacher Forcing 模式给模型输入一句完整的目标句子Shifted Right 之后让它在每个位置预测下一个位置。比如“机器 学习 有趣”Shifted Right 之后变成SOS 机器 学习 有趣模型并行地预测位置 0 根据SOS预测 “机器”位置 1 根据SOS 机器预测 “学习”位置 2 根据SOS 机器 学习预测 “有趣”位置 3 根据SOS 机器 学习 有趣预测EOS如果没有 Look-Ahead Mask位置 2 的预测能直接看到后面的有趣训练出来的模型就是废物。所以训练时这个掩码是硬性条件。数据加载阶段我们先把中英文句子都 tokenize 成长度统一的张量生成两份 padding mask。一份给 encoder 输入用一份给 decoder 输入用。然后构造 look-ahead mask再和 decoder 侧 padding mask 合并。前向传播时mask 的作用贯穿 encoder 和 decoder。最后计算损失通常只对真实 token 计算交叉熵忽略 padding 位置的损失loss criterion( logits.permute(0, 2, 1), # shape: [batch, vocab, seq_len] labels # shape: [batch, seq_len] ) # 在 criterion 里设置 ignore_indexpad_token_id这一步我见过太多人忽视。mask 不仅要进注意力还要进损失。如果你对 padding 位置也计算损失模型会被迫去预测一堆pad无论它学到什么都是噪音。所以现代语言模型的交叉熵损失基本都有一个ignore_index参数本质也是一种 Mask只在真实位置回传梯度。3.3 现代实现的变化Flash Attention 与“不生成 Mask”的掩码如果只看原版实现会以为 Mask 必须是显式矩阵。但近年在做 LLM 推理优化时显式 mask 矩阵已经没那么必要了。比如自回归生成的时候你一个 token 一个 token 地往里放推理阶段根本不存在“未来位置”因为你的 K/V 里压根没有未来 token。这时 Look-Ahead Mask 的上三角部分是天然恒定的形状于是很多高效实现直接省掉它。另一个变化是 Flash Attention。它做分块计算时特别设计了参数比如attn_mask可以传入布尔矩阵还可以传入每个序列的有效长度。更常见的做法是不生成 padding mask而是传一个形如(batch, seq_len)的seqlen_k告诉注意力核“每个序列真实的 key 长度是多少”。核函数内部自行处理 padding省掉了在显存里存一份大矩阵的开销也顺便跑得更快。这并不是说 Mask 概念消失了而是它的物理形态变得更语义化了。你在面试或在项目里遇到 Mask 术语时要清楚问的到底是“用于限制注意力的掩码”还是“传给优化核的序列长度参数”。这点搞清楚面试官一提 Flash Attention你就知道它不是不用 mask而是把 mask 变成了更高效的表示方式。还有一个常被提起的扩展是位置编码和 Mask 的配合。像旋转位置编码 RoPE 这类方法加在 Q 和 K 上之后得分矩阵里未来位置的分数也可能是普通数值必须靠 Look-Ahead Mask 拉住。而 ALiBi 则把相对位置信息直接做成一个负的惩罚矩阵加在注意力分数上这其实也是一种“位置掩码”只不过它不是二元遮罩而是连续的惩罚。明白了这种思路会让你以后阅读新位置编码论文时轻松不少。4. 实操最容易踩的 6 个坑与排查实录纸上谈兵告一段落。接下来总结一下我实际调试 Transformer 模型过程中反复出现的坑。每一条都是我亲眼见到过的报错或异常表现有些特别隐蔽。4.1 设备不一致GPU 上的 Mask 放到了 CPU 上最常见的第一板子问题。模型和输入都在 CUDA 上但 mask 是用 CPU 上创建的 Tensor 生成后忘记.to(device)。报错信息通常是“Expected all tensors to be on the same device”或“Expected device cpu but found device cuda”。解决方案根本不需要找模型问题写代码时养出一个习惯mask mask.to(device)一旦你手写的 attention 层里同时用到Q、K、V和 mask请自成一套规则所有辅助张量在创建后立刻和设备同步。不要依赖 PyTorch 的隐式搬运它不会为你隐式搬运 mask。4.2 Mask 的类型和数据范围搞错很多实现中 mask 是 bool tensorbool 和 float 的 t 之间差一个.float()转换。另一个隐患是 -inf 和 -1e9 的选择。用-inf当遮挡值时理论上 softmax 会出现0/0的情况吗不会因为真实位置有分数exp 后非零更安全。而-1e9在部分 float16 精度下会有安全问题float16 能表示的最小数量级有限-1e9 在 float16 里可能会被下溢成一个不稳定的值导致某些位置本应被 mask 掉却仍然保留小权重。常用补救是如果训练用半精度就把 mask 的地方填成float(-inf)如果框架里不允许 inf填-10000.0或-65504.0但务必经过测试。这个坑如果不加注意在混合精度训练下会让模型在长序列任务上的表现奇怪地劣化。另一个容易踩的是漏掉了维度。mask 形状若写成(batch, seq_len)而注意力分数是(batch, num_heads, seq_len_q, seq_len_k)广播时会直接把序列长度对到 num_heads 上报维度不匹配。正确做法是给 mask 补上 head 维和 key 维或者使用.unsqueeze(1).unsqueeze(2)使其可以广播到(batch, 1, 1, seq_len)。4.3 位置编码和掩码相互“打架”位置编码常常会在 token 被 padding 后产生问题。比如你给整句话加绝对位置编码时padding token 也拿到了一个真实的位置编码。但在注意力里它已经被 mask 掉所以问题不大。但如果你使用可学习式位置编码位置 0 到 99 都要维护对应的编码向量而训练样本全是短句后面几十个位置的位置向量几乎得不到更新跨长度泛化时容易崩。处理办法有几种一种是在数据采样时尽量平衡序列长度分布另一种是使用相对位置编码如 RoPE/ALiBi天然不依赖“位置 100 没出现过”这种问题。这个不算 Mask 本身但 Mask 让你以为 padding 都处理好了实际位置编码信息没有过时它仍然存在。还有就是训练完做序列截断时mask 和位置编码必须同时截。如果你把序列长度从 1024 截到 512忘记把位置编码矩阵也截到 512模型会看到“明明只有 512 个 token但位置编码的下标还在用 1024 的词表”位置信息全乱结果莫名其妙变差。4.4 推理阶段缓存 Cache 时的掩码变化自回归推理时为了不重复计算历史 K/V我们通常维护一个 kv cache。第一次前向时输入一个SOS之后每次输入一个 token都会以(batch, 1)的序列长度送去解码。很多人的错误是仍然在生成阶段使用训练阶段那种(batch, seq_len)的大 look-ahead mask。在推理阶段你需要的是当前 token 的 Query形状是(batch, 1, num_heads, d_head)。K/V 是历史缓存的全部形状是(batch, past_len, num_heads, d_head)。注意力分数形状是(batch, num_heads, 1, past_len 1)。此时 padding mask 仍然需要但 look-ahead mask 实际上已经没必要了因为历史缓存里都是真实且在当前时刻之前的 token没有“未来”。如果你还硬套训练时的上三角矩阵就会报维度对不上或者性能反而变差。一个稳妥做法是在推理阶段单独使用函数生成当前步的 mask不再复用训练函数。4.5 掩码放错层该 Mask 的地方没 Mask模型里有 Embedding、有 Self-Attention、有 Cross-Attention、有 FFN、还有 LayerNorm。有的同学把 mask 放在 LayerNorm 之前或者 Embedding 输出后然后发现注意力权重仍是全 1。原因是 mask 只对注意力分数有约束你遮住 embedding 的向量并不能阻止 attention 把 padding 位置和真实位置关联起来。正确位置是注意力机制的 score 矩阵处。任何在 score 之前处理向量本身的行为都不能替代 attention mask。如果你自己写注意力层请让 masked_fill 出现在缩放后的 score 上。4.6 训练损失也别忘了 Mask训练时 loss 的计算如果不忽略 padding 下标模型会非常努力地学习预测pad。看起来 loss 下降很猛但验证集上表现很差。交叉熵加一个ignore_indexpad_id是最简单也是最不可或缺的一步。很多预训练模型里还会对样本内的 token 做加权本质都是在损失层面再做一层“语义 mask”。好在大多数框架自带这个参数你只要把 pad token id 正确传进去即可。还有一种更隐蔽的情况如果标签里也有未来的 token也就是你构造 label 时用整句话做 shift 而不是只取前 seq_len-1 个位置那么最后一个位置的标签会被错接到最前面的SOS上。这种错误的 label shift 用 mask 也救不回来需要单独检查。5. 常见问题速查表与排查思路问题可能原因排查方向报错 shape mismatchmask 是(batch, seq_len)但 score 是(batch, heads, q, k)mask 缺少 head 维和 key 维用.unsqueeze(1).unsqueeze(2)扩展到可广播四维注意力权重和没被 mask 时一模一样mask 放在 softmax 之后移动到 softmax 之前混合精度训练中 padding 位置仍有小权重-1e9 在 fp16 下不够大换用float(-inf)或 -65504GPU 报错 device mismatchmask 没.to(device)统一设备辅助张量与模型同步训练 loss 下降但生成文本乱码loss 没忽略 pad 下标在交叉熵设ignore_indexpad_token_id推理速度怪慢kv cache 没用起来检查 mask 是否错误地阻止了历史 token 的 attention导致必须重算第一个位置预测错误Shifted Right 不对齐检查 decoder 输入是否整体右移一位并在开头补上起始符长序列性能抖padding mask 和位置编码长度不一致确认 mask 和 embedding 都根据当前 seq_len 截断还有一种我不太推荐、但在工程上常见的行为在图里直接 hack把 mask 直接写成~mask之类。极容易把自己绕晕。建议写一个辅助函数统一生成 mask并老老实实写清语义哪怕多几行代码也不要用巧妙的布尔取反增加心智负担。我在调试一个两三千行的翻译模型时很多时间就是花在这些 mask 相关的问题上。最让人头疼的不是模型不收敛而是它“收敛得很正常但结果不对”。后来我写了一套可视化工具直接在训练循环里把 mask 打印出来对照原始文本一眼就能发现哪些 token 被遮了、哪些 pad 没遮住。这个方法到现在还在用。如果你也在被同类问题折磨建议动手试一下“把 mask 打印出来看看”这个动作多半能省下一晚上的排查时间。最后再分享一个小技巧如果你从头写 Attention 模块先别急着上多头用单头把 Mask 形状和加法位置确认无误了再扩展成多头。否则报错发生时既要想 mask 又想 head 拆分真的会让人头大。