有一天我训练一个问答模型,把生成结果打印出来,差点没笑出声:模型答完问题后,还在结尾输出了几十个 <pad>,仿佛在说“我还能继续,但没有词可以说了”。我第一反应是采样逻辑写错了。排查到深夜才发现,不是代码的问题——是训练数据里到处都是 <pad>,模型早就把它当成了一种“正常输出”。

这件事逼我把特殊 token 的设计从头到尾想了一遍。PAD、BOS、EOS、UNK 这四个符号,看起来只是词表里多出的四个 id,实际上每一个都钉在深度学习系统的一个“硬伤”上。理解了它们,你才会明白为什么训练代码里到处是 mask,为什么生成模型的代码里总有“遇到 EOS 就停”的判断。
先给它们拍一张身份证底照
特殊 token 的位置因 tokenizer 而异。比如 SentencePiece 风格的词表里,常见布局是:
<unk> <s> </s> <pad>
而 BERT 的 WordPiece 则把 [PAD] 放在 0,[UNK] 放在 100。Hugging Face tokenizer 只负责加载这些词表,并统一用 special tokens 接口访问它们。这些符号不来自语料,是 tokenizer 在初始化时预留的槽位。但预留槽位只是开始,真正的难点是:模型在训练时到底该怎么对待它们?
UNK:当词汇表容不下世界
先聊 UNK。在词级别(word-level)词表时代,UNK 是必需品。语料里可能有几十万个词,但词表只留五万个位置,剩下的词只能统一塞进 <unk>。它的作用就是“其他”按钮——遇到没见过的词,不会让系统崩溃,而是归到同一个桶里。
但这个桶的设计有个讨厌的副作用:模型永远不知道桶里到底是谁。“苹果手机”和“苹果很好吃”中的“苹果”,如果其中一个因为低频被替换成 UNK,模型只能学到“这里有东西”,却学不到苹果本身。
到了 BPE 子词时代,这个困境淡化了。BPE 会把不认识的整词拆成子词片段,例如 “tokenization” 拆成 “token” + “ization”,绝大多数词都能被碎片覆盖。GPT-2 的 tokenizer.json 里 unk_token 就是 null——因为字节级 BPE 已经能编码任何输入,不需要“其他”桶了。
但 UNK 依然没有完全消失。中文的生僻字、不断出现的新 emoji、奇怪的造字,总会有词表覆盖不到的地方。UNK 的意义是保证系统遇到未知输入时不直接抛异常,就像程序里的 Exception——你希望永远别触发,但必须留一个兜底。
BOS:给生成一个“起跑信号”
生成模型的任务是预测下一个 token。当输入为空时,模型需要知道“现在开始生成了”。这就是 BOS 的职责。
在机器翻译的 encoder-decoder 架构里,decoder 的第一步输入就是 <bos>,之后每生成一个词,就把该词拼接在 BOS 后面继续预测。Attention Is All You Need 的 setup 中,decoder 端就使用了一个学到的 start token。训练时,大量样本都以 <bos> 开头,模型学到的第一句话往往是“看到这个符号,就该动笔了”。BOS 相当于发令枪。
注意,BOS 不是所有架构都必需。BERT 用的是 [CLS],但它和 BOS 的角色不同:BERT 论文里 [CLS] 的最终隐状态被当作整个句子的聚合表示,用于分类;BOS 则主要是解码器的起点。GPT 这种 decoder-only 模型甚至不用 BOS,直接用一段 prompt 作为起点。如果你的模型没有 BOS,不代表它坏了,只是生成流程不需要。
EOS:模型学会“闭嘴”的关键
如果 BOS 是发令枪,EOS 就是终点线的彩带。没有 EOS,模型不知道一句话在哪里结束,只能一直预测下去。
EOS 的重要性体现在训练数据的构造方式上。假设你的目标句是“我喜欢你”,在 teacher forcing 下,data loader 会这样构造:
decoder 输入:<bos> 我 喜 欢 你
decoder 标签:我 喜 欢 你 <eos>
看到没有?EOS 不是“输入端的符号”,而是“输出端的标签”。模型必须在预测“你”之后预测 <eos>,这个预测会被计为一条训练样本。模型是在反复学习“到了该收尾的地方,就输出 EOS”,而不是靠代码硬性截断。
所以当你训练一个生成模型却忘了在标签里加入 EOS,模型永远不会学到“结束”的概念,只能在推理时靠 max_length 强行掐断。很多“看似聪明但废话连篇”的模型,就是这样养成的。
PAD:批量矩阵的“矩形强迫症”
深度学习框架处理一个 batch 时,要求所有序列长度一致。但真实句子有长有短,所以短句要用 PAD 补齐到 batch 最大长度。PAD 的唯一使命是让矩阵变成矩形。
但矩形有了,麻烦也来了。Transformer 的注意力机制默认会让每个 token 与其他所有 token 交互,如果 PAD 也参与交互,短句子的语义就会被一堆占位符稀释。PyTorch 官方 Transformer 翻译教程 里专门展示了怎么处理:把 PAD 位置的 attention score 设为 -inf,softmax 后注意力归零;同时在 cross entropy 中使用 ignore_index 来忽略 PAD 预测的损失。
更隐蔽的是,即使 PAD 在注意力里被 mask 了,它的 embedding 依然可能会被更新。比如 left padding 的一些实现里,PAD 作为 query 仍能 attend 到后面的真实 token,梯度就会传到 PAD 的 embedding 上。你可以手动在 embedding 之后把 PAD 位置清零,但很多人不会注意到这一点。这也是为什么 attention_mask 必须同时作用在 attention 和 loss 上,否则模型迟早会学到“PAD 是某种平均占位符”,并在生成长度不足时输出 PAD。
一张表看穿四个特殊 Token
| token | 角色 | 是否作为标签参与 loss | 典型位置 | 缺了会怎样 |
|---|---|---|---|---|
<unk> |
未知输入的兜底桶 | 通常不,但自身会收到模糊梯度 | 输入任意位置 | 遇到未登录词就报错 |
<bos> |
解码生成起点 | 不作为标签 | 序列最左 | decoder 不知道如何起步 |
<eos> |
序列终止信号 | 会作为标签预测 | 序列末尾 | 模型不会主动停 |
<pad> |
批量对齐占位符 | 必须被 mask,不参与 loss | 右侧或左侧补齐 | 无法组成定长 batch |
几个容易踩的坑
PAD 会不会被模型学到语义?
如果你忘了 mask,它会被学到,但学到的只是“填充”的统计规律。一个简单的自查方法:把短句子的预测结果打出来,如果模型倾向于输出 PAD,说明你漏了 mask 或 loss 忽略。
BOS 和 [CLS] 能不能互相替代?
不能。BOS 是解码管线的起点;[CLS] 是编码端最后汇聚全局信息的集合点。二者针对的任务不同,强行互换会破坏结构一致性。
UNK 的 embedding 会更新吗?
会,但它学到的是“各种未知词的模糊平均”,不是任何具体词的表示。所以 UNK 更像防御机制,不是特征工程。
回到开头的问题。特殊 token 本质上是深度学习在离散符号世界和连续向量空间之间搭的脚手架。模型需要起点和终点才能生成,需要定长矩阵才能批量计算,需要兜底才能面对开放文本。没有这些补丁,Transformer 的训练不会稳定。
但也别忘了它们的局限:特殊 token 是人为约定,不是语言自己的组成部分。训练时你怎么摆放它们,推理时模型就会怎么模仿。把 PAD 当普通词,它会回敬你一屏幕的 <pad>。特殊 token 的语义完全来自你的数据构造——理解这一点,比背下四个 token 的名字重要得多。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/762.html