大模型处理的是"文本",但计算机真正理解的是数字。在文本和数字之间,有一座关键的桥梁------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,字节对编码)的核心思想是迭代合并:
- 初始化:将所有字节作为最基础的 token
- 统计:统计所有相邻 token 对的出现频率
- 合并:选择频率最高的 token 对合并成新 token
- 迭代:重复步骤 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、性能相关的技术文章和实践总结,可以查看我的代码花园: