AI/ML 详解:Tokenization 深度

Day 11 · 2026-05-28 · 难度 ★★★☆☆
面向:有编程经验的非 AI 方向工程师

字节对编码BPE · Byte Pair Encoding

子词算法压缩起源
一句话类比

BPE 本质是一个贪心的字典压缩算法——和 gzip / LZ 系列"把高频字节序列替换成短码"压缩文件是同一个祖先(1994 年 Gage 的数据压缩算法)。区别只在优化目标:压缩软件让文件变小,BPE 让词表够用又不爆炸。Sennrich 等 2016 年把它从压缩界搬进了 NLP。

它解决什么问题 + 工作机制

两个极端都不行。词级 vocab(每词一个 token):英语几十万词 + 拼写变体 + 新词,词表无限大,且遇到训练时没见过的词(OOV,out-of-vocabulary,未登录词)直接歇菜。字符级:词表只剩几十个字符,但一句话炸成几百个 token,而 attention 是 O(n²),序列一长就吃不消。BPE 取中间点:常见词整体成一个 token,罕见词拆成子词片段

机制是贪心合并:① 初始词表 = 所有单字符;② 统计语料里相邻 token 对的出现频率;③ 把最高频的一对合并成新 token 加入词表;④ 重复 ②③ 直到词表达到目标大小(如 5 万)。训练产物就是一张有序的 merge 规则表,推理时按同样顺序套用:

语料(先拆成字符): low low low lower newest widest

第 1 轮:最高频对 e + s 合并出 es
第 2 轮:最高频对 es + t 合并出 est
第 3 轮:最高频对 l + o 合并出 lo
第 4 轮:lo + w low  (于是 "low" 成了一个整 token)

词表 = [单字符...] + es + est + lo + low ... "newest" → low? 不,→ "new"+"est"
代码示例
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace

# 1) 空 BPE,先按空格预切分(BPE 假设已分词)
tok = Tokenizer(BPE(unk_token="[UNK]"))
tok.pre_tokenizer = Whitespace()

# 2) 训练:目标词表 5000,反复合并高频对
trainer = BpeTrainer(vocab_size=5000, special_tokens=["[UNK]"])
tok.train(["corpus.txt"], trainer)   # 产物 = 有序 merge 规则表

# 3) 编码:把同一套 merge 规则套到新文本
out = tok.encode("tokenization")
print(out.tokens)   # 可能 → ['token','ization'],也可能拆得更碎
常见误区 + 实践场景
"BPE 按语言学词根拆词"——错。BPE 纯按频率统计,"tokenization" 拆成 "token"+"ization" 只是因为这两段高频,和语法/词根无关;换个语料可能拆得很怪。这也是模型对罕见词"理解"时常显得别扭的底层原因。
📌 妈妈场景:估算 prompt 成本时,先用 tokenizer 数 token——同一句话,用更主流(高频)的措辞往往切出更少 token,既省钱又减少模型"看走眼"的概率。
Takeaway + 思考题
💡 BPE 是"频率驱动的压缩",不是"语言学分词"——它对语义一无所知,只认统计。
🤔 如果 token 边界由频率而非含义决定,模型对"低频但语义重要"的术语(你的领域黑话)会有什么系统性劣势?

WordPieceWordPiece · BERT 系

子词算法BERT
一句话类比

WordPiece 和 BPE 是"同一套贪心合并框架,换了打分函数"——好比两个数据库都用 B-tree,但一个按访问频率决定缓存什么,另一个按收益/成本比决定。BPE 看绝对频率,WordPiece 看互信息式的增益。来源是 Schuster & Nakajima 2012(日韩语音搜索),被 BERT (Devlin et al. 2018) 发扬光大。

它解决什么问题 + 工作机制

BPE 合并"出现最多的对"有个毛病:两个各自都高频、只是碰巧常相邻的片段(如 "the" 后面跟各种词),会被错误地优先合并。WordPiece 改成合并"合并后最能提升语料似然"的对,打分公式:

score(a, b) = freq(ab) / ( freq(a) × freq(b) )

直觉:分子是这对一起出现的频率,分母是两者各自单独的频率。如果 a、b 只是各自高频却随机相邻,分母很大、得分低、不合并;只有当 a、b 强绑定(合体远比拆开常见)时得分才高。这正是统计学的点互信息(PMI)思想——衡量"两个东西一起出现,是否超过纯随机"。

推理时用最长匹配贪心切分,词内续接的子词用 ## 前缀标记(playing → play + ##ing),这样能无损还原原文:带 ## 的接前面、不带的前面补空格。

代码示例
from transformers import BertTokenizer
tok = BertTokenizer.from_pretrained("bert-base-uncased")

