4.2 自定义分词器训练
在上一节中,我们了解了分词的基本概念与主流算法。对于从零构建大语言模型而言,一个关键决策是:使用现有的开源分词器,还是在自己的语料上训练一个专属分词器? 本节将深入讨论词表大小的选择策略、编码效率的评估方法,并以一个 6400 词表的小模型实践案例,完整展示从训练到验证的全过程。
4.2.1 词表大小的选择策略
词表大小(vocabulary size)是分词器设计中最核心的超参数之一。它直接影响模型的参数量、编码效率和语言覆盖能力。
主流模型的词表大小。 下表列出了若干代表性模型的词表规模:
| 分词器 | 词表大小 | 来源 |
|---|---|---|
| Yi tokenizer | 64,000 | 01万物 |
| Qwen2 tokenizer | 151,643 | 阿里云 |
| GLM tokenizer | 151,329 | 智谱 AI |
| Mistral tokenizer | 32,000 | Mistral AI |
| LLaMA 3 tokenizer | 128,000 | Meta |
| MiniMind tokenizer | 6,400 | 自定义训练 |
表 4-1:主流大语言模型的词表大小对比。
可以看到,当前主流大语言模型的词表规模在 3 万到 15 万之间。词表越大,模型在处理多语言文本时的覆盖能力越强,编码压缩率也越高(即相同文本被编码为更少的 token)。但更大的词表也意味着词嵌入层(embedding layer)的参数量成比例增长——嵌入矩阵的形状为
"头重脚轻"问题。 对于参数量仅几千万的小模型而言,词表大小的选择需要格外谨慎。如果小模型直接复用大模型的十几万级词表,嵌入层和输出投影层(LM Head,通常与嵌入层共享权重)的参数就会占据模型总参数的绝大部分,导致用于实际计算推理的 Transformer 层参数严重不足。这种"头重脚轻"的参数分配是小模型性能不佳的常见原因之一。
以具体数字为例:假设嵌入维度
词表大小的选择准则。 综合来看,词表大小的选择需要在以下几个因素之间取得平衡:
- 模型总参数量:模型越小,词表应越紧凑,以避免嵌入层占比过高。
- 语言覆盖需求:仅处理中英文与处理数十种语言,所需的词表规模截然不同。
- 编码效率:词表过小会导致常见词被拆分为过多子词(如"hello"被拆成"h e l l o"五个 token),增加序列长度,降低推理效率。
- 训练语料规模:词表中的每个 token 都需要足够多的训练样本才能学到良好的表示。词表过大、语料不足时,低频 token 的嵌入将得不到充分训练。
4.2.2 编码效率评估

图 4-5:BPE 合并规则的迭代学习过程。每轮统计相邻 token 对频率,合并最高频对并更新词表,直到达到目标大小。
训练完分词器后,需要评估其编码效率,即分词器在实际文本上的压缩能力。常用的评估指标包括:
压缩比(Characters per Token)。 定义为原始文本的字符数除以编码后的 token 数。压缩比越高,表示分词器越能用更少的 token 表示相同长度的文本,模型在相同上下文窗口内可以处理更多内容。
不同语言的压缩比差异很大。对于一个 6400 词表的小型分词器,在中文文本上的压缩比约为 1.5~1.7 字符/token,而纯英文文本的压缩比约为 4~5 字符/token。作为对比,Qwen2 等大型分词器在中文上可以达到 2.5~3.5 字符/token 的压缩比——更大的词表使得更多常见的中文词汇(而非单字)被收录为独立 token。
编解码一致性。 一个合格的分词器必须满足编解码的双向一致性:对任意文本
流式解码兼容性。 在实际推理场景中,模型逐 token 生成输出,分词器需要能够正确地逐步解码。由于 ByteLevel BPE 中一个中文字符通常由 2~3 个字节组成,单个 token 可能只包含一个中文字符的部分字节,解码时会产生不完整的 UTF-8 序列(表现为 \ufffd 替换字符)。因此,流式解码需要实现字节缓冲机制:累积 token 直到能解码出完整字符后再输出。

