在处理变长序列时,padding是一个看似简单却暗藏玄机的预处理步骤。许多开发者在从BERT迁移到GPT风格模型时,都会遇到一个令人困惑的问题:同样的数据预处理流程,为什么在BERT上工作正常,在LLaMA上却产生荒谬的输出?答案藏在decoder-only架构的生成机制中。
变长序列的处理困境
当模型需要处理一批文本时,GPU的并行计算特性要求所有输入具有相同的形状。一个批次中的序列长度各异——“你好"只有两个token,而一段完整的文章可能包含数千个token。将它们组成一个批次张量时,必须解决长度不一致的问题。
有两种基本策略:截断过长的序列,或者填充过短的序列。这两种操作看似简单,却直接影响模型的输入表示和最终的输出质量。
填充操作需要引入一个特殊的token——PAD token。这个token在模型的词汇表中通常有一个固定的ID。当序列"A"有5个token,序列"B"有3个token时,需要在序列"B"的末尾添加2个PAD token使其长度与序列"A"相同。这样两个序列才能组成一个形状为(2, 5)的张量。
但这里有一个关键问题:PAD token放在序列的哪一侧?是放在真实内容的后面(右填充),还是放在前面(左填充)?这个选择并非随意,而是取决于模型架构和使用场景。
graph LR
subgraph 原始序列
A["序列A: [BOS] 你 好 吗 [EOS]"]
B["序列B: [BOS] 是 的 [EOS]"]
end
subgraph 右填充结果
AR["序列A: [BOS] 你 好 吗 [EOS]"]
BR["序列B: [BOS] 是 的 [EOS] [PAD] [PAD]"]
end
subgraph 左填充结果
AL["序列A: [BOS] 你 好 吗 [EOS]"]
BL["序列B: [PAD] [PAD] [BOS] 是 的 [EOS]"]
end
原始序列 --> 右填充结果
原始序列 --> 左填充结果
Attention Mask:模型的"注意力导航”
仅仅添加PAD token是不够的。模型需要知道哪些位置是真实内容,哪些位置是无意义的填充。这就是attention mask的作用——一个与输入序列等长的二进制向量,用1表示"关注这个位置",用0表示"忽略这个位置"。
在自注意力计算中,attention mask通过一个简单而优雅的机制发挥作用。注意力分数在经过softmax归一化之前,mask会将需要忽略的位置的分数设为负无穷大(-inf)。经过softmax后,这些位置的权重变为0,有效地从计算中排除。
其中$M$就是mask矩阵,在需要屏蔽的位置填入$-\infty$。对于上面的例子,序列"B"的attention mask是[1, 1, 1, 1, 0, 0]——前四个位置是真实内容,后两个是填充。
flowchart TB
subgraph 注意力计算流程
direction TB
A["输入序列<br/>[BOS] 是 的 [EOS] [PAD] [PAD]"]
B["生成 Q, K, V 矩阵"]
C["计算注意力分数<br/>QK^T / sqrt(d_k)"]
D["应用 Attention Mask<br/>PAD位置加 -inf"]
E["Softmax 归一化<br/>PAD位置权重变为0"]
F["加权求和得到输出"]
end
A --> B --> C --> D --> E --> F
但这里有一个容易被忽视的细节:attention mask与因果掩码(causal mask)的交互。在decoder-only模型中,每个token只能关注它之前的token,这通过一个下三角矩阵实现。当同时存在padding mask和causal mask时,两者需要合并为一个统一的掩码矩阵。
graph TB
subgraph 掩码合并过程
PM["Padding Mask<br/>序列长度为6的例子<br/>[1,1,1,1,0,0]"]
CM["Causal Mask<br/>下三角矩阵<br/>防止看到未来"]
FM["最终掩码<br/>两者叠加"]
end
PM --> FM
CM --> FM
flowchart LR
subgraph 右填充的掩码矩阵示例
direction TB
R1["行1: [0, -∞, -∞, -∞, -∞, -∞]"]
R2["行2: [0, 0, -∞, -∞, -∞, -∞]"]
R3["行3: [0, 0, 0, -∞, -∞, -∞]"]
R4["行4: [0, 0, 0, 0, -∞, -∞]"]
R5["行5: [-∞, -∞, -∞, -∞, -∞, -∞]<br/>PAD行完全屏蔽"]
R6["行6: [-∞, -∞, -∞, -∞, -∞, -∞]<br/>PAD行完全屏蔽"]
end
subgraph 左填充的掩码矩阵示例
direction TB
L1["行1: [-∞, -∞, -∞, -∞, -∞, -∞]<br/>PAD行"]
L2["行2: [-∞, -∞, -∞, -∞, -∞, -∞]<br/>PAD行"]
L3["行3: [0, 0, 0, -∞, -∞, -∞]<br/>第一个真实token"]
L4["行4: [0, 0, 0, 0, -∞, -∞]"]
L5["行5: [0, 0, 0, 0, 0, -∞]"]
L6["行6: [0, 0, 0, 0, 0, 0]<br/>最后一个真实token"]
end
Decoder-only模型的生成机制与左填充的必要性
为什么GPT、LLaMA等decoder-only模型在推理时必须使用左填充?这涉及自回归生成的核心机制。
在生成阶段,模型每次预测下一个token。预测的逻辑是:取序列最后一个位置(即当前已生成内容的末尾)的hidden states,通过语言模型头映射到词汇表大小的logits,然后采样得到下一个token。
关键在于"最后一个位置"。假设我们使用右填充,一个长度为3的实际内容后面跟着2个PAD token:
输入:[BOS] 是 的 [EOS] [PAD] [PAD]
位置: 0 1 2 3 4 5
当生成函数尝试预测下一个token时,它会取位置5(最后一个PAD token)的logits。尽管attention mask告诉模型忽略这些PAD位置,但这个位置的hidden states仍然是被PAD token的嵌入向量主导的——它从未见过有意义的内容。从这样的logits采样,自然会产生无意义或错误的输出。
左填充则完全改变了这个局面:
输入:[PAD] [PAD] [BOS] 是 的 [EOS]
位置: 0 1 2 3 4 5
现在,最后一个位置(位置5)是真实的[EOS] token。模型的注意力机制会正确地从之前的真实内容中获取信息,生成合理的下一个token预测。
flowchart TB
subgraph 右填充的问题
direction LR
RIN["输入: [BOS] 是 的 [EOS] [PAD] [PAD]"]
RPOS["最后一个位置是 PAD"]
RLOG["从 PAD 的 hidden state 取 logits"]
ROUT["输出: 无意义/错误结果"]
end
RIN --> RPOS --> RLOG --> ROUT
subgraph 左填充的正确性
direction LR
LIN["输入: [PAD] [PAD] [BOS] 是 的 [EOS]"]
LPOS["最后一个位置是 [EOS]"]
LLOG["从 [EOS] 的 hidden state 取 logits"]
LOUT["输出: 正确的下一个token"]
end
LIN --> LPOS --> LLOG --> LOUT
Hugging Face Transformers库在检测到右填充时会明确发出警告:“A decoder-only architecture is being used, but right-padding was detected! For correct generation results, please set padding_side=‘left’ when initializing the tokenizer.”
这个问题的根源在于许多decoder-only模型在预训练时根本不使用padding——它们通常在单条序列上训练,或者使用其他技术处理变长数据。因此,这些模型的词汇表中甚至没有专门的PAD token,需要开发者在微调时手动添加。
Encoder-only模型为何偏爱右填充
BERT及其变体(RoBERTa、ALBERT等)采用完全不同的工作模式。作为encoder-only架构,它们不进行自回归生成,而是对整个输入序列进行双向编码,提取上下文表示用于下游任务。
在encoder中,每个token可以关注序列中的所有其他token(双向注意力),不存在因果约束。在这种设置下,PAD token放在左侧还是右侧,对模型的表示学习影响很小。
graph TB
subgraph Encoder双向注意力
direction TB
E1["Token 1"]
E2["Token 2"]
E3["Token 3"]
EP["PAD"]
E1 <-.-> E2
E1 <-.-> E3
E2 <-.-> E3
E1 -.->|"mask"| EP
E2 -.->|"mask"| EP
E3 -.->|"mask"| EP
end
subgraph Decoder因果注意力
direction TB
D1["Token 1"]
D2["Token 2"]
D3["Token 3"]
D1 --> D2
D1 --> D3
D2 --> D3
end
右填充成为主流选择有几个实际原因。首先,从阅读习惯上,人类自然地将"空白"放在文本末尾而非开头。其次,许多encoder模型在预训练时确实使用了右填充,模型已经学会了在序列末尾处理PAD token。第三,某些任务如序列分类,只需要取[CLS] token的表示,PAD位置根本不影响最终输出。
但这并不意味着encoder模型可以随意处理padding。attention mask仍然必须正确设置,否则模型会将PAD token纳入上下文理解,可能导致表示质量下降。
Truncation策略:当序列超出模型容量
当输入序列超过模型的最大上下文长度时,截断是必要的。与padding类似,截断也面临"从哪一头截"的问题。
Hugging Face的tokenizer提供了多种截断策略:
longest_first(默认):如果输入是一对序列,交替从两个序列中删除token直到满足长度要求only_first:只截断第一个序列only_second:只截断第二个序列
对于单序列输入,截断默认从序列末尾删除token。这在大多数情况下是合理的,因为开头通常包含重要信息(如文章标题、对话背景)。但在某些场景下,末尾信息可能更重要——比如问题末尾的关键词。
截断与padding的组合使用需要特别注意。如果设置了max_length=512和padding=True,序列会被截断到512或填充到512,最终所有序列都是512的长度。但如果设置padding='longest',则只会填充到批次中最长序列的长度(不超过max_length),这可以节省计算资源。
# 典型的tokenizer调用
tokenizer(
batch_texts,
padding='max_length', # 或 'longest', True
truncation=True, # 或 'only_first', 'only_second'
max_length=512,
return_tensors='pt'
)
位置编码与Padding的微妙交互
位置编码为序列中的每个token提供位置信息,是Transformer理解词序的关键。Padding与位置编码的交互是一个容易被忽视的技术细节。
在绝对位置编码(如原始Transformer的sinusoidal编码)中,每个位置有一个固定的编码向量。对于左填充的序列:
位置编码:[P0, P1, P2, P3, P4, P5]
实际输入:[PAD, PAD, [BOS], 是, 的, [EOS]]
PAD token使用位置编码P0和P1。但这些位置本应是序列的开头!这可能导致模型对位置信息的混淆。
解决方案是使用动态位置编码。在Hugging Face的实现中,当提供attention mask时,模型可以自动调整位置编码,使其与真实内容的位置对应。开发者也可以手动提供position_ids参数来精确控制:
# 左填充时,position_ids应该跳过PAD位置
# 对于 [PAD, PAD, [BOS], 是, 的, [EOS]]
# position_ids = [0, 0, 0, 1, 2, 3](PAD位置可任意,通常置0)
# 或更常见的:让真实内容从位置0开始
旋转位置编码(RoPE)在现代LLM中广泛使用(LLaMA、Mistral等),它通过旋转向量来编码相对位置。RoPE的一个优势是其相对位置特性——两个token之间的位置差才是关键,而非绝对位置。这使得RoPE对padding的敏感度较低,但左填充时仍需确保位置ID的正确设置。
flowchart TB
subgraph 绝对位置编码
A1["位置0的编码向量"]
A2["位置1的编码向量"]
A3["位置2的编码向量"]
A4["..."]
A1 --> A2 --> A3 --> A4
end
subgraph RoPE相对位置编码
R1["Token A 旋转角度 θ_a"]
R2["Token B 旋转角度 θ_b"]
R3["相对位置 = θ_b - θ_a"]
R1 -.->|"相对位置差"| R2
R2 --> R3
end
subgraph Padding影响
P1["绝对编码: PAD位置占用固定位置ID"]
P2["RoPE编码: 相对位置更灵活"]
P3["都需要正确的position_ids设置"]
end
序列打包:Padding的替代方案
Padding本质上是一种计算浪费。一个批次中,PAD token的数量可能远超真实内容,却仍然需要计算注意力分数(即使最终被mask掉)。
序列打包(Sequence Packing)是一种更高效的替代方案。核心思想是:将多个短序列拼接成一个长序列,用EOS token分隔,避免任何padding。
graph LR
subgraph 传统Padding
P1["序列A: [内容] [PAD] [PAD] [PAD]"]
P2["序列B: [内容] [PAD] [PAD] [PAD]"]
P3["计算量: 大量浪费在PAD上"]
end
subgraph 序列打包
PK["打包序列: [内容A] [EOS] [内容B] [EOS] [内容C] [EOS]"]
PE["零Padding浪费"]
PM["需要特殊的注意力掩码"]
end
P1 -.->|"浪费计算"| PK
P2 -.->|"浪费计算"| PK
PK --> PE --> PM
但序列打包需要特殊的注意力掩码处理。标准因果掩码允许每个token关注所有之前的token,但在打包序列中,token A不应该关注token B——它们来自完全不同的样本。
解决方案是使用"块对角因果掩码"。每个独立序列使用自己的因果掩码,不同序列之间完全屏蔽。这需要在注意力计算中引入额外的掩码逻辑。
现代训练框架如Hugging Face的Trainer和DeepSpeed都支持序列打包,可以显著提升训练吞吐量。在长序列训练场景下,打包相比padding可以提升30%以上的训练效率。
Flash Attention与变长序列处理
Flash Attention作为现代注意力计算的标配优化,在处理padding时也有其特殊考量。
标准的Flash Attention实现假设序列长度固定,对所有位置统一计算。当批次中存在大量padding时,这部分计算仍然会执行——Flash Attention无法直接跳过mask掉的位置。
针对这个问题,Flash Attention提供了flash_attn_varlen_func变体,专门处理变长序列。它接受cu_seqlens参数(每个序列的累积长度),实现真正的无padding计算。但这带来额外的复杂性:需要在预处理阶段跟踪每个样本的实际长度,并在注意力计算时传递这些信息。
实践中,许多开发者发现对于中等长度的序列,标准Flash Attention配合padding仍然比varlen版本更快——后者的额外开销可能超过节省的计算量。最佳选择取决于具体的序列长度分布和硬件配置。
训练与推理的Padding策略差异
训练和推理阶段对padding的要求往往不同,这是许多"模型训练正常但推理异常"问题的根源。
在监督微调(SFT)阶段,模型需要学习预测每个位置的下一个token。对于因果语言模型,即使使用左填充,训练时损失函数通常只计算真实内容的token(通过label mask实现)。PAD位置的预测被忽略,不参与梯度更新。
# 典型的训练数据准备
# 左填充的输入
input_ids = [PAD, PAD, BOS, tok1, tok2, EOS]
attention_mask = [0, 0, 1, 1, 1, 1]
# 标签:通常将PAD位置设为-100(PyTorch忽略)
labels = [-100, -100, BOS, tok1, tok2, EOS]
但推理时的情况完全不同。生成函数需要从最后一个真实token开始采样,这就是为什么左填充在推理时至关重要。如果模型在训练时习惯了右填充(某些早期教程的做法),在推理时切换到左填充可能导致分布偏移。
最佳实践是在训练和推理中使用一致的padding策略。对于decoder-only模型,这通常意味着都使用左填充。
PAD Token的选择与陷阱
许多现代LLM(如早期版本的LLaMA)在预训练时没有专门的PAD token。开发者需要在微调前决定如何处理这个问题。
有几种常见方案:
使用EOS token作为PAD token。这是最简单的方案:
tokenizer.pad_token = tokenizer.eos_token
但这有一个潜在问题:模型在训练时会看到EOS token既作为序列结束标志,又作为无意义的填充。这可能导致模型对EOS token的理解产生混淆,在推理时不正确地提前停止生成。
使用UNK token作为PAD token。这是Meta官方LLaMA配方推荐的做法:
tokenizer.pad_token = tokenizer.unk_token
UNK token在正常数据中出现频率较低,用它作为padding对模型的影响较小。
创建新的PAD token。这是最干净的方案:
tokenizer.add_special_tokens({'pad_token': '[PAD]'})
model.resize_token_embeddings(len(tokenizer))
新增的PAD token需要随机初始化,并在训练中学习其表示。如果使用LoRA等参数高效微调方法,token embedding的更新可能需要额外处理。
flowchart TB
subgraph 方案对比
direction TB
EOS["使用EOS作为PAD<br/>简单但有混淆风险"]
UNK["使用UNK作为PAD<br/>Meta推荐,风险较低"]
NEW["创建新PAD token<br/>最干净,需训练embedding"]
end
subgraph 决策因素
F1["微调预算"]
F2["对生成质量的要求"]
F3["是否使用LoRA"]
end
F1 --> EOS
F2 --> NEW
F3 --> UNK
动态批处理与高效Padding
在实际部署中,固定的max_lengthpadding会导致大量计算浪费。假设最大长度是4096,但批次中大多数样本只有几百个token,那么超过90%的计算都在处理padding。
动态批处理通过将相似长度的样本分到同一批次来优化这个问题。这通常通过"分桶"(bucketing)实现:将数据集按长度排序,相近长度的样本进入同一个桶,每个桶独立组成批次。
graph TD
subgraph 数据集
D["原始样本<br/>长度各异"]
end
subgraph 分桶策略
B1["桶1: 长度0-100"]
B2["桶2: 长度100-200"]
B3["桶3: 长度200-300"]
B4["..."]
end
subgraph 批次生成
BT1["批次1<br/>padding到~100"]
BT2["批次2<br/>padding到~200"]
end
D --> B1
D --> B2
D --> B3
D --> B4
B1 --> BT1
B2 --> BT2
更进一步的优化是"padding到批次内最长"(padding='longest'),每个批次只填充到该批次中实际最长样本的长度,而非全局最大长度。这在推理服务中尤其重要,可以显著降低延迟和计算成本。
连续批处理(Continuous Batching)在vLLM等推理框架中实现了更激进的优化:不同请求可以有不同的批次位置,当某个请求完成后,其占用的位置可以立即分配给新请求,无需等待整个批次完成。这彻底改变了传统的padding范式。
常见问题诊断与解决方案
在实际开发中,padding相关问题通常表现为以下症状:
症状:模型生成无限循环或提前停止
- 原因:PAD token使用了EOS token,模型混淆了两者
- 解决:使用单独的PAD token或UNK token
症状:推理输出完全无意义
- 原因:使用了右填充,生成函数取了PAD位置的logits
- 解决:设置
tokenizer.padding_side = 'left'
症状:训练loss为NaN或突然飙升
- 原因:attention mask未正确设置,模型关注了padding位置
- 解决:确保attention mask与input_ids长度一致,PAD位置为0
症状:微调后模型生成质量下降
- 原因:新增PAD token后未正确resize embedding,或embedding未充分训练
- 解决:使用
model.resize_token_embeddings()并考虑对embedding层进行单独的预训练
症状:多卡训练时padding不一致
- 原因:分布式采样器打乱了样本顺序,不同卡的样本长度分布不同
- 解决:使用分桶采样器,确保同一批次内样本长度相近
flowchart TB
subgraph 问题诊断流程
P1["检查 padding_side 设置"]
P2["验证 attention_mask 形状和值"]
P3["确认 PAD token 选择"]
P4["检查 position_ids 配置"]
P5["验证 labels 中的 -100 设置"]
end
P1 --> P2 --> P3 --> P4 --> P5
subgraph 常见错误
E1["右填充 + Decoder推理"]
E2["EOS 作为 PAD token"]
E3["attention_mask 全为1"]
E4["忘记 resize_token_embeddings"]
end
最佳实践总结
对于decoder-only模型(GPT、LLaMA、Mistral等):
- 推理时使用左填充
- 训练时也建议使用左填充以保持一致性
- 避免使用EOS token作为PAD token
- 正确设置attention mask,PAD位置为0
对于encoder-only模型(BERT、RoBERTa等):
- 右填充是标准做法
- 确保attention mask正确覆盖所有PAD位置
通用建议:
- 使用
padding='longest'而非固定的max_length以节省计算 - 在大规模训练中考虑序列打包代替padding
- 始终验证attention mask的形状和值是否正确
- 训练和推理使用相同的tokenization配置
Padding看似是一个简单的预处理步骤,但它与注意力机制、位置编码、生成算法等多个核心组件紧密相连。理解这些交互关系,才能在模型开发中避免那些令人困惑的"幽灵bug"。
参考
- Hugging Face Transformers Documentation: Padding and Truncation
- Dao, Tri, et al. “FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness.” NeurIPS 2022.
- Kaitchup Blog: Padding Large Language Models
- Junrong Lin: Why Current LLM Uses Left Padding
- AI21 Labs: Model-Agnostic Padding Minimization for LLM Training
- Hugging Face Blog: Efficient LLM Pretraining with Packed Sequences
- PyTorch Forums: Right vs Left Padding Discussion
- Stack Overflow: Padding Side Matters in Decoder Models