大语言模型研究11——分词器从原理到实战

作者: 引线小白-本文永久链接:https://www.limoncc.com/post/ac9b442b90c51cd5/
知识共享许可协议: 本博客采用署名-非商业-禁止演绎4.0国际许可证

一、映射到 Unicode 可见字符

直接对原始 Unicode 字符集进行分词会面临一个根本性难题:基础字符数量极其庞大,且实际应用中总会出现训练时从未见过的罕见汉字、表情符号等,造成严重的 OOV(Out-Of-Vocabulary)问题。

当前主流大模型的分词器普遍采用字节级子词分词来彻底解决这一难题。其核心思路是:任何 Unicode 文本都可以用 UTF-8 编码为一个字节序列,每个字节的取值范围是 0x00–0xFF(即 0–255)。既然只有 256 种基础字节,就可以把“字节”当作最小的词汇单元——这样一来,任何文本都能用这 256 个符号的组合来表示,永远不会出现未知字符。

但单纯把字节当作字符存在工程问题:0x00–0x1F 等属于控制字符,直接放进文本中会带来各种麻烦。因此,常见的做法会额外引入一个无损映射,具体分为两步:

  1. 编码f(文本) = utf8_bytes(文本),将任意文本转换为 UTF-8 字节序列。
  2. 映射g(byte) = byte + 256,将每个字节值(0–255)映射到 Unicode 码点区间 [U+0100, U+01FF](即码点值 256–511)。

这一步骤的必要性可以从两方面理解:

  • 数学上byte → byte + 256 是整数区间 [0, 255] 到 [256, 511] 的双射,可逆且无碰撞,保证了原始信息能够被完全还原。
  • 工程上:[U+0100, U+01FF] 区间内的字符全部是可见字符(如 Ā、ā、Ă 等拉丁扩展字母),既避开了控制字符带来的处理麻烦,又能让分词算法把它们当作普通文本来工作。

评述:这个 +256 映射是所有字节级 BPE[^1][^2]算法中最安静也最关键的一步。它没有改变任何信息量,只是让后续的 BPE 算法可以“假装”自己在处理普通文本。早期我自己照着论文实现时,曾觉得这步可有可无,直到在日志里看到那些控制字符把终端搞乱,才明白工程上避开控制字符有多重要。

经过这两步转换,任意一段文本都变成了由 [U+0100, U+01FF] 区间内字符构成的序列。接下来在这个序列上运行 BPE(字节对编码)算法。BPE 根本不需要知道这些字符背后代表的是“字节”,它看到的仅仅是 256 个初始符号。它会反复统计这些符号的共现频率,将最常相邻出现的符号对合并成一个新的 token,不断扩充词表。训练完成后,词表中的 token 既可能是单个字节映射符,也可能是多个字节合并而成的子词——比如一个完整汉字所对应的多字节组合(汉字在 UTF-8 中通常占 3 个字节,合并后就可能用一个 token 表示)。

在推理时,处理流程完全对称:先将输入文本经 UTF-8 编码和 +256 映射转换为字符序列,再利用训练好的词表将其切分为 token ID;解码时,将 token 对应字符串中的每个字符减去 256,恢复为原始字节序列,最后用 UTF-8 解码,即可完美还原文本。这样,无论输入中包含多么罕见或全新的符号,都能被表示为若干字节级子词的组合,从而从根源上消灭了 OOV 问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
text = "中"
print(f"原始字符串: {text}")
utf8_bytes = text.encode('utf-8')
print(f"utf8字节: {utf8_bytes}")
mapped_chars = [chr(b + 256) for b in utf8_bytes]
mapped_str = ''.join(mapped_chars)
print(f"映射后字符串: {mapped_str!r}")
recovered_bytes = bytes(ord(c) - 256 for c in mapped_str)
recovered_text = recovered_bytes.decode('utf-8')
print(f"解码字符串: {recovered_text}")
# 原始字符串: 中
# utf8字节: b'\xe4\xb8\xad'
# 映射后字符串: 'ǤƸƭ'
# 解码字符串: 中

