手寫 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 點。內容經審核後公開。
正在載入討論…