图 4-6:BPE 分词器训练实践流程。从语料准备开始,经过字节级编码、迭代合并,最终生成目标大小的词表。
4.2.3 BPE 分词器训练实践
下面以训练一个 6400 词表的 ByteLevel BPE 分词器为例,展示完整的训练流程。我们使用 Hugging Face 的 tokenizers 库作为训练后端——该库底层由 Rust 实现,能够充分利用多核 CPU 进行高效训练。
训练数据准备。 训练分词器需要大量文本语料。与模型预训练数据不同,分词器训练并不要求数据格式严格统一——核心目标是让语料覆盖目标语言的常见词汇和子词模式。典型的做法是使用预训练语料的一个子集。以 JSONL 格式的语料为例,每行包含一个 JSON 对象,其中 text 字段存储原始文本:
{"text": "鉴别一组中文文章的风格和特点..."}
{"text": "根据输入的内容,编写一个类别标签..."}完整训练代码。 以下代码实现了分词器训练、配置文件生成和质量验证的完整流程:
import json
import os
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, decoders
from transformers import AutoTokenizer
def train_tokenizer(data_path, tokenizer_dir, vocab_size=6400):
"""从 JSONL 数据集训练 ByteLevel BPE 分词器。
Args:
data_path: JSONL 格式训练数据路径,每行含 "text" 字段
tokenizer_dir: 分词器保存目录
vocab_size: 目标词表大小
"""
# ---- 步骤 1:定义数据迭代器 ----
def read_texts(file_path):
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
data = json.loads(line)
yield data["text"]
# ---- 步骤 2:初始化 BPE 分词器 ----
# ByteLevel 预处理:将文本按 UTF-8 字节拆分后再进行 BPE 合并,
# 确保任意文本都可编码,从根本上消除 OOV 问题
tokenizer = Tokenizer(models.BPE())
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
# ---- 步骤 3:定义特殊 token ----
# 这些 token 的 ID 由它们在列表中的位置决定(0, 1, 2)
special_tokens = ["<|endoftext|>", "<|im_start|>", "<|im_end|>"]
# ---- 步骤 4:配置训练器并训练 ----
trainer = trainers.BpeTrainer(
vocab_size=vocab_size,
special_tokens=special_tokens,
show_progress=True,
# 将所有 256 个单字节加入初始词表,保证字节级无遗漏
initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
)
tokenizer.train_from_iterator(read_texts(data_path), trainer=trainer)
# ---- 步骤 5:设置解码器并验证特殊 token ----
tokenizer.decoder = decoders.ByteLevel()
# 验证特殊 token 的 ID 分配正确(后续聊天模板依赖此顺序)
assert tokenizer.token_to_id("<|endoftext|>") == 0
assert tokenizer.token_to_id("<|im_start|>") == 1
assert tokenizer.token_to_id("<|im_end|>") == 2
# ---- 步骤 6:保存分词器文件 ----
os.makedirs(tokenizer_dir, exist_ok=True)
# tokenizer.json:包含完整的 BPE 模型、词表和合并规则
tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
# vocab.json + merges.txt:BPE 词表和合并规则的独立文件
tokenizer.model.save(tokenizer_dir)
# ---- 步骤 7:生成 transformers 兼容的配置文件 ----
config = {
"add_bos_token": False,
"add_eos_token": False,
"add_prefix_space": False,
"added_tokens_decoder": {
"0": {"content": "<|endoftext|>", "special": True,
"lstrip": False, "normalized": False,
"rstrip": False, "single_word": False},
"1": {"content": "<|im_start|>", "special": True,
"lstrip": False, "normalized": False,
"rstrip": False, "single_word": False},
"2": {"content": "<|im_end|>", "special": True,
"lstrip": False, "normalized": False,
"rstrip": False, "single_word": False},
},
"bos_token": "<|im_start|>",
"eos_token": "<|im_end|>",
"pad_token": "<|endoftext|>",
"unk_token": "<|endoftext|>",
"clean_up_tokenization_spaces": False,
"model_max_length": 32768,
"tokenizer_class": "PreTrainedTokenizerFast",
"chat_template": (
"{% if messages[0]['role'] == 'system' %}"
"{{ '<|im_start|>system\\n' + messages[0]['content']"
" + '<|im_end|>\\n' }}"
"{% else %}"
"{{ '<|im_start|>system\\nYou are a helpful assistant"
"<|im_end|>\\n' }}"
"{% endif %}"
"{% for message in messages %}"
"{% if message['role'] != 'system' %}"
"{{ '<|im_start|>' + message['role'] + '\\n'"
" + message['content'] + '<|im_end|>\\n' }}"
"{% endif %}"
"{% endfor %}"
"{% if add_generation_prompt %}"
"{{ '<|im_start|>assistant\\n' }}"
"{% endif %}"
),
}
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"),
"w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
print(f"分词器训练完成,已保存至 {tokenizer_dir}")
def eval_tokenizer(tokenizer_dir):
"""加载并验证训练好的分词器。"""
tokenizer = AutoTokenizer.from_pretrained(tokenizer_dir)
# 验证 1:聊天模板格式化
messages = [
{"role": "system", "content": "你是一个优秀的聊天机器人,总是给我正确的回应!"},
{"role": "user", "content": "你来自哪里?"},
{"role": "assistant", "content": "我来自地球"},
]
formatted = tokenizer.apply_chat_template(messages, tokenize=False)
print(formatted)
# 验证 2:词表大小
print(f"词表大小: {len(tokenizer)}")
# 验证 3:编解码一致性
encoded = tokenizer(formatted)
decoded = tokenizer.decode(encoded["input_ids"], skip_special_tokens=False)
print(f"编码长度: {len(encoded['input_ids'])} tokens")
print(f"编解码一致: {decoded == formatted}")
# 验证 4:压缩比
ratio = len(formatted) / len(encoded["input_ids"])
print(f"压缩比: {ratio:.2f} 字符/token")
if __name__ == "__main__":
train_tokenizer("dataset/pretrain_hq.jsonl", "model/", vocab_size=6400)
eval_tokenizer("model/")代码执行流程详解。
- 步骤 2(初始化):
models.BPE()创建一个空的 BPE 模型;pre_tokenizers.ByteLevel指定预分词策略为字节级——所有输入文本先被转换为 UTF-8 字节表示,再在字节序列上执行 BPE 合并。add_prefix_space=False表示不在每个词前自动添加空格前缀。 - 步骤 3(特殊 token):定义了三个特殊 token,它们的 ID 由列表中的位置决定。
<|endoftext|>(ID=0)用作文本结束符和填充符;<|im_start|>(ID=1)和<|im_end|>(ID=2)分别标记聊天消息的开始和结束,这与 ChatML 格式一致。 - 步骤 4(训练):
initial_alphabet=pre_tokenizers.ByteLevel.alphabet()将全部 256 个字节加入初始词表,确保任何字节序列都有对应的基础 token。train_from_iterator接受一个迭代器,无需将全部数据一次性加载到内存。训练过程即 BPE 的核心循环:统计所有相邻 token 对的出现频率,将频率最高的 token 对合并为一个新 token,重复此过程直到词表达到目标大小。 - 步骤 7(配置文件):
tokenizer_config.json是分词器与 Hugging Facetransformers生态的桥梁。其中chat_template字段使用 Jinja2 模板语法,定义了如何将多轮对话消息列表格式化为模型输入字符串。

