AI 工程基础体系 · 第 73/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

Tokenizer 训练与诊断:BPE、Unigram、特殊符号和多语言覆盖

Tokenizer(分词器)把文本转换为模型可以处理的离散 token 序列,再把 token 映射为整数 ID:

x=文本(t1,t2,,tn)(i1,i2,,in)x=\text{文本} \longrightarrow (t_1,t_2,\ldots,t_n) \longrightarrow (i_1,i_2,\ldots,i_n)

其中,tjt_j 是 token 字符串,iji_j 是词表中的整数 ID。模型实际接收的是 ID,通常经过 embedding 层转换为向量:

hj=E[ij]\mathbf{h}_j=\mathbf{E}[i_j]

Transformer 的自注意力层随后处理这些向量。Tokenizer 不属于 Attention 本身,但它决定了序列长度 nn、词表大小、未知字符的处理方式以及特殊控制指令的边界。由于标准自注意力的计算和显存开销通常随 n2n^2 增长,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 的初始符号可以是:

  1. Unicode 字符;
  2. Unicode code point;
  3. UTF-8 字节;
  4. 预先切分后的词或子词片段。

字节级方法的一个重要性质是:任意 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 训练的形式化过程

设初始符号集合为 V0V_0,训练语料经过预切分后得到若干符号序列。对每一个相邻 pair (a,b)(a,b),统计它在语料中的出现次数:

c(a,b)=pair (a,b) 的出现次数c(a,b)=\text{pair }(a,b)\text{ 的出现次数}

kk 次合并选择:

(a\*,b\*)=argmax(a,b)c(a,b)(a^\*,b^\*)= \arg\max_{(a,b)}c(a,b)

然后创建新 token abab,并把语料中所有相邻的 a,ba,b 替换为 abab

[a,b][ab][a,b]\rightarrow[ab]

同时将这条合并规则记录在 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”。如果词表中同时存在 aababc,最终结果取决于训练得到的 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 有一个概率,然后在多个可能的分词路径中选择概率最高或按概率采样的路径。

设词表为 VV,每个 token tt 有概率 p(t)p(t),满足:

p(t)>0,tVp(t)=1p(t)>0,\qquad \sum_{t\in V}p(t)=1

对于一个由 token 序列 s=(t1,,tm)s=(t_1,\ldots,t_m) 表示的字符串,其概率为:

P(s)=j=1mp(tj)P(s)=\prod_{j=1}^{m}p(t_j)

一个字符串 xx 通常有多种分词方式,因而其概率是所有合法路径概率之和:

P(x)=sS(x)tsp(t)P(x)=\sum_{s\in\mathcal{S}(x)}\prod_{t\in s}p(t)

这里 S(x)\mathcal{S}(x) 表示所有能拼接成 xx 的 token 序列。

4.1 Unigram 的 EM 训练步骤

Unigram 训练通常可以理解为:

  1. 生成一个相对较大的候选词表;
  2. 用当前 token 概率计算每个训练字符串的可能分词路径;
  3. 通过前向后向算法估计每个 token 的期望计数;
  4. 更新 token 概率;
  5. 删除对整体语料似然贡献较小的 token;
  6. 重复上述步骤,直到达到目标词表大小。

设语料为 DD,目标是最大化:

L=xDlogP(x)\mathcal{L}=\sum_{x\in D}\log P(x)

直接枚举所有分词路径通常不可行,因此使用动态规划。对于字符串位置 ii,定义前向概率:

α(i)=从字符串开头到位置 i 的总概率\alpha(i)=\text{从字符串开头到位置 }i\text{ 的总概率}

若 token tt 覆盖位置 jjii,则:

α(i)=t:jiα(j)p(t)\alpha(i)=\sum_{t:j\rightarrow i}\alpha(j)p(t)

反向概率同理。某个 token 在字符串中的后验使用概率可以写成:

γ(t,j,i)=α(j)p(t)β(i)P(x)\gamma(t,j,i) = \frac{\alpha(j)p(t)\beta(i)}{P(x)}

它表示在考虑所有合法分词路径后,这个 token 覆盖该位置的概率。把所有位置和样本中的后验概率相加,就得到 token 的期望计数。

4.2 Unigram 的完整小算例

假设字符串是:

abab

候选词表只有:

a, b, ab

并且当前概率为:

p(a)  = 0.3
p(b)  = 0.2
p(ab) = 0.5

合法分词路径及概率如下:

分词路径 概率
ab + ab 0.5×0.5=0.250.5\times0.5=0.25
a + b + ab 0.3×0.2×0.5=0.030.3\times0.2\times0.5=0.03
ab + a + b 0.5×0.3×0.2=0.030.5\times0.3\times0.2=0.03
a + b + a + b 0.3×0.2×0.3×0.2=0.00360.3\times0.2\times0.3\times0.2=0.0036

所以:

P(abab)=0.25+0.03+0.03+0.0036=0.3136P(\text{abab})=0.25+0.03+0.03+0.0036=0.3136

ab 的期望使用次数为:

