手写 BPE 分词器
第 01 模块说过模型看到的是词元。这一课亲手写一个字节级 BPE 分词器,在唐诗上训练它,看它怎样从字节一步步拼出汉字和常用词,也看清它在小数据上的毛病。
- 约 45 分钟
- 难度:进阶
- 实测:2026-09-15 纯 Python,数据为《全唐诗》
第 01 模块第 1 课讲过:模型不认识文字,它看到的是一串词元编号。这一个模块要从零搭一个 GPT,第一步就是决定怎么把文字切成词元。
这一课手写 GPT 系列、DeepSeek、通义千问都在用的方法:BPE(Byte Pair Encoding,字节对编码)。写完你会明白两件在第 01 模块只能先记住的事:为什么同一段话在不同模型里的词元数不一样,以及为什么一个生僻字会被切成好几个词元。
准备数据
整个模块都用唐诗做数据。它来自开源项目 chinese-poetry(MIT 许可),收录了《全唐诗》五万多首。
uv add torch opencc
python prepare_poems.py
原始数据是繁体的,脚本用 OpenCC 转成简体。OpenCC 按词组转换,比一个字一个字地替换准确。然后只留下格律整齐的诗:五言或七言,四句(绝句)或八句(律诗),里面只有汉字、逗号和句号。
《全唐诗》共 57607 首,留下格律整齐的 35135 首,共 1557776 个字符,6288 个不同的字符
五言绝句:3656 首
七言绝句:10087 首
五言律诗:13865 首
七言律诗:7527 首
前三首:
闲却白云居,行踪出去初。窗中聊取笔,架上独留书。日背林光冷,潭澄岳影虚。长闻得药力,此说复何如。
秋溪南岸菊霏霏,急管烦弦对落晖。红叶树深山径断,碧云江静浦帆稀。不堪孙盛嘲时笑,愿送王弘醉夜归。流落正怜芳意在,砧声徒促授寒衣。
东城晓出静尘埃,紫画神旗向日开。锦袖半攘争捧辔,银鞍不下小传杯。马盘草上朱弓满,鴈落云中白羽回。晚向三通残皷尽,北原千骑卷行来。
一行一首,共 3.5 万首、156 万个字符。仔细看第三首,"鴈"和"皷"没有转成"雁"和"鼓":它们是异体字,不是繁简对应的字,OpenCC 的繁转简不处理。数据清洗很少能做到百分之百干净,这一点在后面训练时影响不大,就留着了。
从字节开始
计算机里的文字,最底层是字节。UTF-8 编码下,英文字母一个字节,一个汉字三个字节:
一个汉字在 UTF-8 里是 3 个字节:'月' → e6 9c 88
BPE 从字节出发,所以它的初始词表只有 256 个元素(一个字节能表示的 0~255)。好处是任何文字都能表示,不会遇到"这个字不在词表里"的情况,最坏也就是拆成字节。
然后反复做一件事:在训练数据里找出现次数最多的一对相邻词元,把它们合并成一个新词元。
def train(text, n_merges):
ids = list(text.encode("utf-8")) # 从字节开始:词表一开始就是 0~255 这 256 个字节
merges = {} # (a, b) -> 新编号
vocab = {i: bytes([i]) for i in range(256)} # 编号 -> 它代表的字节串
for k in range(n_merges):
pairs = count_pairs(ids)
pair, count = pairs.most_common(1)[0] # 出现最多的一对相邻的词元
new_id = 256 + k
ids = merge(ids, pair, new_id)
merges[pair] = new_id
vocab[new_id] = vocab[pair[0]] + vocab[pair[1]]
count_pairs 用 Counter 数一遍所有相邻的对,merge 把所有出现这一对的地方换成新编号,都只有几行(见 code/09-transformer/bpe.py)。
每合并一次,词表就多一个词元,训练数据就变短一些。合并的次数决定了最终的词表有多大,是一个要自己选的数。
看它学到了什么
用 2000 首诗训练 1500 次合并(纯 Python 很慢,数据再多就要等很久了):
训练数据:2000 首诗,89863 个字符,265591 个字节
第 1 次合并: [80] + [82] → [80 82] (出现 6396 次),训练数据变成 259195 个词元
第 2 次合并: [ef] + [bc] → [ef bc] (出现 6394 次),训练数据变成 252801 个词元
第 3 次合并: [ef bc] + [8c] → , (出现 6394 次),训练数据变成 246407 个词元
第 4 次合并: [e3] + [80 82] → 。 (出现 6394 次),训练数据变成 240013 个词元
第 5 次合并: [e4] + [b8] → [e4 b8] (出现 3702 次),训练数据变成 236311 个词元
第 6 次合并: 。 + ↵ → 。↵ (出现 1999 次),训练数据变成 234312 个词元
第 7 次合并: [e4] + [ba] → [e4 ba] (出现 1819 次),训练数据变成 232493 个词元
第 8 次合并: [e5] + [a4] → [e5 a4] (出现 1749 次),训练数据变成 230744 个词元
……
第 1001 次合并:[ef bc 8c e8 a1] + [8c] → ,行 (出现 26 次),训练数据变成 106108 个词元
第 1500 次合并: [e7 a5] + [96] → 祖 (出现 15 次),训练数据变成 96124 个词元
训练 1500 次合并,用了 27 秒,词表大小 1756
方括号里的是还拼不成完整字符的字节。前几次合并很有意思:
- 前 4 次拼出了逗号和句号。每首诗都有它们,出现得最多。第 1 次和第 4 次合在一起,是先把句号的后两个字节
80 82拼上,再加上开头的e3。 - 第 6 次把"句号 + 换行"合成了一个词元,因为每首诗都以句号结尾,接着换行。
- 第 5、7、8 次合并的是汉字 UTF-8 编码的前两个字节。很多常用汉字的前两个字节相同,比如
e4 b8开头的有"不""与""世""东"等等,所以这些"半个字"反而比任何一个完整的汉字出现得都多。
合并了 1500 次之后,训练数据从 26.6 万个字节缩短到了 9.6 万个词元,比 8.99 万个字符还多一点。
1500 个新词元里:883 个正好是一个完整的字符,172 个是两个字符以上,其余 445 个是半个汉字、或者跨了字的边界
两个字符以上的,最早学到的 30 个:
。↵ ,不 ,一 。不 ,何 ,山 人。↵ ,风 万里 ,春 ,应 ,天 ,江 。何 ,无 千里 何处 ,清 ,白 。自 。莫 人间 ,秋 ,寒 ,月 。↵一 ,相 。一 ,云 ,日
它学到了几个真正的词:"万里""千里""何处""人间"。但更多的是"逗号 + 一个字",比如",不"",何"",春"。这是因为唐诗每句的第一个字前面总是一个标点,这种组合出现得很频繁。BPE 只看频率,不懂语言,它不知道标点和后面的字没有关系。
实际使用的分词器会在训练之前先用规则把文字粗切一遍,比如标点、空格、数字各自单独切开,BPE 只在切好的片段内部合并。GPT-2 用的就是一个正则表达式。这样就不会学出",不"这种跨了标点的词元。
编码和解码
训练得到的是一张合并规则表。编码一段新文字时,先转成字节,再按学到合并规则的先后顺序合并:
def encode(text, merges):
ids = list(text.encode("utf-8"))
while len(ids) >= 2:
# 在所有相邻的对里,找最早学到的那个合并规则先用上:和训练时的顺序一致
pair = min(set(zip(ids, ids[1:])), key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break
ids = merge(ids, pair, merges[pair])
return ids
def decode(ids, vocab):
return b"".join(vocab[i] for i in ids).decode("utf-8", errors="replace")
为什么要按顺序?因为后面的合并规则是建立在前面的基础上的。比如",行"这个词元,是由"逗号的前两个字节加上'行'的前两个字节"再加上 8c 拼成的,前面那些合并不先做,后面的就没有东西可合并。
解码简单得多:把每个词元代表的字节串接起来,再按 UTF-8 解码。
在没见过的诗上
用训练时没见过的 500 首诗测试:
在训练时没见过的 500 首诗上:
68179 个字节,23059 个字符,BPE 分成 25078 个词元,平均每个词元 0.92 个字符
训练用的 2000 首诗里有 3655 个不同的字符;测试诗里的字符,1.0% 在训练数据里一次都没出现过
例子:自君入城市,北邙无新坟。始信壶中药,不落白杨根。如何忽告归,蕣华还笑人。玉笙无遗音,怅望缑岭云。
切成:自 | 君 | 入 | 城 | [e5 b8] | [82] | [ef bc 8c e5] | [8c] | [97] | [e9 82] | [99] | 无 | 新 | [e5 9d] | [9f] | [e3 80 82 e5] | [a7 8b] | 信 | [e5 a3] | [b6] | 中 | 药 | ,不 | 落 | 白 | 杨 | 根 | 。如 | 何 | 忽 | [e5 91] | [8a] | 归 | [ef bc 8c e8] | [95] | [a3] | 华 | 还 | 笑 | 人 | 。玉 | [e7 ac] | [99] | 无 | 遗 | 音 | [ef bc 8c e6 80] | [85] | 望 | [e7 bc] | [91] | 岭 | 云 | 。
解码回去和原文完全一样
结果并不好看:词元比字符还多,平均每个词元只有 0.92 个字符。"市""北""邙""坟""始"这些字,都被拆成了两三个碎片。
原因是数据太少。2000 首诗里只有 3655 个不同的字,1500 次合并只把其中 883 个拼成了完整的字符。一个字只出现几次,就排不上合并的队。"市"是个常用字,可在这 2000 首诗里出现得不够多,所以还是被拆开了。
不过有一点它做得很好:解码回去和原文完全一样。不管切得多碎,信息都没有丢失。遇到英文也一样能处理,只是退回到一个字节一个词元:
一句含英文的:月 | 落 | 乌 | 啼 | 霜 | 满 | 天 | 。 | H | e | l | l | o
真实的分词器
真实大模型的分词器也是这样训练出来的,只是数据多了几个数量级:几十上百 GB 的各种语言的文字,合并十几万次。截至 2026 年 9 月,主流模型的词表大多在十万到二十几万之间。数据足够多,常用汉字都会被合并成完整的词元,常见的词语也会成为一个词元。
这也解释了第 01 模块里看到的现象:
- 不同模型的分词器是在不同的数据上训练的,所以同一段话的词元数不一样。中文训练数据多的模型,切中文更省词元。
- 生僻字在训练数据里出现得少,没有被合并,就会被拆成几个字节级的词元。
这个模块用什么分词
我们的 GPT 不用这个 BPE,而是用最简单的字符级分词:一个字符就是一个词元。
class CharTokenizer:
"""字符级分词器:一个字符就是一个词元。换行符表示一首诗结束。"""
def __init__(self, text):
self.chars = sorted(set(text))
self.index = {c: i for i, c in enumerate(self.chars)}
def encode(self, text):
return [self.index[c] for c in text]
def decode(self, ids):
return "".join(self.chars[i] for i in ids)
原因上面已经看到了:在这么少的数据上,BPE 反而比字符切得更碎。而唐诗的语言单位本来就以字为主,一个字一个词元很自然。全部 3.5 万首诗一共 6288 个不同的字符,词表不大,每个字都会在训练时多次出现。
字符级分词的代价是:遇到词表之外的字就没办法了。这对我们的实验没有影响,因为词表是用全部的诗建立的。但它不能处理训练数据之外的任意文字,这也是真实的大模型都用字节级 BPE 的原因。
练习
- 把训练数据从 2000 首诗增加到 5000 首(会慢一些),在测试集上平均每个词元有几个字符了?
- 训练前先把逗号、句号和换行都单独切出来,只在每一句的内部统计和合并。学到的两个字符以上的词元,变成了哪些?
- 字节级 BPE 的初始词表是 256 个字节。如果改成从字符开始(初始词表是训练数据里所有不同的字符),会有什么好处和坏处?
自测
1. BPE 训练时每一步在做什么?什么时候停?
统计训练数据里每一对相邻词元出现的次数,把出现最多的那一对合并成一个新词元,加进词表,并在数据里替换掉。重复这个过程,直到合并了事先定好的次数,也就是词表达到了想要的大小。
2. 为什么编码时要按照学到合并规则的先后顺序来合并?
后面的合并规则是在前面合并的结果上学出来的,它合并的对象可能就是前面合并得到的新词元。不按原来的顺序,后面的规则就找不到可以合并的对象,切出来的结果和训练时不一致。
3. 为什么这一课的 BPE 在测试集上切出的词元比字符还多?真实的分词器为什么没有这个问题?
训练数据只有 2000 首诗,很多汉字出现的次数不够多,没有被合并成完整的字符,只能拆成两三个字节级的碎片。真实的分词器用海量的数据训练,合并十几万次,常用汉字和常见的词语都会成为完整的词元。
提问与讨论
这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。
提问 +3 积分,回答别人 +6 积分。内容经审核后公开。
正在加载讨论…