二、Byte-level BPE 分词原理

Byte Pair Encoding(BPE,字节对编码)原本是一种数据压缩算法,2016 年被应用到自然语言处理中的子词切分,成为现代大模型分词的核心方法之一。它的核心思想很简单:用当下最频繁的相邻符号对,不断合并生成新的子词,直到词汇表达到预定大小。这样既能保留常见词的整体性,又能把罕见词拆成可理解的片段,大幅度缓解未登录词问题。

下面给出一个最小的 MVP 例子,方便大家理解。

2.1、准备训练语料(模拟词频)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
corpus_words = {
"low": 5,
"lower": 2,
"newest": 6,
"widest": 3,
}

# 用空格把每个词重复相应次数,构造一个字符串文本
# 因为字节级 BPE 把空格当作普通字节,天然就是词边界
raw_corpus = ""
for word, freq in corpus_words.items():
raw_corpus += (word + " ") * freq

# 把文本转换成 UTF-8 字节序列 → 初始 token ids 就是这些字节值
print(f"训练文本: {raw_corpus}")
raw_bytes = raw_corpus.encode("utf-8")
token_ids = list(raw_bytes)

print(f"初始 token ids 数量: {len(token_ids)}")
print(f"前 20 个 token ids: {token_ids[:20]}")
print(f"对应字节: {bytes(token_ids[:20])}")
print()

# 训练文本: low low low low low lower lower newest newest newest newest newest newest widest widest widest
# 初始 token ids 数量: 95
# 前 20 个 token ids: [108, 111, 119, 32, 108, 111, 119, 32, 108, 111, 119, 32, 108, 111, 119, 32, 108, 111, 119, 32]
# 对应字节: b'low low low low low '
2.2、字节级 BPE 训练
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
from collections import Counter

def train_byte_bpe(token_ids, num_merges):
# 初始化词表:0-255 是单字节,后面的是合并产生的新符号
# 我们用列表表示每个 token id 对应的字节序列
vocab = [bytes([i]) for i in range(256)] # 0..255

# 合并规则列表,元素为 (id_a, id_b),按应用顺序保存
merges = []

for step in range(num_merges):
# 2.1 统计所有相邻 pair 的频率
pairs = Counter()
for i in range(len(token_ids) - 1):
pair = (token_ids[i], token_ids[i + 1])
pairs[pair] += 1

if not pairs:
break

# 2.2 取出频率最高的一对(频率相同时取第一个)
best_pair = max(pairs, key=lambda p: (pairs[p], p))
best_count = pairs[best_pair]
print(f"合并步骤 {step + 1}: {best_pair} → 新 id {len(vocab)} (出现 {best_count} 次)")

# 2.3 创建新符号
new_id = len(vocab)
id_a, id_b = best_pair
# 新符号对应的字节 = id_a 的字节 + id_b 的字节
new_bytes = vocab[id_a] + vocab[id_b]
vocab.append(new_bytes)
merges.append(best_pair)

# 2.4 在 token_ids 中把 (id_a, id_b) 全部替换为 new_id
new_token_ids = []
i = 0
while i < len(token_ids):
if (i < len(token_ids) - 1 and
token_ids[i] == id_a and token_ids[i + 1] == id_b):
new_token_ids.append(new_id)
i += 2
else:
new_token_ids.append(token_ids[i])
i += 1
token_ids = new_token_ids

return token_ids, vocab, merges


# 训练 BPE
final_ids, vocab, merge_rules = train_byte_bpe(token_ids, num_merges=15)

