大模型进食的“咀嚼”艺术:深入浅出 Tokenization(BPE、WordPiece 与 SentencePiece)

当我们在与 ChatGPT、Claude 或是文心一言对话时,我们输入的是人类熟悉的自然语言文本,但在大语言模型(LLM)那庞大的神经网络背后,它并不能直接“读懂”这些文字。模型的眼中没有古诗和代码,只有高维空间中流淌的向量。

那么,人类的文字是如何跨越这道鸿沟,转化为模型能够理解的数学表示的呢?这就要归功于大模型处理文本的第一道关卡,也是至关重要的基石——Tokenization(分词)

如果把大模型比作一个需要进食的庞然大物,那么 Tokenization 就是它咀嚼食物的牙齿。咀嚼得好不好,直接决定了营养(信息)吸收的效率。今天,我们就来深度解析大模型时代最核心的几种 Tokenization 算法:BPE、WordPiece 与 SentencePiece,揭开它们背后的设计哲学与工程实现。


一、 为什么我们需要 Tokenization?

在深入算法之前,我们需要先明白一个基础问题:为什么我们需要分词?直接把整句话或者单个字母丢给模型不行吗?

实际上,自然语言处理(NLP)历史上经历过三种主要的文本切分粒度,每种都有其明显的优缺点:

1. 词级别

最直观的方法,按空格和标点把句子切成单词。

  • 优点:符合人类直觉,语义保留完好。例如 "I love apples" 切分为 ["I", "love", "apples"]
  • 缺点词表极其庞大。英语还好,但在中文里,词的组合千变万化。更致命的是,面对未见过的词(OOV,Out-of-Vocabulary,如拼写错误的 "appleeeee"),模型只能被迫分配一个 <UNK>(未知)标记,导致信息丢失。此外,像 "unfriendly" 这样的词,明明带有 "un-" 前缀,却被当成一个毫无关联的整体。

2. 字符级别

把句子切分成单个字符,甚至字母。

  • 优点:词表极小(英文只需 26 个字母加一些符号,中文常用汉字几千个),彻底消除了 OOV 问题。
  • 缺点序列过长,且缺乏高级语义"apple" 变成了 ["a", "p", "p", "l", "e"],模型为了理解一个简单的单词,需要处理更长的序列,这在 Transformer 架构中会导致计算量呈平方级爆炸。

3. 子词级别—— 最优解

为了折中上述两种方法的缺陷,子词分词应运而生,也是目前大模型的绝对主流。它的核心理念是:

  • 高频词保留为整词(如 "apple")。
  • 低频词拆解为有意义的子词或字符(如 "unfriendly" 拆分为 "un", "friend", "ly")。
  • 通过这种方式,既能控制词表大小在合理的范围内(通常在 3万 到 15万 之间),又能有效处理几乎所有的 OOV 问题。

接下来,我们将详细介绍统治大模型领域的三大子词分词算法。


二、 BPE (Byte-Pair Encoding):GPT 系列的“绝对主力”

BPE 最初是一种古老的数据压缩算法,在 2015 年被 Sennrich 等人引入到 NLP 领域。目前,OpenAI 的 GPT 系列(GPT-2, GPT-3, GPT-4)、Meta 的 LLaMA 等绝大多数主流大模型,都采用了 BPE 或其变体。

1. BPE 的核心思想

BPE 的核心逻辑非常简单:寻找文本中出现频率最高的一对相邻 Token,将它们合并为一个新的 Token,不断重复这个过程,直到达到预设的词表大小。

2. BPE 算法演练(手把手推演)

假设我们有一段微型语料库(已经进行了初始的词频统计,并按字符切分,词尾加上了 </w> 标记表示单词结束):

  • low 出现了 5 次 -> l o w </w> : 5
  • lower 出现了 2 次 -> l o w e r </w> : 2
  • newest 出现了 6 次 -> n e w e s t </w> : 6
  • widest 出现了 3 次 -> w i d e s t </w> : 3

第一步:统计相邻字符对的频率
我们统计所有相邻字符对在整个语料库中出现的总次数:

  • es 相邻出现了 6 (newest) + 3 (widest) = 9 次。
  • st 相邻出现了 9 次。
  • (其他组合如 lo 出现了 7 次等等)

