Transformer 解剖:从 Attention 到推理系统
第 10 章 Tokenizer 工程:BPE / WordPiece / SentencePiece
第 10 章 Tokenizer 工程:BPE / WordPiece / SentencePiece
第 9 章我们用字符级 tokenizer 在《全唐诗》上训了一个 mini-GPT。字符级简单粗暴,对中文小数据集够用,但真实工业里的大模型几乎全都不用字符级——它们用 BPE / WordPiece / SentencePiece / Tiktoken 等子词级(subword)tokenizer。
为什么?字符级看起来简洁,但代价是序列变长——同样一段英文句子,字符级要 100+ 个 token,BPE 大约 20-30 个。模型推理成本和序列长度的平方成正比(attention 是 O(N²)),多 5 倍 token 意味着多 25 倍 attention 计算。词级 tokenizer 序列短,但词表会爆炸(英文常用词 50 万+,加上各种变形拼写,OOV 率永远压不下来)。
子词 tokenizer 是这两个极端的中间方案:把常见词整体当一个 token,把不常见词拆成若干个常见子词,把任何字符都能拆到字节级别兜底。这一章我们把它的来龙去脉讲清楚。
读完这章你能:
- 解释字符级、词级、子词级三类方案各自的得失;
- 详细推导 BPE 的合并算法,并理解 WordPiece、SentencePiece、Tiktoken 各自的微妙差别;
- 看懂主流模型 tokenizer 的关键参数(词表大小、特殊 token、规范化策略);
- 给一个新模型选 tokenizer 时,知道在「英文为主」「中文为主」「多语言」「代码」等场景下分别该选什么;
- 解释为什么 GPT-4 给「strawberry 有几个 r」数错。
10.1 三类 tokenizer 的得失对照
先把三类方案的特点放一起对比:
| 维度 | 字符级 | 词级 | 子词级 |
|---|---|---|---|
| 词表大小 | 100-1000 | 数十万-数百万 | 30K-256K |
| 序列长度(同一段英文) | 长(4-5×) | 短(1×) | 中(1.3×) |
| OOV(未登录词)问题 | 无(任何字符都能编码) | 严重 | 可控(拆成已知子词) |
| 形态学信息保留 | 全部保留 | 部分丢失(变形不归一) | 部分保留 |
| 中文友好度 | 好(一字一 token) | 中(中文分词难) | 中(依赖词表) |
| 多语言友好度 | 好 | 差 | 中 |
| 训练成本 | 不需要训练(直接字符表) | 需要分词器 | 需要训练 |
| 模型表达力 | 弱(字符 → 语义路径长) | 强(一词一 token) | 强(折中) |
直观一点:
flowchart LR CHAR["字符级<br/>序列太长<br/>表达力弱"] -.-> SUBW["子词级 (BPE / SP)<br/>2010-2025 主流方案"] WORD["词级<br/>词表爆炸<br/>OOV 严重"] -.-> SUBW SUBW --> BPE[BPE] SUBW --> WP[WordPiece] SUBW --> SP[SentencePiece] SUBW --> TT[Tiktoken]
子词 tokenizer 是 2016 年 Sennrich 等人提出 BPE 之后才成主流的,核心思想都一致:给定大量文本,统计出最频繁的「字符片段」组合,把它们合并成单独的 token。常见词整体保留("the"、"and"),不常见的词被拆开("unbelievably" → "un" + "believ" + "ably"),生僻字符仍然能通过字节级兜底。
下面我们先把 BPE 讲透——它是其他几种方案的共同基础。
10.2 BPE 算法的来龙去脉
BPE(Byte Pair Encoding,字节对编码)原本是一种通用的数据压缩算法(Gage, 1994),最早用于压缩文件。Sennrich 等人在 2016 年的论文 Neural Machine Translation of Rare Words with Subword Units 把它改造成了 NLP 的子词分词方法——这是现代 tokenizer 的起点。
BPE 训练算法
给定一个语料库(一大堆文本),目标是训练出一个大小为 V 的词表,能用最少的 token 拼出所有常见词。
步骤如下:
-
初始化:把所有词拆成字符序列(或字节序列)。每个词后面加一个特殊标记(例如
</w>)表示词尾,避免「子词跨词」的混淆。"low" → l, o, w, </w> "lower" → l, o, w, e, r, </w> "newest" → n, e, w, e, s, t, </w> "widest" → w, i, d, e, s, t, </w> -
统计每对相邻 token 的频率。比如:
("l", "o"): 2 次 ("o", "w"): 2 次 ("w", "</w>"): 1 次 ("w", "e"): 2 次 ("e", "r"): 1 次 ("r", "</w>"): 1 次 ("n", "e"): 1 次 ("e", "w"): 1 次 ("e", "s"): 2 次 ("s", "t"): 2 次 ("t", "</w>"): 2 次 ("w", "i"): 1 次 ("i", "d"): 1 次 ("d", "e"): 1 次 -
找出频率最高的对,把它合并成一个新 token。比如
("e", "s")频率 2,合并成es。所有出现这一对的地方都被替换:"newest" → n, e, w, es, t, </w> "widest" → w, i, d, es, t, </w> -
重复 2-3 步,直到词表达到目标大小 V,或者没有可合并的对。
每次合并都会让训练语料里的 token 总数减少。最常见的字符组合先被合并("th"、"the"、"ing" 这种),最终词表里既有单字符(兜底)、又有中等长度子词(处理常见词缀)、又有完整常见词("the"、"and")。
BPE 编码(推理)
训练完得到一个有序的合并规则列表。对一个新词「unbelievably」编码时:
- 拆成字符:u, n, b, e, l, i, e, v, a, b, l, y
- 按合并规则的顺序,扫描相邻对,找到能合并的就合并。
- 一直合并到没有可合并的为止。
- 最终得到 token 序列,比如
un+believ+ably(具体取决于训练数据)。
flowchart TB WORD["unbelievably"] --> CHARS["u,n,b,e,l,i,e,v,a,b,l,y<br/>(12 个字符 token)"] CHARS --> M1["合并规则 1: u+n → un"] M1 --> S1["un, b, e, l, i, e, v, a, b, l, y"] S1 --> M2["规则 2: a+b → ab"] M2 --> S2["un, b, e, l, i, e, v, ab, l, y"] S2 --> M3["规则 3: ab+l → abl"] M3 --> S3["un, b, e, l, i, e, v, abl, y"] S3 --> DOTS[...继续合并...] DOTS --> FINAL["un, believ, ably<br/>(3 个 token)"]
这就是 BPE 的全部工作流程。简单、确定性、可控。
重要的工程细节
字节级 BPE(GPT-2 引入):原始 BPE 在字符级操作,但字符在 Unicode 下有 100K+ 个,BPE 的初始词表会很大。GPT-2 改用字节级——每个字节(0-255)作为最小单元,无论什么 Unicode 字符都能被字节序列表示。词表初始 256(所有字节),通过 BPE 合并后变成 50K 量级。
字节级 BPE 的好处:任何字符串都能被编码——绝不会出现 OOV。代价是中文等多字节字符会被拆成多个字节,输出 token 数偏多(一个汉字通常 2-3 个 token)。GPT-3.5 / GPT-4 都用字节级 BPE。
正则化预处理:BPE 训练前一般会做空格、标点、大小写等预处理。GPT-2 的 BPE 在文本里把每个空格都当作下一个词的前缀(比如 "hello world" 会被处理成 "hello"、" world",注意 " world" 前面带空格),这样 BPE 自然地学到「单词在句子中间」和「单词在句子开头」的区别。
10.3 WordPiece:BERT 的变种
WordPiece 由 Google 提出(最早用在语音识别里,后来用在 BERT),思路与 BPE 几乎一致,主要差别在合并准则:
- BPE:合并频率最高的对
- WordPiece:合并能让似然增益最大的对
具体地,WordPiece 选合并对的标准是:
分子是「合并后的对的频率」,分母是「两个 token 各自的频率乘积」。这相当于「相对相关性」——只有当两个 token 经常一起出现、单独出现少时,合并才有意义。
直觉对比:BPE 倾向合并最常见的对(如 "th"、"e " 这种连贯字符),WordPiece 倾向合并「相关性最强」的对(更倾向语义单元)。
WordPiece 的另一个特殊之处:子词的标记。BPE 用 </w> 标记词尾;WordPiece 在子词前加 ## 表示「这是上一个 token 的延续」:
"unbelievably" → un, ##believ, ##ably
"playing" → play, ##ing
这种标记让你看 token 序列时能立刻判断哪些是词的开头、哪些是延续——视觉上比 BPE 更清晰。BERT、DistilBERT、ELECTRA 都用 WordPiece。
实际效果上 BPE 和 WordPiece 在大多数任务上差不多——选哪个更多是历史习惯(BERT 系用 WordPiece,GPT 系用 BPE)。
10.4 SentencePiece:处理多语言的全能选手
到了 T5 / Llama / mT5 这一代多语言模型,BPE 和 WordPiece 都遇到了一个共同问题:它们假设输入已经被空格分词了——也就是说,它们用空格作为「词边界」的硬指示。
这在英文里没问题,但在中文、日文、泰文等没有空格的语言里崩盘。中文分词本身就是一门学问(jieba / pkuseg / LAC 等都有不同分词方案),强行先做中文分词再 BPE 会引入新的不一致。
SentencePiece(Kudo & Richardson, 2018)的核心思想:直接把空格当成一个普通字符,不做预分词。算法上仍然是 BPE 或 unigram,但输入是「整段原始文本」而非「已分词的词序列」。
SentencePiece 用一个特殊符号 ▁(U+2581)替代空格——比如 "hello world" 会被表示成 "▁hello▁world"——这样空格本身也成了 token 学习对象。
原始: "hello world 你好世界"
SP: ▁hello ▁world ▁你 好 世 界
这个简单的改动带来三个革命性优势:
优势一:跨语言统一。同一个 SentencePiece tokenizer 可以同时处理英文、中文、日文、阿拉伯文——不需要任何语言特定的预处理。
优势二:可逆。从 token 序列恢复原文是无损的——「▁」对应空格、其他字符直接还原。BPE 的预分词丢失了原始空格信息,恢复时要做猜测。
优势三:和模型对齐。空格作为 token 让模型显式地建模「词边界」——比如生成时模型会自己决定下一个 token 前要不要加空格。
T5、Llama 1/2/3、Mistral、PaLM、Qwen、DeepSeek 等多语言模型几乎全都用 SentencePiece。
Unigram vs BPE 模式
SentencePiece 内部支持两种算法:BPE 和 Unigram。Unigram 模型(Kudo, 2018)的训练目标是让所有训练文本的对数似然最大化,从词表里逐步删除贡献最小的 token,直到词表大小达到目标。
| 算法 | 思路 | 优劣 |
|---|---|---|
| BPE | 自下而上:从字符开始合并 | 训练快,主流 |
| Unigram | 自上而下:从全词表开始删除 | 概率框架更纯粹,统计意义更清晰 |
实际上两种模式效果接近。Llama 默认用 BPE 模式的 SentencePiece,T5 用 Unigram 模式。
10.5 Tiktoken:GPT-4 的工程加速
Tiktoken 是 OpenAI 在 GPT-3.5 / GPT-4 时引入的 BPE 实现。它的算法和 GPT-2 的字节级 BPE 大体一致,但工程上做了几项关键优化:
- Rust 实现:核心 BPE 编码用 Rust 写,比 GPT-2 的 Python 实现快 3-6 倍。
- Hashing-based merge:用哈希表加速合并规则查找。
- 更大词表:GPT-4 用的
cl100k_base词表 100K,比 GPT-2 的 50K 大一倍。
更大词表的好处:单 token 表达更多内容,序列变短。GPT-4 的 100K 词表里 "function"、"return"、"def" 等编程关键字都是单 token——这让代码生成的效率显著提升。
GPT-4 / GPT-4o 用的 o200k_base 词表更激进——200K 大小,对中文友好(多数常用汉字是单 token,少数双字成语也是单 token)。
flowchart LR G2["GPT-2: 50K BPE"] --> G3["GPT-3.5/4: 100K cl100k_base"] G3 --> G4["GPT-4o: 200K o200k_base"] G2 -.中文.-> CHN1[一字 2-3 token] G3 -.中文.-> CHN2[一字 1-2 token] G4 -.中文.-> CHN3[一字基本 1 token]
10.6 词表大小:怎么选
选词表大小是 tokenizer 工程的核心决定,影响以下维度:
词表越大:
- 序列越短(每 token 表达更多内容)→ 推理 / 训练成本下降
- embedding 参数越多(
V × d_model)→ 模型参数膨胀 - 罕见 token 训练信号稀疏 → 难训练充分
词表越小:
- 序列越长 → 推理 / 训练成本上升
- embedding 参数少 → 模型紧凑
- 每个 token 训练信号充分 → 训练更稳定
主流模型的词表大小:
| 模型 | 词表大小 | 备注 |
|---|---|---|
| GPT-2 | 50,257 | 字节级 BPE |
| GPT-3 | 50,257 | 同上 |
| GPT-3.5 / GPT-4 | 100,277 | cl100k_base |
| GPT-4o | 200,019 | o200k_base,中文友好 |
| BERT-base | 30,522 | WordPiece |
| RoBERTa | 50,265 | BPE |
| T5 | 32,128 | SentencePiece Unigram |
| Llama 1/2 | 32,000 | SentencePiece BPE |
| Llama 3 | 128,256 | Tiktoken (主要为多语言) |
| DeepSeek-V2/V3 | 102,400 / 129,280 | 自训 BPE |
| Qwen 2.5 | 151,936 | 自训 BPE |
| Mistral | 32,000 | SentencePiece |
可以看到一个明显趋势:2023 年后的模型词表普遍 100K+,主要驱动是多语言(特别是中文/日文/韩文)和代码——这些场景下大词表能显著缩短序列。
词表对中文的影响
英文中一个 token 对应一个完整的常用词("the", "function", "computer"),但 32K 词表里中文每个字仍然要 2-3 个字节级 token。比如 Llama-2 的 tokenizer 处理「机器学习」会输出大约 8 个 token:
"机器学习" → ["机", "器", "学", "习"] 但每个汉字又被拆成 2-3 字节
而 Llama-3 的 128K 词表里大多数常用汉字是单 token:
"机器学习" → ["机器", "学习"] 或 ["机器学习"] (取决于具体词表)
后者的训练 / 推理成本是前者的 1/8 到 1/4——这对中文用户是质变。
DeepSeek-V3 的 128K 词表更进一步针对中文优化,常见双字词(如「人工」「智能」)很多都是单 token。
10.7 Tokenizer 怪象:为什么 GPT-4 数不准 r
「strawberry 有几个 r」这个问题让 GPT-4 在 2024 年初闹出大笑话——它会回答「2 个」(错的,正确是 3 个)。这不是模型能力问题,而是 tokenizer 的诅咒。
让我们看 GPT-4 的 tokenizer(cl100k_base)怎么处理 "strawberry":
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
print(enc.encode("strawberry")) # [496, 1135, 19772]
print(enc.decode([496])) # "str"
print(enc.decode([1135])) # "aw"
print(enc.decode([19772])) # "berry"
strawberry 被拆成 3 个 token:["str", "aw", "berry"]。模型「看到」的不是 10 个字符 s,t,r,a,w,b,e,r,r,y,而是 3 个意义抽象的子词。
要让模型回答「strawberry 有几个 r」,它需要:
- 在 token 序列里识别「r」字符
- 但 "str" 里有一个 r,"berry" 里有两个 r——这些在 tokenizer 表示下是黑盒
- 模型的训练数据里也没几个例子告诉它「str 这个 token 含 1 个 r」
结果就是模型瞎猜——它在训练数据里见过类似的「数字母」问题,但答对率取决于巧合。
flowchart LR STR["人类看到的: s-t-r-a-w-b-e-r-r-y<br/>(10 个字符, 3 个 r)"] --> TOK[tokenizer] TOK --> TOK_OUT["模型看到的: [str], [aw], [berry]<br/>(3 个 token,r 字符不可见)"] TOK_OUT --> ASK["问: 几个 r?"] ASK --> GUESS["模型瞎猜:<br/>2? 3? 取决于训练样本"]
这就是 tokenizer 怪象。它揭示了一个深刻的事实:模型不是在「字符」层面理解语言,而是在「token」层面。凡是涉及「子 token 操作」(拼写、字符计数、字母组合)的任务,模型都先天受限。
类似的现象:
- 代码缩进问题:早期 GPT-2 的 BPE 把空格和后续字符合并,缩进 4 空格 vs 2 空格生成的 token 完全不同——同样的 Python 代码训练时表现不一致。GPT-3.5 之后的 tokenizer 把缩进单独处理。
- 数字 tokenization:早期 BPE 把数字也按频率合并(「1234」可能是单 token,「1235」拆成多个 token),导致数学能力受 tokenization 偶然性的影响。Llama 3 / Qwen 等新模型把每个数字单独成 token 来缓解。
- 多语言不平衡:英文一词一 token,泰文一字数 token——同样长度的 prompt 在不同语言下消耗的 token 量差几倍,API 成本显著差异。
10.8 用 SentencePiece 训练自己的 tokenizer
最后我们用代码训一个 SentencePiece tokenizer,体验一下完整流程。
安装:
pip install sentencepiece
训练:
import sentencepiece as spm
spm.SentencePieceTrainer.Train(
input='train_corpus.txt', # 输入文本(一行一句)
model_prefix='my_tokenizer', # 输出文件前缀
vocab_size=32000, # 词表大小
model_type='bpe', # 'bpe' 或 'unigram'
character_coverage=0.9995, # 字符覆盖率(0.9995 适合多语言)
pad_id=0, unk_id=1, bos_id=2, eos_id=3, # 特殊 token id
)
生成的 my_tokenizer.model 是一个二进制文件,可以加载使用:
sp = spm.SentencePieceProcessor()
sp.Load('my_tokenizer.model')
# 编码
ids = sp.EncodeAsIds("Hello world! 你好世界!")
print(ids) # [..., ..., ...]
# 解码
text = sp.DecodeIds(ids)
print(text) # "Hello world! 你好世界!"
# 看 token 字面值
print(sp.EncodeAsPieces("Hello world!")) # ['▁Hello', '▁world', '!']
注意 ▁ 是 SentencePiece 表示词首空格的特殊字符。
几个工程经验
经验一:词表大小要匹配数据量。如果你训练数据只有几百 MB,词表 32K 已经够大;如果有几 TB 数据(如 Common Crawl 全量),可以用 128K 或更大。词表太大、数据太少 → 罕见 token 学不动。
经验二:字符覆盖率影响多语言。character_coverage=1.0 会保证所有字符都有对应 token;多语言场景常用 0.9995(牺牲极罕见字符以减小词表)。中文为主的场景建议 0.9999 以上。
经验三:从模型特性反推 tokenizer。如果模型主要做代码生成,要确保 tokenizer 对常见编程关键字(function、return、def、class)是单 token;如果主做中文 chat,要让常见中文词是单 token。这些都需要在训练 tokenizer 时调整数据分布或显式 force 添加 token。
经验四:tokenizer 训完不能改。一旦词表确定,就不能给已训好的模型「加新 token」——embedding 表的形状写死了。要扩词表,只能从头训模型(或者用 token expansion 这种特殊技术),代价非常大。
10.9 Tokenizer 选型经验
如果你今天为一个新模型选 tokenizer,下面是经验法则:
-
绝大多数情况选 SentencePiece BPE——多语言友好、可逆、生态成熟。Llama / Mistral / Qwen / DeepSeek 都是这条路线。
-
纯英文场景可以用 GPT 风格 Tiktoken——cl100k_base / o200k_base 是开箱即用的成熟选项,词表已经针对代码、空格、缩进等做了优化。
-
代码 / 数学密集场景:考虑专门词表——常见编程关键字、运算符(
==、!=、++)作为单 token。一些模型(如 Code Llama)会在通用词表基础上扩展几千个代码 token。 -
极致中文场景:用大词表(128K+)让常用中文词单 token 化。DeepSeek 2024 之后的词表是这条路线的代表。
-
embed 模型用什么词表?:通常和它派生的基础模型一致(BGE 用 BERT 的 WordPiece,E5 用 BERT 的 WordPiece)。这是因为 embed 模型常常是 fine-tune 出来的,没法换词表。
-
不要自己魔改 BPE——除非你有非常具体的研究目的。SentencePiece 已经把所有踩过的坑封装好了,自己实现版本会引入新 bug。
10.10 主流模型 tokenizer 速查
把今天主流模型的 tokenizer 信息汇总:
| 模型族 | 算法 | 词表大小 | 实现库 |
|---|---|---|---|
| BERT 系 | WordPiece | 30K | HuggingFace tokenizers |
| GPT-2 / GPT-3 | 字节级 BPE | 50K | tiktoken / HF |
| GPT-3.5 / GPT-4 | 字节级 BPE | 100K (cl100k_base) | tiktoken |
| GPT-4o | 字节级 BPE | 200K (o200k_base) | tiktoken |
| T5 | SentencePiece Unigram | 32K | sentencepiece |
| Llama 1/2 | SentencePiece BPE | 32K | sentencepiece |
| Llama 3 | tiktoken-based BPE | 128K | tiktoken |
| Mistral | SentencePiece BPE | 32K | sentencepiece |
| Qwen 2.5 | tiktoken-based BPE | 152K | tiktoken-style |
| DeepSeek-V3 | 字节级 BPE | 129K | 自实现 |
| ChatGLM 3+ | tiktoken-based BPE | 65K | tiktoken-style |
可以看到一个微妙的变化:Llama 3 之后开源大模型从 SentencePiece 转向 tiktoken-style 实现——主要是为了更好的速度和与 GPT 生态的对齐。但底层算法仍然是 BPE,只是工程实现不同。
本章小结
- Tokenizer 是文本和 token id 之间的桥——它的算法、词表大小直接影响模型表达力、训练成本、推理效率。
- 三类方案:字符级(简单但序列长)、词级(OOV 严重)、子词级(主流)。
- BPE 的核心是「贪心合并最频繁的相邻对」——从字符或字节出发,反复合并最常见的对,直到词表达到目标大小。
- WordPiece 用「似然增益最大」代替「频率最高」——和 BPE 几乎等价,但 BERT 系用它。
- SentencePiece 把空格当普通字符,不做预分词——多语言场景的最佳选择,今天主流大模型几乎全用它(或 tiktoken)。
- Tiktoken 是 GPT-4 的工程加速版 BPE——Rust 实现 + 大词表(100K-200K),中文友好。
- 词表大小要在「序列长度」和「embedding 参数量」之间权衡——主流趋势从 32K 扩到 128K-200K。
- Tokenizer 怪象:模型在 token 层面理解语言,「数字母」「字符级操作」类任务先天受限——「strawberry 有几个 r」是经典案例。
- 训练自己的 tokenizer:用 SentencePiece 库即可,关键参数是 vocab_size、model_type、character_coverage。
- Tokenizer 训完几乎不能改——词表大小写死了 embedding 形状。
第四部分到这里完结。我们从 50 行 Self-Attention 走到了完整的 mini-GPT,再到工业级 tokenizer——读者已经具备了「自己从零搭一个能跑的小语言模型」的全栈能力。
第五部分(11、12、13 章)我们升级视角到「规模化」——Scaling Laws 告诉我们模型、数据、算力之间该怎么配比,MoE 让万亿模型成为可能,长上下文之战推动了 4K → 1M 的演化。
延伸阅读
- Sennrich et al., Neural Machine Translation of Rare Words with Subword Units, ACL 2016——BPE 在 NLP 的经典论文。
- Kudo & Richardson, SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing, EMNLP 2018——SentencePiece 论文。
- Kudo, Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates, ACL 2018——Unigram 模型的提出。
- Radford et al., Language Models are Unsupervised Multitask Learners, 2019——GPT-2 论文,字节级 BPE 的引入。
- HuggingFace 的 tokenizers 库与教程: https://huggingface.co/docs/transformers/tokenizer_summary
- OpenAI tiktoken: https://github.com/openai/tiktoken
- 苏剑林博客《漫话中文分词》系列——非常透彻的中文 tokenization 工程视角。