给 VLM 加一个标记 token,顺便补了下 tokenizer
最近要给我们自己的 VLM 加一个标记 token。以前一直直接用 Qwen 现成的 tokenizer,从来没认真看过分词表怎么来的。真正要改才发现,新增 token 这件事本身不复杂,后面还连着 embedding、lm_head、loss mask、LoRA 和生成停止条件,漏一个就有可能白训。
顺便把 tokenizer 相关的东西重新补了一遍,记在这里,免得以后又从头查。
BPE 和 Unigram
常见的子词算法主要有 BPE 和 Unigram。
BPE 从字符或 byte 这类小粒度单位开始,统计训练语料里最常出现的相邻 token 对,每轮合并一对,直到词表达到预设大小。它学到的是一套有顺序的 merge rules,实际分词时按照这套规则执行。
Unigram 的方向相反。它先从训练语料构造一个较大的候选词表,给每个 token 一个概率,再反复删除对整体似然贡献较低的候选,最后保留到目标词表大小。分词时可以比较同一句话的多种切法,选择概率最高的结果;训练阶段也可以采样不同切法做 subword regularization。
我最开始看到“从大候选词表开始”,第一反应是它需要事先穷尽所有大词,那使用范围应该很有限。这个理解不对。候选词表通常从训练语料里的高频子串生成,并不需要枚举语言中所有可能的词;常见字符、
<unk> 或 byte fallback 还可以负责兜底。Unigram 的麻烦主要在候选集构造、概率估计和反复裁剪,算法与实现都比贪心合并更复杂。现在很多 decoder-only LLM 使用 BPE,尤其常见 Byte-level BPE。Unigram 仍然用在不少 SentencePiece、机器翻译和多语言模型里,不能简单概括成已经被 Google 放弃。
这里说的“训练 tokenizer”也是传统算法训练,主要是频率统计、概率估计、动态规划和贪心合并,和训练神经网络没有关系。
tiktoken 和 SentencePiece
tiktoken 和 SentencePiece 是工具,BPE 和 Unigram 才是算法。
SentencePiece 同时支持 BPE 和 Unigram。它直接处理原始 Unicode 文本,并把空格转成特殊字符
▁,所以经常会看到 ▁hello 这种 token。开启 byte fallback 后,词表覆盖不到的字符还可以继续退化成 UTF-8 bytes。tiktoken 是 OpenAI 的高性能 Byte-level BPE 实现。文本先按 UTF-8 转成 bytes,最底层有 0~255 共 256 个 byte,理论上任意文本都可以表示。训练之后,常见 byte 序列会逐步合并成更长的 token。
tiktoken 在执行 BPE 前还会用正则做 pre-tokenization,先把字母、数字、标点和换行等内容拆成片段。后面的 BPE merge 通常不会跨过这些片段边界。
所以“常规 BPE 禁止多词组合”这个说法不够准确。限制多半来自 pre-tokenizer,BPE 本身只关心相邻符号能不能合并。SuperBPE 做的事情就是调整这层边界。第一阶段保留常规词边界,先学习 subword;第二阶段放开边界,继续合并高频的多词组合。
中文是怎么切的
英文有天然空格,中文没有。中文 tokenizer 一般会形成几层不同粒度的 token:
- 最底层的 UTF-8 bytes,用来给生僻字和异常字符兜底;
- 单个汉字,例如“中”“国”“人”“工”“智”“能”;
- 常见词,例如“中国”“人工”“智能”“模型”;
- 更长的高频组合,例如“人工智能”“大语言模型”。
Byte-level BPE 从 bytes 开始。以“中”为例,它的 UTF-8 是
E4 B8 AD,训练时可能先把这三个 byte 合并成“中”,再继续得到“中国”“人工智能”等更长 token。Qwen 这类 tokenizer 走的主要就是这条路线。它的好处是编码统一、没有 OOV,也适合中英和代码混合。代价是词表里可能存在落在 Unicode 字符内部的 byte token。完整 token 序列仍然可以无损还原,但单独查看某个 token 时可能出现乱码,词表也没那么容易解释。
另一条路线是 Unicode SentencePiece BPE 或 Unigram,再配 byte fallback。常见汉字直接作为基础字符学习,多字组合继续合并,覆盖不到的字符才退回 bytes。这类词表通常更直观,也更容易避免把常见汉字拆在 UTF-8 中间。
词表大小
词表大小记作
V,模型隐藏维度记作 d,输入 embedding 的参数量就是 V × d。词表变大以后,同一段文本通常可以压缩成更少的 token,序列更短,attention 和 KV Cache 的压力也会下降。与此同时,embedding 和输出层会变大,输出 softmax 更贵,低频 token 分到的训练次数也更少。
词表较小时,embedding 占用更低,单个 token 的出现次数通常更多,文本却会被拆成更长的序列,训练和推理需要处理更多位置。
所以词表大小同时影响参数量、文本压缩率、多语言覆盖和推理成本。
Qwen3.5-2B 的公开配置里,词表大小是 248,320,文本隐藏维度是 2,048。单个 embedding 矩阵有:
248,320 × 2,048 = 508,559,360也就是约 5.09 亿参数。Qwen3.5-2B 配置了 embedding tying,输入 embedding 和 lm_head 共用这一个矩阵。没有 tying 的话,输出层还要再放一份同样大小的权重。tying 省的是参数量和模型存储,
V × d 的输出投影计算仍然要做。Token fertility 通常表示一段文本平均每个词会产生多少 token。英文可以按 word 统计,中文的“词”边界不统一,也经常按字符或固定语料的 token 数直接比较。fertility 越高,同样的内容需要的 token 越多,能放进上下文的有效内容越少,训练和推理成本也会跟着增加。
新增一个标记 token
先要区分它只是一个希望保持完整的普通 token,还是模型协议里的控制 token。
普通领域词只需要保证不被拆开,可以用
add_tokens。像角色边界、模态占位符、输出标签这类控制符,再放进 additional_special_tokens。special token 会影响 tokenizer 的特殊 token 处理逻辑,不需要把所有新增词都设成 special。用 Transformers 增加一个控制 token,大致是这样:
resize_token_embeddings 会扩展输入 embedding。模型类实现了 tie_weights() 时,Transformers 也会重新处理 tied weight,一般不用自己手动绑定 token ID、embedding 和 lm_head。后面还有几件事要检查:
- 新 token 是正常输入时,attention mask 设为 1。只有 padding 位置才设为 0。
- Loss mask 取决于模型要不要学会生成它。只作为输入提示或 padding 时可以设为
-100;它属于输出协议时要保留 loss。
- 只有确实表示生成结束的 token 才加入
eos_token_id或 stopping criteria。普通分隔符不能一股脑设成 stop token。
- Chat template 只在对话协议跟着变化时修改。
- tokenizer 和模型要一起保存。只保存 adapter,漏掉改过的 tokenizer,部署时 token ID 很容易对不上。
- 训练数据里必须真的出现这个 token。扩一行 embedding 不会自动赋予它语义。
新增 token 也不一定要全参微调。普通 LoRA 默认冻结 embedding 和 lm_head,新 token 的向量确实学不到。PEFT 现在可以用
trainable_token_indices 只训练新增 token 对应的 embedding 行;也可以把 embedding 或 lm_head 放进 modules_to_save,代价是对应模块会整体保存和训练。这里还要看模型有没有 embedding tying。tied 模型的输入和输出共用权重;untied 模型里,输入 embedding 和 lm_head 是两份参数,只训练输入侧还不足以让模型学会输出新 token。加完 LoRA 后最好直接检查新增行有没有梯度,保存、加载一次 adapter,再确认 token ID 和权重都还在。
Embedding tying
Embedding tying 就是让同一个 token 的输入表示和输出分类器共用一组向量。它能少掉一个
V × d 矩阵,也给输入空间和输出空间加了一层约束。不做 tying 也有合理性。输入表示和输出分类毕竟是两个不同任务,拆开以后两套权重可以各自学习。不同模型两种方案都有,不能只看有没有 tying 判断结构好坏。
拿 CV 类比,embedding 有点像网络入口,lm_head 类似最后的分类层。这个类比只能帮助理解它们所在的位置。Embedding 实际是按 token ID 查表,第一层卷积还包含局部空间计算,两者的运算并不一样。
分词边界会不会影响模型
我之前还有个疑惑。同一句“今天天气真好”,切成“今天 / 天气 / 真好”,或者“今 / 天天 / 气 / 真好”,模型看到的序列完全不同,结果当然也可能受影响。
固定 tokenizer 对同一段完整文本的切分通常是确定的。训练时“今天天气真好”怎么切,推理时遇到完全相同的字符串仍然会怎么切。换成“今天的天气真好”以后,局部 token 组合可能变化,模型需要靠上下文表示去适应。
token 本来也不要求和人理解的词义一一对应。“今天”可以是一个 token,也可以拆成两个;后面的 Transformer 仍然会通过上下文把它们组合起来。切分质量会影响序列长度、低频片段的训练密度和模型对拼写变化的鲁棒性,影响大小要看语料与任务,不能只从某个词切得顺不顺判断。
Unigram sampling 和 BPE-dropout 还会在训练阶段主动给同一句话采样不同切法,提高模型对分词变化的适应能力。常规 LLM 在推理时一般仍然使用固定切分。
Packing
训练样本长度不一样,全部 pad 到同一长度会浪费很多计算。Packing 会把多个短样本塞进一个固定长度序列,尽量减少 padding。
实现时不能只做简单拼接。样本之间至少要放 EOS 或其他边界标记,还要处理好 labels、position IDs 和 attention 边界。SFT 里通常只给 assistant 输出计算 loss;是否允许不同样本互相 attention,也要和训练框架的实现对齐。