AI-LECTURER 速報課|2026-09-22|約 25 分鐘

GPU 別再空等 CPU:tokenizers v1 怎麼把切詞加速數十倍,還保證結果一模一樣

📍 真實場景
品妍,機器學習工程師,任職於一家做客服機器人的新創公司

每天深夜替公司的客服模型跑訓練,資料是幾百萬筆客服對話,跑在租來的 GPU 機器上,租金按小時算

😖 卡住的地方:品妍盯著監控圖,發現 GPU 使用率像鋸齒一樣忽高忽低,常常掉到接近零。查了半天才懷疑:是不是 GPU 在等 CPU 把文字切成模型讀得懂的數字?更麻煩的是,就算想換更快的工具,也怕升級後同一句話切出的數字不同,讓已訓練好的模型全部對不上。
💡 這課用 Hugging Face 剛公布的 tokenizers v1 當範例,帶你看懂:分詞器為什麼會拖慢 GPU、v1 如何做到「更快但輸出完全不變」,並且親手在自己的電腦上量一次。

🧭 這到底是什麼(白話版)

AI 模型其實看不懂文字,它只吃整數。所以文字送進模型之前,要先經過一台「翻譯機」:把一句話切成一小塊一小塊,再把每一小塊換成一個編號。這台翻譯機就是分詞器(tokenizer)把一段文字切成小塊,再把每一小塊換成編號的工具。切出來的每一小塊叫 token分詞器切出來的最小單位,可能是一個字、半個字或一個符號,它的編號叫 token ID某一小塊在字典裡的整數編號,模型讀的就是這串編號。tokenizers 是 Hugging Face 提供的一個常用函式庫別人寫好、你可以直接拿來用的一包程式功能,專門做這件事,這次公布的 v1 版本主打大幅提速。

tokenizers 做切詞分四個階段。第一階段「正規化」:把原始文字整理成統一格式,例如全部轉小寫,或統一 Unicode 的寫法。第二階段「預切詞」:先把文字粗切成較小的片段,稱為預切詞片段(pre-token)。第三階段「模型」:把每個片段切成 token,並到詞表裡查出編號。第四階段「後處理」:補上模型要求的特殊標記固定加在句首或句尾的記號,用來告訴模型句子從哪裡開始、到哪裡結束

第三階段是文章描述的大部分工作發生的地方。文章量測了十種模型家族,其中八種使用 BPE(位元組對編碼)先把文字拆到最細的位元組,再反覆把排名最高的相鄰兩塊合併成一塊的切法。BPE 的做法是:從一個預切詞片段的位元組出發,一直把「排名最高的相鄰兩塊」合併起來,直到再也找不到有排名的相鄰組合為止。這個排名是在訓練分詞器時學出來的,之後隨著分詞器一起發布。

v1 的重點有兩個。第一,速度:文章說 v1 比舊版 v0.23 快,而且常常快數十倍。第二,相容:v1 產生的 token ID 和 v0.23 完全相同,API、詞表(詞表(vocabulary)分詞器認得的所有小塊,和它們編號的對照總表)以及合併排名都保留。也就是說,升級只換掉「怎麼算」,不改變「算出什麼」。

🎯 為什麼值得你花時間

別讓昂貴的 GPU 空等 CPU文章指出,當模型越來越快、要處理的資料越來越大(訓練巨量資料、同時服務很多請求、反覆處理很長的輸入),切詞的壓力會大到讓模型「餓肚子」拿不到資料。GPU 是租金最貴的零件,它閒著等 CPU 切詞,等於燒錢。v1 的目標就是讓切詞夠輕、能跟著你的工作量一起擴充。
升級幾乎沒有相容性風險v1 承諾產生和 v0.23 相同的 token ID,並保留 API、詞表與合併排名,而且仍然通用,v0.23 能載入的分詞器 v1 都能載入。所以已經訓練好的模型、已經切好的資料,都不會因為升級而對不上,這讓「更快」可以放心換。
效能主張可以自己驗證團隊用 tokbench 這個專案公開量測,範圍包含單執行緒、多執行緒、隨執行緒數量的擴充性、各模型家族、各語言、延遲、解碼吞吐、記憶體堆積和套件體積,還附上指令讓你在自己的硬體重跑。你不必只相信數字,可以自己量。

⚙️ 它是怎麼運作的

