AI 工程基础体系 · 第 73/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。
Tokenizer 训练与诊断:BPE、Unigram、特殊符号和多语言覆盖
Tokenizer(分词器)把文本转换为模型可以处理的离散 token 序列,再把 token 映射为整数 ID:
其中, 是 token 字符串, 是词表中的整数 ID。模型实际接收的是 ID,通常经过 embedding 层转换为向量:
Transformer 的自注意力层随后处理这些向量。Tokenizer 不属于 Attention 本身,但它决定了序列长度 、词表大小、未知字符的处理方式以及特殊控制指令的边界。由于标准自注意力的计算和显存开销通常随 增长,token 切得过碎会直接增加训练和推理成本。
1. Tokenizer 不是简单的“按空格切词”
一个生产级 Tokenizer 通常包含以下组件:
原始文本
│
▼
Normalizer:Unicode 归一化、大小写转换、空白处理
│
▼
Pre-tokenizer:按空格、标点、字节或脚本边界预切分
│
▼
Model:BPE、Unigram、WordPiece 等子词算法
│
▼
Post-processor:添加 BOS、EOS、CLS、SEP 等特殊 token
│
▼
编码结果:input_ids、attention_mask、offset_mapping
│
▼
Decoder:将 token 序列还原为文本
这几个阶段的职责不能混淆。
- Normalizer 改变输入字符串的表示形式。例如 NFKC 可能把全角字符转换为半角字符。
- Pre-tokenizer 规定模型在哪些边界上建立候选片段。
- Model 使用词表和算法将片段拆成 token。
- Post-processor 添加不一定出现在原始文本中的控制 token。
- Decoder 负责把 token 字符串拼回可读文本。
- Tokenizer 的词表 是 token 到 ID 的映射;模型的 embedding 矩阵则是 ID 到向量的映射。二者必须保持一致。
同一个 BPE 算法,使用不同的归一化方式、预切分方式、字节表示或特殊 token 配置,也会得到不同结果。因此,“模型使用 BPE”并不能唯一确定它的实际分词行为。
2. 训练前必须先定义符号空间
2.1 字符级、字节级和 Unicode code point
Tokenizer 的初始符号可以是:
- Unicode 字符;
- Unicode code point;
- UTF-8 字节;
- 预先切分后的词或子词片段。
字节级方法的一个重要性质是:任意 Unicode 文本最终都能表示为有限字节序列,因此通常不需要依赖一个有限的“所有字符词表”。不过这不等于每个字节都必须作为最终 token;训练算法仍会把常见字节序列合并为更长的 token。
例如,中文字符“你”的 UTF-8 表示是三个字节:
你 -> E4 B D A 你?
实际十六进制应为:
你 -> E4 BD A0
如果使用 byte-level BPE,词表可以学习出常见汉字、汉字片段,也可以退化为字节序列。字节级方案的代价是:某些字符可能被拆成多个 byte token,尤其是低频文字、罕见符号和组合字符。
2.2 Unicode 归一化的可逆性边界
Unicode 中可能存在视觉相同但编码不同的字符串。例如:
- 预组字符:
é - 分解形式:
e+ combining acute accent
如果训练集同时包含两种形式,Tokenizer 可能为它们分别建立统计结构,导致词表浪费和覆盖指标不稳定。
常见归一化包括:
- NFC:倾向于使用预组形式;
- NFD:倾向于拆分为基础字符和组合标记;
- NFKC/NFKD:还会进行兼容性转换,例如全角和半角的统一。
归一化不是无条件安全的。NFKC 可能丢失某些排版或语义区别,代码、数学公式、密码、法律文书中的原始字符都可能依赖这些差异。必须先确定业务是否要求字符级可逆,再选择归一化规则。
3. BPE:通过频繁合并构造词表
BPE(Byte Pair Encoding)最初用于压缩,应用到 Tokenizer 时,核心思想是:
从较小的初始符号集合开始,反复把训练语料中最常见的相邻符号对合并成一个新符号。
3.1 BPE 训练的形式化过程
设初始符号集合为 ,训练语料经过预切分后得到若干符号序列。对每一个相邻 pair ,统计它在语料中的出现次数:
第 次合并选择:
然后创建新 token ,并把语料中所有相邻的 替换为 :
同时将这条合并规则记录在 merge list 中。训练可以在达到目标词表大小、达到目标合并次数或没有 pair 满足 min_frequency 时停止。
BPE 的训练目标不是显式最大化句子似然,而是根据局部 pair 频率构造合并规则。它速度快、实现简单,但局部频率并不等同于全局语言模型概率。
3.2 一个完整的 BPE 合并算例
假设训练语料只有三个词,并保留词尾标记 </w>:
low 2 次
lower 1 次
初始序列为:
l o w </w> 2
l o w e r </w> 1
第一次统计 pair:
| Pair | 次数 |
|---|---|
(l, o) |
3 |
(o, w) |
3 |
(w, </w>) |
2 |
(w, e) |
1 |
(e, r) |
1 |
(r, </w>) |
1 |
(l,o) 和 (o,w) 并列。实际实现需要确定 tie-breaking 规则;不同实现可能根据扫描顺序、词典序或内部堆结构选择不同 pair。假设选择 (l,o):
l o w </w> -> lo w </w>
l o w e r </w> -> lo w e r </w>
重新统计后:
(lo, w) = 3
(w, </w>) = 2
(w, e) = 1
(e, r) = 1
(r, </w>) = 1
第二次合并 (lo,w):
lo w </w> -> low </w>
lo w e r </w> -> low e r </w>
第三次合并可能选择 (low, </w>),因为它出现 2 次:
low </w> -> low</w>
low e r </w> -> low e r </w>
继续合并可能产生:
low</w>
low
lower</w>
训练结果由初始表示、合并次数、词尾标记策略和 tie-breaking 共同决定。
3.3 推理阶段如何应用 BPE 合并
训练时记录的是有序 merge list:
1. (l, o) -> lo
2. (lo, w) -> low
3. (low, </w>) -> low</w>
推理时,输入先被转换为初始符号,然后根据合并规则逐步合并。规则的顺序很重要:
输入:l o w </w>
先合并 (l,o):
lo w </w>
再合并 (lo,w):
low </w>
再合并 (low,</w>):
low</w>
BPE 并不是简单地“每次寻找最长词表 token”。如果词表中同时存在 a、ab、abc,最终结果取决于训练得到的 merge rank 和预切分边界。用最长匹配替代 merge 规则,可能得到不同序列。
3.4 BPE 的反例:局部高频不一定是合理词元
假设语料中大量出现:
theater
theoretical
theorem
the 可能成为高频 token,但它未必能解释其他语言中的边界,也未必适合代码、数学表达式或中文。BPE 只依据训练语料中的统计共现:
- 高频片段容易被合并;
- 低频但语义重要的片段可能被拆散;
- 如果训练数据分布偏向英文,其他语言的 token 可能严重碎片化;
- merge 数量越多,常见词越容易形成整词 token,但长尾覆盖可能变差。
因此,增大词表不等于普遍提高质量。大词表减少序列长度,却增加 embedding、输出层和存储成本,也可能让低频 token 的训练次数不足。
4. Unigram:用概率模型选择分词
Unigram Language Model Tokenizer 与 BPE 的出发点不同。它先维护一个候选词表,每个 token 有一个概率,然后在多个可能的分词路径中选择概率最高或按概率采样的路径。
设词表为 ,每个 token 有概率 ,满足:
对于一个由 token 序列 表示的字符串,其概率为:
一个字符串 通常有多种分词方式,因而其概率是所有合法路径概率之和:
这里 表示所有能拼接成 的 token 序列。
4.1 Unigram 的 EM 训练步骤
Unigram 训练通常可以理解为:
- 生成一个相对较大的候选词表;
- 用当前 token 概率计算每个训练字符串的可能分词路径;
- 通过前向后向算法估计每个 token 的期望计数;
- 更新 token 概率;
- 删除对整体语料似然贡献较小的 token;
- 重复上述步骤,直到达到目标词表大小。
设语料为 ,目标是最大化:
直接枚举所有分词路径通常不可行,因此使用动态规划。对于字符串位置 ,定义前向概率:
若 token 覆盖位置 到 ,则:
反向概率同理。某个 token 在字符串中的后验使用概率可以写成:
它表示在考虑所有合法分词路径后,这个 token 覆盖该位置的概率。把所有位置和样本中的后验概率相加,就得到 token 的期望计数。
4.2 Unigram 的完整小算例
假设字符串是:
abab
候选词表只有:
a, b, ab
并且当前概率为:
p(a) = 0.3
p(b) = 0.2
p(ab) = 0.5
合法分词路径及概率如下:
| 分词路径 | 概率 |
|---|---|
ab + ab |
|
a + b + ab |
|
ab + a + b |
|
a + b + a + b |
所以:
ab 的期望使用次数为:
a 的期望使用次数为:
b 的期望次数同样约为 。更新概率时,不是简单地把最高概率路径作为唯一标注,而是可以利用所有路径的后验贡献。
在推理时,通常使用 Viterbi 动态规划选择最大概率的单一路径:
本例中选择:
ab + ab
但如果启用 Unigram sampling,则可能按照路径概率进行随机采样,从而产生不同的合法分词结果。这种子词正则化可以增加训练扰动,降低模型过度依赖单一分词边界的风险;生产推理通常需要关闭随机采样,保证可复现。
4.3 BPE 与 Unigram 的根本差异
| 维度 | BPE | Unigram |
|---|---|---|
| 训练方向 | 从小词表不断合并 | 从大候选词表不断删减 |
| 主要依据 | 相邻 pair 频率 | 分词路径概率和语料似然 |
| 推理 | 按 merge 规则应用 | Viterbi 或采样 |
| 是否天然表达多种分词 | 通常使用确定性路径 | 概率模型显式表示多种路径 |
| 训练解释 | 局部贪心合并 | EM 与词表裁剪 |
| 典型风险 | merge 规则受频率和边界影响 | 概率、候选词表和剪枝策略影响 |
二者都能训练出有效的子词词表;实际效果还取决于数据混合比例、归一化、byte fallback、词表大小和模型架构,不能仅凭算法名称判断质量。
5. 特殊 token:字符串、ID 和控制语义必须同时一致
特殊 token 是具有模型控制语义的 token,常见例子包括:
[PAD]:批处理中补齐序列;[UNK]:无法表示的输入;[BOS]或<s>:序列开始;[EOS]或</s>:序列结束;[CLS]:某些编码模型的分类表示位置;[SEP]:分隔句子或句对;<|user|>、<|assistant|>:对话角色控制标记。
特殊 token 不能只看字符串本身。例如,<|assistant|> 既可以是普通文本,也可以是一个控制 token。只有当它被注册到 tokenizer 的特殊 token 配置中,并且模型训练过相应语义时,它才真正具有角色控制作用。
5.1 特殊 token 的三个层次
一个特殊 token 至少涉及三件事:
字符串:"<|assistant|>"
词表项:token -> ID
模型语义:ID 在训练中代表“助手开始回答”
其中任何一项不一致都会出错:
- 字符串存在,但没有加入词表:可能被拆成多个普通 token;
- 已加入词表,但模型未训练过:模型只能看到一个新 ID,未必理解语义;
- tokenizer 返回了该 ID,但模型 embedding 没有对应行:可能触发索引越界;
- tokenizer 和模型对 EOS ID 的理解不同:生成可能不停止,或过早停止。
5.2 添加特殊 token 后必须调整模型 embedding
使用 Transformers 时,向已有 tokenizer 添加新 token 后,通常需要:
num_added = tokenizer.add_special_tokens({
"additional_special_tokens": ["<|user|>", "<|assistant|>"]
})
if num_added > 0:
model.resize_token_embeddings(len(tokenizer))
add_special_tokens 只修改 tokenizer 的词表配置,不会自动让模型学会这些 token 的含义。resize_token_embeddings 只解决 embedding 矩阵尺寸问题,也不等于完成训练。
如果新 token 用于对话模板,还必须确保训练数据在相同模板下生成。例如训练使用:
<|user|>你好<|assistant|>你好,我是助手。
推理时却使用:
用户:你好
助手:
那么模型接收到的 token 序列分布发生变化,问题不一定来自分词器算法,而可能来自模板协议不一致。
5.3 “添加 token”与“重新训练 tokenizer”不同
- 添加 token:保留原词表和原 merge 规则,额外增加若干 token。
- 重新训练 tokenizer:可能改变大量文本的切分结果和 token ID 语义。
如果已有模型已经训练完成,随意重新训练 tokenizer 会破坏 embedding 与 token ID 的对应关系。除非同时进行兼容迁移和模型再训练,否则不能把新 tokenizer 直接替换给旧模型。
6. 特殊 token 的边界与转义问题
特殊 token 可能出现在用户原始文本中。例如用户输入:
请解释 <|assistant|> 这个字符串。
如果 tokenizer 把它当作控制 token,文本内容就被解释成了协议指令,而不再是普通字符串。这是 prompt 注入和数据污染的一类基础来源。
需要区分两种意图:
控制语义:把 <|assistant|> 解释为角色边界
字面文本:把 "<|assistant|>" 当作用户要讨论的内容
常见处理方式包括:
- 对用户内容进行结构化封装,而不是直接拼接字符串;
- 使用明确的 chat template;
- 对需要展示的控制字符串执行转义;
- 训练和推理使用同一套特殊 token 注册表;
- 对日志和数据集保留原文、解析结果和最终 token 序列,便于审计。
不能仅依靠“这个字符串看起来很特殊”来判断语义。真正的判定来自 tokenizer 配置和上层模板逻辑。
7. 多语言覆盖:不是“支持字符”这么简单
多语言 Tokenizer 的覆盖能力至少有四个层次:
- 可表示性:输入是否能编码,不产生未知 token;
- 压缩效率:同一语义需要多少 token;
- 边界合理性:词、词缀、字符或代码片段是否被切得合适;
- 跨语言公平性:不同语言的序列长度和模型计算成本是否差异过大。
7.1 词表大小与语言覆盖的竞争
假设总词表预算为 ,训练语料包含英语、中文、日语、阿拉伯语、代码和数学表达式。高频英语片段可能快速占据大量词表项:
the, ing, tion, and, ...
如果英语占语料绝大多数,其他语言的常见片段可能没有机会形成较长 token。结果是:
英文:This is a tokenizer.
中文:这是一个分词器。
中文可能被拆成单字或多个 byte token,日文假名、阿拉伯文连接形式、混合脚本标识符也可能具有更高 token/字符比。
扩大词表可以改善部分语言的压缩率,但代价包括:
- embedding 参数增加;
- 输出 softmax 或输入输出权重占用更多内存;
- 低频 token 的训练样本不足;
- tokenizer 文件增大;
- 新语言的收益可能被训练数据质量限制,而不是词表大小限制。
7.2 字节回退与 [UNK] 的区别
[UNK] 表示 tokenizer 无法把输入映射到已知词表。字节级 tokenizer 或启用了 byte fallback 的方案,通常可以把未知字符串拆成字节,从而避免真正的不可表示输入。
但“没有 [UNK]”不代表覆盖良好:
罕见文字 -> 许多 byte token
它仍然可能造成:
- 序列急剧变长;
- 注意力开销增加;
- 相同语义在不同语言中的 token 数差异过大;
- 模型对低频字符的表示能力下降。
因此应同时观察:
- unknown rate;
- byte fallback rate;
- 每字符 token 数;
- 每词 token 数;
- 按语言、脚本和领域切分的序列长度。
7.3 组合字符和脚本边界
“一个人眼中的字符”不一定对应一个 Unicode code point。例如:
é
可能是一个预组字符,也可能是:
e + ◌́
emoji 也可能由多个 code point 组成:
👨👩👧👦
如果诊断代码直接按 Python 字符数计算长度,结果可能与用户感知的字符数不同。多语言覆盖评测应至少保留三种长度:
- Unicode code point 数;
- UTF-8 byte 数;
- tokenizer token 数。
对用户可见字符更严格的评测,可以使用 grapheme cluster(字素簇)计数,但它不是所有 tokenizer 的原生单位。
8. 一个可运行的多语言诊断示例
下面示例使用 Hugging Face 的 fast tokenizer,观察 token、ID、字符偏移和长度。模型名称只是演示,运行时需要网络访问或本地缓存:
from transformers import AutoTokenizer
model_name = "bert-base-multilingual-cased"
tokenizer = AutoTokenizer.from_pretrained(
model_name,
use_fast=True,
)
samples = {
"english": "Tokenizer diagnostics are useful.",
"chinese": "分词器诊断很重要。",
"japanese": "トークナイザーを診断します。",
"arabic": "نختبر تغطية اللغات.",
"code": "def tokenize(x): return x + 1",
"emoji": "家庭 👨👩👧👦 café",
}
for name, text in samples.items():
encoded = tokenizer(
text,
add_special_tokens=False,
return_offsets_mapping=True,
)
tokens = tokenizer.convert_ids_to_tokens(encoded["input_ids"])
unk_id = tokenizer.unk_token_id
unk_count = sum(i == unk_id for i in encoded["input_ids"])
print(f"\n[{name}] {text}")
print("tokens:", tokens)
print("ids:", encoded["input_ids"])
print("offsets:", encoded["offset_mapping"])
print("token_count:", len(encoded["input_ids"]))
print("unk_count:", unk_count)
每一步的含义如下:
use_fast=True使用 Rust 实现的 fast tokenizer,通常才能稳定提供offset_mapping;add_special_tokens=False排除[CLS]、[SEP]等模板 token,避免把控制开销误计为文本覆盖;convert_ids_to_tokens显示模型真正看到的 token;offset_mapping显示 token 对应原始字符串的字符区间;unk_count只能诊断显式[UNK],不能识别 byte fallback 带来的碎片化。
某些模型的 offset 是字节偏移,某些 fast tokenizer 的行为受底层实现和版本影响;诊断时应检查一个非 ASCII 字符的 offset 是否符合预期,不能盲目把 offset 当作 Python 字符索引。
8.1 统计每种语言的碎片化
可以为每条样本记录:
对于没有空格的中文、日文,不能直接使用空格分词作为分母。更稳妥的做法是使用外部语言分词器、字素簇计数,或至少同时报告 token/code point 和 token/byte。
示例:
def token_stats(tokenizer, text):
encoded = tokenizer(
text,
add_special_tokens=False,
return_attention_mask=False,
)
ids = encoded["input_ids"]
unk_id = tokenizer.unk_token_id
return {
"bytes": len(text.encode("utf-8")),
"codepoints": len(text),
"tokens": len(ids),
"tokens_per_codepoint": len(ids) / max(len(text), 1),
"unk_rate": sum(x == unk_id for x in ids) / max(len(ids), 1),
}
for name, text in samples.items():
print(name, token_stats(tokenizer, text))
这个指标只能用于比较同一批评测样本,不能单独证明某种语言得到了公平支持。短文本尤其容易被特殊字符、标点和整词 token 放大统计波动。
9. 训练一个 Byte-level BPE
下面使用 tokenizers 库训练一个最小的 Byte-level BPE。它适合演示训练生命周期,不代表可以直接替代面向生产模型的词表设计。
先准备 corpus.txt:
Tokenizer handles English.
分词器需要覆盖中文。
Tokenizer handles code: def add(x, y): return x + y
安装依赖:
pip install "tokenizers>=0.15,<0.22"
训练代码:
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.pre_tokenizers import ByteLevel
from tokenizers.decoders import ByteLevel as ByteLevelDecoder
from tokenizers.trainers import BpeTrainer
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
# ByteLevel 会把输入转换为字节级初始符号。
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=False)
tokenizer.decoder = ByteLevelDecoder()
trainer = BpeTrainer(
vocab_size=200,
min_frequency=2,
special_tokens=[
"[UNK]",
"[PAD]",
"[BOS]",
"[EOS]",
],
)
tokenizer.train(["corpus.txt"], trainer)
tokenizer.save("tokenizer.json")
encoded = tokenizer.encode("分词器 handles code")
print("tokens:", encoded.tokens)
print("ids:", encoded.ids)
print("offsets:", encoded.offsets)
print("decoded:", tokenizer.decode(encoded.ids))
9.1 每个配置为何成立
BPE(unk_token="[UNK]")创建 BPE 模型,并规定无法编码时使用哪个 token;ByteLevel将任意输入转换为字节级候选符号,降低未知字符风险;vocab_size=200是最终词表上限,实际大小可能受初始字节集合和合并条件影响;min_frequency=2排除只出现一次的 pair;在真实训练中应根据语料规模选择;special_tokens在训练开始时预注册,通常可以获得稳定的低位 ID;decoder负责把 ByteLevel 的内部表示还原为正常文本;offsets用于将 token 映射回原始文本,适合做数据审计和 span 对齐。
由于演示语料很小,大多数词不会形成理想的完整 token。训练输出的目标是验证流程和数据流,而不是获得高质量通用词表。
9.2 包装为 Transformers Tokenizer
如果要把训练结果用于 Transformers,需要让 tokenizer 配置和模型配置一致:
from transformers import PreTrainedTokenizerFast
hf_tokenizer = PreTrainedTokenizerFast(
tokenizer_file="tokenizer.json",
unk_token="[UNK]",
pad_token="[PAD]",
bos_token="[BOS]",
eos_token="[EOS]",
)
encoded = hf_tokenizer(
"分词器 handles code",
add_special_tokens=True,
)
print(encoded["input_ids"])
print(encoded["attention_mask"])
print(hf_tokenizer.decode(encoded["input_ids"]))
这里有两个独立生命周期:
训练 tokenizer
-> 保存 tokenizer.json、special tokens 配置
-> 加载并验证编码/解码
-> 与模型 embedding 和配置绑定
-> 训练或微调模型
-> 在同一 tokenizer 上进行推理
只保存词表而不保存 normalizer、pre-tokenizer、decoder 和 post-processor,可能导致加载后行为变化。生产发布应固定 tokenizers、transformers 版本,并对序列化文件做哈希校验。
10. 训练数据决定 tokenizer 的统计偏差
Tokenizer 训练不是无成本的数据预处理。它会继承训练语料的分布和质量问题。
10.1 重复数据会放大错误合并
如果某篇网页被复制一万次,BPE 会认为其中的片段极其高频,Unigram 也会提高相关 token 的概率。重复数据可能造成:
- 模板片段占据大量词表;
- 某些站点的 HTML 或广告字符串被合并;
- 低频语言和领域术语被挤出词表;
- 训练和评测之间出现不真实的压缩优势。
因此,去重、语言识别、脚本识别和数据质量检查必须发生在 tokenizer 训练之前。
10.2 数据清洗可能损害覆盖
过度清洗会删除:
- 代码中的缩进和符号;
- 数学公式;
- URL、邮箱和路径;
- 少数语言字符;
- emoji 和组合字符;
- 表格、日志和结构化文本。
Tokenizer 不应只在“干净自然语言”上训练,除非模型明确只服务于这一领域。通用模型需要根据目标流量混合自然语言、代码、表格、数字和控制格式。
10.3 权限、隐私和成本属于同一系统
训练 tokenizer 通常需要扫描大量原始数据,因此要同时处理:
- 数据集许可是否允许派生词表和模型;
- 训练日志是否会泄露原文;
- 词表中是否保留邮箱、手机号、内部项目名等高频敏感片段;
- 训练计算和存储预算;
- 不同语言数据的配额和采样权重。
词表本身也可能泄露训练语料中的独特字符串。对企业内部语料训练 tokenizer 时,应把词表视为数据派生物,而不是完全无敏感性的元数据。
11. Tokenizer 诊断:从“能编码”到“是否可用”
11.1 可逆性测试
对不包含特殊协议语义的普通文本,验证:
text = "分词器、Unicode、emoji 👨👩👧👦 和代码 def f(x): return x"
encoded = tokenizer.encode(text)
decoded = tokenizer.decode(encoded.ids)
print(repr(text))
print(repr(decoded))
print(text == decoded)
这里的 tokenizer 是前面 tokenizers 库中的对象。若结果不相等,需要区分:
- decoder 是否正确配置;
- normalizer 是否有意改变了文本;
- 空格是否由 ByteLevel 编码;
- 特殊 token 是否被过滤;
- 输入是否包含不可逆归一化。
不能把所有不相等都当作 bug。有些 tokenizer 的设计就是进行大小写折叠、重音去除或 Unicode 兼容归一化,此时应验证“规范化后的可逆性”,而不是要求恢复原始字节。
11.2 未知率和碎片化率
至少报告以下指标:
指标要按以下维度分桶:
- 语言;
- 脚本;
- 文本类型;
- 长度区间;
- Unicode 类别;
- 代码、URL、数字、emoji;
- 是否包含稀有字符。
只看全局平均值会掩盖问题。例如英语占 90% 的测试集可能让全局 token/字符指标很好看,但中文、阿拉伯语和少数语言的序列长度已经不可接受。
11.3 长度分布和截断风险
模型上下文窗口限制的是 token 数,而不是字符数。对每个样本计算:
length = len(tokenizer.encode(text, add_special_tokens=False))
然后观察 P50、P90、P95、P99 和最大值。真实故障通常出现在长尾:
- 普通文本正常,法律合同被大量拆分;
- 英文正常,混合中英文本超出窗口;
- emoji 或数学公式使长度突然增加;
- chat template 加入控制 token 后,边界样本被截断。
截断发生在 tokenizer、数据 collator 或模型推理框架的不同阶段。诊断时需要保存:
原文长度
模板前 token 数
模板后 token 数
截断位置
被删除的 token
否则只能看到模型输出异常,却无法判断是分词问题还是上下文长度配置问题。
12. 偏移量、特殊 token 和数据标注对齐
NER、问答抽取、文本高亮等任务需要把字符 span 对齐到 token span。基本流程是:
原始字符区间
-> tokenizer offset_mapping
-> token 区间
-> 模型标签
但存在几个边界:
- 添加的特殊 token 通常没有对应原始文本,offset 可能是
(0, 0); - Unicode 组合字符可能导致视觉字符和 code point 区间不同;
- ByteLevel tokenizer 的内部空格标记不能直接当作原始字符;
- 归一化后文本与原始文本长度可能不一致;
- 一些预切分器会把标点和空格映射成独立 token。
因此,span 对齐不能只用:
text.find(token)
因为 token 可能是归一化后的片段、字节表示或重复出现的短字符串。应使用 fast tokenizer 返回的 offsets,并在数据预处理时记录原始文本到规范化文本的映射。
13. 常见失败表现与定位路径
13.1 大量 [UNK]
可能原因:
- 训练时没有覆盖该脚本;
- 归一化或预切分配置不一致;
- 加载了错误的 tokenizer 文件;
- 推理文本包含训练时不允许的字符;
- 模型 wrapper 没有正确读取
unk_token。
诊断顺序:
- 直接打印 tokens 和 IDs;
- 检查
tokenizer.unk_token_id; - 用单个异常字符和完整样本分别测试;
- 检查原始 tokenizer 文件及版本;
- 对比训练端和推理端的 normalizer、pre-tokenizer、decoder;
- 确认不是把普通字符串误当作特殊 token。
13.2 没有 [UNK],但序列极长
这通常是 byte fallback 或 byte-level 表示在工作,而不是覆盖质量优秀。应检查:
- 每个字符对应的 token 数;
- 罕见脚本是否大量变成 byte token;
- 长文本 P95/P99;
- 训练和推理的吞吐是否随语言显著变化。
13.3 解码后空格变化
常见原因:
- ByteLevel decoder 未配置;
add_prefix_space训练和推理不一致;- 手工拼接 token 字符串,而不是调用 decoder;
- 特殊 token 被错误地保留或跳过;
- normalizer 改变了空白字符。
不要使用:
" ".join(tokens)
来模拟解码。token 字符串不是独立的自然语言词,空格、前缀标记和字节转义通常需要由正式 decoder 处理。
13.4 添加特殊 token 后模型报索引错误
典型路径是:
tokenizer 词表变大
-> 生成了新 ID
-> model embedding 仍是旧大小
-> 查表时 index out of range
恢复步骤:
- 计算
len(tokenizer)与模型 embedding 行数; - 使用
model.resize_token_embeddings(len(tokenizer)); - 保存 tokenizer 和模型;
- 用包含新 token 的最小样本执行一次前向计算;
- 再检查生成时的
eos_token_id、pad_token_id是否一致。
如果模型已经训练完成,新增 token 的 embedding 通常还需要通过训练获得有效语义。
13.5 训练和推理 token ID 不一致
即使 token 字符串看起来相同,只要 ID 映射变化,模型看到的就是不同输入。不要对已训练模型重新排序词表,也不要手工合并多个词表后沿用旧模型。
可验证:
assert tokenizer.convert_tokens_to_ids(tokenizer.all_special_tokens)
assert model.get_input_embeddings().num_embeddings == len(tokenizer)
还应固定 tokenizer 文件哈希、版本号和配置快照。
14. BPE 与 Unigram 的工程取舍
14.1 词表大小
词表越大:
- 常见短语可能变成单个 token;
- 平均序列长度可能下降;
- embedding 和输出层变大;
- 长尾 token 的统计可靠性下降;
- 多语言之间的资源分配更敏感。
词表越小:
- 泛化到新字符串通常更平滑;
- 低频文本更容易被拆解;
- 序列变长,训练和推理成本上升;
- 特殊格式和代码可能产生大量碎片。
没有一个脱离数据和模型上下文长度的通用最优值。应在固定模型预算下同时评估 token 数、显存、吞吐、任务准确率和不同语言的长尾表现。
14.2 min_frequency 与低频语言
提高 min_frequency 可以减小词表、抑制噪声,但会优先伤害低资源语言、专有名词和代码标识符。如果语料按语言直接混合,频率阈值实际上偏向高资源语言。
更合理的评估方式包括:
- 先按语言或领域分桶;
- 检查候选 token 的跨语言收益;
- 对关键语言设置最小采样比例;
- 比较统一训练和分层采样的结果;
- 不仅看词表占比,还看最终 token 化长度。
14.3 确定性与采样
训练数据增强可以使用 Unigram sampling,但必须区分:
训练:允许多个合法分词路径,增加扰动
验证:通常使用确定性 Viterbi
推理:通常关闭随机采样,保证复现
如果在线推理意外启用了随机 tokenization,同一请求可能得到不同的输入 ID,导致缓存命中率下降、问题难以复现、评测结果不稳定。
15. 生产验证应围绕完整数据流
Tokenizer 的生产状态不是一个孤立的 tokenizer.json,而是一组必须共同版本化的对象:
flowchart LR
A[原始文本] --> B[Normalizer]
B --> C[Pre-tokenizer]
C --> D[BPE 或 Unigram]
D --> E[Special-token Post-processor]
E --> F[input_ids 与 attention_mask]
F --> G[模型 Embedding]
G --> H[Transformer]
H --> I[生成 token IDs]
I --> J[Decoder]
J --> K[最终文本]
L[Tokenizer 配置、词表、merge/probability、版本哈希] -.绑定.-> D
L -.绑定.-> E
M[模型配置:vocab_size、BOS/EOS/PAD IDs] -.绑定.-> G
M -.绑定.-> H
关键路径是:
- 原文先经过固定的归一化和预切分;
- BPE 使用 merge rules,Unigram 使用 token 概率或 Viterbi;
- post-processor 添加控制 token;
- 生成的 ID 必须存在于模型 embedding;
- 模型输出的 ID 必须使用同一词表和 decoder 还原。
任何一处配置漂移都会造成隐蔽问题。比如只更新 tokenizer.json 而不更新模型配置,可能导致 PAD、EOS 或特殊角色 ID 错位。
生产发布前至少执行以下验证:
普通多语言样本:编码成功、解码符合预期
特殊 token 样本:边界和 ID 正确
长文本样本:截断位置符合配置
代码和符号样本:标点、空格、换行不异常
未知字符样本:明确记录 UNK 或 byte fallback
模型联调:embedding 大小等于 tokenizer 长度
批处理:PAD 和 attention_mask 语义正确
生成联调:EOS 能正常终止
版本校验:配置、词表、代码和依赖可复现
Tokenizer 的评测结果还应与模型任务评测关联起来。某种 tokenizer 可能降低平均 token 数,却损害代码补全、跨语言问答或长文档检索;也可能在离线长度指标上变好,却因为特殊 token 配置错误而破坏对话生成。
16. 结论
BPE 通过高频 pair 的有序合并构造词表,核心是局部统计和确定性 merge rules;Unigram 为候选 token 建立概率模型,通过路径似然、EM 估计和词表裁剪选择分词。二者都不是独立于数据的黑盒,归一化、预切分、初始符号、词表大小和采样策略都会改变最终行为。
特殊 token 不只是几个保留字符串,而是 tokenizer 词表、ID、post-processor、模型 embedding 和训练模板共同定义的协议。多语言覆盖也不应只用“是否出现 [UNK]”判断,还要检查不同语言、脚本、组合字符、代码和长尾文本的 token 长度与模型成本。
可靠的 tokenizer 交付必须同时验证:
- 编码和解码是否符合预期;
- token、ID 和模型 embedding 是否一致;
- 特殊 token 的控制语义是否一致;
- 多语言和长尾文本是否出现碎片化;
- 训练、评测、推理和生成是否使用同一版本协议;
- 数据权限、隐私和计算成本是否纳入发布决策。
当这些条件同时满足时,Tokenizer 才不仅是一个预处理脚本,而是模型输入协议、序列成本和多语言能力的一部分。
系列导航与关联阅读
- 系列入口:AI 工程完整学习路线:从机器学习与 Transformer 到 RAG、Agent 和生产治理
- 上一篇:Transformer 位置编码:绝对位置、RoPE、ALiBi 与长度外推
- 下一篇:LLM 预训练数据工程:采集、去重、过滤、配比、版权与污染
官方资料
本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论