Transformer 解剖:从 Attention 到推理系统

第 10 章 Tokenizer 工程:BPE / WordPiece / SentencePiece

作者 杨艺韬 · 5,110 字

第 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,把不常见词拆成若干个常见子词,把任何字符都能拆到字节级别兜底。这一章我们把它的来龙去脉讲清楚。

读完这章你能:

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 拼出所有常见词。

步骤如下

  1. 初始化:把所有词拆成字符序列(或字节序列)。每个词后面加一个特殊标记(例如 </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>
  2. 统计每对相邻 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 次
  3. 找出频率最高的对,把它合并成一个新 token。比如 ("e", "s") 频率 2,合并成 es。所有出现这一对的地方都被替换:

    "newest"  → n, e, w, es, t, </w>
    "widest"  → w, i, d, es, t, </w>
  4. 重复 2-3 步,直到词表达到目标大小 V,或者没有可合并的对。

每次合并都会让训练语料里的 token 总数减少。最常见的字符组合先被合并("th"、"the"、"ing" 这种),最终词表里既有单字符(兜底)、又有中等长度子词(处理常见词缀)、又有完整常见词("the"、"and")。

BPE 编码(推理)

训练完得到一个有序的合并规则列表。对一个新词「unbelievably」编码时:

  1. 拆成字符:u, n, b, e, l, i, e, v, a, b, l, y
  2. 按合并规则的顺序,扫描相邻对,找到能合并的就合并。
  3. 一直合并到没有可合并的为止。
  4. 最终得到 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 几乎一致,主要差别在合并准则

具体地,WordPiece 选合并对的标准是:

score(t1,t2)=freq(t1t2)freq(t1)freq(t2)\text{score}(t_1, t_2) = \frac{\text{freq}(t_1 t_2)}{\text{freq}(t_1) \cdot \text{freq}(t_2)}

分子是「合并后的对的频率」,分母是「两个 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 大体一致,但工程上做了几项关键优化:

  1. Rust 实现:核心 BPE 编码用 Rust 写,比 GPT-2 的 Python 实现快 3-6 倍。
  2. Hashing-based merge:用哈希表加速合并规则查找。
  3. 更大词表: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 工程的核心决定,影响以下维度:

词表越大

词表越小

主流模型的词表大小:

模型 词表大小 备注
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」,它需要:

  1. 在 token 序列里识别「r」字符
  2. 但 "str" 里有一个 r,"berry" 里有两个 r——这些在 tokenizer 表示下是黑盒
  3. 模型的训练数据里也没几个例子告诉它「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 操作」(拼写、字符计数、字母组合)的任务,模型都先天受限。

类似的现象:

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 对常见编程关键字(functionreturndefclass)是单 token;如果主做中文 chat,要让常见中文词是单 token。这些都需要在训练 tokenizer 时调整数据分布或显式 force 添加 token。

经验四:tokenizer 训完不能改。一旦词表确定,就不能给已训好的模型「加新 token」——embedding 表的形状写死了。要扩词表,只能从头训模型(或者用 token expansion 这种特殊技术),代价非常大。

10.9 Tokenizer 选型经验

如果你今天为一个新模型选 tokenizer,下面是经验法则:

  1. 绝大多数情况选 SentencePiece BPE——多语言友好、可逆、生态成熟。Llama / Mistral / Qwen / DeepSeek 都是这条路线。

  2. 纯英文场景可以用 GPT 风格 Tiktoken——cl100k_base / o200k_base 是开箱即用的成熟选项,词表已经针对代码、空格、缩进等做了优化。

  3. 代码 / 数学密集场景:考虑专门词表——常见编程关键字、运算符(==!=++)作为单 token。一些模型(如 Code Llama)会在通用词表基础上扩展几千个代码 token。

  4. 极致中文场景:用大词表(128K+)让常用中文词单 token 化。DeepSeek 2024 之后的词表是这条路线的代表。

  5. embed 模型用什么词表?:通常和它派生的基础模型一致(BGE 用 BERT 的 WordPiece,E5 用 BERT 的 WordPiece)。这是因为 embed 模型常常是 fine-tune 出来的,没法换词表。

  6. 不要自己魔改 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,只是工程实现不同。

本章小结

  1. Tokenizer 是文本和 token id 之间的桥——它的算法、词表大小直接影响模型表达力、训练成本、推理效率。
  2. 三类方案:字符级(简单但序列长)、词级(OOV 严重)、子词级(主流)。
  3. BPE 的核心是「贪心合并最频繁的相邻对」——从字符或字节出发,反复合并最常见的对,直到词表达到目标大小。
  4. WordPiece 用「似然增益最大」代替「频率最高」——和 BPE 几乎等价,但 BERT 系用它。
  5. SentencePiece 把空格当普通字符,不做预分词——多语言场景的最佳选择,今天主流大模型几乎全用它(或 tiktoken)。
  6. Tiktoken 是 GPT-4 的工程加速版 BPE——Rust 实现 + 大词表(100K-200K),中文友好。
  7. 词表大小要在「序列长度」和「embedding 参数量」之间权衡——主流趋势从 32K 扩到 128K-200K。
  8. Tokenizer 怪象:模型在 token 层面理解语言,「数字母」「字符级操作」类任务先天受限——「strawberry 有几个 r」是经典案例。
  9. 训练自己的 tokenizer:用 SentencePiece 库即可,关键参数是 vocab_size、model_type、character_coverage。
  10. Tokenizer 训完几乎不能改——词表大小写死了 embedding 形状。

第四部分到这里完结。我们从 50 行 Self-Attention 走到了完整的 mini-GPT,再到工业级 tokenizer——读者已经具备了「自己从零搭一个能跑的小语言模型」的全栈能力。

第五部分(11、12、13 章)我们升级视角到「规模化」——Scaling Laws 告诉我们模型、数据、算力之间该怎么配比,MoE 让万亿模型成为可能,长上下文之战推动了 4K → 1M 的演化。

延伸阅读