TwinkleTokenizer:為什麼繁體中文需要一個從零訓練的詞表
201K 的詞表打贏 248K,不是因為演算法比較好,是因為對方有 11% 的名額在訓練前就浪費掉了。這篇講設計決策、三層驗證,以及一個差點讓結論反過來的評測錯誤。
先講結論:TwinkleTokenizer 在我們自建的 tw-tokenizer-bench(24,294 筆、1,844 萬字元)上,tokens/char 是 0.4395,比 Qwen3.8-27B 的 0.7120 低 38%——而詞表規模只有它的 81%。
小的贏大的,這件事需要解釋。不是我們的 BPE 實作比較聰明,是對方的名額在訓練詞表的當下就已經花掉了。
一、為什麼續訓救不了這件事
先看一個數字。Qwen3.8-27B 有 47,929 個多字漢字 token,其中 27,364 個是簡體專屬——對繁體中文完全無用。那是它 248K 詞表的 11%。
| tokenizer | 多字漢字 token | 繁中可用 | 簡體專屬 | 繁體專屬 |
|---|---|---|---|---|
| Qwen3.8-27B | 47,929 | 17,568(36.7%) | 27,364(57.1%) | 2,997(6.3%) |
| DeepSeek-V4 | 29,920 | 12,167(40.7%) | 16,458(55.0%) | 1,295(4.3%) |
| PangolinTokenizer | 30,164 | 11,310(37.5%) | 9,490(31.5%) | 9,364(31.0%) |
這些不是「用不到就算了」的閒置名額。BPE 訓練是在固定預算下搶位置:每一個給了「的話說」的位置,就是一個沒給「增修條文」的位置。詞表越是以簡體語料訓練,繁中詞彙越擠不進去。
Qwen 仍然能拿到不算差的中文壓縮率,靠的是詞表夠大,浪費得起。
而這件事續訓改不掉。詞表在預訓練開始前就固定了,continued pretraining 只動權重不動 vocab。你可以在繁中語料上再訓十億 token,那 27,364 個簡體 token 還是躺在那裡佔位。
所以唯一的解法是從零訓練。用純繁中為主的語料重訓,那個浪費從源頭就不會產生——這就是 201K 的可用名額打贏 248K 的原因。
二、配方
| 演算法 | byte-level BPE |
| 詞表大小 | 200,000 + 1,069 特殊 token = 201,069 |
| Normalizer | NFC(不是 NFKC) |
| 純漢字 token 上限 | 6 字 |
| 訓練語料 | 19.27 億字元 |
| 語料組成 | 繁中 60.6% / 英文 22.8% / 程式碼 10.4% / 數學 6.2% |
| 對話格式 | ChatML |
英文、程式碼、數學合計佔 39.4%,不是湊數。繁中詞表如果以犧牲英文為代價,實務上沒人敢用——後面會看到英文效率的數字。
三、前處理:四個步驟,順序不能換
- 異體字正規化(12.7 萬處)——爲→為、裏→裡、麽→麼、着→著。不做的話同一個詞會學出兩套 merge,白白吃掉名額。
- 陸用語整列丟棄——55 個無歧義詞(質量、身份、通脹、網絡…),命中即丟整列。
- NFC 正規化。
- 近似去重——判決書實測近似重複率 23%。
第 2 步刻意不改寫、不用 OpenCC 整段轉換。整段轉換會把本來就正確的繁中一起改壞,而且是靜默地改壞——你不會在 loss 上看到它。
還有一個例外值得單獨講:法律語料不套第 2 步。
「制作」是刑事訴訟法第 39 條的法定用語,「公安」是同法第 10 條的。它們長得像陸用語,但在法條裡就是正確寫法。一個無腦的繁簡過濾器會把台灣法律語料改成不是台灣法律。
這種事只有讀過條文才會知道。這也是為什麼領域語料的清理不能外包給通用規則。
四、NFC 而不是 NFKC,以及一個「失敗」其實是對的
NFKC 會把全形英數轉半形、把「㈠」拆解掉。這兩者在繁中文本裡都是有意義的——公文的條列就是用「㈠㈡㈢」,全形括號跟半形括號在排版上不是同一回事。所以用 NFC。
然後評測時出現一件有趣的事。
我們的詞表和 Qwen3.8 各有 62 筆嚴格往返失敗(decode(encode(x)) != x),而 Pangolin 和 DeepSeek 是 0 筆。
逐筆查完,根因是來源文本含 CJK 相容表意文字(U+F900–FAFF 區,例如 U+F98E「年」),集中在 gov_news 61 筆、web_translated 1 筆。
而 NFC 的定義就是要把這些字折疊成標準碼位(U+F98E → U+5E74)。
| tokenizer | normalizer | 嚴格往返失敗 | NFC 往返失敗 |
|---|---|---|---|
| TwinkleTokenizer | NFC | 62 | 0 |
| Qwen3.8-27B | NFC | 62 | 0 |
| PangolinTokenizer | 無 | 0 | 0 |
| DeepSeek-V4 | Sequence | 0 | 0 |
有做 NFC 的全部「失敗」,沒做的全部「通過」。
但不折疊才是缺陷——那會讓「年」(U+F98E) 和「年」(U+5E74) 佔兩個不同的 token,白白浪費名額。把嚴格往返設成硬性關卡,等於懲罰有正確做正規化的 tokenizer。
所以 benchmark 兩種都報,以 NFC 版為準。以 NFC 為基準時,本詞表往返失敗 0 筆。
五、為什麼把純漢字 token 砍在 6 字
長 token 壓縮率高,但會遮蔽詞形資訊。
Haslett 在 Computational Linguistics(2025)量過一個很直觀的例子:GPT-4 在字元配對任務上,文本被拆成 byte 時得分 .986,同樣的文本作為單一 token 時掉到 .219。模型看不見自己 token 內部的字。
代價是壓縮率少 0.9%。我們認為值得——一個看不見字的詞表,在需要處理錯字、異體字、注音、法條編號的場景會出問題。
六、驗證:壓縮率是必要不充分條件
近年文獻對「壓縮率 = 品質」是存疑的(Schmidt et al., EMNLP 2024;Lotz et al., 2025 量到 ρ = −0.59,是負相關)。所以我們做了三層。
6.1 第一層:壓縮率
tw-tokenizer-bench,指標 tokens/char,越低越好:
| subset | ours | Pangolin | Qwen3.8-27B | DeepSeek-V4 | gemma-4-31B |
|---|---|---|---|---|---|
| law_judgment | 0.3380 | 0.7622 | 0.7570 | 0.7924 | 0.8395 |
| law_statute | 0.4355 | 0.7267 | 0.7316 | 0.7681 | 0.7767 |
| web_translated | 0.4403 | 0.6780 | 0.6496 | 0.6711 | 0.7152 |
| gov_news | 0.4967 | 0.7055 | 0.7020 | 0.7327 | 0.7771 |
| encyclopedia | 0.5557 | 0.7292 | 0.7239 | 0.7298 | 0.7835 |
| OVERALL | 0.4395 | 0.7200 | 0.7120 | 0.7402 | 0.7785 |
| vocab | 201,069 | 114,688 | 248,044 | 128,000 | 262,144 |
| 單字 token 率 | 18.0% | 46.0% | 44.4% | 52.5% | 60.6% |
held-out 資料上的字元/token(越高越省,與上表互為倒數):
| tokenizer | vocab | 中文平均 | 英文 |
|---|---|---|---|
| ours | 201,069 | 2.026 | 4.657 |
| Qwen3.8-27B | 248,044 | 1.388 | 4.674 |
| DeepSeek-V4 | 128,000 | 1.369 | 4.855 |
| PangolinTokenizer | 114,688 | 1.339 | 2.658 |
| gemma-4-31B | 262,144 | 1.282 | 4.735 |
中文提升 46%,英文幾乎持平(4.657 vs Qwen 4.674,差 0.4%)。前面說語料放 22.8% 英文不是湊數,就是為了這一欄。Pangolin 的英文是 2.658,那是它為語音模型做的取捨——中英混雜的用途要留意。
6.2 第二層:切得對,不只切得少
壓縮率高不代表切分正確。以 jieba 繁中斷詞為參考,量切點是否落在詞邊界:
| tokenizer | 字/token | 切點命中詞邊界 | 單字 token 佔比 |
|---|---|---|---|
| ours | 2.169 | 85.6% | 17.6% |
| Qwen3.8-27B | 1.475 | 77.8% | 41.7% |
| PangolinTokenizer | 1.441 | 77.8% | 45.1% |
| DeepSeek-V4 | 1.391 | 74.9% | 49.6% |
單字 token 佔比 17.6% vs Qwen 41.7%,差距很直接:對方有四成的中文 token 是在逐字拆。
專業素養、特質或經公告審查優勝
ours:['專業素養', '、', '特質', '或經', '公告', '審查', '優勝']
Qwen:['專業', '素', '養', '、', '特質', '或', ...] ← 「素養」被拆成兩字
6.3 第三層:下游從零訓練,比 bits-per-character
這層最重要,也是唯一能反駁「壓縮率灌水」的證據。
用兩個詞表各從零訓練一顆 270M 模型(同架構、同資料來源),比 bits-per-character。BPC 以字元數為分母,是唯一能跨詞表公平比較的指標——perplexity 不行,因為 token 單位不同。
| 條件 | ours | Qwen 詞表 | 結論 |
|---|---|---|---|
| 等算力(各 200M token) | 4.434 | 4.591 | ours 低 3.4% |
| 等文字量(各 4 億字元) | 4.595 | 4.379 | Qwen 低 4.7%(但多花 43% 算力) |
兩組結論相反,這不是矛盾,是兩個不同的問題。
等算力下我們勝出,而且是在參數少 13% 的條件下(229M vs 259M,vocab 較小 → embedding 較小)。同一個 token 預算,我們的模型看到 4.4 億字元,Qwen 只看到 3.08 億——多看 43% 的資料。
等文字量下 Qwen 勝出屬預期:它用 43% 更多的算力跑同樣的內容,多花算力本來就該贏。
實務上算力才是限制,所以等算力那組是主要結論。但我把兩組都放出來,因為只報對自己有利的那組,就是在騙人。
6.4 這裡差點出過一次大錯
第三層的初版評測,我是用 token 數截斷測試文本,而不是字元數。
結果兩個模型預測的文本長度根本不同——100 萬字 vs 84 萬字。BPC 的分母不一樣,比出來的數字沒有意義。
那一版的結論方向是反的。
後來 tw-tokenizer-bench 就把「固定字元窗格(300–1,500 字元)」寫成建置原則,就是從這裡來的。tokenizer 評測只要讓不同詞表看到不同長度的文本,壓縮率就不可比——這個坑我踩過一次。
七、PangolinBench 與兩個落後的 subset
PangolinBench 是 OpenFormosa 為台灣語境設計的評測,10 個 subset。整體我們是 0.4765,Pangolin 0.4852、DeepSeek-V4 0.5222、gemma-4 0.5259、Qwen3.8 0.5407。
但有兩個 subset 落後,原因值得說清楚:
rich_transcription_structured_text:Pangolin 0.1844,我們 0.4508。差距來自特殊 token 命名而非壓縮能力——那個 subset 的內容就是<|transcript_start|>、<|speaker|>、<|non_speech_event|>這些標記,Pangolin 詞表內建它們(各算 1 個 token),我們沒有,要拆成十幾個。rich_transcription_json:同樣的原因。
另外要揭露:PangolinBench 有一道硬性關卡要求詞表必須含那 10 個 token,我們是以 --no-fail-on-quality-gate 跳過該關卡才完成評測的。
還有樣本量。 10 個 subset 共 30 筆,平均一個 subset 3 筆,單筆差異就足以翻轉排名。原作者自己也註明這「still a synthetic test set」。這張表不宜過度解讀——這也是我們另外建 tw-tokenizer-bench 的原因。
(執行正確性的佐證:原作者報告的 Pangolin overall 是 0.485,我們本地實測 0.4852;其 Qwen 3.6 為 0.541,我們實測 Qwen3.8 為 0.5407。兩者吻合,代表評測腳本沒跑錯。)
八、詞表污染稽核
全部 201,069 個 token 逐一檢查:
| 數量 | 佔比 | |
|---|---|---|
| 含漢字 token | 146,339 | 72.8% |
| 含異體字 | 0 | 0% |
| 含簡體專屬字 | 55 | 0.04% |
那 55 個逐一判讀後多數是誤報:叁(大寫數字,判決書與契約的法定寫法)、恒(人名)、咨(咨文)、卺(合卺)。真簡體約 8 個單字 token。
九、還沒解決的
- 台語與客語支援未經充分驗證。 訓練語料中台語只有 4.7 萬字元。PangolinBench 的
taigi_han_roman_mixed(0.5495)與hakka_romanized(0.4567)雖然優於對照組,但樣本量太小,結論不穩固。在以漢字為主的歇後語樣本上,台羅拼音(á、î、iann)確實會被拆得比較碎。 - 下游驗證規模太小。 270M 參數、200M token,遠低於實用規模。結果支持但不證明大模型上也成立。
- vocab size 未達邊際遞減點。 64K→200K 的掃描顯示增益仍在遞減但尚未轉平,更大的詞表可能還有空間。
- 主要 benchmark 是自己建的。 tw-tokenizer-bench 由我們建置,而我們在全部 5 個 subset 領先。請把它當「作者自評」而非第三方評測。防範措施(固定字元窗格、排除訓練污染分片、指紋比對)與資料、腳本全部公開,歡迎複驗。
關於最後一點多說一句:web_translated 的來源共 1,288 個分片,其中 300 個被 TwinkleTokenizer 的訓練語料用過。benchmark 只從剩下的 988 個抽,並逐篇做指紋比對確認零重疊。不做這件事,自家詞表會在自家 benchmark 上不公平地佔優。
怎麼用
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("twinkle-ai/TwinkleTokenizer")
tok.tokenize("中華民國憲法增修條文第十條")
# ['中華民國憲法', '增修條文', '第十條']
對話模板含思考通道,reasoning 欄位會被渲染進 <|think_start|> … <|think_end|>:
messages = [
{"role": "user", "content": "刑法第 271 條的構成要件為何?"},
{"role": "assistant",
"reasoning": "先確認條文位置,再拆解構成要件……",
"content": "刑法第 271 條規定殺人罪,構成要件為……"},
]
tok.apply_chat_template(messages, tokenize=False, enable_thinking=True)
特殊 token 具名 45 個(對話、思考、工具、語音、視覺、文件結構),另保留 1,024 個 reserved token——未來加新功能可以直接改名,不必擴充 embedding 重訓。
在 benchmark 上複驗:
pip install datasets transformers
python evaluate.py --tokenizer twinkle-ai/TwinkleTokenizer
資源
- 詞表:twinkle-ai/TwinkleTokenizer(Apache-2.0)
- 評測集:twinkle-ai/tw-tokenizer-bench
- 先行工作:OpenFormosa/PangolinTokenizer 與 PangolinBench——感謝 OpenFormosa 團隊公開這些,讓繁中詞表這件事有了可對照的基準。
繁體中文在主流詞表裡是二等公民,這件事不會有人替我們解決。詞表是模型的地基,地基歪了,上面蓋什麼都會歪——而且是靜默地歪,不會在 loss 上出現。