图 4-7:特殊 token 的作用。<|endoftext|>、<|im_start|> 和 <|im_end|> 等特殊 token 在聊天模板中标记消息边界,其 ID 分配顺序影响模型的对话格式化。
4.2.4 训练产物与验证
运行训练脚本后,在指定目录下会生成以下文件:
model/
├── tokenizer.json # 分词器核心文件(BPE 模型 + 词表 + 合并规则)
├── tokenizer_config.json # transformers 兼容配置(聊天模板、特殊 token 映射等)
├── vocab.json # BPE 词表(token -> ID 映射)
└── merges.txt # BPE 合并规则(按优先级排列的 token 对)其中 tokenizer.json 是自包含的完整分词器文件,而 vocab.json 和 merges.txt 是 BPE 模型参数的独立导出,主要用于兼容旧版接口。
验证结果示例。 对聊天模板的验证输出如下:
<|im_start|>system
你是一个优秀的聊天机器人,总是给我正确的回应!<|im_end|>
<|im_start|>user
你来自哪里?<|im_end|>
<|im_start|>assistant
我来自地球<|im_end|>
词表大小: 6400
编码长度: 42 tokens
编解码一致: True从输出可以看到:(1) 聊天模板正确地将多轮对话消息格式化为带有 <|im_start|> 和 <|im_end|> 标签的结构化文本;(2) 实际词表大小确认为 6400;(3) 编解码完全一致,没有信息丢失。
编码效率分析。 上面的 42 个 token 编码了一段包含中文、英文和特殊标记的混合文本。若去掉特殊 token 只看纯中文部分,6400 词表的分词器在中文上的压缩比约为 1.5~1.7 字符/token——这意味着平均每个中文字符需要约 0.6~0.7 个 token 来表示(一个中文字符的 UTF-8 编码占 3 个字节,ByteLevel BPE 会将高频字节组合合并为子词 token)。与 Qwen2 等大型分词器相比,这个压缩比明显偏低,但对于一个仅 6400 词表的极小型分词器而言是合理的。小词表的优势体现在模型参数的紧凑性上:它使总参数量低至 2600 万左右,适合教学实验和个人设备上的快速训练。
4.2.5 自定义训练 vs. 复用开源分词器

