做大语言模型的人,第一步往往是搞分词器(Tokenizer)。没有分词器,模型连"词"都看不懂,更别提理解句子了。
市面上训练分词器的工具不少,但大多是Python写的——HuggingFace的tokenizers库、OpenAI的tiktoken、Google的SentencePiece。功能确实全,但问题也来了:
那有没有可能,用一个纯C++的头文件,不依赖任何第三方库,直接把语料扔进去,就能训练出一个标准的BPE词汇表?而且输出格式和HuggingFace完全兼容,Python/C++都能直接用?
答案是:完全可以。而且只有一个头文件,编译完就是一个可执行程序。
用最直白的话说,这是一个用现代C++17写的BPE训练器。它只有一个头文件,零外部依赖,标准库就能编译。输入是原始语料(纯文本或JSONL),输出是两个文件——vocab.txt和merges.txt,格式和HuggingFace的BPE Tokenizer完全一致。
BPE训练的本质,就是一场"找搭档、合并、再找搭档"的循环游戏:
第一步:预处理
把原始语料清洗一下——转小写、拆分标点、规范化空格、给每个词末尾加上</w>标记(表示词边界)。
第二步:初始拆分
把每个词拆成字符。比如"hello"变成[h, e, l, l, o, </w>]。
第三步:统计频率
扫描整个语料,统计每一对相邻字符出现的次数。比如"l"后面跟着"l"出现了1000次,"e"后面跟着"l"出现了800次。
第四步:合并最频繁的 pair
找到出现次数最多的相邻字符对,把它们合并成一个新符号。比如"l + l"合并成"ll"。然后更新整个语料,把所有"ll"都替换成这个新符号。
第五步:重复
回到第三步,重新统计,再合并,再统计,再合并……直到词汇表达到预设大小。

