模組 09 · 第 1 課

手寫 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_pairsCounter 數一遍所有相鄰的對,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 的原因。

練習

  1. 把訓練資料從 2000 首詩增加到 5000 首(會慢一些),在測試集上平均每個詞元有幾個字元了?
  2. 訓練前先把逗號、句號和換行都單獨切出來,只在每一句的內部統計和合並。學到的兩個字元以上的詞元,變成了哪些?
  3. 位元組級 BPE 的初始詞表是 256 個位元組。如果改成從字元開始(初始詞表是訓練資料裡所有不同的字元),會有什麼好處和壞處?

自測

1. BPE 訓練時每一步在做什麼?什麼時候停?

統計訓練資料裡每一對相鄰詞元出現的次數,把出現最多的那一對合併成一個新詞元,加進詞表,並在資料裡替換掉。重複這個過程,直到合併了事先定好的次數,也就是詞表達到了想要的大小。

2. 為什麼編碼時要按照學到合併規則的先後順序來合併?

後面的合併規則是在前面合併的結果上學出來的,它合併的物件可能就是前面合併得到的新詞元。不按原來的順序,後面的規則就找不到可以合併的物件,切出來的結果和訓練時不一致。

3. 為什麼這一課的 BPE 在測試集上切出的詞元比字元還多?真實的分詞器為什麼沒有這個問題?

訓練資料只有 2000 首詩,很多漢字出現的次數不夠多,沒有被合併成完整的字元,只能拆成兩三個位元組級的碎片。真實的分詞器用海量的資料訓練,合併十幾萬次,常用漢字和常見的詞語都會成為完整的詞元。

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…