Skip to content

4.2 自定义分词器训练

在上一节中,我们了解了分词的基本概念与主流算法。对于从零构建大语言模型而言,一个关键决策是:使用现有的开源分词器,还是在自己的语料上训练一个专属分词器? 本节将深入讨论词表大小的选择策略、编码效率的评估方法,并以一个 6400 词表的小模型实践案例,完整展示从训练到验证的全过程。

4.2.1 词表大小的选择策略

词表大小(vocabulary size)是分词器设计中最核心的超参数之一。它直接影响模型的参数量、编码效率和语言覆盖能力。

主流模型的词表大小。 下表列出了若干代表性模型的词表规模:

分词器词表大小来源
Yi tokenizer64,00001万物
Qwen2 tokenizer151,643阿里云
GLM tokenizer151,329智谱 AI
Mistral tokenizer32,000Mistral AI
LLaMA 3 tokenizer128,000Meta
MiniMind tokenizer6,400自定义训练

表 4-1:主流大语言模型的词表大小对比。

可以看到,当前主流大语言模型的词表规模在 3 万到 15 万之间。词表越大,模型在处理多语言文本时的覆盖能力越强,编码压缩率也越高(即相同文本被编码为更少的 token)。但更大的词表也意味着词嵌入层(embedding layer)的参数量成比例增长——嵌入矩阵的形状为 V×DV 为词表大小,D 为嵌入维度),词表翻倍则嵌入参数翻倍。

"头重脚轻"问题。 对于参数量仅几千万的小模型而言,词表大小的选择需要格外谨慎。如果小模型直接复用大模型的十几万级词表,嵌入层和输出投影层(LM Head,通常与嵌入层共享权重)的参数就会占据模型总参数的绝大部分,导致用于实际计算推理的 Transformer 层参数严重不足。这种"头重脚轻"的参数分配是小模型性能不佳的常见原因之一。

以具体数字为例:假设嵌入维度 D=512,若词表大小 V=150,000,则仅嵌入矩阵就有约 7680 万参数;若 V=6,400,嵌入矩阵参数仅约 328 万——相差超过 20 倍。对于一个总参数仅 2600 万的小模型,前者的嵌入层参数已远超模型总量,而后者只占约 12.6%,留给 Transformer 层充足的参数预算。

词表大小的选择准则。 综合来看,词表大小的选择需要在以下几个因素之间取得平衡:

  1. 模型总参数量:模型越小,词表应越紧凑,以避免嵌入层占比过高。
  2. 语言覆盖需求:仅处理中英文与处理数十种语言,所需的词表规模截然不同。
  3. 编码效率:词表过小会导致常见词被拆分为过多子词(如"hello"被拆成"h e l l o"五个 token),增加序列长度,降低推理效率。
  4. 训练语料规模:词表中的每个 token 都需要足够多的训练样本才能学到良好的表示。词表过大、语料不足时,低频 token 的嵌入将得不到充分训练。

4.2.2 编码效率评估

BPE 合并规则的学习过程

图 4-5:BPE 合并规则的迭代学习过程。每轮统计相邻 token 对频率,合并最高频对并更新词表,直到达到目标大小。

训练完分词器后,需要评估其编码效率,即分词器在实际文本上的压缩能力。常用的评估指标包括:

压缩比(Characters per Token)。 定义为原始文本的字符数除以编码后的 token 数。压缩比越高,表示分词器越能用更少的 token 表示相同长度的文本,模型在相同上下文窗口内可以处理更多内容。

压缩比=原始文本字符数编码后 token 数

不同语言的压缩比差异很大。对于一个 6400 词表的小型分词器,在中文文本上的压缩比约为 1.5~1.7 字符/token,而纯英文文本的压缩比约为 4~5 字符/token。作为对比,Qwen2 等大型分词器在中文上可以达到 2.5~3.5 字符/token 的压缩比——更大的词表使得更多常见的中文词汇(而非单字)被收录为独立 token。

编解码一致性。 一个合格的分词器必须满足编解码的双向一致性:对任意文本 tdecode(encode(t))=t。这意味着编码过程不能丢失信息。ByteLevel BPE 通过将所有文本先映射到字节空间再进行子词合并,天然保证了这一性质——任何字节序列都能被基础字节 token 覆盖,不会出现未登录词(OOV)问题。

流式解码兼容性。 在实际推理场景中,模型逐 token 生成输出,分词器需要能够正确地逐步解码。由于 ByteLevel BPE 中一个中文字符通常由 2~3 个字节组成,单个 token 可能只包含一个中文字符的部分字节,解码时会产生不完整的 UTF-8 序列(表现为 \ufffd 替换字符)。因此,流式解码需要实现字节缓冲机制:累积 token 直到能解码出完整字符后再输出。

BPE 分词器训练实践流程:从语料准备到词表生成的完整过程

图 4-6:BPE 分词器训练实践流程。从语料准备开始,经过字节级编码、迭代合并,最终生成目标大小的词表。

4.2.3 BPE 分词器训练实践

下面以训练一个 6400 词表的 ByteLevel BPE 分词器为例,展示完整的训练流程。我们使用 Hugging Face 的 tokenizers 库作为训练后端——该库底层由 Rust 实现,能够充分利用多核 CPU 进行高效训练。

训练数据准备。 训练分词器需要大量文本语料。与模型预训练数据不同,分词器训练并不要求数据格式严格统一——核心目标是让语料覆盖目标语言的常见词汇和子词模式。典型的做法是使用预训练语料的一个子集。以 JSONL 格式的语料为例,每行包含一个 JSON 对象,其中 text 字段存储原始文本:

json
{"text": "鉴别一组中文文章的风格和特点..."}
{"text": "根据输入的内容,编写一个类别标签..."}

完整训练代码。 以下代码实现了分词器训练、配置文件生成和质量验证的完整流程:

python
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 Face transformers 生态的桥梁。其中 chat_template 字段使用 Jinja2 模板语法,定义了如何将多轮对话消息列表格式化为模型输入字符串。

特殊 token 在 GPT-4 分词器中的分配示意

图 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.jsonmerges.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 万级别,且在编解码一致性和基本中英文覆盖上表现合格,适合资源受限场景下的教学和实验。