第二步:合并最高频的字符对
假设 es 出现频率最高(9次),我们将它们合并为 es。此时语料库更新为:

  • l o w </w> : 5
  • l o w e r </w> : 2
  • n e w es t </w> : 6
  • w i d es t </w> : 3

第三步:不断重复(迭代)
接下来,新的最高频组合变成了 est(它们总是相邻,出现了9次)。我们将它们合并为 est

  • l o w </w> : 5
  • l o w e r </w> : 2
  • n e w est </w> : 6
  • w i d est </w> : 3

如此循环往复,比如下一步可能会合并 lo 得到 lo,再合并 low 得到 low。当词表大小达到我们预设的阈值(例如 50000)时,迭代停止。

3. BPE 的编解码机制

  • 编码:当遇到一个新词时,比如 "lowest",BPE 会按照训练时生成的合并规则的顺序,从左到右进行贪婪匹配。先合并 es,再合并 est,最终切分为 ["low", "est"]
  • 解码:将所有的 Token 拼接起来,并将特殊的结束标记 </w> 替换为空格即可还原文本。

4. Python 代码实现:从零构建一个简单的 BPE

为了更好地理解,我们用几十行 Python 代码来模拟 BPE 的训练过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import re
from collections import defaultdict

def format_word(word, end_of_word='</w>'):
"""将单词拆分为字符序列,并添加结束符"""
return list(word) + [end_of_word]

def get_pairs(corpus):
"""统计语料库中相邻token的频率"""
pairs = defaultdict(int)
for word, freq in corpus.items():
symbols = word.split()
for i in range(len(symbols) - 1):
pairs[(symbols[i], symbols[i+1])] += freq
return pairs

def merge_vocab(pair, corpus):
"""将语料库中的特定字符对合并为一个新的token"""
new_corpus = {}
# 正则表达式,用于匹配需要合并的相邻字符
p = re.compile(r'(?<!\S)' + re.escape(' '.join(pair)) + r'(?!\S)')
for word in corpus:
# 将匹配到的 pair 替换为合并后的新词
new_word = p.sub(''.join(pair), word)
new_corpus[new_word] = corpus[word]
return new_corpus

# 初始语料库 (单词及其频率,单词内字符已用空格分隔)
corpus = {
'l o w </w>': 5,
'l o w e r </w>': 2,
'n e w e s t </w>': 6,
'w i d e s t </w>': 3
}

num_merges = 10 # 设定合并次数

for i in range(num_merges):
pairs = get_pairs(corpus)
if not pairs:
break
# 找到频率最高的字符对
best_pair = max(pairs, key=pairs.get)
# 更新语料库
corpus = merge_vocab(best_pair, corpus)
print(f"Step {i+1}: 合并了 {best_pair} -> {''.join(best_pair)}")
print(f"更新后的语料库: {dict(corpus)}\n")

三、 WordPiece:BERT 的“拼图游戏”

WordPiece 算法最早由 Google 团队提出,并在著名的 BERT 模型中大放异彩。它和 BPE 非常相似,也是一种基于子词的分词方法,但在合并策略上有着本质的区别。

1. BPE 与 WordPiece 的区别

  • BPE 选择合并的对象:出现频率最高的相邻 Token 对。
  • WordPiece 选择合并的对象:能够最大化语言模型似然度(Likelihood)的相邻 Token 对,也就是互信息最大的对。

2. 什么是“最大化似然度”?

通俗地讲,WordPiece 在考虑是否将 AB 合并为 AB 时,它不是单纯看 AB 在一起出现了多少次,而是看合并后的 AB 这个整体,是否比 AB 单独出现时的独立性概率乘积要高出多少。

公式表达为:

Score(A,B)=P(AB)P(A)×P(B)Score(A, B) = \frac{P(AB)}{P(A) \times P(B)}

为什么要这么做?
假设在语料库中,th 这个组合出现了 1000 次,er 出现了 1500 次,而 the 出现了 900 次。
如果按照 BPE 的逻辑,可能会优先合并 er
但 WordPiece 会认为,er 单独出现的概率也很高,所以它们组合在一起并不稀奇;相反,th 组合成 th 的概率,远远大于 th 单独出现概率的乘积。这说明 th 是一个非常紧密、不可分割的语言单元,应该优先合并 th

因此,WordPiece 倾向于找出那些**“真正应该属于一起”**的词根、前缀和后缀。

3. 词表前缀标记 ##

WordPiece 在处理文本时有一个非常明显的特征:为了区分一个 Token 是词的开头,还是词的后续部分,它使用了 ## 前缀。

例如,对于单词 "unwanted"

  • 它可能会被切分为:["un", "##want", "##ed"]
  • "un" 作为词的开头,没有前缀。
  • "##want""##ed" 表示它们不能独立作为一个单词的开头,只能附属于前面的词。

这种机制在处理英文等以空格分隔的语言时非常优雅。


四、 SentencePiece:一统多语言天下的“瑞士军刀”

无论是基础的 BPE 还是 WordPiece,它们都有一个隐含的前提假设:文本是由空格和标点天然分隔的(主要针对英语等印欧语系)。

但是,当我们面对中文、日文、韩文(CJK 字符)时,这个假设就崩塌了。中文是没有空格的!如果在训练大模型前,我们必须先调用一次 Jieba 分词或 HanLP 等外部工具对中文进行预处理,这不仅会增加工程复杂度,还会因为外部分词工具的误差导致大模型的学习受限。

为了解决这个问题,Google 开源了 SentencePiece。它是一种与语言无关的纯数据驱动的分词框架,广泛应用于 LLaMA、ChatGLM、BART 等现代多语言大模型中。

1. SentencePiece 的核心革命:把空格也当字符

SentencePiece 的最伟大之处在于,它彻底抛弃了“空格即边界”的成见
在 SentencePiece 的眼中,无论什么语言的文本,都只是一长串纯粹的字符流。空格只不过是一个名叫 (U+2581) 的特殊字符(这个符号叫 Lower One Eighth Block,视觉上像一个小黑块或下划线)。

  • 输入:"Hello world"
  • SentencePiece 内部处理:"▁Hello▁world"(去掉了原始空格,替换为特殊字符

这样一来,中文的 "你好世界" 和英文的 "Hello world" 在算法面前没有任何区别,算法可以直接在统一的字符序列上进行 BPE 或 Unigram(SentencePiece 默认使用的另一种子词算法)训练,完美实现了跨语言的统一处理。

2. Unigram Language Model (ULM)

虽然 SentencePiece 支持加载 BPE 模式,但它的默认核心算法是 Unigram Language Model
与 BPE 的“自底向上(从字符不断合并成词)”不同,ULM 是**“自顶向下”**的:

  1. 它首先用一个巨大的词表(比如几百万个候选子词)初始化。
  2. 通过 EM 算法(期望最大化)评估每个子词在现有语料上的概率。
  3. 不断剪枝,剔除那些对总体似然度贡献最小的子词,直到词表缩小到目标大小(如 32000)。
  4. 在编码时,ULM 会计算出同一个单词多种切分方式的概率,选择概率最大的那一种(通常使用 Viterbi 算法寻找最优路径)。

3. SentencePiece 代码实战

让我们看看如何用 sentencepiece 库训练一个支持中英文混合的分词器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 安装: pip install sentencepiece
import sentencepiece as spm

# 假设我们有一份混合了中英文的语料文本文件 'mixed_corpus.txt'
# 内容举例: "大模型的发展非常迅速。GPT-4 is amazing!"

# 训练 SentencePiece 模型
spm.SentencePieceTrainer.train(
input='mixed_corpus.txt',
model_prefix='my_llm_tokenizer', # 输出模型前缀
vocab_size=32000, # 词表大小,大模型常用 32k, 64k 等
model_type='unigram', # 可选 'unigram' (默认) 或 'bpe'
character_coverage=0.9995, # 字符覆盖率,中文通常设为 0.9995,英文可设为 1.0
pad_id=0, # 设置特殊 token 的 ID
unk_id=1,
bos_id=2,
eos_id=3,
)

# 加载训练好的模型进行分词
sp = spm.SentencePieceProcessor()
sp.load('my_llm_tokenizer.model')

# 测试中文文本
text_zh = "大模型正在改变世界。"
print("中文 Tokens:", sp.encode_as_pieces(text_zh))
# 输出示例: ['▁大', '模型', '正在', '改变', '世界', '。']

# 测试英文文本
text_en = "Hello world!"
print("英文 Tokens:", sp.encode_as_pieces(text_en))
# 输出示例: ['▁He', 'll', 'o', '▁world', '!']

# 转换为 ID
print("中文 IDs:", sp.encode_as_ids(text_zh))

五、 进阶探讨:大模型时代的 Tokenizer 进化

在了解了三大基础分词器后,我们来看看在当今动辄千亿参数的大模型时代,Tokenizer 有哪些值得关注的工程细节和进化方向。

1. 令人抓狂的“Tokenizer 痛点”:Token 瘟疫

你可能遇到过这样一种情况:当你让 GPT-3 数数或者拼写单词时,它表现得像个笨蛋。比如它很难数清楚单词 "strawberry" 里有几个 r

罪魁祸首就是 Tokenizer!
在 GPT-3 的 BPE 字典里,"strawberry" 可能被完整地切分为了一个单独的 Token ["strawberry"],而不是 ["s", "t", "r", "a", "w", "b", "e", "r", "r", "y"]。既然在模型眼中它就是一个不可分割的符号,它自然就不知道里面包含了几个 r

2. Tiktoken:OpenAI 的速度引擎

到了 GPT-3.5 和 GPT-4,OpenAI 换用了基于 BPE 的 tiktoken 库。
与传统的 SentencePiece 或 HuggingFace Tokenizer(主要基于 Rust 编写)不同,tiktoken 是一个高度优化的纯 Python 库,它的运算速度极快。它通过将所有合并规则放入一个巨大的字典映射中,牺牲了一点内存,换取了极高的吞吐量,这对于需要处理海量并发请求的 ChatGPT 来说至关重要。

3. 多语言与代码的平衡术

如果你观察 LLaMA-1 的分词器,你会发现它在处理中文时非常吃力。因为它的训练语料绝大部分是英文,导致一个中文字符往往被切分成好几个无意义的 UTF-8 字节 Token。这不仅消耗了极其宝贵的 Context Window(上下文窗口),还削弱了模型对中文的理解能力。

为了解决这个问题,后来的开源模型(如 QwenBaichuanChatGLM)在训练 Tokenizer 时,大幅增加了中文高质量语料的比例,甚至在词表中硬性增加了数万个中文字符,使得大模型对中文的压缩率大幅提升。同样的句子,使用的 Token 数量减少了,模型就能“看到”更多的上下文,智能水平也随之提升。


六、 总结

Tokenization 是连接人类语言与机器数学世界的桥梁。从基础的字符切分,到如今大模型中百花齐放的子词算法,分词技术的演进反映了 NLP 领域对“如何最高效地传递语义”这一核心问题的不断探索。

让我们用一张表来总结三大核心算法的特点:

算法 代表模型 合并策略 特点 空格处理
BPE GPT-2, LLaMA 最高频的相邻字符对 简单高效,纯统计驱动 需要预分词,保留空格边界
WordPiece BERT 基于似然度最大化(互信息) 倾向合并语义强关联的词根 预分词,使用 ## 标记非词首子词
SentencePiece LLaMA-2, T5, GLM 支持 Unigram (默认) 和 BPE 语言无关,支持多语言统一处理 将空格转为特殊字符 ,无需预分词

随着 Byte-level 模型(如 MegaByte)和多模态大模型的兴起,未来我们可能会逐渐淡化传统的 Tokenizer,转而让模型直接从原始字节甚至音频波形中学习。但不可否认的是,在现阶段和可预见的未来,BPE、WordPiece 和 SentencePiece 构成的子词分词体系,依然是支撑大模型智能爆发的最坚实的基石。

理解了 Tokenization,你就真正迈出了看懂大模型底层机制的第一步。