# ## = "紧接前一个子词,中间无空格",用于无损还原
print(tok.tokenize("playing tokenization"))
# 示意 → ['playing', 'token', '##ization']
# 高频词整体保留,罕见词被拆成一串 ##子词

print(tok.tokenize("antidisestablishment"))
# 越罕见 → ## 碎片越多(实际切法取决于词表)
常见误区 + 实践场景
"## 是 BERT 输出里的乱码"——错。## 是 WordPiece 标记"这是词内续接片段",是无损还原的关键,不是噪声。把它当乱码删掉,detokenize 时空格就会错位。
📌 妈妈场景:用 BERT / 句向量模型做语义检索时,理解 ## 切分能帮你诊断——为什么某些专有名词、代码标识符的 embedding 质量差?因为它们被切成一堆无意义的 ##碎片,语义被稀释了。
Takeaway + 思考题
💡 BPE 比"谁出现最多",WordPiece 比"谁绑定最紧"——后者用 PMI 思想抑制了"高频却无关"的伪合并。
🤔 同一份语料,BPE 和 WordPiece 训出的词表会不同。这种差异会怎样影响下游模型对"复合词 / 新造词"的处理?

SentencePiece 与 UnigramSentencePiece · Unigram LM

框架语言无关
一句话类比

BPE / WordPiece 都预设你能先按空格切词——这个隐藏假设对中文 / 日文(本就没有空格分词)直接崩。SentencePiece 把整句话当成一串原始 Unicode 流处理,连空格也当普通字符(用 ▁ 表示)。类比:BPE 像"先按分隔符 split 再解析"的解析器,SentencePiece 像直接在原始字节流上跑的无 schema 解析器——不依赖任何语言的分词规则,因此能无损还原(detokenize 回原文一字不差)。

它解决什么问题 + 工作机制

先厘清一个常见混淆:SentencePiece 是框架 / 工具(Google 开源),内部可跑 BPEUnigram 两种算法。Unigram 是它的默认、也是最大贡献(Kudo 2018)。

Unigram 的方向和 BPE 正好相反:BPE 是自底向上合并(从字符往上拼),Unigram 是自顶向下裁剪——

  • ① 先建一个很大的候选子词集;
  • ② 给每个子词一个概率,把一句话的所有可能切分按概率打分;
  • ③ 用 EM 算法迭代,每轮删掉对总似然贡献最小的一批子词;
  • ④ 直到词表缩到目标大小。

好处:每个 token 自带概率,同一个词允许多种合法切分——这被用作 subword regularization(训练时随机采样不同切法做数据增强)。T5、LLaMA、Gemma 等都用 SentencePiece。

预分词假设的差别

BPE / WordPiece:先按空格切词 子词合并 (中文无空格 → 崩)
SentencePiece: 原始流 + 空格当 ▁ 子词 (中英日统一,可无损还原)

"机器学习 hello" → ['▁机','器','学','习','▁hello'] ▁ 记录了空格位置
代码示例
import sentencepiece as spm

# 训练 unigram:直接吃原始文本,无需预分词
spm.SentencePieceTrainer.train(
    input="corpus.txt", model_prefix="m",
    vocab_size=8000, model_type="unigram")  # 也可 "bpe"

sp = spm.SentencePieceProcessor(model_file="m.model")
print(sp.encode("机器学习 hello", out_type=str))
# 空格 → ▁,中英文统一处理
print(sp.decode(sp.encode("机器学习 hello")))  # == 原文(无损)
常见误区 + 实践场景
"SentencePiece 是一种 tokenization 算法"——不准确。它是框架,里面装的是 BPE 或 Unigram。说"这模型用 SentencePiece"只说了一半,还得问"用的哪种算法"。
📌 妈妈场景:跨学科阅读常涉及多语种术语(拉丁文、德文哲学词、日文佛学概念)。基于 SentencePiece 的模型(LLaMA / Gemma)对非英语的 token 效率,通常优于纯英语训练的 BPE——选模型处理多语内容时值得纳入考量。
Takeaway + 思考题
💡 BPE 自底向上"拼",Unigram 自顶向下"裁";SentencePiece 的真正突破是取消"必须先分词"的假设,让 tokenizer 语言无关、可逆。
🤔 "可无损还原"为什么重要?想想代码生成、JSON 输出这类一个字符都不能错的场景。

字节级 BPE 与 UTF-8 边界Byte-level BPE · UTF-8 Boundary · Vocab 权衡

字节回退词表权衡
一句话类比

再聪明的子词算法也会撞上训练时没见过的字符(生僻字、emoji、新符号)。byte-level BPE 的解法像编程里"永远有 fallback 到原始字节":任何字符在 UTF-8 下最终都是 1–4 个字节,而字节只有 256 种——把这 256 个塞进基础词表,就永不 OOV。GPT-2 起就用这招。

