核心结论
- Decoder only 模型在批量生成时必须使用左填充,因为自回归采样依赖序列最后一个位置的 logits;训练阶段通常使用右填充,配合损失掩码屏蔽填充位置。
- GRPO 这类"生成---训练"交替的方法采用分阶段混合策略:Prompt 左填充以保障生成正确性,Completion 右填充以简化损失计算。
- 左填充并非"设一个参数就安全",位置编码偏移是它最隐蔽的副作用;在 PyTorch 的
F.scaled_dot_product_attention中实现左填充还需要额外处理布尔掩码语义反转和完全掩码行导致的 NaN 问题。
基础知识:padding 的目标、做法与左右实现
这一节首先明确 padding 要解决什么问题、通常怎么做。
目标。 仅解码器模型和编码器模型都按 batch 计算张量,而一个 batch 内各条序列长度往往不同。padding 的目标是把它们补齐到同一长度,使 batch 可以表示为一个规则张量。设第 iii 条序列长度为 LiL_iLi,batch 内最大长度为
Lmax=maxiLi L_{\max}=\max_i L_i Lmax=imaxLi
则第 iii 条序列需要填充的数量为 Lmax−LiL_{\max}-L_iLmax−Li。其中 LiL_iLi 是真实 token 数,LmaxL_{\max}Lmax 是补齐后的统一长度。
padding 本身不携带语义,因此必须满足两个条件:填充位置不能参与注意力计算,也不能参与损失计算。前者由 attention_mask 控制,后者由标签中的 ignore_index(如 PyTorch 的 -100)控制。对于 RoPE 等位置编码,还需要保证填充不改变真实 token 之间的相对位置。
做法。 常见流程是:tokenizer 将每条序列编码为 token id,并生成 attention_mask;按 padding_side 将 <pad> 追加到指定一侧;训练时把填充位置的标签设为 -100;生成时传入 attention_mask,并确保采样位置对应真实末位 token。例如两条序列长度分别为 4 和 1,补齐到 4:
- 右填充:
["hey", <pad>, <pad>, <pad>] - 左填充:
[<pad>, <pad>, <pad>, "hey"]
两者的真实 token 数相同,但真实 token 在张量中的列位置不同。右填充让真实 token 左对齐,左填充让真实 token 右对齐。
左右填充的实现方式。 在 Hugging Face tokenizer 中,方向由 tokenizer.padding_side 控制:
python
tokenizer.padding_side = "right" # 默认常见设置
# 或
tokenizer.padding_side = "left" # 解码器批量生成常用
调用 tokenizer(..., padding=True) 时,tokenizer 会按该方向补齐,并同时返回 attention_mask。手动实现时,右填充是在序列末尾追加 <pad>,左填充是在序列开头前置 <pad>:
python
# 右填充:真实 token 在前,pad 在后
right = real_ids + [pad_id] * (L_max - len(real_ids))
# 左填充:pad 在前,真实 token 在后
left = [pad_id] * (L_max - len(real_ids)) + real_ids
两种方式生成的 attention_mask 都是真实 token 位置为 1、填充位置为 0,只是 0 出现在右侧还是左侧。
一、右填充带来的问题
假设一个 batch 包含两条 prompt:
- Prompt A:
"hello my name is"(长度 4) - Prompt B:
"hey"(长度 1)
需要填充到长度 4 进行批量生成。如果采用右填充 ,B 变成 ["hey", <pad>, <pad>, <pad>],序列的最后一个位置 是 <pad>。仅解码器模型的 generate() 默认从最后一个位置的 logits 采样下一个 token,于是它从一个填充 token 的位置"开始生成",而模型从未被训练过从 <pad> 续写。输出的错乱几乎是必然的。
左填充把 <pad> 挪到序列开头,[<pad>, <pad>, <pad>, "hey"] 的最后一位重新变回真实 token "hey",采样逻辑恢复正常。
二、何时使用右填充、左填充
批量推理必须左填充
自回归生成的采样机制是理解填充方向的钥匙。给定输入序列,模型对每个位置输出一个 logits 向量,但 generate() 只取最后一个位置 的 logits 来采样新 token。在右填充的批次中,较短序列的最后一个位置被 <pad> 占据,采样从这个无效位置出发,输出质量无从保证。
左填充则保证批次内所有序列的真实末位 token 对齐在最后一列 ,无论每条序列前面有多少填充。Hugging Face 的代码会在检测到解码器模型配合右填充进行批量生成时发出警告,明确要求将 padding_side 设为 "left";TRL 的 issue 中也记录了实际触发的报错信息: "You are attempting to perform batched generation with padding_side='right' ... Make sure to call tokenizer.padding_side = 'left' before tokenizing the input" 。
训练阶段通常选择右填充
训练阶段不依赖"最后一位采样",优化目标由损失函数定义。右填充在这个场景下的优势在于:填充 token 的标签可以用 ignore_index(通常是 -100)屏蔽,使其不参与梯度更新;同时真实 token 在序列中保持左对齐,位置 ID 的递增关系与未填充时一致,直观且不易出错。
业界常规可以概括为:训练时通常右填充,推理时左填充 。这一惯例主要适用于监督微调(SFT)等输入与标签预先对齐的场景。编码器模型(如 BERT)是另一回事:它们一次性读取整个序列做理解或分类,不依赖末位生成,右填充配合 [CLS] token 取用是更自然的选择。
三、GRPO 的混合填充策略
GRPO(Group Relative Policy Optimization)同时包含生成回复 和训练策略 两个环节,这决定了它不能用单一的 padding_side 覆盖全局。
生成阶段:Prompt 必须左填充
GRPO 的 rollout 阶段用 model.generate() 为每个 prompt 批量生成多个 completion。与所有仅解码器批量生成一样,如果 prompt 采用右填充,较短序列的末尾会被 <pad> 占据,生成将从填充位置出发。因此,Prompt 的填充方向必须是左侧 。Hugging Face TRL 的 GRPO 文档明确要求 processing_class 的 padding side 设为 "left"。
训练阶段:Completion 右填充
生成完成后,prompt 和 completion 被拼接,送入策略模型计算 log probs 和损失。训练不再依赖"最后一位采样",右填充的 completion 配合损失掩码可以干净地屏蔽填充位置的梯度;同时 completion 的真实 token 保持左对齐,便于与位置 ID 对齐。
TRL 的 GRPOTrainer 源码直接体现了这一分工:sampling_per_token_logps 在 padding 时使用 padding_side="right",而 prompt 侧保留左填充。Unsloth 的 GRPO 实现中同样存在 left_pack_padding 等函数,用于在计算 old/ref log probs 之前处理 prompt 的左填充。
拼接后的结构:保留左填充,追加右填充
关键的问题是:训练时是否移除推理阶段引入的 prompt 左填充,再重新填充?
不是。 TRL 的实现是直接保留 prompt 的左填充,并在 completion 右侧追加填充,最终结构为:
left-padded Prompt\] + \[right-padded Completion\] \[\\text{left-padded Prompt}\]\\;+\\;\[\\text{right-padded Completion}\] \[left-padded Prompt\]+\[right-padded Completion
注意力掩码由 prompt_mask(左侧填充位置为 0)和 completion_mask(右侧填充位置为 0)直接拼接,物理上不移除任何填充 token,而是通过掩码控制"能看见什么"和"哪些位置参与损失"。损失计算时 logits_to_keep 只覆盖 completion 区域,prompt 部分(包括其左填充)不参与梯度更新。
这种设计的动机是避免"先去填充、再重填"的冗余操作:生成阶段产生的左填充 Prompt 已经是可直接使用的张量,保留它并用掩码管理,比物理重建更高效。
四、左填充的隐藏代价:位置编码偏移
注意力掩码只能解决"填充 token 不被看到"的问题,解决不了"有效 token 被赋予什么位置"的问题。这是左填充最容易被忽视的风险来源。
偏移是如何发生的
对于采用 RoPE(旋转位置编码)的模型(Llama、Qwen、Mistral 等),每个 token 的注意力分数依赖于它与其他 token 之间的相对位置 。在无填充的单条序列中,第一个真实 token 的位置 ID 通常从 0 开始。但在左填充批次中,如果一条序列前面有 kkk 个 <pad>,它的第一个真实 token 的默认位置 ID 会变成 kkk,后续 token 依次递增。
这意味着:同一条 prompt,在"单独生成"和"批次中左填充生成"两种场景下,有效 token 收到的位置编码是不同的 。即使注意力掩码正确屏蔽了 <pad>,模型内部对 token 间相对距离的感知已经发生了偏移。
一个具体的错误案例
Qwen3-VL 的 batch_generate 中曾报告过一个左填充位置偏移的 bug:一条包含 18 个左填充 token 的序列,从第二个生成 token 开始,解码位置从预期序列
15, 16, 17, 18, 19, ... 15,\;16,\;17,\;18,\;19,\;\dots 15,16,17,18,19,...
变成了
15, −2, −1, 0, 1, ... 15,\;-2,\;-1,\;0,\;1,\;\dots 15,−2,−1,0,1,...
原因在于 rope_deltas 将 padding 偏移量计算了两次。更值得注意的是后果的隐蔽性:生成的文本"看起来仍然合理",但它已经从一个错误的概率分布中采样 。在控制实验中,左填充行与干净模型的 argmax 一致率从无填充对照的 98.6% 降至 83.2% ,部分 token 的 log-prob 低至 −36.83-36.83−36.83。
这组数据说明了一个重要区分:生成"读起来通顺"不等于生成"正确"。位置偏移可以在不触发明显语法错误的情况下,系统性地改变模型的输出分布。
五、使用 F.scaled_dot_product_attention 实现左填充?
如果使用 PyTorch 的 F.scaled_dot_product_attention(SDPA)手动实现左填充的因果注意力,需要同时处理两个容易出错的问题。
问题一:布尔掩码语义反转
nn.MultiheadAttention 的 key_padding_mask 中,True 表示这是填充,需要屏蔽 。但 SDPA 的布尔 attn_mask 语义相反:True 表示保留,即参与注意力计算。PyTorch 的文档澄清 issue 和对应的 PR 都专门指出了这一点,PR 中写道:
"The boolean mask semantics for
attn_maskare the inverse oftorch.nn.MultiheadAttention'skey_padding_mask. Inscaled_dot_product_attention,Trueindicates values to participate in attention. Innn.MultiheadAttention,Trueindicates values to be masked out."
从 SDPA 内部伪代码也可以看出:它先对 attn_mask 取 logical_not() 再填充负无穷,进一步确认了"True = 保留"的语义。
因此,如果手头有一个标准的填充掩码(1 表示真实 token,0 表示填充),不能直接传给 SDPA 的布尔 attn_mask,需要取反。
问题二:左填充产生完全掩码行,导致 NaN
即使用对了掩码方向,左填充的因果掩码还会触发另一个底层限制。考虑一条开头有 kkk 个填充 token 的序列,因果掩码的前 kkk 行(对应填充位置的 query)只能关注自身及之前的 token------而这些位置全是填充。加上填充掩码后,这些行变成完全被掩码:没有任何 key 可被关注。
标准 softmax 对一整行全为负无穷的输入会产生 0/00/00/0,即 NaN。SDPA 的内存高效内核明确要求不允许完全被掩码的行存在 。Hugging Face Transformers 中 _unmask_unattended 函数正是为此而设,其相关 PR 的描述是: "produces nans if sequences are completely unattended in the attention mask" 。
构造安全的左填充因果 SDPA 掩码
解决思路分为两步:合并因果掩码与 key 填充掩码,然后修复完全掩码行 。修复方式是将完全掩码行的对角线位置置为 True,使其"只关注自身",从而避免 softmax 分母为零。
以下是一个可直接运行的示例:
python
import torch
import torch.nn.functional as F
def make_left_padded_sdpa_mask(padding_mask: torch.Tensor,
is_causal: bool = True) -> torch.Tensor:
"""
padding_mask: [batch, seq_len], bool.
True = 真实 token, False = padding。(标准语义)
返回:
sdpa_mask: [batch, 1, seq_len, seq_len], bool.
True = 保留(SDPA 语义)。
"""
bsz, seq_len = padding_mask.shape
device = padding_mask.device
# 1. 基础因果掩码:True = 保留
causal_mask = torch.ones(seq_len, seq_len, dtype=torch.bool,
device=device).tril()
# 2. 屏蔽 padding 位置的 key
# padding_mask[:, None, None, :] 形状 [bsz, 1, 1, seq_len]
# True = 有效 key,False = padding key
key_mask = padding_mask[:, None, None, :]
sdpa_mask = causal_mask[None, None, :, :] & key_mask
# 3. 修复完全掩码行:每行若没有任何 True,则让对角线为 True
row_has_any = sdpa_mask.any(dim=-1) # [bsz, 1, seq_len]
diag_true = torch.zeros(seq_len, seq_len,
dtype=torch.bool, device=device)
diag_true.fill_diagonal_(True)
sdpa_mask = torch.where(
row_has_any.unsqueeze(-1), # [bsz, 1, seq_len, 1]
sdpa_mask,
diag_true[None, None, :, :]
)
return sdpa_mask
用文件中的测试用例验证:
python
padding_mask = torch.tensor([
[False, False, True, True, True], # [pad, pad, a, b, c]
[True, True, True, True, True], # [a, b, c, d, e]
])
sdpa_mask = make_left_padded_sdpa_mask(padding_mask)
print((~sdpa_mask.any(dim=-1)).sum().item()) # 0,无完全掩码行
q = torch.randn(2, 2, 5, 8)
k = torch.randn(2, 2, 5, 8)
v = torch.randn(2, 2, 5, 8)
out = F.scaled_dot_product_attention(q, k, v,
attn_mask=sdpa_mask,
is_causal=False)
print(torch.isnan(out).any().item()) # False
两个调用细节需要强调:
is_causal=False:因果逻辑已经编码在sdpa_mask的下三角结构中,如果再设is_causal=True,可能与显式attn_mask产生冲突或重复施加约束。attn_mask的形状[batch, 1, seq_len, seq_len]可以广播到多头维度;如果显式传入[batch, num_heads, seq_len, seq_len],则每头可以有不同的掩码。
六、实践中的额外陷阱与决策路径
除了位置偏移和 SDPA 掩码,还有两个值得单独留意的实现层面问题。
Batch size = 1 时的缓存注意力可能跳过掩码。 Unsloth 的一个 PR 记录了这个问题:在 Llama 和 Qwen3 的 cached decode 路径中,当 batch size 为 1 时,注意力掩码被跳过,左填充 prompt 持续关注自己的 <pad> token,输出逐渐漂移。修复后,带 8 个左填充 token 的 prompt 才与无填充 prompt 的生成结果完全一致。这类问题的隐蔽性在于:用户通常不会预期"单条生成"也需要担心填充掩码。
训练与推理模式可能自动切换填充方向。 部分框架会根据 model.eval() 状态调整 padding_side(评估模式左填充,训练模式右填充)。在混合训练与生成的手动流程中,需要显式确认当前 tokenizer 的 padding_side 符合预期。
在上述常规方案之外,如果框架支持,padding-free 或序列打包 是从根本上绕开填充问题的方案。TRL 的 AsyncGRPOTrainer 已经默认采用 padding-free 模式:不再将每个序列填充到固定长度,而是将 batch 内所有序列展平拼接,利用 FlashAttention 的变长处理能力区分序列边界,从源头消除 <pad> token 及其带来的位置编码风险。
| 场景 | 推荐填充方向 | 关键理由 |
|---|---|---|
| 仅解码器模型批量生成 | 左填充 | 保证最后一位是真实 token,采样正确 |
| 仅解码器模型SFT / 微调 | 右填充(常规) | 配合 ignore_index,位置 ID 直观 |
| 编码器模型分类 / 理解 | 右填充(常规) | 不依赖末位生成,[CLS] 取用方便 |
| GRPO 生成阶段(Prompt) | 左填充 | 与所有解码器批量生成相同 |
| GRPO 训练阶段(Completion) | 右填充 | 损失掩码干净,Completion 真实 token 左对齐 |
| 框架支持 padding-free | 不填充 | 消除掩码与位置偏移的复杂性 |
一条简明的操作顺序是:生成前显式设置 tokenizer.padding_side = "left";确保传入 attention_mask;在自定义推理或 SDPA 路径中检查 position_ids 是否基于掩码动态修正;如果使用 GRPO,保持"Prompt 左、Completion 右"的默认混合结构,不要将 padding_side 全局设为 "left"。
References
- Hugging Face TRL 中填充方向告警的 issue: https://github.com/huggingface/trl/issues/3034
- TRL
GRPOTrainer中 completion 右填充的源码片段: https://github.com/huggingface/trl/blob/c72bcc9429110a64f7ced40d65e2514b2aa8be1e/trl/trainer/grpo_trainer.py - 未掩码 padding 对攻击成功率影响的 NeurIPS 2025 Workshop 论文: https://arxiv.org/abs/2510.01238
- PyTorch SDPA 布尔掩码语义澄清 issue: https://github.com/pytorch/pytorch/issues/170400
- 对应的文档修复 PR: https://github.com/pytorch/pytorch/pull/170415
- Qwen3-VL 左填充 RoPE 位置偏移的 issue: https://github.com/Blaizzy/mlx-vlm/issues/2517
- Unsloth batch size = 1 缓存注意力跳过掩码的 PR: https://github.com/unslothai/unsloth/pull/13256
- Hugging Face
_unmask_unattended相关 PR: https://github.com/huggingface/transformers/pull/29318 - TRL
AsyncGRPOTrainerpadding-free 默认行为的报道: https://repojournal.com