1
正規化先把原始文字整理成統一格式,例如轉小寫、統一 Unicode 寫法。這一步的目的是讓長得不同但意思相同的輸入,走進後面流程時是同一種樣子。
2
預切詞把整段文字粗切成較小的片段(pre-token)。後面的 BPE 合併只會在片段內部進行,不會跨片段合併,所以這一步決定了合併的邊界。
3
BPE 合併從片段的位元組開始,反覆找出排名最高的相鄰兩塊,把它們合併成一塊,一直做到沒有任何有排名的相鄰組合為止。排名是訓練時學出來的。
4
查詞表得到 ID每個合併後的 token 到詞表裡查出它的整數編號,文字就變成一串整數。v1 承諾這串整數和 v0.23 完全一致。
5
後處理加特殊標記補上模型需要的特殊標記(例如句首、句尾記號),最後把整數清單交給模型。解碼則是反方向:把整數變回文字。
tokenizers v0.23 與 v1 的對照(依原文整理)
項目v0.23v1
token ID 輸出基準與 v0.23 完全相同
API基準保持不變
詞表與合併排名基準保留
切詞速度基準常快數十倍
支援的分詞器家族通用(不只 BPE)保持通用,v0.23 能載入的 v1 都能載入

🔍 程式碼漫遊(點有 ● 的行看白話講解)

這段程式用 tokenizers 現場組出一個小型 BPE 分詞器,把上面講的四個階段一個一個接上去。這樣你能親眼看到每一階段各自做了什麼,也就知道 v1 加速的是哪一條流水線。

tok = Tokenizer(models.BPE())
💬 建立一個以 BPE 為「模型階段」的分詞器。文章量測的十種模型家族裡有八種是 BPE,所以這是最常見的選擇。
tok.normalizer = normalizers.Sequence([normalizers.NFKC(), normalizers.Lowercase()])
💬 階段一:正規化。先統一 Unicode 寫法(NFKC),再全部轉小寫。注意這是有損的:解碼回來會是小寫,所以驗證時要和正規化後的文字比對。
tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
💬 階段二:預切詞。用位元組層級的切法,任何字元最壞都能拆成 256 種位元組,所以不會有認不得的字。這也呼應原文說的 BPE 從位元組出發。
tok.decoder = decoders.ByteLevel()
💬 設定解碼方式,讓整數能被還原成原本的文字。編碼和解碼要配成一對,否則還原出來會是亂碼。
trainer = trainers.BpeTrainer(vocab_size=400, special_tokens=['[CLS]', '[SEP]'],
💬 準備訓練器:詞表最多 400 個項目,並預留兩個特殊標記。真實模型的詞表會大得多,這裡只是為了離線就能跑。
initial_alphabet=pre_tokenizers.ByteLevel.alphabet())
💬 把 256 種位元組當作起始字母表,確保任何文字都有起點可以合併。
tok.train_from_iterator(CORPUS, trainer)
💬 重點行:這一步就是「學排名」。訓練器統計哪些相鄰組合最常出現,依序排出合併順序。這份排名之後會被存起來,編碼時照著它合併。
tok.post_processor = processors.TemplateProcessing(
💬 階段四:後處理,用範本規定每個句子前後要補什麼標記。
single='[CLS] $A [SEP]',
💬 $A 代表你的句子本體,前後各補一個特殊標記。
special_tokens=[('[CLS]', tok.token_to_id('[CLS]')), ('[SEP]', tok.token_to_id('[SEP]'))])
💬 告訴範本這兩個標記對應的 ID,這些 ID 是訓練時分配的。
enc = tok.encode(text)
💬 單句編碼:文字進去,得到 enc.tokens(切出的小塊)和 enc.ids(對應編號)。這就是 v1 要加速的核心路徑。
back = tok.decode(enc.ids)
💬 解碼:把整數變回文字。原文特別量測了解碼吞吐,因為服務端也要把模型輸出的整數轉回文字。

🛠️ 動手做:自己量一次:切詞四階段,加上升級前後的速度與 ID 對照

這是一個真實小專案:下載(或複製)檔案,照步驟在你電腦上跑起來。

📄 stages.py ⬇ 下載
# tokenizers 四階段示範:正規化 -> 預切詞 -> 模型(BPE) -> 後處理
# 現場用幾句話訓練一個小 BPE,全程離線,不需要下載任何模型
import json
import sys
from pathlib import Path

import tokenizers
from tokenizers import Tokenizer, decoders, models, normalizers, pre_tokenizers, processors, trainers

# Windows 主控台預設編碼不一定是 UTF-8;先強制改,避免印出 byte-level 符號時噴錯
sys.stdout.reconfigure(encoding='utf-8')

