大模型底层学习(三)-从零训练一个 BPE 分词器

105 阅读10分钟

大模型处理的是"文本",但计算机真正理解的是数字。在文本和数字之间,有一座关键的桥梁——Tokenizer(分词器)。本文不空谈理论,而是结合实际代码,从数据清洗到 BPE 训练再到分词验证,完整走一遍分词器的构建流程。

前言

无论是 ChatGPT、DeepSeek 还是 Qwen,所有大语言模型的第一步都是把文本变成数字。这个过程看似简单,但分词器的设计直接决定了模型的参数量、序列长度和最终效果。

本文参考 MiniMind 项目的架构,从零实现了一个完整的 Tokenizer 训练流程,包含:

  • 数据预处理:正则清洗 + 质量过滤 + SimHash/MinHash 去重
  • BPE 训练:ByteLevel 预分词 + 迭代合并
  • 产物解析:vocab.json / merges.txt / tokenizer.json / tokenizer_config.json 各自的作用
  • 分词验证:编解码一致性、流式解码、压缩率测试

一、Token 是什么?

Token 是模型处理的最小语义单元,它既不是字符,也不是传统意义上的词,而是一个介于两者之间的概念。

原始文本 → Tokenizer → Token ID 序列 → Embedding 层 → 向量序列

"Hello world" → [15496, 995] → E[15496], E[995][0.2, -0.1, ...], [0.7, 0.3, ...]

为什么不按字符或按词切分?

粒度优点缺点
字符级词表极小(~100),无 OOV 问题序列极长,模型难以捕捉长距离依赖
词级语义完整,符合人类直觉词表巨大,OOV 问题严重
Subword 级平衡序列长度和词表大小需要额外算法

Subword 分词的核心思想是:常用词保留完整,低频词拆成更小的片段。BPE 就是最经典的 Subword 算法。

二、BPE 算法原理

BPE(Byte Pair Encoding,字节对编码)的核心思想是迭代合并

  1. 初始化:将所有字节作为最基础的 token
  2. 统计:统计所有相邻 token 对的出现频率
  3. 合并:选择频率最高的 token 对合并成新 token
  4. 迭代:重复步骤 2-3,直到达到预设的词表大小

用一个具体例子理解。假设初始词表是 {l, o, w, e, r, ...},语料中 "lower" 出现很多次:

1轮:统计发现 (l, o) 频率最高 → 合并为 "lo"2轮:统计发现 (lo, w) 频率最高 → 合并为 "low"3轮:统计发现 (low, er) 频率最高 → 合并为 "lower"
...
直到词表达到预设大小

停止条件:词表大小达到 vocab_size。词表越大,合并次数越多,高频字/词越可能被合并成单个 token,压缩率越高。

三种主流分词算法对比

特性BPEWordPieceSentencePiece (Unigram)
核心思想迭代合并高频相邻对合并使似然最大化的 pair从大候选集剪枝低概率 token
构建方向自底向上自底向上自顶向下
是否需要预分词需要需要不需要
典型模型GPT-2, LLaMA, QwenBERTT5, ALBERT

本文实现的是 ByteLevel BPE——GPT-2/4、Qwen 等主流模型采用的方案。

三、数据预处理:清洗与去重

分词器训练之前,必须先对原始语料进行清洗和去重。"Garbage in, garbage out"——数据质量直接决定词表质量。

3.1 文本清洗

清洗流水线包含 7 个步骤:

def clean_text(text):
    text = RE_HTML_TAG.sub('', text)           # 1. 去除 HTML 标签
    text = RE_URL.sub('', text)                # 2. 去除 URL
    text = RE_CONTROL_CHAR.sub('', text)       # 3. 去除控制字符(保留 \t \n \r)
    text = RE_ZERO_WIDTH.sub('', text)         # 4. 去除零宽字符(不可见但干扰分词)
    text = RE_SPECIAL_CHAR.sub('', text)       # 5. 去除特殊 Unicode 字符
    text = RE_MULTI_SPACE.sub(' ', text)       # 6. 折叠多余空白
    text = RE_MULTI_NEWLINE.sub('\n\n', text)
    text = text.strip()                        # 7. 首尾空白
    return text

3.2 质量过滤

清洗后还要做三轮质量过滤:

过滤规则阈值作用
长度过滤5 ~ 10000 字符过滤过短(信息量不足)或过长(异常数据)
中文占比≥ 10%过滤中文占比过低的文本
符号污染≤ 50%过滤符号占比过高的噪声文本
def is_valid(text):
    length = len(text)
    if length < MIN_LENGTH: return False, "长度过短"
    if length > MAX_LENGTH: return False, "长度过长"

    chinese_ratio = len(RE_CHINESE.findall(text)) / length
    if chinese_ratio < MIN_CHINESE_RATIO: return False, "中文占比过低"

    symbol_ratio = len(RE_SYMBOL.findall(text)) / length
    if symbol_ratio > MAX_SYMBOL_RATIO: return False, "符号污染"

    return True, "通过"