E[Nab]=2×0.25+1×0.03+1×0.030.31361.689\mathbb{E}[N_{ab}] = \frac{2\times0.25+1\times0.03+1\times0.03}{0.3136} \approx 1.689

a 的期望使用次数为:

E[Na]=1×0.03+1×0.03+2×0.00360.31360.214\mathbb{E}[N_a] = \frac{1\times0.03+1\times0.03+2\times0.0036}{0.3136} \approx 0.214

b 的期望次数同样约为 0.2140.214。更新概率时,不是简单地把最高概率路径作为唯一标注,而是可以利用所有路径的后验贡献。

在推理时,通常使用 Viterbi 动态规划选择最大概率的单一路径:

s\*=argmaxsS(x)P(s)s^\*=\arg\max_{s\in\mathcal{S}(x)}P(s)

本例中选择:

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|>" 当作用户要讨论的内容

常见处理方式包括:

  1. 对用户内容进行结构化封装,而不是直接拼接字符串;
  2. 使用明确的 chat template;
  3. 对需要展示的控制字符串执行转义;
  4. 训练和推理使用同一套特殊 token 注册表;
  5. 对日志和数据集保留原文、解析结果和最终 token 序列,便于审计。

不能仅依靠“这个字符串看起来很特殊”来判断语义。真正的判定来自 tokenizer 配置和上层模板逻辑。


7. 多语言覆盖:不是“支持字符”这么简单

多语言 Tokenizer 的覆盖能力至少有四个层次:

  1. 可表示性:输入是否能编码,不产生未知 token;
  2. 压缩效率:同一语义需要多少 token;
  3. 边界合理性:词、词缀、字符或代码片段是否被切得合适;
  4. 跨语言公平性:不同语言的序列长度和模型计算成本是否差异过大。

7.1 词表大小与语言覆盖的竞争

假设总词表预算为 VV,训练语料包含英语、中文、日语、阿拉伯语、代码和数学表达式。高频英语片段可能快速占据大量词表项:

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 统计每种语言的碎片化

可以为每条样本记录:

fertility(x)=token 数词数或字素簇数\text{fertility}(x)=\frac{\text{token 数}}{\text{词数或字素簇数}}

对于没有空格的中文、日文,不能直接使用空格分词作为分母。更稳妥的做法是使用外部语言分词器、字素簇计数,或至少同时报告 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,可能导致加载后行为变化。生产发布应固定 tokenizerstransformers 版本,并对序列化文件做哈希校验。


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 未知率和碎片化率

至少报告以下指标:

UNKRate=UNK token 数总 token 数\text{UNKRate} = \frac{\text{UNK token 数}}{\text{总 token 数}}

Compression=UTF-8 字节数token 数\text{Compression} = \frac{\text{UTF-8 字节数}}{\text{token 数}}

Fragmentation=token 数字素簇数或词数\text{Fragmentation} = \frac{\text{token 数}}{\text{字素簇数或词数}}

指标要按以下维度分桶:

  • 语言;
  • 脚本;
  • 文本类型;
  • 长度区间;
  • 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 区间
  -> 模型标签

但存在几个边界:

  1. 添加的特殊 token 通常没有对应原始文本,offset 可能是 (0, 0)
  2. Unicode 组合字符可能导致视觉字符和 code point 区间不同;
  3. ByteLevel tokenizer 的内部空格标记不能直接当作原始字符;
  4. 归一化后文本与原始文本长度可能不一致;
  5. 一些预切分器会把标点和空格映射成独立 token。

因此,span 对齐不能只用:

text.find(token)

因为 token 可能是归一化后的片段、字节表示或重复出现的短字符串。应使用 fast tokenizer 返回的 offsets,并在数据预处理时记录原始文本到规范化文本的映射。


13. 常见失败表现与定位路径

13.1 大量 [UNK]

可能原因:

  • 训练时没有覆盖该脚本;
  • 归一化或预切分配置不一致;
  • 加载了错误的 tokenizer 文件;
  • 推理文本包含训练时不允许的字符;
  • 模型 wrapper 没有正确读取 unk_token

诊断顺序:

  1. 直接打印 tokens 和 IDs;
  2. 检查 tokenizer.unk_token_id
  3. 用单个异常字符和完整样本分别测试;
  4. 检查原始 tokenizer 文件及版本;
  5. 对比训练端和推理端的 normalizer、pre-tokenizer、decoder;
  6. 确认不是把普通字符串误当作特殊 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

恢复步骤:

  1. 计算 len(tokenizer) 与模型 embedding 行数;
  2. 使用 model.resize_token_embeddings(len(tokenizer))
  3. 保存 tokenizer 和模型;
  4. 用包含新 token 的最小样本执行一次前向计算;
  5. 再检查生成时的 eos_token_idpad_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

关键路径是:

  1. 原文先经过固定的归一化和预切分;
  2. BPE 使用 merge rules,Unigram 使用 token 概率或 Viterbi;
  3. post-processor 添加控制 token;
  4. 生成的 ID 必须存在于模型 embedding;
  5. 模型输出的 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 才不仅是一个预处理脚本,而是模型输入协议、序列成本和多语言能力的一部分。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。