做 NLP 模型的同学应该都经历过这种场景:单条样本前向没问题,一拼 batch 就报 shape 不一致;或者训练 loss 一直正常,一上线推理生成结果就乱码、重复、崩到没边。排查到最后,大多数时候问题不在网络结构,而是藏在 tokenizer 的 padding 用法里。这篇文章是我从几个模拟项目里整理出来的实操笔记,主要记录训练和推理两个阶段里 padding 分别该怎么设、有哪些坑、怎么验证。
这里说的 padding,不是网络层里的 padding,而是 tokenization 阶段的 padding。它的核心使命只有一个:把长度不等的 token 序列补齐到同一个长度,让数据能塞进 tensor 里做批量计算。听起来很简单,但做训练和做推理时,处理方式完全不一样,用错了连报错都报得让你摸不着头脑。下面我按训练、推理、踩坑、经验四个维度展开,内容偏实操,代码尽量可跑。
1. 先搞清楚 Padding 到底在解决什么问题
1.1 变长序列与定长张量之间的矛盾
文本数据天然是变长的。一句话可能 5 个 token,一段话可能 500 个 token。而深度学习框架里的 tensor,无论是batch_size, seq_len还是batch_size, seq_len, hidden,都要求同一维度上的每个 batch 样本长度一致。于是我们需要 padding,也就是在较短序列的尾部或头部补上无意义的内容,让整批数据对齐到一个共同长度。
这个长度可以是全数据集统一 max length,也可以是每个 batch 内部的最大长度。前者叫静态 padding,后者叫动态 padding。二者的差别只在计算效率和内存占用,但很多人写代码时习惯图省事,固定一个超大的 max length,结果就是 GPU 显存被大量 padding token 白白吃掉。
另外要明确一点:padding 不是给序列加了一个字符,而是往 token id 序列里塞了pad_token_id。模型在 embedding 层会把 pad token 也映射到一个向量,如果 attention mask 不处理,模型会把 padding token 当成真实内容参与计算,语义就乱了。所以 padding 必须和 attention mask 配合使用。
1.2 Padding Token、Attention Mask 与位置信息的关系
一个完整的 tokenizer 输出通常包含三样东西:input_ids、attention_mask、token_type_ids(非必需)。input_ids里填充了pad_token_id,attention_mask里对应位置是 0,真实 token 位置是 1。模型前向计算时,attention mask 告诉自注意力机制哪些位置不用管,哪些位置要参与计算。
还有一点容易被忽略:位置编码。大多数 Transformer 模型使用的是绝对位置编码或者旋转位置编码,位置 id 是根据 token 在序列中的位置生成的。如果做的是右侧 padding,那么真实 token 的位置不受影响;如果做左侧 padding,那么每个真实 token 的绝对位置会发生偏移。
对于绝对位置编码的模型,左侧 padding 会让位置 id 整体偏移,这可能导致模型看到的位置信息和训练时不一致。不过现在很多模型都采用相对位置编码或 RoPE,左侧 padding 的位置漂移影响相对小一些,但 Transformer-XL 这类模型就得额外小心。
1.3 训练和推理对 Padding 的核心诉求差异
训练阶段,padding 主要影响计算效率和损失计算。我们希望把 batch 里所有样本对齐,但又希望 padding token 越少越好,所以通常用动态 padding;同时计算 loss 时要用 label mask 把 padding 位置过滤掉,否则模型会学着预测出 PAD token,或者 loss 被一大堆 padding 位置稀释。
推理阶段,padding 影响的不仅是速度,还有生成质量。尤其对 decoder-only 的自回归模型来说,padding 加在左边还是右边,会直接影响生成结果。右侧 padding 会让 padding token 退出 attention 窗口的能力变弱,进而让模型产生错误的 token 分布。关于这一点,我在后面的章节里会详细讲。
从整体来看,padding 本质上是一个“数据对齐”手段,它不等于“给模型添加有效信息”。所以所有用到 padding 的地方,都必须配套掩码机制,让模型“看得到”哪些位置是假的。理解了这个基本点,下面的各种细节才不会晕。
2. 训练阶段的 Tokenizer Padding 用法
2.1 静态 Padding:设置 max_length 后的固定长度策略
静态 padding 是指在 tokenize 时直接指定max_length,所有样本都截断或补齐到同一个长度。代码上通常是这样的:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("some-llm") encoded = tokenizer( texts, max_length=512, padding="max_length", truncation=True, return_tensors="pt", )这里padding="max_length"表示按max_length长度做 padding。优点很明显:代码简单,dataset 输出的张量形状固定,DataLoader 里不用做任何额外处理,也方便复现。缺点是显存浪费非常严重。
我实际测试过一个 token 平均长度在 200 左右的训练集,如果把 max length 统一设为 512,padding token 占比大约超过 60%。这意味着模型计算时有一大半位置在“空转”,不仅浪费算力,还会让 attention 的 softmax 分布被 pad token 稀释。虽然理论上 attention mask 能让 padding token 不参与计算,但矩阵运算还是会把 padding 部分的 logits 算出来,只是最后被 mask 掉,计算吞吐照样被拖慢。
静态 padding 适合什么场景呢?如果你的文本长度分布非常集中,或者 batch size 很小,同时对训练吞吐没有太高要求,静态 padding 的简单性可以带来很大便利。另外在一些分布式采样、TPU 训练场景下,固定 shape 能避免动态 shape 带来的编译开销,这种取舍是值得的。
但如果你跑的是大模型微调,显存紧张,我建议不要一上来就无脑固定到 512 或 1024。先统计一下数据集的长度分布,比如 90% 样本长度不超过 256,那你把 max length 设成 256 是合理的;如果设成 512,就纯属浪费了。
2.2 动态 Padding:按 batch 最大值补齐,效率更高
动态 padding 的思路是:每个 batch 内部的文本都补齐到“本 batch 的最大长度”,而不是全局最大长度。这样每个 batch 的长度不一样,但都能满足 tensor 的矩阵运算要求。
具体做法是在自定义collate_fn里对 batch 内的 token 输出做二次 padding。一般流程是:
- 先让 tokenizer 对每条文本单独 tokenize,不设置 padding,只做截断。
- 在 DataLoader 的
collate_fn中拿到一个 list of encoded dict。 - 找到这个 batch 中
input_ids的最大长度。 - 用 tokenizer 的
pad方法,把这批样本统一 pad 到该长度。 - 生成对应的
attention_mask,并构造 label。
from torch.utils.data import DataLoader from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("some-llm") tokenizer.pad_token = tokenizer.eos_token # 如果原模型没有 pad_token def collate_fn(batch): texts = [item["text"] for item in batch] encodings = tokenizer( texts, truncation=True, max_length=1024, padding=True, # 动态 padding 的最简写法 return_tensors="pt", ) labels = encodings["input_ids"].clone() # 把 padding 位置替换为 -100,计算 loss 时忽略 labels[labels == tokenizer.pad_token_id] = -100 return { "input_ids": encodings["input_ids"], "attention_mask": encodings["attention_mask"], "labels": labels, } dataloader = DataLoader(dataset, batch_size=32, collate_fn=collate_fn)注意padding=True在大多数 tokenizer 实现里就等于动态 padding,它会自动把当前 batch 补齐到最大长度。如果你用的是其他库,也可以手动做:
max_len = max(len(item["input_ids"]) for item in batch) padded = tokenizer.pad( batch, padding="max_length", max_length=max_len, return_tensors="pt", )动态 padding 的收益在长尾分布的数据集上非常明显。我做过一次对比:同样 1000 条样本、batch size 16,动态 padding 比固定 512 长度的静态 padding 训练速度快了大概 35%,显存占用也低了将近 40%。原因很简单:绝大多数 batch 的最大长度都远小于 512,白白计算的 token 少了一大半。
2.3 Loss 计算时如何避开 Padding 位
训练语言模型时,我们通常让模型预测下一个 token。输入input_ids和标签labels有对齐关系,但在标签里,padding token 对应的位置是不应该计算 loss 的。
做法很统一:把labels里所有pad_token_id替换成-100。因为常规的交叉熵损失函数里,ignore_index=-100是默认值,计算 loss 时会自动跳过这些位置。
这里有个细节:在自回归语言模型训练里,标签需要向左偏移一位。很多现成 trainer 会自动做 shift,但如果你自己写训练循环,就很容易搞错。
我见过一个很隐蔽的 bug:有人在构造 label 时把整条序列的input_ids复制了一份,然后把 padding 位改成-100,但没做 shift。这等于让模型在每个位置学习预测“当前 token 自己”,而不是“下一个 token”。模型训练 loss 会很快下降到很低,但是生成效果稀烂。
正确做法是这样:
labels = encodings["input_ids"].clone() labels[labels == tokenizer.pad_token_id] = -100 # 如果模型没有自动 shift,自己实现 LM head 时要做一次偏移 # 例如:shift_logits = logits[..., :-1, :],shift_labels = labels[..., 1:]如果你用某个主流的因果语言模型训练框架,它会自动处理 shift,你只需要保证 labels 里 padding 位是-100。如果你手写 attention 或者自定义损失函数,一定要在 loss 计算前做对齐检查,把 shape 打出来对比一下。
2.4 训练时 position_ids 的处理
很多人在用 tokenizer 做 padding 时忽略了position_ids,因为大多数模型会从input_ids自动生成位置 id。但如果你用左侧 padding,或者手动改过 attention mask,位置 id 也要跟着调整。
以绝对位置编码模型为例,position_ids应该只对真实 token 递增,padding 位置随便给一个占位值即可。大多数情况下框架会自己处理,但在一些自定义模型里,位置 id 一旦错位,训练和推理会表现得很不一致。
更稳的做法是直接让 tokenizer 返回return_attention_mask=True,然后用 attention mask 去计算 position ids:
position_ids = attention_mask.long().cumsum(-1) - 1 position_ids[attention_mask == 0] = 0这样生成的position_ids在真实 token 上从 0 递增,padding 位置统一为 0。虽然不是唯一的做法,但在绝大多数模型里是合理的。
3. 推理阶段的 Padding 细节(容易被忽略)
3.1 Padding Side 决定生成质量:Left vs Right
推理阶段最容易被忽略、也最容易出问题的,就是 padding side。padding_side决定 padding token 是加在序列左侧还是右侧。在训练阶段,主流做法通常是右侧 padding;但在 decoder-only 模型的自回归生成场景里,左侧 padding 才是更合理的选择。
为什么这么说?因为 decoder-only 模型在生成时,模型会对整条序列做 attention 计算。如果 padding 加在右侧,那么对于一个 batch 里较短的真实序列来说,它的末尾 padding 区域内没有真实 token,attention 还是会看到这些 padding 位置上的 key/value。虽然 attention mask 可以把 padding 位置屏蔽掉,但 mask 的构造在生成过程中会变得复杂,而且很多 token 在生成第一个新 token 时,padding 尾巴还在序列末尾,容易影响采样分布。
更关键的是,右侧 padding 会导致 batch 内不同样本的“真实 token 结束位置”不统一。模型对每个样本生成时,需要在当前样本的最后一个真实 token 之后追加新 token,但 padding 在右侧时,这个“末尾”被 pad token 占着,处理逻辑很容易写错。
左侧 padding 的好处是:所有样本的真实 token 都是从左向右排列,padding 在最左边,序列末尾都是真实 token。这样在做 batch 生成时,每个样本的新 token 都追加在右边,序列的尾部对齐,后续处理非常自然。
3.2 Decoder-only 生成时为什么要用 left padding
我们在做某个模拟项目的推理服务时,已经把 tokenizer 的padding_side设为"left",代码是这样的:
tokenizer.padding_side = "left"然后执行:
encoded = tokenizer( texts, truncation=True, max_length=1024, padding=True, return_tensors="pt", )如果你用的是右侧 padding,生成器内部返回的 sequences 会包含开头的真实 token、后面的 pad token,以及新增生成的 token,最终你要利用 attention mask 去过滤掉 padding 和 prompt,非常麻烦。左侧 padding 下,返回的完整序列结构是[pad..., real tokens..., generated tokens],你只要从真实 token 起始位置开始截取,结果就对了。
还有一个实际问题:在某些生成框架里,如果attention_mask构造不当,右侧 padding 会让模型在采样时把 pad token 加入候选。比如模型可能生成出 EOS token 后又继续生成,或者首 token 概率分布被右侧 padding 污染。我见过一个线上案例,生成接口用 right padding,短文本结果经常莫名多出几个</s>或<pad>,改成 left padding 后问题立刻消失。
所以对于纯 decoder 模型,推理时请无条件使用 left padding。如果你的 tokenizer 默认是 right padding,需要在初始化模型之后显式修改。注意修改padding_side会影响所有调用该 tokenizer 的地方,如果你同一个 tokenizer 既做训练又做推理,最好在推理函数内部临时设置,或者复制一份 tokenizer 实例,避免污染训练数据加载流程。
3.3 Encoder-decoder 与纯 Decoder 场景的注意力掩码差异
Encoder-decoder 架构(比如经典的 T5、BART)在训练和推理时的 padding 逻辑又不太一样。Encoder 端通常使用右侧 padding 就可以了,因为 encoder 是双向编码,右侧 padding 的注意力 mask 只需把 padding 位置置 0,上下文编码不受影响。Decoder 端在训练时也常用右侧 padding,配合 -100 label 来屏蔽 padding 位置,没什么大问题。
但在推理(生成)阶段,encoder-decoder 模型同样更推荐把 decoder 的输入做 left padding?严格来说,生成时我们往往不会手动给 decoder 输入做 padding,因为解码过程是逐步生成 token,每个样本的 decoder 输入长度不一样,框架内部会自己管理。不过如果做 beam search 或批量生成,left padding 仍然能保证每个解码步的 token 在尾部对齐,减少不必要的复杂度。
纯 decoder 模型还有一个特殊场景:无 prompt 生成。如果你直接给一个空字符串或只有 pad token 的输入,left padding 之后整个序列全是 pad,attention mask 全为 0,模型无论如何都无法生成有效内容。所以即便是 left padding,也要保证至少有一个真实 token 在序列中,通常做法是把 pad_token_id 和 eos_token_id 区分开,并在输入里插入一个起始 token。
3.4 推理时对输入长度和位置信息的一致性要求
推理阶段除了 padding_side,还要保证 position_ids 和 attention mask 与训练阶段一致。很多模型是从 HuggingFace 风格接口加载的,它会自动从 attention mask 推断 position_ids,但如果你用的是 TensorRT-LLM 或 vLLM 这类推理引擎,往往需要手动传入 position_ids。
左侧 padding 的 position_ids 尤其要小心。因为 padding 在左,真实 token 的位置不再从 0 开始。你需要根据每个样本的真实 token 数量构造逐样本的 position ids。
举例来说,两条样本 A 长度 5、B 长度 10,batch padding 后长度为 10。A 左侧有 5 个 pad token,A 的真实 token 位置应该是 5~9,而不是 0~4。如果直接使用全局 cumsum,A 的位置会变成 0~4,和训练时的绝对位置不一致,可能导致生成质量下降。
如果你用的推理框架支持position_ids参数,一定要传入经过正确计算的 position ids。如果不支持,可以考虑把输入重新排序,按长度从大到小排列,保证同一 batch 内样本都左对齐,位置信息比较容易维护。
另外,推理时的截断策略也要保持和训练一致。如果训练时max_length=2048,推理时经常有人忘了设置max_length,导致输入过长被无限截断或报错。建议把 tokenizer 的max_length、truncation、padding全部封装成一个公共函数,训练和推理入口都走同一个函数,从源头避免不一致。
4. 常见坑与排查思路
4.1 忘设 pad_token 导致的报错或性能问题
很多预训练模型的 tokenizer 没有专门的 pad token。有的模型用eos_token兼任 pad token,有的模型根本没有。如果你直接跑tokenizer.padding=True,可能得到报错:“tokenizer.pad_tokenis not set”或者无法 padding。
一个常见的做法是设置:
tokenizer.pad_token = tokenizer.eos_token这能解决大部分问题,但要小心:这样 pad token 和 eos token 变成同一个 id。如果训练时你计算 LM loss、生成时又需要 EOS 作为结束符,同一个 id 可能导致模型混淆,模型可能学到“EOS 位置之后还要继续预测 EOS”,或者在推理时把 pad 当结束信号。
更稳妥的做法是给 tokenizer 新加入一个专用 pad token:
if tokenizer.pad_token is None: tokenizer.add_special_tokens({"pad_token": "<pad>"})如果模型 embedding 是随机初始化的,你需要 resize token embedding,并微调一下新加 token 的 embedding,否则新 pad token 的向量是随机初始化的,训练初期会出现梯度震荡。
4.2 padding 方向不一致导致训练和推理结果漂移
训练时用 right padding,推理时忘了改 padding_side,模型效果可能悄悄变差,却没有任何报错。这个问题特别隐蔽,因为模型能跑,loss 也会下降,但线上生成结果百花齐放,看起来像过拟合,实际上是输入分布和训练分布不一致。
我之前排查过一个模拟项目:训练代码和推理代码各自写了一套数据处理函数。训练时用 tokenizer 默认的 right padding,推理时为了生成方便把padding_side改成 left,结果同类 prompt 的预测稳定性下降了 10% 以上。后来统一了数据预处理函数,训练和推理都固定用 left padding,指标立刻恢复。
建议你写一个PREPROCESS_CONFIG或TOKENIZER_CONFIG的配置对象,把padding_side、truncation、max_length等全部写死,训练和推理都必须从同一个配置里读参数。至少在 CI 测试里加一条断言,检查训练和推理用的是同一个 tokenizer 配置。
4.3 多条样本 batch 推理时的 index 错位问题
在做批量推理的时候,如果一条样本被 padding 了,生成完成后要还原到原始样本时,很容易发生 index 错位。尤其是你做 left padding 并手动拼接 input 和 generated token 时,容易错。
一个典型错误场景:你对 batch 做了 left padding,模型生成了generated_ids,但它的形状是[batch, total_len]。由于每个样本的原始 prompt 不一样长,padding 数量不一样,不能直接用一个固定 offset 截断 prompt。你需要根据每条样本的attention_mask计算真实 token 起始位置:
# left padding 下,attention mask 中第一个 1 的位置就是真实输入起点 true_start = (attention_mask == 1).max(dim=1).indices # 每个样本第一个真实token然后对每条结果用generated_ids[i, true_start[i]:]还原生成内容。如果用的是 right padding,则可以很自然地用每个样本的input_len截断。这也是我推荐推理用 left padding 的另一个原因:虽然它需要计算真实起点,但生成的 token 都在末尾,不容易切错。
4.4 长文本分段、滑窗与 padding 边界
长文本超过 max length 时,通常需要做切片。切片后每个 chunk 都可能产生自己独立的 padding。这个过程中最容易出的问题有两个:
第一个是重复计算。相邻 chunk 之间如果 overlap 不够,会漏掉上下文;如果 overlap 过多,padding 的部分又会把重复内容引入 attention,可能造成语义循环。
第二个是 padding 边界处的 attention 信息泄漏。比如你在 chunk 的右侧 padding,但下一个 chunk 紧接着上一个 chunk 的真实内容,padding 位置理论上应该是空白,可如果你把 chunk 的 attention mask 设置错,下一个 chunk 的 token 可能会 attend 到 padding token 上。这种错误很难从 loss 上看出来,但生成的时候会出现跨 chunk 的幻觉内容。
我的建议是:长文本处理尽量把切片和 padding 封装成同一个函数,对所有 chunk 的 attention mask 做一次可视化检查,确保每个 chunk 只 attend 到本 chunk 的真实 token。如果使用滑窗式 attention,也要在 padding 位置特意把 mask 置 0,避免把 padding token 的 key/value 残留在窗口里。
5. 我的一些经验建议
5.1 能用动态 padding,就不用静态 padding(训练场景)
如果不是推理引擎有固定 shape 要求,训练阶段首选动态 padding。计算收益非常明显,尤其是在文本长度分布不均匀的对话语料、网页文本语料上。你可以先用一个小的 profiling 脚本统计数据集长度分布,然后用动态 padding 跑一个 batch,观察 shape 变化,再和静态 padding 对比显存占用。实测下来,动态 padding 的收益几乎总是正向的。
唯一的例外是使用编译优化或者 xla 等设备时,动态 shape 可能导致重复编译,反而变慢。这种场景下可以退一步做“分桶静态 padding”:把样本按长度分到 128、256、512 等几个桶里,每个桶内部静态 padding。这样既保留了 shape 的稳定性,又比全局固定 1024 省很多内存。
5.2 推理时优先组织同长度 batch,减少 padding 浪费
推理阶段对延迟敏感,padding 浪费会直接变成无效计算。我在线上服务里一般会加一个长度分桶逻辑:维护一个请求队列,把长度相近的请求凑成一个 batch。这样每个 batch 内 padding 比例很小,推理吞吐能提升明显。
具体操作可以是:先对所有输入文本用 tokenizer 统计长度(不 pad),然后按长度排序或分组,每组内动态 padding。注意控制每一组的最大长度不超过模型的 max length,并且优先处理更长的请求,避免长尾请求长时间得不到响应。
5.3 Padding 也是信息,要确保掩码同步
很多人觉得 padding token 反正会被 mask 掉,就忽略了 padding 本身的潜在副作用。实际上,pad token 对应的 embedding 向量会参与 attention 计算前的 key/value 生成,只是 attention 分数被 mask 了。但对于一些不使用 mask 的模块,比如某些 MLP 或者 LayerNorm,padding token 的 embedding 还是会进入统计量。
如果你发现模型在 batch size 增大后效果突然变差,尝试把attention_mask显式传入模型的每个注意力层,而不是只在最外层传一次。有些模型的实现会因为 mask 维度不对或 dtype 不对,悄悄退化为全 1 mask。排查方法是在模型前向里加一个 hook,打印某层 attention weights 的 mask 情况,看 padding 位置是不是真的被屏蔽掉了。
5.4 Padding 相关参数速查表
| 场景 | padding 方式 | padding_side | attention_mask | labels/position_ids |
|---|---|---|---|---|
| 训练 | 动态 padding 优先 | 与推理保持一致 | 必须有,padding 位置为 0 | labels padding 位设为 -100 |
| 训练(TPU/XLA) | 分桶静态 padding | 统一 | 必须有 | 同上 |
| Decoder-only 推理 | left padding | 显式设为 left | 必须传入 | position_ids 手动计算 |
| Encoder-decoder 训练 | right padding | 默认 | 必须有 | decoder labels 中 pad 位 -100 |
| Encoder-decoder 推理 | 尽量复刻训练配置 | 不随意切换 | 必须有 | 按框架要求传 |
| 长文本切片 | 每个 chunk 内部动态 padding | 统一 | 每个 chunk 独立生成 | 注意跨 chunk 不泄漏 |
这个表可以参考,但不要背下来就完事。真正要紧的是理解每一条背后的原因:训练时 padding 影响效率和 loss,推理时 padding 影响生成结果和位置偏移,所有环节里,掩码、位置、方向一致是最核心的三个要求。
最后分享一个小技巧:当你不确定 padding 设置是否正确时,把同一个 batch 的input_ids、attention_mask、position_ids打印出来,对齐到文本层面看一遍。输入序列每个真实 token 对应的 mask 是否为 1、padding 位置是否为 0,一目了然。花五分钟做这种检查,能省掉后面排查 bug 的五个小时。