print(f"\n训练后 token id 序列: {final_ids}")
print(f"词表大小: {len(vocab)}")
print(f"合并规则列表 (id1, id2): {merge_rules}")
print()
# 合并步骤 1: (116, 32) → 新 id 256 (出现 9 次)
# 合并步骤 2: (115, 256) → 新 id 257 (出现 9 次)
# 合并步骤 3: (101, 257) → 新 id 258 (出现 9 次)
# 合并步骤 4: (111, 119) → 新 id 259 (出现 7 次)
# 合并步骤 5: (108, 259) → 新 id 260 (出现 7 次)
# 合并步骤 6: (119, 258) → 新 id 261 (出现 6 次)
# 合并步骤 7: (110, 101) → 新 id 262 (出现 6 次)
# 合并步骤 8: (262, 261) → 新 id 263 (出现 6 次)
# 合并步骤 9: (32, 260) → 新 id 264 (出现 6 次)
# 合并步骤 10: (263, 263) → 新 id 265 (出现 5 次)
# 合并步骤 11: (264, 264) → 新 id 266 (出现 4 次)
# 合并步骤 12: (119, 105) → 新 id 267 (出现 3 次)
# 合并步骤 13: (267, 100) → 新 id 268 (出现 3 次)
# 合并步骤 14: (268, 258) → 新 id 269 (出现 3 次)
# 合并步骤 15: (269, 269) → 新 id 270 (出现 2 次)
#
# 训练后 token id 序列: [260, 266, 266, 264, 101, 114, 264, 101, 114, 32, 265, 265, 265, 270, 269]
# 词表大小: 271
# 合并规则列表 (id1, id2): [(116, 32), (115, 256), (101, 257), (111, 119), (108, 259), (119, 258), (110, 101), (262, 261), (32, 260), (263, 263), (264, 264), (119, 105), (267, 100), (268, 258), (269, 269)]

评述:BPE 的合并顺序本身就是一个有向无环图的构建过程。训练完成后,合并规则是按优先级排列的,推理时严格按此顺序应用,这保证了无论输入何种文本,切分结果都是确定且唯一的。实际使用中,你会发现词表大小通常在 32k 到 100k 之间,过小会导致切分过碎,过大则浪费参数。选多少取决于你的主要语言和计算预算,没有一个绝对标准,但可以从 50k 开始作为一个合理的起点。

2.3、推理:用训练好的规则切分新词 lowest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def apply_bpe_to_text(text, merge_rules, vocab):
# 把文本变成字节,再转成 token id 列表
byte_seq = text.encode("utf-8")
ids = list(byte_seq)

# 按顺序应用合并规则
for (a, b) in merge_rules:
new_ids = []
i = 0
while i < len(ids):
if i < len(ids) - 1 and ids[i] == a and ids[i + 1] == b:
new_ids.append(pair_to_id[(a, b)])
i += 2
else:
new_ids.append(ids[i])
i += 1
ids = new_ids
return ids


# 建立 (id_a, id_b) -> new_id 的映射
pair_to_id = {}
for idx, (a, b) in enumerate(merge_rules):
pair_to_id[(a, b)] = 256 + idx

# 切分 "lowest" (训练中没出现过的词)
new_word = "lowest"
# 为了和训练时一致,后面也加空格(因为训练文本里词后有空格)
token_ids_lowest = apply_bpe_to_text(new_word + " ", merge_rules, vocab)

print(f"'{new_word}' 的 BPE 切分结果 (token id 序列): {token_ids_lowest}")
print()


def decode_token(tok_id, vocab):
"""递归解码一个 token id 为字节,然后尝试以 UTF-8 字符串显示"""
if tok_id < 256:
return vocab[tok_id].decode("utf-8", errors="replace")
else:
# 通过 vocab 的字节序列直接解码
return vocab[tok_id].decode("utf-8", errors="replace")


subwords = [decode_token(tid, vocab) for tid in token_ids_lowest]
print(f"对应的子词: {subwords}")
# 去掉最后可能的空格子词展示更清晰
subwords_clean = [s for s in subwords if s != " "]
print(f"真实切分: {subwords_clean}")

# 'lowest' 的 BPE 切分结果 (token id 序列): [260, 258]
#
# 对应的子词: ['low', 'est ']
# 真实切分: ['low', 'est ']

