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

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

前言

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

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

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

一、Token 是什么?

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

css 复制代码
原始文本 → 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" 出现很多次:

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

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

三种主流分词算法对比

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

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

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

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

3.1 文本清洗

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

python 复制代码
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% 过滤符号占比过高的噪声文本
python 复制代码
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 近似去重------适合发现完全重复或高度相似的文本:

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

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

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

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

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

四、BPE 训练实战

4.1 语料读取

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

python 复制代码
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 模型

python 复制代码
from tokenizers import Tokenizer, models, pre_tokenizers, trainers, decoders

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

4.3 ByteLevel 预分词

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

python 复制代码
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)

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

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

arduino 复制代码
"人" → UTF-8 字节 [0xE4, 0xBA, 0xBA] → 3 个基础 token

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

4.4 配置训练器

python 复制代码
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 是词表中的元数据标记,不对应自然语言词汇:

Token ID 含义 用途
<pad> 0 填充 将不同长度的序列补齐到统一长度
`< im_start >` 1
`< im_end >` 2

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

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

4.5 执行训练

python 复制代码
texts = get_texts(data_path, MAX_LINES)
tokenizer.train_from_iterator(texts, trainer=trainer)

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

4.6 设置解码器

python 复制代码
tokenizer.decoder = decoders.ByteLevel()

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

4.7 校验 Special Token ID

python 复制代码
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

json 复制代码
{"<pad>": 0, "<|im_start|>": 1, "<|im_end|>": 2, "!": 3, ...}

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

merges.txt

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

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

tokenizer_config.json

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

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

text 复制代码
<|im_start|>system
你是一个助手<|im_end|>
<|im_start|>user
你好<|im_end|>
<|im_start|>assistant

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

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

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

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

七、验证分词效果

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

A. ChatTemplate 渲染

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

B. 编解码一致性

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

C. 流式解码

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

python 复制代码
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. 压缩率测试

python 复制代码
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 矩阵的行数也必须变。

css 复制代码
Embedding 矩阵形状 = [vocab_size, hidden_dim]

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

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

模型 PAD ID EOS ID
LLaMA-2 0 1
Qwen 151643 151645
forwishmind 0 2

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

总结

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

项目地址:GitHub - forwishmind

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

📦 github.com/AdolescentJ...

相关推荐
Dawson Zhu2 小时前
工业世界模型——基于AI Agent+数学仿真架构
人工智能·架构·agi
Wang's Blog2 小时前
AI Agent白手起家52: 从零搭建钉钉智能助手——资源准备与核心架构实现
人工智能·架构·钉钉
蛋先生DX2 小时前
大模型参数存储格式揭秘:BF不是男朋友
深度学习·算法·llm
jufeng13072 小时前
【系列:手搓自主 AI Agent:Hermes 架构原理剖析 · 第 1 篇】
人工智能·python·架构·agent
制造业的搬运工2 小时前
智能窗帘PCB低功耗设计方案:架构要点与设计建议
人工智能·科技·架构·制造·pcb工艺
带娃的IT创业者2 小时前
DeepTutor:当 Agent-Native 架构撞上个性化学习的临界点
学习·架构·ai agent·大模型应用·个性化学习·教育技术·agent-native架构
啷里格啷5 小时前
Linux进程管理完全指南:从基础到云原生编排
后端·架构
无忧智库5 小时前
别再“裸奔”等护网了!万字拆解常态化攻防运营体系:从“应试教育”到“实战免疫”的进阶实录(PPT)
大数据·架构
manyingAi5 小时前
AIGC 落地影视内容行业:漫映 AI 漫剧全链路工作流技术架构解析
人工智能·架构·aigc