HERE = Path(__file__).parent

CORPUS = [
    'A tokenizer converts text into the list of integers a model reads.',
    'Normalization applies operations such as lowercasing or Unicode normalization.',
    'Pre-tokenization splits the text into smaller pieces called pre-tokens.',
    'The model turns each pre-token into tokens and maps them to IDs in its vocabulary.',
    'Post-processing adds any special tokens the model expects.',
    'BPE repeatedly joins the highest ranked adjacent pair until no ranked pair remains.',
    'Your GPUs should never sit idle waiting for the CPU to complete its tokenization.',
    'Tokenization should be light and should scale with your workflow.',
]
EXTRA = ['Café GPUs never sit idle!', 'Tokenization should scale with your workflow.']


def build_tokenizer():
    tok = Tokenizer(models.BPE())
    # 階段一 正規化:統一 Unicode 寫法並轉小寫(有損,解碼回來會是小寫)
    tok.normalizer = normalizers.Sequence([normalizers.NFKC(), normalizers.Lowercase()])
    # 階段二 預切詞:byte-level 讓任何字元都能拆成位元組,不會出現認不得的字
    tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
    tok.decoder = decoders.ByteLevel()
    trainer = trainers.BpeTrainer(
        vocab_size=400,
        special_tokens=['[CLS]', '[SEP]'],
        initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
    )
    # 階段三 模型:訓練時學出合併排名,編碼時照排名合併
    tok.train_from_iterator(CORPUS, trainer)
    # 階段四 後處理:句首句尾補特殊標記(ID 要等訓練完才知道,所以最後才設)
    tok.post_processor = processors.TemplateProcessing(
        single='[CLS] $A [SEP]',
        special_tokens=[('[CLS]', tok.token_to_id('[CLS]')), ('[SEP]', tok.token_to_id('[SEP]'))],
    )
    return tok


def show_stages(tok, text):
    print('原文        :', text)
    norm = tok.normalizer.normalize_str(text)
    print('1 正規化    :', norm)
    pieces = tok.pre_tokenizer.pre_tokenize_str(norm)
    print('2 預切詞    :', [p for p, _ in pieces])
    core = tok.encode(text, add_special_tokens=False)
    print('3 模型(BPE) :', core.tokens)
    print('  對應 ID   :', core.ids)
    full = tok.encode(text)
    print('4 後處理    :', full.tokens)
    back = tok.decode(full.ids)
    print('解碼還原    :', back)
    # 正規化是有損的,所以拿正規化後的文字比對才公平
    print('還原檢查    :', '一致' if back == norm else '不一致!')
    print()


def main():
    tok = build_tokenizer()
    print('tokenizers 版本:', tokenizers.__version__)
    print()
    for text in EXTRA:
        show_stages(tok, text)
    tok.save(str(HERE / 'tokenizer.json'))
    # 快照:訓練當下每句話的 token ID,升級後用來驗證『輸出沒變』
    snapshot = {t: tok.encode(t).ids for t in CORPUS + EXTRA}
    (HERE / 'ids_snapshot.json').write_text(
        json.dumps(snapshot, ensure_ascii=False, indent=1), encoding='utf-8'
    )
    print('已存檔:tokenizer.json、ids_snapshot.json(升級前後都用同一份,才是公平比較)')


if __name__ == '__main__':
    main()
📄 bench.py ⬇ 下載
# 量測:逐筆 encode vs 批次 encode_batch vs decode_batch,並確認升級後 token ID 沒變
# 同一份 tokenizer.json 在不同版本載入,才能在『同樣輸入、同樣輸出』下比較速度
import json
import os
import sys
import time
from pathlib import Path

import tokenizers
from tokenizers import Tokenizer

from stages import CORPUS, EXTRA

sys.stdout.reconfigure(encoding='utf-8')

HERE = Path(__file__).parent
REPEAT = 5000  # 把句子重複多次,讓總量夠大,計時才不會被雜訊淹沒
ROUNDS = 3


def best_of(fn):
    # 取最快的一次:排除首次載入、快取與作業系統排程造成的偶發變慢
    best = None
    for _ in range(ROUNDS):
        t0 = time.perf_counter()
        fn()
        dt = time.perf_counter() - t0
        best = dt if best is None else min(best, dt)
    return best