三、Byte-level BPE 分词实战

实际分词器训练,一般都使用 Hugging Face 出品的 tokenizers 库。它是一个基于 Rust 的高性能、可定制的子词分词器库。这个库的使用要注意如下几点。

3.1、tokenizer_config 的配置

先了解一下 tokenizer_config.json 关于特殊 token 的配置。

配置项 作用 是否控制自动添加?
bos_token / eos_token 定义哪个 token 是开始/结束标记 ❌ 仅定义
add_bos_token / add_eos_token 控制 add_special_tokens=True 时,是否自动在序列首尾插入 BOS/EOS 是,核心开关
pad_token / unk_token 定义填充/未知词的 token ❌ 仅定义标记,不控制 encode 自动添加行为
additional_special_tokens 声明额外的特殊标记列表,防止被分词器拆分,并可被生成逻辑识别
added_tokens_decoder 所有特殊 token 的完整注册表(角色、是否特殊、前后缀行为等)
  • 想让 encode(…, add_special_tokens=True) 自动加上 BOS/EOS只需保证 add_bos_token=True 和 add_eos_token=True
  • <|im_start|><|im_end|> 放进 additional_special_tokens 是好的实践,但不会改变 encode 的自动追加行为。

另外 Hugging Face 的 transformers 库中,各类 Tokenizer 的 from_pretrained 会丢弃 add_bos_token/add_eos_token必须在加载后手动设置。否则 encode(..., add_special_tokens=True) 不会生效。

1
2
3
# tokenization_utils_base.py:1787-178
init_kwargs.pop("add_bos_token", None) # 删掉!
init_kwargs.pop("add_eos_token", None) # 删掉!

评述:这里是个经典的坑。很多新人在本地训练完分词器、一切正常,但推到 Hub 上再 from_pretrained 加载后,发现 encode 再也不自动加 BOS/EOS 了。原因就是 transformers 在加载时主动丢弃了这两个开关。最简单的解法:加载后马上执行 tokenizer.add_bos_token = Truetokenizer.add_eos_token = True。建议手写 tokenizer_config.json 时保留这两个字段,但别指望自动加载会尊重它们——这是目前官方行为,不是 bug,是设计。

3.2、Control Tokens 配置
3.2.1、特殊词元速查表

控制词元是插入到序列中的特殊标记,用于传递元信息。例如,在预训练阶段,多个文档可能会被打包进单个序列。对 Qwen 而言,控制词元 <|endoftext|> 会被插入在每个文档之后,以表示该文档已结束、新文档即将开始。常见的控制标记及其在 Qwen 中的状态可参见下表:

Qwen 控制令牌(Control Tokens)速查表

  1. eod token(文档结束符) <|endoftext|>:文档结束标记。在打包的训练序列中,插入在文档之间,用于分隔不同文档。
  2. bot token(回合开始符) <|im_start|>:回合开始标记。在对话微调时,添加到每个对话回合的开头
  3. eot token(回合结束符) <|im_end|>:回合结束标记。在对话微调时,添加到每个对话回合的末尾
  4. unk token(未知词符) 无:Qwen 使用的 BBPE 分词算法确保了不会出现未知词符,因此无需此标记。
  5. pad token(填充符) 无:Qwen 在训练中不使用填充序列。推理时如需填充,可结合分词器返回的注意力掩码,将任意特殊标记用作 pad_token,通常设置为与 eod 相同的 <|endoftext|>
  6. bos token(序列开始符) 无:Qwen 不会在打包的训练序列开头添加固定标记。
  7. eos token(序列结束符) 无:Qwen 不会在打包的训练序列末尾添加固定标记。然而,由于多数框架没有 eot 的概念,在推理时需要使用 eos_token 作为停止条件,因此最终将 eos_token 设置为与 eot_token 相同的 <|im_end|>