左图展示了从原始语料到最终词汇表的完整训练流程,右图展示了用训练好的词汇表把句子编码成token ID的过程。
BPE(Byte Pair Encoding)最初是数据压缩算法,后来被引入自然语言处理,成为最流行的分词方法之一。
它的核心思想是:从字符开始,逐步合并最频繁的相邻字符对,形成子词单元。
举个例子,语料里有这些词:
low → [l, o, w, </w>]
lower → [l, o, w, e, r, </w>]
newest → [n, e, w, e, s, t, </w>]
widest → [w, i, d, e, s, t, </w>]
第一轮统计,发现"e"后面跟着"s"出现了2次(newest、widest),"s"后面跟着"t"也出现了2次。假设"es"的频率最高,就把它合并:
newest → [n, e, w, es, t, </w>]
widest → [w, i, d, es, t, </w>]
第二轮,发现"es"后面跟着"t"出现了2次,合并成"est":
newest → [n, e, w, est, </w>]
widest → [w, i, d, est, </w>]
第三轮,"l"后面跟着"o"出现了2次(low、lower),合并成"lo":
low → [lo, w, </w>]
lower → [lo, w, e, r, </w>]
就这样一轮一轮合并,词汇表从几十个字符慢慢增长到几万个子词。高频的组合(如"ing"、"tion"、"hello")会被合并成单独的token,低频的组合保持拆分状态。
BPE解决了分词领域的两个经典难题:
OOV问题(Out-Of-Vocabulary)
传统词级分词器,训练时没见过的词就是OOV,只能映射到<unk>。比如训练语料里没有"ChatGPT",词级分词器就懵了。
BPE不怕这个——"ChatGPT"可能被拆成[Chat, G, PT]或[Ch, at, GP, T],每个子词都在词汇表里,没有OOV。
词汇表大小平衡
BPE在两者之间取平衡:词汇表通常3万-5万,序列长度适中,既控制了模型大小,又保留了语义信息。
BPE在每个词末尾加一个特殊标记</w>,它的作用是**告诉模型"这个子词是不是词的结尾"**。
比如"hello"和"hell":
</w>:hello → [he, llo],hell → [he, ll]——模型不知道"llo"和"ll"的区别</w>:hello → [he, llo</w>],hell → [he, ll</w>]——"llo"明确表示这是词尾这个标记让模型能区分"词内的子词"和"词尾的子词",对理解词边界很重要。
训练前的预处理直接影响分词质量:
| 转小写 | ||
| 拆分标点 | ||
| 规范化空格 | ||
| 特殊token |
训练完成后,输出两个文件:
vocab.txt:词汇表,每行一个token,按ID排序
<|endoftext|>
<|unk|>
<|pad|>
<|mask|>
!
"
#
...
the
of
and
ing</w>
er</w>
...
merges.txt:合并规则,按合并顺序排列
#version: 0.2
i n
t h
th e
e r
er</w>
...
这两个文件就是BPE分词器的全部"知识"。编码时,按merges的顺序依次合并;解码时,按vocab查ID。
有了vocab和merges,怎么把新句子编码成token ID?
// 伪代码:BPE编码
vector<string> encode(string text){
// 1. 预处理
text = lowercase(text);
text = split_punctuation(text);
// 2. 拆成单词
vector<string> words = split_whitespace(text);
// 3. 对每个单词做BPE拆分
vector<string> tokens;
for (string word : words) {
// 初始拆成字符 + </w>
vector<string> pieces = split_to_chars(word);
pieces.push_back("</w>");
// 按merges规则合并
for (auto& merge : merges) {
// 从左到右扫描,合并所有匹配的pair
pieces = apply_merge(pieces, merge);
}
tokens.insert(tokens.end(), pieces.begin(), pieces.end());
}
// 4. 查vocab得到ID
vector<int> ids;
for (string token : tokens) {
if (vocab.contains(token)) {
ids.push_back(vocab[token]);
} else {
ids.push_back(vocab["<|unk|>"]); // 未登录词
}
}
return ids;
}
关键细节:合并时必须按merges的顺序来。先学的合并优先级高,后学的合并优先级低。这样才能保证编码和解码的一致性。
| BPE | |
| Tokenizer | |
| Vocabulary | |
| Subword | |
| OOV | |
| Merge Rule | |
| HuggingFace Tokenizers | |
| JSONL | |
| UTF-8 | |
| Normalization | |
| Special Token | |
| Frequency-Based | |
| Deterministic | |
| Compression Ratio |
整个训练器只有一个.hpp头文件,没有Boost、没有Eigen、没有OpenSSL,纯C++标准库。这意味着:
g++ -std=c++17 -O3一行搞定#include进你的项目训练大语料时(比如几十GB的维基百科),不可能一次性读进内存。这个训练器支持流式读取,逐行处理,内存占用稳定。
API设计用了链式调用,配置参数一气呵成:
trainer
.set_lowercase(true)
.set_split_punctuation(true)
.set_normalize_whitespace(true)
.set_special_tokens("<|endoftext|>", "<|unk|>", "<|pad|>", "<|mask|>");
输出格式严格遵循HuggingFace Tokenizers的BPE格式,Python端一行代码就能加载:
from tokenizers import Tokenizer
from tokenizers.models import BPE
tokenizer = Tokenizer(BPE(vocab="vocab.txt", merges="merges.txt"))
min_freq参数控制"多频繁的pair才值得合并"。值越大,训练越快,但学到的模式越少。值越小,训练越慢,但词汇表更精细。可以根据语料大小灵活调整。
./bpe-trainer --demo
这个命令会用内置的示例数据跑一遍训练流程,让你快速看到效果。
./bpe-trainer -i corpus.txt -v 16000 -o my_tokenizer
参数说明:
-i, --input | ||
-o, --output | ||
-v, --vocab-size | ||
-m, --min-freq | ||
--jsonl | ||
--text-field | ||
--no-lowercase | ||
--no-punct-split |
./bpe-trainer -i dataset.jsonl --jsonl --text-field content -v 32000 -o my_tokenizer
JSONL格式每行一个JSON对象,程序会提取指定字段的文本内容。
./bpe-trainer --test"Hello, world! This is a test."
训练完成后,用这个命令测试分词效果,看句子被拆成了哪些token。
小语料(1MB以内):
./bpe-trainer -i small.txt -v 8000 -m 2 -o small_tok
# 预计30秒,8K词汇表
中等语料(100MB):
./bpe-trainer -i medium.txt -v 32000 -m 5 -o medium_tok
# 预计10分钟,32K词汇表
大语料(1GB+):
./bpe-trainer -i large.txt -v 50000 -m 10 -o large_tok
# 预计1-2小时,50K词汇表
min-freq的建议:
-m 2(频率门槛低,尽量多学模式)-m 5(平衡速度和质量)-m 10(频率门槛高,过滤噪声,加速训练)从HuggingFace Datasets下载数据,导出为文本或JSONL:
# 导出为纯文本
from datasets import load_dataset
dataset = load_dataset("imdb", split="train")
with open("corpus.txt", "w", encoding="utf-8") as f:
for example in dataset:
text = example.get("text") or example.get("content")
f.write(text.replace("\n", " ").strip() + "\n")
# 导出为JSONL
import json
from datasets import load_dataset
dataset = load_dataset("imdb", split="train")
with open("corpus.jsonl", "w", encoding="utf-8") as f:
for i, example in enumerate(dataset):
f.write(json.dumps({"id": i, "text": example["text"]}) + "\n")
from tokenizers import Tokenizer
from tokenizers.models import BPE
# 加载训练好的BPE
tokenizer = Tokenizer(BPE(
vocab="my_tokenizer_vocab.txt",
merges="my_tokenizer_merges.txt"
))
# 编码
text = "Hello, world!"
encoding = tokenizer.encode(text)
print("Tokens:", encoding.tokens)
print("IDs:", encoding.ids)
# 解码
decoded = tokenizer.decode(encoding.ids)
print("Decoded:", decoded)
配合C++ Tokenizer库使用:
#include"TextTokenizer.hpp"
TextTokenizer tokenizer;
tokenizer.load_vocab("my_tokenizer_vocab.txt");
auto token_ids = tokenizer.encode("Hello, world!");
// token_ids = {1523, 5, 1847, 8, ...}
# 检查文件是否存在且可读
ls -la corpus.txt
file corpus.txt
# 尝试更小的词汇表
./bpe-trainer -i corpus.txt -v 8000 -m 1
# 提高最小频率阈值
./bpe-trainer -i corpus.txt -v 32000 -m 10
# 先用小语料测试
head -n 10000 large.txt > small_test.txt
./bpe-trainer -i small_test.txt -v 8000
# 减小词汇表
./bpe-trainer -i corpus.txt -v 16000
# 监控内存使用
top -p $(pgrep bpe-trainer)
If you need the complete source code, please add the WeChat number (c17865354792)
这个训练器最大的价值,在于证明了BPE分词器可以完全脱离Python生态运行。它不是简单的"用C++包装Python库",而是从语料读取、预处理、统计频率、迭代合并到输出词汇表的全链路原生实现。
对于想深入理解NLP底层原理的人来说,这里面有太多值得啃的细节:
</w>标记对词边界理解很重要?更重要的是,它建立了一种**"不依赖Python也能动手"**的自信。当你亲手用C++写出一个BPE训练器,看着语料里的字符一轮一轮合并成"ing"、"tion"、"hello"这些子词,再回头看HuggingFace的tokenizers.train(),你会会心一笑——原来你们底层干的,也就是这些事啊。
如果你正在寻找一条从"调包做NLP"到"真正掌控分词器全流程"的进阶路径,这个项目涉及的工程实践和算法细节,应该能帮你打开一扇新的大门。
Welcome to follow WeChat official account【程序猿编码】