它解决什么问题 + 工作机制

代价是UTF-8 边界问题:一个中文字 = 3 个 UTF-8 字节,一个 emoji 可能 4 字节,而 token 边界可能切在一个字符的字节中间。两个反直觉后果:

UTF-8 边界:一个字被切碎

"你好" UTF-8 字节: E4 BD A0E5 A5 BD (每字 3 字节)
token 切分可能落在: [E4 BD] [A0 E5 A5] [BD] ← 字符被切碎

流式输出: 你 好 (半个字节先到,凑齐才显示)
  • ① 流式乱码:逐 token 吐字时,半个汉字的字节先到、渲染成 �,等后半截到了才拼成完整字(你偶尔看到的流式输出闪现方块就是它);
  • ② 模型"看不见字符":模型操作的是 token / 字节,不是人类的"字母"。所以"strawberry 有几个 r""把这个词倒着拼"这类任务模型常错——它根本没有"字符"这个表示层。

Vocab 大小的权衡(核心 trade-off,纯机制非调参):

  • 词表大:序列短(token 少)→ 推理快、context 装得多、成本低; embedding 矩阵 = V × d_model 线性变大(V 翻 4 倍这块参数也翻 4 倍),且罕见 token 训练样本少、学不透;
  • 词表小:省参数,但同样文本切出更多 token,序列变长,O(n²) 成本上升。

趋势是词表越做越大:Llama 2 = 3.2 万,Llama 3 = 12.8 万,GPT-4(cl100k)≈ 10 万,GPT-4o(o200k)≈ 20 万。主因是多语言效率——非英语常用 2–4 倍 token(俗称 "tokenization tax,分词税"),大词表能缓解。

代码示例
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")  # GPT-4 的词表

ids = enc.encode("你好🌍")
print(len(ids), ids)         # 中文/emoji 往往多个 token

# 单个汉字在 UTF-8 下是 3 字节
print("你".encode("utf-8"))  # b'\xe4\xbd\xa0' —— 3 字节

# 256 个字节 token 兜底,所以永不 OOV
for t in ids:
    print(t, enc.decode_single_token_bytes(t))
常见误区 + 实践场景
"1 token ≈ 1 词 / 1 汉字"——错。英文约 1 token ≈ 0.75 词;中文常 1 字 ≈ 1–2 token(视词表而定)。用英文 token 经验外推中文成本,往往严重低估。
📌 妈妈场景:估算 API 成本和 context 预算时,中文内容的 token 数往往比直觉多得多。先用 tiktoken / SDK 实测一段代表性文本的 token 数,再按比例外推——别拿英文经验拍脑袋。
Takeaway + 思考题
💡 字节回退保证"永不 OOV",代价是字符被切碎——模型的世界里没有"字母",只有 token 和字节。
🤔 既然模型看不见字符,那它能流利拼写、能写正确代码,靠的到底是什么?(提示:海量数据里"字符级模式"被间接编码进了 token 共现统计)

深入资源Further Reading

深入思考Deep Questions