评述:Qwen 对 pad_token 的处理是一个实用技巧:训练时不填充,推理时随意指定一个已经存在的特殊 token 充当 pad(并用 attention_mask 屏蔽)。这样不会额外占用词表位置,也不干扰其他 token 的语义。类似的设计在 LLaMA 等模型中也常见,背后的哲学是:填充只是工程需要,不值得为它专门训练一个嵌入向量。

3.2.2、预训练阶段的特殊词元

特殊 token 设置,主要是看训练目标。从千问情况看,千问的设置是:

1
2
3
4
5
{
"bos_token": null,
"eos_token": "<|im_end|>",
"pad_token": "<|endoftext|>"
}

这意味着预训练数据格式,即输入模型的数据大致是这样的结构:

1
文档1的文本内容...<|endoftext|>文档2的文本内容...<|endoftext|>...

预训练特殊词元重要结论:通过在无数文档间插入 <|endoftext|> 进行训练,模型学会了用它来划分语义边界,防止把不同文档的知识混淆。也就是说在预训练阶段,模型完全没见过 <|im_start|><|im_end|>,对“对话”这种形式没有概念。

评述:预训练阶段不用对话标记,是刻意的。这保证了基础模型的通用性——它理解的是“文档”概念,而非特定应用格式。所有对话能力都通过后训练注入,这样的好处是基础模型可以复用给不同下游任务(翻译、摘要、代码生成等),而不会被对话格式束缚。

3.2.3、后训练阶段的特殊词元

后训练阶段 <|im_start|><|im_end|> 才作为核心角色登场。Qwen 采用了 ChatML 格式来结构化对话数据。一个典型的训练样本会被组织成这样:

1
2
3
4
5
6
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
Who won the world series in 2020?<|im_end|>
<|im_start|>assistant
The Los Angeles Dodgers won the World Series in 2020.<|im_end|>

后训练特殊词元重要结论

  1. 角色与认知转变:在这个全新的数据结构中,<|im_end|> 被模型重新学习为对话的结束标记。因为它总出现在每个对话的结尾,模型逐渐认知到,当生成这个标记时,就代表一次对话的结束。
  2. 预训练阶段很重要的 <|endoftext|> 不再出现,其“结束符”的意义被 <|im_end|> 所取代。
  3. 分词器配置的最终形态:训练完成后,为了适配这种新的认知,分词器的配置会被更新。eos_token 被设置为 <|im_end|>,pad_token 被设置为 <|endoftext|> 让它退居二线负责填充(padding)等辅助工作,bos_token 保持为 null,因为 ChatML 模板中的 <|im_start|> 已经承载了“开始”的职责。

所以特殊 token 应该根据你训练目的灵活设置,例如有人就直接这样设置,在预训练中直接使用 <|im_start|><|im_end|>

1
2
3
4
{
"bos_token": "<|im_start|>",
"eos_token": "<|im_end|>"
}

评述:选择在哪个阶段引入对话标记,没有绝对正确。如果在预训练阶段就加入,模型从第一天起就把 <|im_end|> 当作“生成结束”信号,省去后训练的重新认知过程,但代价是预训练数据也要打包成类似对话的格式,成本更高。Qwen 的做法属于“先通用、后专用”的工程折中,是目前的主流路线。