图 4-8:词表大小与编码效率的关系。更大的词表能更好地压缩文本,但也增加了嵌入层参数量和低频 token 的训练难度。
在实际项目中,是否需要自己训练分词器取决于具体场景。下表对比了两种策略的优劣:
| 维度 | 自定义训练 | 复用开源分词器 |
|---|---|---|
| 词表大小 | 可自由控制 | 固定(通常 3 万~15 万) |
| 编码效率 | 取决于词表大小和训练语料 | 通常更高(词表大、训练充分) |
| 模型参数量 | 可通过小词表严格控制 | 嵌入层参数较多 |
| 语言覆盖 | 取决于训练语料 | 通常覆盖数十种语言 |
| 社区兼容性 | 需独立维护 | 可直接复用预训练权重 |
| 适用场景 | 小模型教学、特定领域、极端资源受限 | 生产部署、多语言、需要迁移学习 |
表 4-2:自定义训练与复用开源分词器的对比。
对于生产级别的大语言模型,推荐复用经过大规模数据训练的成熟分词器(如 Qwen2、LLaMA 3 的分词器),以获得更好的编码效率和语言覆盖。自定义训练更适合以下场景:参数量需要严格控制的小模型、需要针对特定领域术语优化的垂直应用、以及希望从零理解分词器工作原理的教学实验。
本节小结
本节围绕自定义分词器的训练展开,覆盖了从设计决策到工程实现的完整流程:
- 词表大小是核心超参数,需在模型参数预算、编码效率和语言覆盖之间取得平衡。对于小模型,过大的词表会导致嵌入层参数占比失衡("头重脚轻"问题)。
- 编码效率通过压缩比(字符/token)来衡量。ByteLevel BPE 通过字节级编码天然消除了 OOV 问题,但小词表的压缩比不如大词表。
- 完整的训练流程包括:准备语料、初始化 ByteLevel BPE 模型、定义特殊 token、执行 BPE 迭代合并、保存分词器文件和兼容配置。Hugging Face
tokenizers库提供了 Rust 加速的高效训练后端。 - 6400 词表的实践案例表明,极小的词表虽然牺牲了编码效率,但能将模型总参数压缩到 2600 万级别,且在编解码一致性和基本中英文覆盖上表现合格,适合资源受限场景下的教学和实验。