1. BPE、WordPiece、Unigram 三者本质都在做"把语料切成多少种最优单元",这和你熟悉的数据库索引设计 / 缓存键设计有什么共通的取舍?
核心同构:都是在固定预算下选一组"复用单元",最大化整体效率。索引设计要选"建在哪些列上"——索引太多写入慢、占空间,太少查询慢;tokenizer 选"词表里放哪些子词"——词表太大 embedding 矩阵爆、罕见 token 学不透,太小序列变长。缓存键设计要选"缓存粒度"——粒度太细命中率高但管理开销大,太粗命中率低;子词粒度同理,字符级"命中率"高(永不 OOV)但序列长,词级序列短但 OOV 频发。三种算法是三种选择策略:BPE = 贪心按频率(像 LFU 缓存),WordPiece = 按互信息增益(像"收益/成本比"驱逐),Unigram = 全局概率优化后裁剪(像先全建索引再按查询代价裁)。BigCat 你设计分布式系统时的"热/冷数据分层""索引选择性"直觉,迁移到理解 tokenizer 几乎是一一对应的。
2. "模型看不见字符"导致它数不清 strawberry 里几个 r。这是 tokenization 的根本缺陷,还是可以靠数据/训练绕过的工程问题?
两者都有,但根子在 tokenization。模型的输入是 token id,"r" 这个字符从未作为独立单元进入模型——它被埋在 "straw"、"berry" 这类 token 的整体向量里。所以"数字母""反转字符串"这种字符级操作对模型是间接推理,不是直接读取,自然容易错。能绕过到什么程度?(a) 数据层面:训练语料里包含大量"拼写、字母游戏、字符计数"的例子,模型能把字符级模式间接编码进 token 共现统计,于是高频词拼得准;(b) 长尾 / 罕见词 / 人造词仍会崩,因为没有足够共现信号;(c) 架构层面的真正解法是 character-level / byte-level 模型(如 ByT5、近年的 BLT),直接吃字节、没有 token,但代价是序列长、算力贵。所以现状是工程妥协:用 token 换效率,用海量数据补字符能力,剩下的长尾交给"模型偶尔会错"。这也提醒:把字符级精确任务(校验码、格式化)外包给模型前,先问它"看得见"这个粒度吗。
3. 词表越做越大(Llama 2 的 3.2 万 → Llama 3 的 12.8 万)。这个趋势会一直持续吗?什么力量在推、什么力量在拉?
推力(往大走):(a) 多语言——非英语的 "tokenization tax" 靠大词表缓解,覆盖更多语言/脚本就需要更多子词;(b) 长 context 效率——同样文本 token 更少,等于变相扩大有效 context、降低 O(n²) 成本;(c) 代码 / 结构化文本——把常见代码片段、缩进、符号收进词表能大幅缩短序列。拉力(往小拽):(a) embedding + 输出 softmax 矩阵 = V × d,V 翻倍这两块参数翻倍,对小模型尤其是显存负担;(b) 罕见 token 欠训练——词表越大,长尾 token 见到的训练样本越少,学不透甚至成为"幽灵 token"(训练里几乎没出现,推理时行为诡异);(c) 输出层计算——每步都要在整个词表上算 softmax,V 越大越慢。所以不会无限大,会收敛到一个由模型规模、目标语言分布、部署成本共同决定的甜点区。当前大模型多落在 10 万–25 万。更激进的方向是干脆取消固定词表(byte-level / 动态分词),把这个 trade-off 从"选词表大小"变成"模型自己学切分"。
4. SentencePiece 强调"无损可逆"(detokenize 完美还原原文)。为什么早期 tokenizer 做不到?这个"可逆性"对哪些下游任务是生死攸关的?
早期 tokenizer 不可逆,是因为它们先做了破坏性的预处理:按空格 split(丢了"几个空格""哪种空白符"的信息)、小写化、去标点、规范化 Unicode——这些步骤把原文的一部分信息永久丢弃,detokenize 时只能"猜"着拼回去(典型如"标点前该不该有空格"的启发式规则,遇到边缘情况就错)。SentencePiece 的关键设计:把空格也当成普通字符(▁)纳入建模,整个流程不做任何丢信息的预处理,于是 encode→decode 是严格的双射。可逆性生死攸关的场景:(a) 代码生成——空格 / 缩进 / 制表符错一个,Python 直接语法报错;(b) 结构化输出(JSON / XML / YAML)——格式字符不能错;(c) diff / patch 生成——必须逐字符精确;(d) 富文本 / markdown——空白和换行有语义;(e) 非英语 + 混合脚本——预处理的"规范化"常破坏 CJK / 阿拉伯文。一句话:凡是"输出会被机器再解析"的任务,tokenizer 的有损性就是隐藏的 bug 源。
5. tokenization 是"人类符号"和"模型数字"之间的翻译层。把它放到更大的认知视角——这个翻译层的存在,对我们理解"模型到底懂不懂语言"有什么意涵?
这是个深问题,触及符号接地(symbol grounding)。tokenizer 把文字压成一串整数 id,模型从未接触"字"或"音",只接触这些 id 在海量文本里的共现统计。意涵有几层:(a) 模型的"语言"是统计几何,不是符号语义——它"懂"strawberry 不是因为认得 s-t-r-a-w...,而是这个 token 的向量在高维空间里和"水果""红色""甜"等向量的几何关系;这和人类"先学发音再学含义"的路径完全不同;(b) tokenization 是一道认知滤镜——它预先决定了模型能感知的最小单元,凡是被切碎的(罕见词、字符级结构、非主流语言)模型先天"近视",这不是模型不努力,是输入端就丢了分辨率;(c) 对比人类——人类同时持有字符、音节、词、概念多个粒度,可自由切换(所以你能玩文字游戏、能拆字、能听音辨义),而模型被锁死在 tokenizer 给的单一粒度上。BigCat 你关注意识与认知——这里有个耐人寻味的对照:当我们说模型"理解"语言,很大程度上是它在一个被 tokenizer 强制定义的离散符号空间里做几何运算。理解的"边界",某种意义上早在分词那一步就被画好了。这也是为什么有人认为:下一代架构若想更接近人类的语言能力,可能要先打破"固定 tokenization"这层天花板。