3.3、BBPE 预分词技巧
  1. Unicode 标准有很多规范形式,把所有 Unicode 字符统一规范为 NFC 形式。比如 é 既可以用单个字符 U+00E9 表示,也可以用 e + 组合重音符表示。NFC 会统一成前者,避免同一个文本有两种底层表示,从而避免不必要的 OOV 或子词分裂。

  2. 正则拆解预分词:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GPT2_SPLIT_PATTERN = (
r"(?i:'s|'t|'re|'ve|'m|'ll|'d)"
# 英文常用缩写后缀(不区分大小写)把 's、'll 等单独切成一块,不会被 BPE 粘到单词上
r"|[^\r\n\p{L}\p{N}]?\p{L}+"
# 一个可选的“非字母、非数字、非回车”的字符,后面跟一个或多个 Unicode 字母
# 切出“一个可能的标点 + 一个完整单词”,比如 "您好"、"Hello"、"(你好"(左括号被当作前面的可选字符)
r"|\p{N}" # 单独一个数字字符 每个数字单独成块(123 会被切成 1、2、3)
r"| ?[^\s\p{L}\p{N}]+[\r\n]*"
# 一个可选空格,后面跟一个或多个非空白、非字母、非数字的符号,然后可能跟回车换行
# 处理标点符号串,比如 !!!、…,并把它们独立出来
r"|\s*[\r\n]+"
# 空白字符(可零个)后面跟至少一个换行,把换行符作为独立的块(连续换行算一块)
r"|\s+(?!\S)"
# 尾部空白(后面没有可见字符的空格),把末尾多余的空格切出来
r"|\s+"
# 其他空白(比如单词之间的空格),把空格单独成块
)

评述:预分词正则看起来复杂,但对英文这种空格分隔的语言来说,主要影响标点和缩写的切分粒度。我自己的经验是,对于中文语料,这套正则的前两条依然有用(标点+词),但数字单独切分(\p{N})有时会过于激进,尤其是遇到日期、小数时会把它们拆散,可以视情况调整。预分词之后,再走字节级映射,就能稳定处理中英混合文本。

3.4、BBPE 核心代码
3.4.1、训练设定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import tokenizers as tk

# 设定词表大小
vocab_size = 6400
tokenizer = tk.Tokenizer(tk.models.BPE())
# add_prefix_space 控制是否在每个单词前加空格,由于处理的是中文,因此将其设置为 False。
# 对于英文,一个以空格开头的英文单词 " the" 和出现在句子中间的 "the" 会被区分开来,拥有不同的 Token ID
tokenizer.normalizer = tk.normalizers.NFC()
GPT2_SPLIT_PATTERN = (
r"(?i:'s|'t|'re|'ve|'m|'ll|'d)" # 英文常用缩写后缀(不区分大小写)把 's、'll 等单独切成一块,不会被 BPE 粘到单词上
r"|[^\r\n\p{L}\p{N}]?\p{L}+" # 一个可选的“非字母、非数字、非回车”的字符,后面跟一个或多个 Unicode 字母 切出“一个可能的标点 + 一个完整单词”,比如 "您好"、"Hello"、"(你好"(左括号被当作前面的可选字符)
r"|\p{N}" # 单独一个数字字符 每个数字单独成块(123 会被切成 1、2、3)
r"| ?[^\s\p{L}\p{N}]+[\r\n]*" # 一个可选空格,后面跟一个或多个非空白、非字母、非数字的符号,然后可能跟回车换行 处理标点符号串,比如 !!!、…,并把它们独立出来
r"|\s*[\r\n]+" # 空白字符(可零个)后面跟至少一个换行,把换行符作为独立的块(连续换行算一块)
r"|\s+(?!\S)" # 尾部空白(后面没有可见字符的空格),把末尾多余的空格切出来
r"|\s+" # 其他空白(比如单词之间的空格),把空格单独成块
)
tokenizer.pre_tokenizer = tk.pre_tokenizers.Sequence([
tk.pre_tokenizers.Split(pattern=tk.Regex(GPT2_SPLIT_PATTERN), behavior="isolated", invert=False),
tk.pre_tokenizers.ByteLevel(add_prefix_space=False),
])
# 为分词器设置一个 ByteLevel 解码器,让其在将 token ID 序列转换回原始文本时,能够正确还原被分词器按字节切分的内容。
tokenizer.decoder = tk.decoders.ByteLevel()
# 定义特殊 token
special_tokens = ["<|endoftext|>", "<|im_start|>", "<|im_end|>"]
# BBPE 的 256 个基础 token
alphabet = tk.pre_tokenizers.ByteLevel.alphabet()
trainer = tk.trainers.BpeTrainer(vocab_size=vocab_size, special_tokens=special_tokens, show_progress=True, initial_alphabet=alphabet)
3.4.2、训练
1
2
3
4
5
6
7
8
9
10
11
12
13
texts = ["你好", '你好,我来自地球']

