· 16 min read

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-27B47,92917,568(36.7%)27,364(57.1%)2,997(6.3%)
DeepSeek-V429,92012,167(40.7%)16,458(55.0%)1,295(4.3%)
PangolinTokenizer30,16411,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
NormalizerNFC(不是 NFKC)
純漢字 token 上限6 字
訓練語料19.27 億字元
語料組成繁中 60.6% / 英文 22.8% / 程式碼 10.4% / 數學 6.2%
對話格式ChatML

英文、程式碼、數學合計佔 39.4%,不是湊數。繁中詞表如果以犧牲英文為代價,實務上沒人敢用——後面會看到英文效率的數字。

三、前處理:四個步驟,順序不能換

  1. 異體字正規化(12.7 萬處)——爲→為、裏→裡、麽→麼、着→著。不做的話同一個詞會學出兩套 merge,白白吃掉名額。
  2. 陸用語整列丟棄——55 個無歧義詞(質量、身份、通脹、網絡…),命中即丟整列
  3. NFC 正規化
  4. 近似去重——判決書實測近似重複率 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)。

tokenizernormalizer嚴格往返失敗NFC 往返失敗
TwinkleTokenizerNFC620
Qwen3.8-27BNFC620
PangolinTokenizer00
DeepSeek-V4Sequence00

有做 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,越低越好

subsetoursPangolinQwen3.8-27BDeepSeek-V4gemma-4-31B
law_judgment0.33800.76220.75700.79240.8395
law_statute0.43550.72670.73160.76810.7767
web_translated0.44030.67800.64960.67110.7152
gov_news0.49670.70550.70200.73270.7771
encyclopedia0.55570.72920.72390.72980.7835
OVERALL0.43950.72000.71200.74020.7785
vocab201,069114,688248,044128,000262,144
單字 token 率18.0%46.0%44.4%52.5%60.6%

held-out 資料上的字元/token(越高越省,與上表互為倒數):

tokenizervocab中文平均英文
ours201,0692.0264.657
Qwen3.8-27B248,0441.3884.674
DeepSeek-V4128,0001.3694.855
PangolinTokenizer114,6881.3392.658
gemma-4-31B262,1441.2824.735

中文提升 46%,英文幾乎持平(4.657 vs Qwen 4.674,差 0.4%)。前面說語料放 22.8% 英文不是湊數,就是為了這一欄。Pangolin 的英文是 2.658,那是它為語音模型做的取捨——中英混雜的用途要留意。

6.2 第二層:切得對,不只切得少

壓縮率高不代表切分正確。以 jieba 繁中斷詞為參考,量切點是否落在詞邊界:

tokenizer字/token切點命中詞邊界單字 token 佔比
ours2.16985.6%17.6%
Qwen3.8-27B1.47577.8%41.7%
PangolinTokenizer1.44177.8%45.1%
DeepSeek-V41.39174.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 單位不同。

條件oursQwen 詞表結論
等算力(各 200M token)4.4344.591ours 低 3.4%
等文字量(各 4 億字元)4.5954.379Qwen 低 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 逐一檢查:

數量佔比
含漢字 token146,33972.8%
含異體字00%
含簡體專屬字550.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

資源


繁體中文在主流詞表裡是二等公民,這件事不會有人替我們解決。詞表是模型的地基,地基歪了,上面蓋什麼都會歪——而且是靜默地歪,不會在 loss 上出現。