我最初接触 Tokenizer Padding 时,以为它只是“给短句子末尾补一堆没意义的占位符”,直到有一次训练 loss 曲线莫名抖动、单独预测和批量预测结果对不上,才意识到这个环节藏着不少约定。这篇笔记,就是我在某短文本分类项目、某序列标注项目和后续生成任务里,反复踩坑之后整理出来的训练与推理阶段 padding 用法总结。
内容围绕三件事展开:padding 到底在解决什么问题、训练和推理两个阶段各自的正确用法、以及一旦没处理好会出现哪些典型事故和对应的排查思路。最后附上一套可以直接改动使用的代码模板。适合刚接触预训练模型的同学,也适合已经跑了几个项目但总在 batch 环节卡住的工程师。
1. Padding 不是凑数的:先弄清楚三种等待模式在解决什么问题
1.1 矩阵计算要求每个 batch 内长度一致,这是 padding 存在的根本原因
Transformer 模型内部绝大部分计算都是张量运算,attention 的核心操作依赖(batch, seq_len, hidden_dim)这样的形状。一个 batch 里如果同时出现长度为 3、5、9 的句子,这三条样本根本没法叠成一个规整的矩阵参与矩阵乘法。你不是在“给模型补词”,你是在“把一张长短不齐的桌子修成矩形”。
但这里有一个很容易被忽略的矛盾点:形状上补齐了,语义上不能补。padding token 只是为了让长度对齐而存在的空位,它在输入序列里并不代表任何真实信息。所以 Transformer 里才有对应的attention_mask,模型在计算注意力分数时要把这些空位的位置权重遮掉。
打个比方:一桌人合影,为了拍出一张整齐的矩形照片,个子矮的人脚下要垫箱子,但垫箱子不是让照片里多一个人,后期必须把箱子修掉。padding 就是垫箱子,attention mask 就是后期修图。前者解决整齐问题,后者解决“别让人误以为箱子是人”的问题。
1.2 三种常见 padding 模式:longest、max_length、动态分桶
我自己第一次写代码时,只知道padding=True,后来才发现这背后其实有几种不同的取舍,分别对应不同的训练场景。
| 模式 | 基准长度 | 是否定长 | 显存波动 | 实现难度 | 适用场景 |
|---|---|---|---|---|---|
| longest | 当前 batch 内最长样本 | 不定,每个 batch 不同 | 较大 | 低 | 训练阶段长度差异大的任务 |
| max_length | 固定长度,如 512 | 恒定 | 小 | 低 | 分布式训练、需固定 shape 的部署 |
| 动态分桶 | 每个桶内最长样本 | 桶内定长,桶间不定 | 中等 | 中 | 大批量训练、吞吐优先 |
padding="longest"的好处是几乎没有多余计算,缺点是每个 batch 的序列长度都在变,GPU 利用率会有波动。padding="max_length"的好处是 shape 稳定,坏处是短样本被白白补一大截,长样本又可能被截断。动态分桶是折中方案:先把样本按长度分到若干桶里,每个桶内部再做 longest padding,这样既减少了无意义的 pad 占比,又不会剧烈波动。
从实践来看,训练阶段我会优先使用 longest 或动态分桶,除非是分布式多卡训练中对固定 shape 有硬性要求。推理阶段则要单独考虑,后面专门讲。
1.3 pad token 本质上是一个真实的符号,不是数字 0
padding 并不是简单往input_ids里填 0。pad_token_id是词汇表里一个实际存在的 token id,它有自己的 embedding 向量。你填进去的是一个有向量表示的符号,只是模型通过 mask 知道这个位置不该产生真实信息。
很多人会把input_ids里的 pad 位置和attention_mask里的 0 混为一谈。实际上input_ids里填的是pad_token_id,attention_mask里对应位置才是 0。一个是内容符号,一个是参与计算的开关,两回事。
还要注意,并不是所有 tokenizer 都自带 pad token。我用过的某些开源模型,Tokenzier 默认没有设置pad_token,如果不处理,训练时就会直接报错或者生成奇怪的结果。此时需要手动添加特殊 token,具体做法在后面代码模板里会写。
2. 训练阶段:真正做对了才不会被维度错误教做人
2.1 tokenizer 参数的正确组合:padding、truncation、max_length 缺一不可
训练阶段最常见的写法是这样:
encoded = tokenizer( batch_texts, padding="longest", # 或者直接写成 padding=True truncation=True, max_length=256, return_tensors="pt", )这里要注意几个容易混淆的细节。
padding=True是padding="longest"的等价写法,它会根据当前这一批数据的最大长度来补。padding="max_length"则不同,它需要配合max_length作为补齐目标长度。max_length这个参数同时也承担 truncation 的长度上限,所以一旦设了它,短文本会被补长,长文本会被截断,整套行为都在一个参数里控制。
训练时我建议至少把truncation=True一直开着。否则当某个 batch 里出现一条超长文本,而你又设置了max_length却没有 truncation,tokenizer 可能不会像你预期的那样截断,而是把超出的内容原样返回,直接打乱后面的形状假设。
另外提一句,tokenizer 的 padding 是针对一次调用里的多条文本而言的。如果你单条文本传入tokenizer(text),就算写了padding=True,也不会有什么效果,因为单条文本不需要对齐,它是“自己和自己对齐”。
2.2 labels 同样要 padding,而且 pad 值必须用 -100
padding 不只是输入侧的事。序列标注、文本生成这类 token 级任务里,labels也是序列,长度必须和input_ids对齐,否则 forward 阶段计算 loss 时维度对不上。
但 labels 里 padding 位置不能随便填,最标准的做法是填-100。PyTorch 的CrossEntropyLoss默认把ignore_index设为-100,也就是说这个位置不参与梯度计算。Transformers Trainer 的大部分任务也都沿用了这个约定。
举个例子,某序列标注样本经过 padding 后:
input_ids: [101, 2023, 2003, 1996, 102, 0, 0] labels: [101, 2023, 2003, 1996, 102, -100, -100]这里的0是 pad token id,而 labels 里对应位置是-100。如果 labels 里也填 0,相当于告诉模型“padding 这个位置我要预测 id 为 0 的 token”,模型会把大量计算浪费在学 padding 位置的规律上,训练曲线会变得非常不稳。
提示:分类任务的 labels 是标量,不需要 padding。需要 padding 的是那些 labels 本身也是 token 序列的任务,比如序列标注、摘要生成、机器翻译、文本生成。
2.3 DataCollator 是训练侧管理 padding 的核心,但别完全依赖默认行为
在 Transformers 训练流程里,DataLoader每次取出的原始样本长度各不相同,需要一个collate_fn把它们整合成 batch。DataCollatorWithPadding就是为了这个场景设计的,它内部会调用tokenizer.pad,把 input 侧处理得很干净。
from transformers import DataCollatorWithPadding collator = DataCollatorWithPadding(tokenizer=tokenizer)但我后来发现一个问题:它处理 input 没问题,但 labels 的 pad 值替换成-100这件事,在不同版本和不同任务里并不总是自动发生。为了让自己睡得踏实,我最终选择了自定义 collator,把 labels 处理逻辑写在明面上。
如果你不用 Transformers 这套 Trainer,而是手写 PyTorch 训练循环,通常会用到torch.nn.utils.rnn.pad_sequence:
from torch.nn.utils.rnn import pad_sequence input_ids = pad_sequence( [torch.tensor(item["input_ids"]) for item in batch], batch_first=True, padding_value=tokenizer.pad_token_id, ) attention_mask = pad_sequence( [torch.tensor(item["attention_mask"]) for item in batch], batch_first=True, padding_value=0, ) labels = pad_sequence( [torch.tensor(item["labels"]) for item in batch], batch_first=True, padding_value=-100, )pad_sequence只会帮你把序列补齐,不会自动选择合理的 pad 值,input 的 pad 值、label 的 pad 值、mask 的 pad 值全靠手动传。这里最容易漏的就是 mask 那一行,我之前就漏过,后面仔细讲。
3. 推理阶段:同样的 padding,为什么坑比训练还多
3.1 attention mask 一旦丢失,批量推理结果会出现系统性的偏差
很多人觉得模型训练完就稳定了,推理时少传点参数无所谓。这个想法在 padding 场景特别危险。
推理阶段如果不传attention_mask,模型仍然会执行完整的 attention 计算,padding 位置会被当作真实 token 参与注意力权重分配。后果不是直接报错,而是结果悄悄变差。具体表现有几种:序列 mean pooling 时被 padding 稀释,注意力权重被吸引到无意义的 pad 位上,分类边界附近的样本预测被系统性拉偏。
我遇到过的情况是:同一个文本,单独预测的结果是对的,放进 batch 之后预测反而错了。后来排查出来,就是因为推理脚本里组装 batch 时只放了input_ids,没放attention_mask。
验证方法也很简单:拿一条长度差异明显的样本,单独跑一次,再塞进一个只有短文本的 batch 里跑一次,对比两次 logits。如果结果不一致,八成就是 mask 或者 padding 相关问题。
3.2 左 padding 与右 padding:生成模型的最后一位必须是真实内容
padding 方向这事,训练阶段不太容易感知,但推理阶段会直接影响生成质量。
对 decoder-only 这类自回归模型来说,生成逻辑是从输入序列的末尾继续往后预测。如果使用的是右 padding,真实内容会被挤到序列开头,最后一个位置是 pad token 的隐状态。当你依靠最后一个位置的隐状态去预测下一个 token 时,拿到的就是一个无意义位置的特征。这也是为什么很多生成任务的推理批次里要求左 padding。
tokenizer.padding_side = "left"设置了左 padding 以后,真实内容靠右,最后一个非 pad 位置就是输入的真实末尾,生成起点是正确的位置。
不过左 padding 也不是设置完就万事大吉。有些实现里position_ids仍然从 0 开始编号,左侧的 pad 位置也会被分配一个位置编号,这会导致位置信息整体偏移。签名侧的策略是:改padding_side之后,自己打印一次input_ids、attention_mask、position_ids,确认最终喂进模型的三个张量在语义上是对齐的。
3.3 单条推理与批量推理:策略不同,别复用同一段代码
单条推理不需要 padding,必要性为零。批量推理则必须处理长度对齐,但我不建议无脑固定一个max_length。
固定长度的问题有两个:短文本被白白 pad 一截,长文本又被截断导致关键信息丢失。更好的做法还是按当前 batch 内最长样本做动态 padding,把 truncation 上限设高一点,只用来兜底。代码上就是padding="longest",配合truncation=True。
另一个经验是:训练阶段用 longest,推理阶段也尽量保持 longest。虽然理论上模型能适应不同 padding 方式,但完全一致的预处理策略会让训练和推理之间的分布偏移最小。
4. 踩坑实录:三次 padding 事故的完整排查链路
4.1 事故一:attention mask 忘传,线上分类精度掉了一截
当时某个二分类模型在训练评估时指标正常,部署到线上推理服务后,发现中长文本的预测结果总是偏向某一个类别。短文本基本正常,长文本越是后面 pad 多,偏差越明显。
排查链路我如实记在这里:
- 先怀疑后处理逻辑,单独构造一条短文本测试,预测正常。
- 再把这条短文本和一条长文本放进同一个 batch,发现同样的内容预测结果变了。
- 打印 batch 里的
input_ids,短文本后面补了一串 pad token。 - 检查送入模型的 dict,发现只有
input_ids和token_type_ids,没有attention_mask。
修复方案是在推理脚本里显式传入attention_mask。修复后再跑同一条样本,单测和 batch 结果完全一致。
这也是我后来一直坚持的经验:只要看到“单独跑对,batch 跑不对”,第一反应就是去查 padding 和 mask,而不是先去调模型参数。
4.2 事故二:labels 没有 padding,形状对不上导致 loss 计算崩溃
做序列标注任务时,有一次我偷懒没有自定义 collator,直接用了框架默认的数据整理流程。前几个 batch 正常,后来某个 batch 里所有样本长度都比较短,而最长样本和最短样本差异很大,loss 计算报错,提示 logits 和 labels 的形状不一致。
排查链路:
- 看报错信息,定位到
loss = criterion(logits.view(-1, num_labels), labels.view(-1))这一行。 - 打印 logits 形状,发现 seq_len 维度对应的是 batch 内最长 padding 后的长度。
- 打印 labels 形状,发现它停留在每条样本的原始长度,没有被补齐。
- 根因是训练数据构造时 labels 没有经过 padding 逻辑。
修复方案是在数据处理阶段让 labels 走一遍和 input_ids 完全相同的 padding 流程,并且把新增位置填为-100。
之后我每次写训练数据处理,都会把 input 和 labels 放到同一个函数里统一处理,避免出现“一个补了另一个没补”的状态。
4.3 事故三:固定 max_length 导致显存被 padding 吃满,吞吐上不去
有一个文本生成项目,数据长度长尾分布严重。大部分样本只有几十个 token,少部分样本接近几百甚至上千 token。我一开始图省事,直接设了max_length=512,训练时显存一直贴着上限,batch size 不敢调大,吞吐低得让人着急。
排查链路:
- 对数据集的 token 长度做分布统计,发现中位数只有 64,95 分位才是 512 左右。
- 计算 padding 占比,发现整个训练过程中超过六成的计算都在处理无意义的 pad 位置。
- 把
padding="max_length"改成padding="longest",显存占用立刻降下来,batch size 可以往上提。
后续我又进一步做了长度分桶,把相似长度的样本放在一起,同一桶内再做 longest padding。吞吐比固定长度提升了将近一倍,且显存波动在可控范围内。
这个教训让我形成习惯:任何用固定max_length的方案,都得先问一句“我的数据长度分布真的支持这个固定值吗”。如果分布很集中,固定长度没问题;如果长尾严重,动态 padding 几乎一定是更优解。
4.4 三次事故的共同根因
回头看,三个事故其实指向同一个本质:padding 有“形状补齐”和“语义屏蔽”两层含义,任何一层没做好都会出问题。
| 事故 | 症状 | 根因 | 修复 |
|---|---|---|---|
| 分类精度掉点 | 单独推理正确,batch 推理偏移 | attention mask 丢失 | 显式传attention_mask |
| loss 崩溃 | logits 与 labels 形状不匹配 | labels 未同步 padding | labels 补到同长度并填 -100 |
| 显存吃满 | GPU 占用率高但有效计算少 | 固定长度导致大量 pad | 动态 padding 或长度分桶 |
5. 一套可直接抄的训练 / 推理 padding 代码模板
5.1 训练侧:自定义一个可靠的 collator
先确认 tokenizer 有没有 pad token,没有就手动加。这里有一个容易忽略的点和模型 embedding 的关系,如果加载模型之后才新增了 pad token,需要重新调整 embedding 矩阵大小。
from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments model_name = "some-base-model" tokenizer = AutoTokenizer.from_pretrained(model_name) if tokenizer.pad_token is None: tokenizer.add_special_tokens({"pad_token": "[PAD]"}) # 如果模型是在添加 pad token 之前加载的,必须 resize embedding model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2) if len(tokenizer) != model.config.vocab_size: model.resize_token_embeddings(len(tokenizer))然后定义一个自定义 collator,把 input 和 labels 的 padding 逻辑放在同一个地方,避免出现“只补 input 不补 label”的问题。
import torch class UnifiedCollator: def __init__(self, tokenizer): self.tokenizer = tokenizer def __call__(self, features): # 让 tokenizer 按 batch 内最长长度补齐所有序列字段 batch = self.tokenizer.pad( features, padding=True, return_tensors="pt", ) if "labels" in batch: # 把 labels 中 padding 占位统一替换为 -100 batch["labels"] = batch["labels"].masked_fill( batch["labels"] == self.tokenizer.pad_token_id, -100 ) return batchTrainer 里直接指定:
trainer = Trainer( model=model, args=TrainingArguments(output_dir="./output"), train_dataset=train_dataset, eval_dataset=eval_dataset, data_collator=UnifiedCollator(tokenizer), )在使用 Trainer 时,很多同学会直接用默认 collator,结果就是 labels 的处理规则不透明。写上这个自定义版本之后,整个 padding 链路就在你自己掌控下了。
5.2 推理侧:批量分类与批量生成
分类推理就是按 batch 动态 padding,注意把attention_mask也放进 model 的入参:
def batch_predict(texts, tokenizer, model, device, max_len=256): encoded = tokenizer( texts, padding="longest", truncation=True, max_length=max_len, return_tensors="pt", ) encoded = {k: v.to(device) for k, v in encoded.items()} with torch.no_grad(): outputs = model(**encoded) probs = outputs.logits.softmax(-1) return probs生成任务则要先检查 padding 方向。对 decoder-only 模型,推理批量生成前把padding_side切到left:
tokenizer.padding_side = "left" encoded = tokenizer( texts, padding=True, truncation=True, max_length=512, return_tensors="pt", ) encoded = {k: v.to(device) for k, v in encoded.items()} generated_ids = model.generate( **encoded, max_new_tokens=64, )这里最心虚的地方是position_ids。部分模型在 left padding 时会正确生成,部分则不会,建议打印一次序列送入前后的位置索引,确认真实 token 的位置编号正确。
5.3 性能优化:pad_to_multiple_of 与长度分桶
如果动态 padding 已经用上了,还想继续优化,有两个方向值得试。
第一个是pad_to_multiple_of。这个参数可以把 padding 后的长度对齐到某个数字的倍数。大部分 GPU 加速库对对齐后的形状处理更友好,实测在小 batch 场景下有一定加速效果,虽然没有夸张到翻倍,但属于零成本收益。
encoded = tokenizer( texts, padding="longest", pad_to_multiple_of=8, return_tensors="pt", )第二个是长度分桶。做法是先统计所有训练样本的 token 长度,按长度排序后切成若干桶,每个桶内动态 padding,并且每个 epoch 开始前打乱桶的顺序。这样既能避免浪费,又不破坏训练随机性。
def build_buckets(texts, bucket_size=100): lengths = [len(tokenizer(text)["input_ids"]) for text in texts] order = sorted(range(len(texts)), key=lambda i: lengths[i]) buckets = [order[i : i + bucket_size] for i in range(0, len(order), bucket_size)] return buckets对大批量训练来说,长度分桶带来的吞吐提升往往比调整学习率还明显。我测试过的某个序列标注任务里,固定长度改成动态分桶后,训练耗时降了大概一半。
6. 我的几条实操体会
写了这么多,最后还是想把自己最直接的几条经验留在这里,比代码更值钱。
第一,把 padding 当成一套完整的协议来对待:input_ids负责形状,attention_mask负责内容参与度,labels里的-100负责梯度屏蔽。三条缺一不可,每次组装 batch 时按这个清单自查。
第二,训练和推理阶段的 padding 策略尽量保持一致。你可以训练时用 longest、推理时用固定长度,但这么做省不了多少事,反而引入不必要的分布偏移。我自己已经改成两套代码全部走动态 padding。
第三,不要盲信默认 collator。训练脚本里如果发现数据处理环节有模糊不清的地方,优先写一个显式的 collator,把 padding 规则打印出来确认,哪怕多花十分钟也值得。
第四,排障时记住一个优先级:先查 data,再查 model,最后才调超参。凡是出现“单条对、batch 错”“训练时好时坏”“显存莫名偏高”这类问题,基本都能在 padding 和 mask 这一步找到答案。
第五,pad token 缺失的问题在任何模型上都有可能遇到。函复的适配方式就两行代码,但少了这两行,后续所有 padding 逻辑都会建立在沙地上。
整理完这份笔记之后,我自己再写数据处理逻辑时,已经把这些规则当成了默认动作。如果你顺着这套流程走一遍,应该也能少踩很多原本看不到的坑。