def read_texts(texts):
for text in texts:
yield text

tokenizer.train_from_iterator(read_texts(texts), trainer=trainer)
# 设定保存目录
tokenizer_dir = r"./model"
os.makedirs(tokenizer_dir, exist_ok=True)
# 保存
tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
tokenizer.model.save(tokenizer_dir)

评述train_from_iterator 是内存友好设计,你可以直接传一个生成器,处理 TB 级数据也不会爆内存。另外注意 initial_alphabet 传入了 ByteLevel.alphabet(),这会在词表中显式保留所有 256 个基础字节映射符,确保哪怕是单字节的罕见二进制数据也能被编码。如果没有这一步,某些模型在遇到非常规字节时会直接退化成 unk,反而违背了字节级 BPE 的初衷。

3.4.3、配置 tokenizer_config.json

Hugging Face 的 tokenizers 库只保存算法和数据,不能自动生成 tokenizer_config.json,这并非疏漏,而是因为它和 transformers 库有明确的分工。简单来说,tokenizers 库专注于“如何分词”(即核心算法),只保存算法和数据;而 transformers 库需要处理“如何使用分词器”这类更高级的配置,所以这些额外的配置工作就落在了它的肩上。

如何生成 tokenizer_config.json 文件?一种是自己手动写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
config = {
"add_bos_token": False,
"add_eos_token": False,
"add_prefix_space": False,
"added_tokens_decoder": {
"0": {
"content": "<|endoftext|>",
"lstrip": False,
"normalized": False,
"rstrip": False,
"single_word": False,
"special": True
},
"1": {
"content": "<|im_start|>",
"lstrip": False,
"normalized": False,
"rstrip": False,
"single_word": False,
"special": True
},
"2": {
"content": "<|im_end|>",
"lstrip": False,
"normalized": False,
"rstrip": False,
"single_word": False,
"special": True
}
},
"additional_special_tokens": [],
"bos_token": "<|im_start|>",
"clean_up_tokenization_spaces": False,
"eos_token": "<|im_end|>",
"legacy": True,
"model_max_length": 32768,
"pad_token": "<|endoftext|>",
"sp_model_kwargs": {},
"spaces_between_special_tokens": False,
"tokenizer_class": "PreTrainedTokenizerFast",
"unk_token": "<|endoftext|>",
"chat_template": "{% if messages[0]['role'] == 'system' %}{% set system_message = messages[0]['content'] %}{{ '<|im_start|>system\\n' + system_message + '<|im_end|>\\n' }}{% else %}{{ '<|im_start|>system\\nYou are a helpful assistant<|im_end|>\\n' }}{% endif %}{% for message in messages %}{% set content = message['content'] %}{% if message['role'] == 'user' %}{{ '<|im_start|>user\\n' + content + '<|im_end|>\\n<|im_start|>assistant\\n' }}{% elif message['role'] == 'assistant' %}{{ content + '<|im_end|>' + '\\n' }}{% endif %}{% endfor %}"
}

# 保存配置文件
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w", encoding="utf-8") as config_file:
json.dump(config, config_file, ensure_ascii=False, indent=4)

还有一种最简单、最标准的做法。你只需用 PreTrainedTokenizerFast 包装已训练好的 tokenizers.Tokenizer 对象,再调用它的 save_pretrained() 方法即可自动生成所有文件。但是这种方法保存的 tokenizer_config.json 格式不如手写

1
2
3
4
5
6
7
8
9
10
11
from tokenizers import Tokenizer
from transformers import PreTrainedTokenizerFast

# 1. 准备你已经训练好的 tokenizers.Tokenizer 对象
tokenizer = Tokenizer.from_file("path/to/your/tokenizer.json")