def check_ids(tok):
    snap_path = HERE / 'ids_snapshot.json'
    if not snap_path.exists():
        print('找不到 ids_snapshot.json,請先執行 python stages.py')
        return False
    snap = json.loads(snap_path.read_text(encoding='utf-8'))
    bad = [t for t, ids in snap.items() if tok.encode(t).ids != ids]
    if bad:
        print(f'ID 檢查:有 {len(bad)} 句和快照不同!例如:{bad[0]}')
    else:
        print(f'ID 檢查:{len(snap)} 句的 token ID 全部和快照相同(升級沒有改變輸出)')
    return not bad


def show_history():
    files = sorted(HERE.glob('results_*.json'))
    if len(files) < 2:
        print('提示:升級版本後再跑一次,這裡就會出現新舊對照表')
        return
    print('--- 版本對照 ---')
    for f in files:
        r = json.loads(f.read_text(encoding='utf-8'))
        print('{version:>12} | 逐筆 {single_mb_s:>7.1f} MB/s | 批次 {batch_mb_s:>7.1f} MB/s | 解碼 {decode_m_tok_s:>6.2f} M tok/s | ID 相同:{ids_ok}'.format(**r))


def main():
    tok = Tokenizer.from_file(str(HERE / 'tokenizer.json'))
    version = tokenizers.__version__
    print('tokenizers 版本:', version, '| CPU 邏輯核心數:', os.cpu_count())
    ids_ok = check_ids(tok)

    texts = (CORPUS + EXTRA) * REPEAT
    total_mb = sum(len(t.encode('utf-8')) for t in texts) / 1e6
    ids_list = [e.ids for e in tok.encode_batch(texts)]
    n_tokens = sum(len(ids) for ids in ids_list)

    # 逐筆:在 Python 迴圈裡一句一句呼叫,含 Python 呼叫本身的開銷
    t_single = best_of(lambda: [tok.encode(t) for t in texts])
    # 批次:一次交出整批文字,函式庫有機會把工作分給多個 CPU 核心
    t_batch = best_of(lambda: tok.encode_batch(texts))
    t_decode = best_of(lambda: tok.decode_batch(ids_list))

    single_mb_s = total_mb / t_single
    batch_mb_s = total_mb / t_batch
    decode_m_tok_s = n_tokens / t_decode / 1e6
    print(f'資料量:{len(texts)} 句 / {total_mb:.2f} MB / {n_tokens} tokens')
    print(f'逐筆 encode  :{t_single:.3f} 秒({single_mb_s:.1f} MB/s)')
    print(f'encode_batch :{t_batch:.3f} 秒({batch_mb_s:.1f} MB/s)')
    print(f'decode_batch :{t_decode:.3f} 秒({decode_m_tok_s:.2f} M tokens/s)')
    print(f'批次相對逐筆的加速比:{t_single / t_batch:.1f} 倍')

    result = {
        'version': version,
        'single_mb_s': round(single_mb_s, 2),
        'batch_mb_s': round(batch_mb_s, 2),
        'decode_m_tok_s': round(decode_m_tok_s, 3),
        'ids_ok': ids_ok,
    }
    out = HERE / f'results_{version}.json'
    out.write_text(json.dumps(result, ensure_ascii=False, indent=1), encoding='utf-8')
    print()
    show_history()


if __name__ == '__main__':
    main()
  1. 開啟 PowerShell,建立並進入工作資料夾:mkdir $HOME\tok-lab ; cd $HOME\tok-lab
  2. 把上面的 stages.py 與 bench.py 兩個檔案存進這個資料夾(用記事本另存新檔時,存檔類型選「所有檔案」、編碼選 UTF-8)
  3. 建立並啟用虛擬環境:py -3 -m venv .venv ; .\.venv\Scripts\Activate.ps1(若被執行原則擋下,先執行 Set-ExecutionPolicy -Scope Process Bypass)
  4. 先裝 v1 之前的舊版當基準:python -m pip install tokenizers==0.23.*
  5. 讓主控台正確顯示中文與特殊符號:chcp 65001 ; $env:PYTHONUTF8 = '1'
  6. 跑四階段示範:python stages.py。觀察每一階段的輸出,注意 Café 的 é 在預切詞階段被拆成位元組。這一步也會存下 tokenizer.json 與 ids_snapshot.json
  7. 跑舊版基準:python bench.py,記下逐筆、批次與解碼的速度
  8. 升級到 v1 預覽版:python -m pip install --pre -U tokenizers。v1 目前是 release candidate;若印出的版本號仍是 0.x,代表預覽版尚未上架,就只能停在舊版對照
  9. 再跑一次 python bench.py:先確認「ID 檢查」仍顯示全部相同,再看最後的版本對照表。不同機器與不同次執行的絕對數字會抖動,重點看新舊的倍率