3.3 去重:SimHash + MinHash

去重提供两种算法,适用于不同场景:

SimHash 近似去重——适合发现完全重复或高度相似的文本:

原理:对文本计算 64 位指纹 → 海明距离 ≤3 视为重复
速度:快,O(n) 级别
场景:复制粘贴、转载

MinHash + LSH 模糊去重——适合发现部分重写、改写的文本:

原理:提取 N-gram → 计算 MinHash 签名 → LSH 分桶加速 → Jaccard 相似度 ≥0.8 视为重复
速度:中等,但能发现 SimHash 漏掉的模糊重复
场景:改写、翻译、摘录

实际运行效果(120 条语料,每条重复 6 次):

原始条数:       120
清洗后通过:     120
[SimHash] 去重: 100 条
最终条数:       20
保留率:         16.7%

四、BPE 训练实战

4.1 语料读取

用生成器逐行产出文本,避免一次性加载全部语料到内存:

def get_texts(data_path, max_lines=0):
    with open(data_path, 'r', encoding='utf-8') as f:
        for i, line in enumerate(f):
            if max_lines and i >= max_lines:
                break
            data = json.loads(line)
            yield data['text']

4.2 初始化 BPE 模型

from tokenizers import Tokenizer, models, pre_tokenizers, trainers, decoders

# 初始化一个空的 BPE 模型
tokenizer = Tokenizer(models.BPE())

4.3 ByteLevel 预分词

这是整个流程中最关键的设计决策。

tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)

为什么用 ByteLevel? 传统 BPE 按字符切分,中文常用字 3500+ 个,词表会爆炸。ByteLevel 按 UTF-8 字节切分,初始词表只有 256 个,却能表示世界上所有语言的任何字符——中文、emoji、生僻字都不会出现 OOV 问题。

一个中文字在 UTF-8 编码下占 3 个字节,所以初始切分时:

"人" → UTF-8 字节 [0xE4, 0xBA, 0xBA] → 3 个基础 token

但 BPE 合并后,高频字会被合并成单个 token,最终不一定是 3 个。

4.4 配置训练器

trainer = trainers.BpeTrainer(
    vocab_size=1000,          # 词表大小:决定合并轮次和压缩率
    special_tokens=["<pad>", "<|im_start|>", "<|im_end|>"],  # id=0, 1, 2
    show_progress=True,
    initial_alphabet=pre_tokenizers.ByteLevel.alphabet()  # 256 个字节作为初始字母表
)

Special Tokens 是词表中的元数据标记,不对应自然语言词汇:

TokenID含义用途
<pad>0填充将不同长度的序列补齐到统一长度
<|im_start|>1对话开始ChatML 格式的对话起始标记
<|im_end|>2对话结束ChatML 格式的对话结束标记

词表大小怎么选? 这是一个核心超参数:

词表越大 → BPE 合并轮次越多 → 高频字/词合并成单个 token → 压缩率越高 → 序列越短
        但 Embedding 层参数量线性增长(vocab_size × hidden_dim)
词表大小适用场景Embedding 参数(hidden=512)
1,000小语料学习51 万
6,400小模型训练328 万
32,000LLaMA-2 级别1638 万
128,000LLaMA-3 级别6554 万

4.5 执行训练

texts = get_texts(data_path, MAX_LINES)
tokenizer.train_from_iterator(texts, trainer=trainer)

train_from_iterator 内部会多轮遍历语料,每轮统计相邻 token 对频率 → 合并最高频对 → 更新语料,直到词表达到 vocab_size

4.6 设置解码器

tokenizer.decoder = decoders.ByteLevel()

编码时按字节切分,解码时也要用 ByteLevel 规则还原,保证 decode(encode(text)) == text

4.7 校验 Special Token ID

assert tokenizer.token_to_id("<pad>") == 0
assert tokenizer.token_to_id("<|im_start|>") == 1
assert tokenizer.token_to_id("<|im_end|>") == 2

这一步至关重要:模型代码会直接用 bos_token_id / eos_token_id / pad_token_id 参与训练逻辑(拼接、mask 计算),ID 分配错位会导致模型完全不可用。

五、训练产物解析

训练完成后,model/forwishmind_tokenizer/ 下会生成 4 个文件:

文件类型作用
vocab.json训练产物token → ID 映射表
merges.txt训练产物BPE 合并规则列表(按优先级排序)
tokenizer.json训练产物上述两者的合集(HuggingFace 单文件格式)
tokenizer_config.json手写配置特殊 token 语义 + chat_template

前三个是 BPE 训练的产物,换一份语料重新训练就会被覆盖。第四个是手写的元配置,与语料无关。

vocab.json

{"<pad>": 0, "<|im_start|>": 1, "<|im_end|>": 2, "!": 3, ...}

词表大小 = 最大 ID + 1 = 778,这直接决定了模型 Embedding 矩阵的行数。

merges.txt

#version: 0.2
Ġ Ĥ           ← 第1条规则:合并这两个字节
ã ĠĤ          ← 第2条规则:在第1条基础上继续合并

分词时从上到下按优先级执行——排在前面的规则先应用。

tokenizer_config.json

{
    "bos_token": "<|im_start|>",
    "eos_token": "<|im_end|>",
    "pad_token": "<pad>",
    "model_max_length": 512,
    "tokenizer_class": "PreTrainedTokenizerFast",
    "chat_template": "..."
}

其中 chat_template 定义了多轮对话如何格式化成模型输入文本:

<|im_start|>system
你是一个助手<|im_end|>
<|im_start|>user
你好<|im_end|>
<|im_start|>assistant

六、分词器如何工作:编码过程

有了词表后,输入一句话如何计算对应的 token?分三步:

输入:"人工智能"
    │
    ▼ ByteLevel 预分词
字节序列: [0xE4, 0xBA, 0xBA, 0xE5, 0xB7, 0xA5, ...]
    │
    ▼ BPE 合并(按 merges.txt 从上到下匹配)
合并后: [人, å·¤, ...]
    │
    ▼ 查 vocab.json(token → ID)
ID 序列: [468, 626, ...]

关键:一个中文字最终切成几个 token,取决于 BPE 是否学到了对应的合并规则。 词表越大,合并规则越多,高频字越可能被合并成单个 token。

七、验证分词效果

训练完成后,从四个维度验证分词器:

A. ChatTemplate 渲染

messages = [
    {"role": "system", "content": "你是一个聊天机器人。"},
    {"role": "user", "content": "你好!"},
    {"role": "assistant", "content": "你好!有什么可以帮你的?"}
]
prompt = tokenizer.apply_chat_template(messages, tokenize=False)

B. 编解码一致性

model_inputs = tokenizer(prompt)
decoded = tokenizer.decode(model_inputs['input_ids'])
assert decoded == prompt  # 必须完全一致

C. 流式解码

推理时模型逐 token 生成,但单个 token 可能只是 UTF-8 字节的一部分(尤其是中文),单独解码会乱码。需要用缓冲区累积:

token_cache = []
for tid in input_ids:
    token_cache.append(tid)
    current_decode = tokenizer.decode(token_cache)
    if current_decode and '\ufffd' not in current_decode:  # 无乱码 = 完整字符
        print(f"Token ID: {tid} -> Decode: {current_decode}")
        token_cache = []

D. 压缩率测试

test_texts = ["今天天气真好", "人工智能", "Hello World"]
for text in test_texts:
    ids = tokenizer.encode(text).ids
    print(f"原文: {text}{len(ids)} tokens, 压缩率: {len(text)/len(ids):.1f} 字/token")

八、Tokenizer 与模型的耦合关系

Tokenizer 不是独立工具,而是模型的核心组件。原因有三:

1. 词表一一对应:每个 Token ID 对应 Embedding 矩阵的一行。词表变了,Embedding 矩阵的行数也必须变。

Embedding 矩阵形状 = [vocab_size, hidden_dim]

2. 分词习惯绑定:模型训练时"习惯"了特定的分词方式。换一个分词器,同一个句子产生不同的 token 序列,Embedding 查表完全错位。

3. Special Token 对齐pad_token_id / eos_token_id 必须与模型内部一致。不同模型的 PAD token ID 差异很大:

模型PAD IDEOS ID
LLaMA-201
Qwen151643151645
forwishmind02

这也是为什么分词器训练必须是所有训练的第一步——一旦词表确定,后续的预训练、SFT、LoRA 等全部阶段都必须使用同一套词表。

总结

本文从数据清洗到 BPE 训练再到分词验证,完整走了一遍 Tokenizer 的构建流程,希望对你有用,完整的代码可以到github查看,注释非常详细

项目地址:GitHub - forwishmind

更多 Agent、前端、Node、性能相关的技术文章和实践总结,可以查看我的代码花园:

📦 github.com/AdolescentJ…