先看一組數字。同樣的意思、同樣長度的句子,只差在用什麼文字寫出來:
| 文字 | 字數 | cl100k_base(GPT-4) | o200k_base(GPT-4o 之後) |
|---|---|---|---|
Tokenization is the first step of every language model. | 55 | 11 | 11 |
斷詞是每個語言模型的第一步。 | 14 | 18 | 13 |
機器學習模型的訓練資料 | 11 | 20 | 11 |
机器学习模型的训练数据 | 11 | 13 | 6 |
(表裡的 token 數都是實際載入 cl100k_base、o200k_base 這兩份詞表算出來的,不是估算;後面每一組數字也一樣。)
最後兩列才是刺眼的地方。同樣 11 個字、同樣的意思,繁體在 GPT-4 上要 20 個 token,簡體只要 13 個;換到 GPT-4o 的詞表,11 比 6,幾乎剛好兩倍。
一個中文字帶的資訊量明明比一個英文字母大得多,繁體和簡體帶的資訊量更是一模一樣。但帳單不是這樣算的。付的錢、context window 被吃掉的份量、embedding 的長度上限,全部按 token 計。所以這個「token」到底是誰決定的,為什麼它對繁體中文特別不友善?
答案要從一個 1994 年、原本拿來壓縮檔案的演算法講起。
為什麼不直接用「字」當單位
模型看不懂文字,只看得懂整數。而且不是隨便什麼整數都行,只能是 0 到某個上限之間的整數。
這個上限就是**詞表(vocabulary)**的大小。語言模型的第一層是一張表,詞表裡有幾個 token,這張表就有幾列,每個 token ID 對應其中一列向量。最後一層方向相反,算出詞表裡每個 token 接在後面的機率,下一個 token 就從這裡挑。GPT-4 的詞表大約十萬個 token,所以它每吐出一個 token,都是在做一次十萬選一的選擇題。
詞表多大,訓練前就得決定。訓練完才想加一個 token,新的那一列從沒被訓練過,模型不知道它代表什麼。
輸入可沒有這種上限。網址、emoji、中英夾雜的句子、一段貼錯地方的 base64,任何一串字元都可能送進來,而且明天還會出現今天沒見過的。
文字送進模型之前,得先有人把它切成一串 token ID,這個角色叫斷詞器(tokenizer)。它要解的題目是:用有限個數字,表達無限多種文字。
聽起來不可能,但這招每個人小學就學過:阿拉伯數字只有十個,卻寫得出任何一個整數,因為位數可以一直往上加。有限的符號排成不限長度的序列,組合就沒有盡頭。
剩下的問題是詞表裡該放什麼。這件事有三種明顯的做法,各有各的死法。
第一種是用字元切。每個字元一個 ID,詞表很小(ASCII 只要 128 個),任何文字都表示得出來,永遠不會遇到沒看過的字。代價是序列爆長:internationalization 這一個字就佔掉 20 個位置。Transformer 的注意力成本跟序列長度的平方成正比,序列長一倍,成本就是四倍。
第二種是用詞切。一個詞一個 ID,序列很短,語意單位也漂亮。問題是它想用有限的詞表,把無限的詞彙一個一個列完,這件事從一開始就辦不到。詞表再大都有漏網之魚——使用者打錯字、新的專有名詞、程式碼裡的變數名,只要沒在詞表裡,就變成一個 <UNK>,資訊直接消失。中文更麻煩,得先有一個斷詞器決定「什麼算一個詞」,而那個斷詞器本身就會出錯。
第三條路是 subword(子詞),前兩種各取一半。詞表裡先放一套最小的零件,保證再怪的字串都拼得出來;剩下的位置拿去替常見的組合開捷徑,讓它們一個 ID 就解決。token 是一個 ID,tokenization 拆成 Token + ization。沒有 <UNK>,序列也不會太長。
扣掉零件,詞表剩下的位置就是開捷徑的預算。問題只剩下一個:預算有限,捷徑該開給誰? 這正是 BPE 要回答的。
BPE:從壓縮演算法借來的
Byte Pair Encoding(BPE,位元組對編碼) 原本是 Philip Gage 在 1994 年提出的資料壓縮法,想法簡單到有點好笑:找出檔案裡出現最頻繁的一對相鄰位元組,用一個沒用過的位元組取代它,記下這筆對應關係,然後重複。
2015 年,Rico Sennrich 等人把它搬到神經機器翻譯上(Neural Machine Translation of Rare Words with Subword Units)。論文摘要的第一句,講的就是前面那道題:神經機器翻譯模型通常只有一份固定詞表,翻譯卻是開放詞彙(open-vocabulary)的問題。
他們只改了一件事:不是為了把檔案變小,而是為了把詞表控制在固定大小。論文裡寫得很白,最後的詞表大小等於初始符號數加上合併次數,而合併次數是整個演算法唯一的超參數。想要多大的詞表,就在第幾次合併停下來。今天所有主流語言模型的斷詞器,都是這篇論文的變形。
演算法本體 30 行就寫得完。從字元開始,重複找出現最多次的相鄰配對,把它合併成一個新符號:
from collections import Counter
corpus = "low low low low low lower lower newest newest newest newest newest newest widest widest widest"
def most_common_pair(seq):
pairs = Counter(zip(seq, seq[1:])) # 統計所有相鄰配對
return pairs.most_common(1)[0] if pairs else (None, 0)
def merge(seq, pair):
out, i = [], 0
while i < len(seq):
if i < len(seq) - 1 and (seq[i], seq[i+1]) == pair:
out.append(seq[i] + seq[i+1]) # 兩個符號黏成一個
i += 2
else:
out.append(seq[i])
i += 1
return out
seq = list(corpus) # 初始詞表 = 出現過的所有字元
merges = [] # 合併表,順序就是優先序
for step in range(10): # 合併幾次,詞表就多幾個符號
pair, count = most_common_pair(seq)
if count < 2:
break
merges.append(pair)
seq = merge(seq, pair)
print(f"merge {step+1:>2}: {pair[0]!r} + {pair[1]!r} -> {(pair[0]+pair[1])!r} (出現 {count} 次)")
跑出來:
merge 1: 'e' + 's' -> 'es' (出現 9 次)
merge 2: 'es' + 't' -> 'est' (出現 9 次)
merge 3: 'est' + ' ' -> 'est ' (出現 8 次)
merge 4: 'l' + 'o' -> 'lo' (出現 7 次)
merge 5: 'lo' + 'w' -> 'low' (出現 7 次)
merge 6: ' ' + 'low' -> ' low' (出現 6 次)
merge 7: 'n' + 'e' -> 'ne' (出現 6 次)
merge 8: 'ne' + 'w' -> 'new' (出現 6 次)
merge 9: 'new' + 'est ' -> 'newest ' (出現 6 次)
merge 10: 'newest ' + 'newest ' -> 'newest newest ' (出現 5 次)
前九步很漂亮。est、low、new 這些真的是有意義的英文字根,而演算法完全不知道英文是什麼,它只是在數次數。
第十步就出事了:它學會了 'newest newest '。
這不是 bug,是純粹按頻率合併的必然結果。語料裡 newest 連續出現,那「newest 後面接 newest 」就是當下最高頻的配對,演算法照做不誤。一旦允許這種事,詞表會被大量跨越單字邊界的垃圾佔滿——. The、, and the、整段重複的樣板文字,全部都會被當成一個 token 學起來。
所以真實的 BPE 一定會先做一件事:在合併之前,把文字切成不准跨越的區塊。
前切分:那串長得像亂碼的 regex
這一步叫 pre-tokenization(前切分)。做法是先用一條正規表示式把文字切成小塊,BPE 的合併只在每一塊「之內」進行,永遠不會跨塊。
tiktoken 的 cl100k_base 用的是這條:
(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+
看起來很嚇人,拆開來其實是幾條互斥的規則:
's|'t|'re|...:英文縮寫單獨切出來,don't不會被拆成奇怪的東西[^\r\n\p{L}\p{N}]?\p{L}+:一個可選的前導符號,加上一整串字母。這條是關鍵:空格會被吸進後面那個字裡\p{N}{1,3}:數字最多三位一組?[^\s\p{L}\p{N}]+:標點符號成串\s*[\r\n]+、\s+(?!\S)、\s+:各種空白與換行
第二條解釋了一個你一定看過的現象。把那句英文切開來看:
"Token" | "ization" | " is" | " the" | " first" | " step" | " of" | " every" | " language" | " model" | "."
" is"、" the" 前面都帶著空格。前導空格是 token 的一部分,因為前切分就是這樣切的。這也是為什麼 API 的 stop 參數寫 "the" 常常攔不到句子中間的 the,以及為什麼 prompt 結尾多打一個空格,有時候會讓輸出品質變差——你把模型最習慣的那個「空格開頭」的 token 給拆掉了。
第三條的效果同樣直接:
"1234567890" -> ["123", "456", "789", "0"]
"3.14159265" -> ["3", ".", "141", "592", "65"]
數字永遠三位一組,而且是從左邊開始切。1234567890 被切成 123|456|789|0,跟人類唸數字時的三位一撇完全不一樣(那是從右邊算的)。模型要做加法,得先在這種切法上把位數對齊。它算術不穩,有一部分責任在這裡。
把前切分加回去,剛才那個 mini BPE 就正常了。順便補上編碼的部分:
import re
from collections import Counter
PAT = re.compile(r" ?\w+") # 前切分:切成「空格+單字」,合併不跨塊
def train(text, num_merges):
words = [list(w) for w in PAT.findall(text)]
merges = {}
for i in range(num_merges):
pairs = Counter()
for w in words:
pairs.update(zip(w, w[1:]))
if not pairs or pairs.most_common(1)[0][1] < 2:
break
pair = pairs.most_common(1)[0][0]
merges[pair] = i # rank:越早學會的,優先度越高
words = [apply_merge(w, pair) for w in words]
return merges
def encode(text, merges):
result = []
for chunk in PAT.findall(text):
sym = list(chunk)
while len(sym) > 1:
# 關鍵:不是從左到右掃,是每輪都挑 rank 最小的那一對先合併
pair = min(zip(sym, sym[1:]), key=lambda p: merges.get(p, float("inf")))
if pair not in merges:
break
sym = apply_merge(sym, pair)
result.extend(sym)
return result
用同一份語料訓練,合併表變成 ['es', 'est', 'lo', 'low', ' low', ' n', ' ne', ' new', ' newest', ' w'],沒有垃圾了。拿去編碼幾個訓練時沒出現過的字串:
'lowest' -> ['low', 'est']
'newer' -> ['n', 'e', 'w', 'e', 'r']
'widest low' -> ['w', 'i', 'd', 'est', ' low']
lowest 從沒出現在語料裡,卻乾淨地被拼成 low + est。這就是 subword 的價值:沒看過的東西,用看過的碎片組出來,不會變成 <UNK>。
newer 則整個散開了。原因藏在合併表裡——學到的是 ' new'(帶空格),不是 'new'。字串開頭的 newer 沒有前導空格,一個合併規則都套不上。真實的 tokenizer 也有一模一樣的脾氣:同一個詞放句首、放句中、首字母大寫,會是完全不同的 token。
編碼的邏輯值得再看一眼。它不是從左到右貪心地找最長匹配,而是每一輪都在目前的序列裡挑 rank 最小(最早學會)的那一對來合併,直到沒有規則可套。合併表的順序不只是訓練的副產品,它就是編碼時的優先序。這也是為什麼 tiktoken 的詞表檔叫 mergeable ranks——存的是「第幾個學會的」,不只是「有這個 token」。
為什麼是 byte,不是字元
上面的示範是在字元上做合併。真實的 GPT 系列不是——它在位元組上做。
理由很現實。Unicode 有 15 萬個字元,如果基礎詞表要涵蓋所有字元,光是還沒開始合併就先燒掉 15 萬個位置。而且無論你收多少,總有收不到的(新的 emoji、罕見漢字、私用區),那就得回到 <UNK>。
改成位元組就乾淨了:基礎詞表固定 256 個,任何 UTF-8 文字都是這 256 個東西的排列。永遠不會有沒看過的輸入,decode(encode(x)) 永遠等於 x。這叫 byte-level BPE,GPT-2 開始用到現在。
這就是阿拉伯數字那一招,只是十個數字換成 256 個位元組。subword 需要的那套最小零件,到這裡才真正湊齊;詞表裡其餘的位置,全是 BPE 一次一次合併出來的捷徑。
代價落在非拉丁文字上。ASCII 字元是 1 個位元組,中日韓漢字在 UTF-8 裡是 3 個位元組。也就是說,一個漢字如果沒能在訓練時被合併成一個 token,它就會佔掉 3 個 token。
實測 cl100k_base 的逐字結果:
| 字 | UTF-8 位元組 | cl100k_base | o200k_base |
|---|---|---|---|
| 的 | 3 | 1 | 1 |
| 器 | 3 | 1 | 1 |
| 學 | 3 | 2 | 1 |
| 機 | 3 | 3 | 1 |
| 習 | 3 | 3 | 1 |
| 腦 | 3 | 3 | 2 |
「機」在 GPT-4 的詞表裡要 3 個 token,正好等於它的位元組數。意思是這個字整個沒被學進詞表,三個位元組各自為政。「學」是 2,代表其中兩個位元組合併過一次,但還是差一步。「的」和「器」則各自佔到了一個完整的 token。
把整句切開來看更清楚:
cl100k_base:「斷詞是每個語言模型的第一步。」
"�"|"�"|"�"|"�"|"是"|"每"|"�"|"�"|"�"|"�"|"言"|"模"|"型"|"的"|"第"|"一"|"步"|"。"
o200k_base:同一句
"�"|"�"|"詞"|"是"|"每"|"個"|"語"|"言"|"模型"|"的"|"第一"|"步"|"。"
那些 � 不是亂碼,是半個字——一個不構成完整 UTF-8 字元的位元組片段,印出來只能顯示成替換字元。
這是串流輸出很常見的一個坑。用 SSE 接收模型回應、每收到一個 token 就直接 decode 丟給前端,中文就會一陣一陣地吐出 �。正確做法是把位元組緩衝起來,湊成完整字元再送。順帶一提,o200k_base 把「模型」和「第一」各自合併成單一 token 了,這是詞表變大之後的直接紅利。
tiktoken 是什麼
tiktoken 是 OpenAI 開源的 BPE 斷詞器實作,核心用 Rust 寫、包成 Python 套件。它不做訓練,只做編碼與解碼。詞表是預先訓練好的,以 .tiktoken 檔案的形式提供。
它管理的是一組具名的 encoding:
| encoding | 合併規則數 | 用在哪 |
|---|---|---|
r50k_base(gpt2) | 50,256 | GPT-2、GPT-3 各版本 |
p50k_base | 50,280 | code-davinci-002、text-davinci-003 |
cl100k_base | 100,256 | GPT-3.5-turbo、GPT-4、text-embedding-3-* |
o200k_base | 199,998 | GPT-4o 與之後的模型 |
(合併規則數是實際載入詞表數出來的,另外還要加上各 encoding 的 special token,數量從 1 到 5 個不等。)
用起來很短:
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
# 或者讓它幫你查:enc = tiktoken.encoding_for_model("gpt-4o")
ids = enc.encode("機器學習")
print(ids, len(ids))
print(enc.decode(ids))
# 想看每個 token 到底長什麼樣:
for t in ids:
print(t, enc.decode_single_token_bytes(t))
有兩個地方容易踩到。
第一個是 special token 預設被擋住。<|endoftext|> 這類控制標記在詞表裡有 ID,但你直接 encode("<|endoftext|>") 會拿到 ValueError,而不是那個 ID。這是刻意的安全設計:使用者輸入裡如果混進這串字,不該被當成控制標記處理,否則就是 prompt injection 的入口。要真的編出來得明講:
enc.encode("<|endoftext|>", allowed_special={"<|endoftext|>"})
第二個是 encoding_for_model 只認得它知道的模型名。新模型出來、你的 tiktoken 版本又舊,它會直接丟 KeyError,不會猜。這種時候固定用 get_encoding("o200k_base") 比較保險。
另外,enc.encode() 算出來的是純文字的 token 數,不等於 Chat Completions 實際計費的數量。每則訊息的 role、分隔標記都會額外佔掉幾個 token,函式定義(tools)也會被序列化進 prompt。要精確估算,得照官方 cookbook 的規則把這些加回去。
回到開場:繁體為什麼比較貴
現在可以正面回答了。再看一次那組實測:
| 文字 | 字數 | r50k_base | cl100k_base | o200k_base |
|---|---|---|---|---|
機器學習模型的訓練資料 | 11 | 26 | 20 | 11 |
机器学习模型的训练数据 | 11 | 27 | 13 | 6 |
電腦程式設計 | 6 | 16 | 10 | 7 |
电脑程序设计 | 6 | 16 | 6 | 3 |
GPT-2 那個年代(r50k_base),繁簡一起慘,每個漢字大約要 2.4 個 token——那份詞表幾乎沒替中文留位置,多數漢字就是老老實實的 3 個位元組。
到了 cl100k_base,簡體明顯翻身(11 個字 13 個 token),繁體只小幅改善(20 個)。o200k_base 拉開得更誇張:簡體 6 個 token,繁體 11 個,差了將近一倍。
原因沒有陰謀,就是統計。BPE 的合併表是訓練語料的投票結果——哪一對位元組出現得多,哪一對就先拿到 token。中文網路內容裡簡體的量遠大於繁體,於是「学习」「数据」這類常見的簡體詞被合併成單一 token,而「學習」「資料」只拿到部分合併,甚至完全沒有。
換句話說,詞表的分配,反映的是這個語言在訓練語料裡佔多少份量。位置就那麼多,全世界的語言、程式碼、數字和標點都在搶同一份詞表。詞表從 10 萬擴到 20 萬時,多出來的 10 萬個位置照樣按語料比例分配,簡體分到的比繁體多,於是差距不但沒縮小,反而被放大了。
詞表要是能無限大,每個詞都分得到自己的 ID,也就沒有誰得付這筆繁體稅。偏偏不行,模型的第一層就是那張列數固定的表。
這裡有個容易誤會的地方:token 數多不代表模型更看不懂繁體。它只代表同一段內容,繁體會佔掉更多 context,也花更多錢。理解能力是另一回事。
另一種切法:SQLite 的 trigram
把中文切碎的不只有 BPE。SQLite 全文搜尋的 trigram tokenizer 面對同一個難題:中文沒有空白,切不出詞。它給的答案剛好相反——不學,也不猜,每三個字元切一刀。
同一句話丟給它,切出來長這樣:
CREATE VIRTUAL TABLE t USING fts5(c, tokenize='trigram');
INSERT INTO t VALUES('機器學習模型的訓練資料');
-- 索引裡實際存的 9 個 token:
-- 機器學 | 器學習 | 學習模 | 習模型 | 模型的 | 型的訓 | 的訓練 | 訓練資 | 練資料
11 個字變成 9 個 token,而且每個字都被重複覆蓋了三次。BPE 是把文字分割成不重疊的片段,trigram 是拿一個三格的窗滑過去,這是兩種完全不同的東西。
| BPE(tiktoken) | trigram(FTS5) | |
|---|---|---|
| 切法從哪來 | 從語料學一張合併表 | 固定規則,不學習 |
| 片段長度 | 變長,不重疊 | 固定 3 字元,重疊 |
機器學習模型的訓練資料 | 20(GPT-4)/11(GPT-4o) | 9 |
机器学习模型的训练数据 | 13(GPT-4)/6(GPT-4o) | 9 |
| 那句 55 字元的英文 | 11 | 53 |
| 切出來要餵給誰 | 模型的輸入 | 反向索引與 BM25 |
第四列是重點:trigram 對繁簡一視同仁。它不學習,自然也就沒有「哪種字在語料裡比較多」的問題,兩邊都是 9 個 token。前面那筆繁體稅是 BPE 獨有的,來自訓練語料的分布,不是中文天生就得付的代價。
第五列則整個翻過來。BPE 處理英文極有效率(55 個字元收成 11 個 token),trigram 處理英文是災難(53 個 token)。同一份文字,兩種切法的成本剛好對調。
trigram 的代價在最小長度。要湊滿三個字元才成一個 token,所以兩個字的詞根本查不到:
SELECT count(*) FROM t WHERE t MATCH '咖啡'; -- 0
SELECT count(*) FROM t WHERE t MATCH '咖啡的'; -- 1
BPE 沒這個問題,因為它從來不必「查得到」,只要能把文字變成一串整數再變回來就夠了。
真正共通的地方在下游。兩種切法都不理解語意,而下游都把切出來的東西當統計單位:BM25 數 token 在幾篇文件出現過來排相關性,Transformer 拿 token 序列算注意力。切法一換,統計的材料就整個換掉。FTS5 那邊的後果我寫在《trigram 換得到中文,換不到相關性》;這邊的後果,就是帳單上多出來的那一截。
這些地方最容易踩坑
最直接的是估算。拿字數乘一個係數去推中文的 token 數,誤差會大到不能用:同一段繁體中文,在 GPT-4 和 GPT-4o 上可以差掉四成以上。要嘛實際跑一次 tiktoken,要嘛留足夠的安全邊際,中間地帶沒有。
做 RAG 切 chunk 也一樣。用字元數切,每一塊的 token 數會劇烈波動,中英夾雜的文件尤其明顯。切割的單位應該跟計費、跟模型限制對齊,也就是 token。
塞結構化資料進 prompt 之前,先把縮排拿掉。實測那一小段格式化的 JSON 是 20 個 token,json.dumps 壓掉空白之後剩 12 個。四格縮排在 cl100k_base 裡甚至不會乖乖變成一個 token,而是三個空格成一塊、剩下那個空格再跟後面的引號黏成另一塊,白白吃掉位置。
最後是那個著名的問題:strawberry 裡有幾個 r。在 cl100k_base 裡它是 ["str", "aw", "berry"],模型看到的是三個整數,不是十個字母。問它有幾個 r,等於問一個沒看過拼寫的人去數拼寫,它只能靠訓練時記住的知識去猜。這不是推理能力的問題,是輸入根本就不含那個資訊。
結語
BPE 不理解語言。它從 1994 年被發明出來的那天起,做的就只有一件事:數哪一對相鄰的東西出現得最多,然後把它們黏起來。今天跑在 GPT-4o 前面的那套斷詞器,本質上還是同一個迴圈,只是資料從一個檔案換成了整個網際網路,符號從字元換成了位元組,外面再套一條 regex 防止它把兩個單字黏在一起。
這個迴圈要解的,一直是同一道題:用有限個數字,表達無限多種文字。256 個位元組保證什麼都拼得出來,合併表負責讓常見的東西拼得短。可是詞表的長度有上限,總有東西分不到捷徑。
而模型看到的世界,就是這個迴圈切出來的樣子。它沒看過「機器學習」這四個字,它看到的是 20 個整數(在 GPT-4 上),或 11 個(在 GPT-4o 上)。你以為模型在讀中文,其實它在讀一串被切得特別碎的位元組。碎成這樣,是因為捷徑的名額有限,而訓練語料裡繁體不夠多。
下次估 token 數估歪了、或是串流輸出吐出半個中文字,你不會再覺得是模型在鬧脾氣——你會知道那是一張合併表,在替你決定文字該怎麼切。