# 2. 用 PreTrainedTokenizerFast 包装它
wrapped_tokenizer = PreTrainedTokenizerFast(tokenizer_object=tokenizer)

# 3. 保存。它会自动生成 tokenizer_config.json 等文件
wrapped_tokenizer.save_pretrained("your_save_directory")

评述:两种方法我用得最多的是手写,因为可以精确控制 add_bos_token 这类容易被忽视的字段,以及 chat_template 这个对对话模型至关重要的模板。自动保存虽然方便,但生成的配置往往缺少 chat_template,而缺少它的话,apply_chat_template 会直接报错。所以如果打算公开发布分词器,建议最终还是用手写方式补全 tokenizer_config.json

3.4.4、测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 测试对话编码

from transformers import PreTrainedTokenizerFast

# 加载预训练的 tokenizer
tokenizer = PreTrainedTokenizerFast.from_pretrained(r"./model")

messages = [
{"role": "system", "content": "你是一个优秀的聊天机器人,总是给我正确的回应!"},
{"role": "user", "content": '你来自哪里?'},
{"role": "assistant", "content": '我来自地球'}
]
new_prompt = tokenizer.apply_chat_template(messages, tokenize=False)

# 获取实际词汇表长度(包括特殊符号)
actual_vocab_size = len(tokenizer)
print('tokenizer 实际词表长度:', actual_vocab_size)

model_inputs = tokenizer(new_prompt)
print('model_inputs', model_inputs)
print('encoder 长度:', len(model_inputs['input_ids']))

input_ids = model_inputs['input_ids']
a = [tokenizer.decode(item, skip_special_tokens=False) for item in input_ids]
print(a)
response = tokenizer.decode(input_ids, skip_special_tokens=False)
print('decoder 和原始文本是否一致:', response == new_prompt)

print('\n输入文本:\n', new_prompt, '\n')
print('解码文本:\n', response, '\n')

# 测试预训练编码
tokenizer.add_bos_token = True
tokenizer.add_eos_token = True
text = "You are a helpful assistant."
# text = "你来自哪里?"
input_ids = tokenizer.encode(text, add_special_tokens=True)
a = [tokenizer.decode(item, skip_special_tokens=False) for item in input_ids]
print(a)
print('input_ids', input_ids)
response = tokenizer.decode(input_ids, skip_special_tokens=False)
print(response)

评述:最后这段测试务必保留。无论你多信任自己的配置,都建议跑一遍 decode(encode(text)) 的往返测试,特别是包含特殊 token 和不同语言混合的情况下。历史上因为 add_bos_token 丢失或 chat_template 错误导致的 silent failure 非常常见——编码解码看似正常,实际多了一个空格或少了一个换行,模型表现就完全不同。

参考文献

[^1]: Sennrich, R., Haddow, B., & Birch, A. (2016). Neural machine translation of rare words with subword units. arXiv. https://doi.org/10.48550/arXiv.1508.07909
[^2]: Radford, A., Wu, J., Child, R., Luan, D., Amodei, D., & Sutskever, I. (2019, February 14). Language models are unsupervised multitask learners. OpenAI. https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf


版权声明
引线小白创作并维护的柠檬CC博客采用署名-非商业-禁止演绎4.0国际许可证。
本文首发于柠檬CC [ https://www.limoncc.com ] , 版权所有、侵权必究。
本文永久链接https://www.limoncc.com/post/ac9b442b90c51cd5/
如果您需要引用本文,请参考:
引线小白. (May. 11, 2026). 《大语言模型研究11——分词器从原理到实战》[Blog post]. Retrieved from https://www.limoncc.com/post/ac9b442b90c51cd5
@online{limoncc-ac9b442b90c51cd5,
title={大语言模型研究11——分词器从原理到实战},
author={引线小白},
year={2026},
month={May},
date={11},
url={\url{https://www.limoncc.com/post/ac9b442b90c51cd5}},
}

'