🧠 工程思維透鏡(資深工程師看到的是什麼)

🔭 為什麼「token ID 完全不變」比「跑得更快」更重要?
分詞器和模型之間是一份契約:模型訓練時看到的是某一套詞表與編號,任何一個 ID 變了,模型讀到的就是另一個字。所以優化只能改「怎麼算」,不能改「算出什麼」。v1 把保持 token ID、API、詞表與合併排名當成前提,再談速度,等於把採用門檻降到最低。資深工程師看效能優化時,第一個問題往往是「輸出有沒有被改動」,因為一旦改動,速度再快也沒人敢升級。
🔭 八成的模型家族用 BPE,為什麼 v1 不乾脆專攻 BPE?
專門化通常能榨出更多速度,但代價是失去通用性。原文明確選擇「保持通用、不特別專攻 BPE」,讓 v0.23 能載入的分詞器 v1 全部都能載入。這是一個典型取捨:放棄一部分極端優化,換取所有既有使用者都能無痛升級。同時,因為要在通用架構下變快,代表要在四階段流水線的各處找改進空間,而不是只加快其中一個熱點。
🔭 為什麼說切詞變成瓶頸是「平衡開始偏移」?
整條資料流水線的速度取決於最慢的一段。以前模型慢,切詞再慢也被模型的計算時間蓋住;現在模型變快,切詞就從配角變成拖慢全局的一環,GPU 只能空等。這也解釋了為什麼原文要分開量單執行緒、多執行緒、延遲與解碼吞吐:訓練關心大批量的吞吐,服務端關心單一小請求的延遲,兩者的瓶頸位置不同。判斷一個優化值不值得做,要先問自己的場景卡在哪一段。
🔭 團隊為什麼公開基準測試程式,還特別感謝其他專案?
效能數字容易被挑選有利的條件,可重現才有可信度:tokbench 附上指令讓你在自己的硬體重跑,就是把驗證權交還給使用者。至於致謝 gigatoken、tiktoken 等專案,說明這次的加速有一部分來自讀別人的實作、借鑑被證明有效的想法。這是開源生態的正向循環:先讓自己的程式碼值得別人貢獻,社群才會回饋,也才會有更多硬體與平台的測試補丁。

📝 隨堂考(點選答案,立即回饋)

Q1. tokenizers 處理一段文字的四個階段,正確順序是?
✅ 先整理格式(正規化),再粗切(預切詞),接著由模型切成 token 並查出 ID,最後補特殊標記(後處理)。
Q2. 下列哪一項是 v1 明確承諾與 v0.23 保持相同的?
✅ v1 的目標是保留輸出、API、詞表與合併排名,只改進速度等可以改進的部分。token ID 一變,既有模型就對不上了。
Q3. 為什麼分詞器會變成 GPU 訓練與服務的瓶頸?
✅ 原文指出當工作量夠大,切詞的壓力會讓模型「餓肚子」。分詞器跑在 CPU 上,GPU 不該閒著等它。
Q4. BPE 的核心做法是什麼?
✅ 排名是訓練分詞器時學出來的,編碼時就照這份排名一直合併。
Q5. 原文為什麼選擇讓 v1 保持通用,而不是只專攻 BPE?
✅ 十種被量測的家族中八種用 BPE,專攻能更極端,但通用性與無痛升級才是團隊的取捨。

🃏 翻牌記憶卡(先想答案,再點開對答)

tokenizers 的四個處理階段?點我翻面
正規化、預切詞、模型(切成 token 並查出 ID)、後處理(加特殊標記)。
v1 相較 v0.23 的核心賣點?點我翻面
速度常快數十倍,同時輸出的 token ID、API、詞表與合併排名都不變,並保持對各分詞器家族的通用支援。
BPE 怎麼切詞?點我翻面
從預切詞片段的位元組出發,反覆合併排名最高的相鄰兩塊,直到沒有有排名的相鄰組合為止;排名在訓練時學出。
分詞器為什麼會讓 GPU 空等?點我翻面
模型變快、資料量或請求量變大時,CPU 端的切詞跟不上,導致 GPU 拿不到資料。
為什麼原文附 tokbench 與重跑指令?點我翻面
讓效能數字可重現:任何人都能在自己的硬體上重跑單執行緒、多執行緒、延遲、解碼吞吐等量測,驗證團隊的說法。

✅ 離開前自測(全勾=這課真的學會了)

0%