大模型通信的基石:万字深度解析 Tokenization(BPE、WordPiece 与 SentencePiece)

引言:大模型是如何“阅读”文字的?

当我们向 ChatGPT、LLaMA 或 GLM 提问时,我们输入的是人类自然语言(如中文、英文)。然而,对于这些拥有千亿参数的大型语言模型(LLM)来说,它们的世界里没有“文字”,只有“数字”。

将人类的自然语言转换为模型能够理解的数字序列,这个过程就是 Tokenization(分词)

很多初学者甚至部分开发者往往将注意力全部集中在 Transformer 架构、注意力机制或 RLHF(基于人类反馈的强化学习)上,而忽视了 Tokenization 的重要性。事实上,Tokenization 是大模型与真实世界交互的咽喉要道。一个糟糕的 Tokenizer 会导致模型无法处理多语言、无法进行基本的拼写检查、对数学公式逻辑混乱,甚至产生严重的“乱码”问题(例如早年模型无法正确拼写特定的单词,往往就是 Tokenization 挖的坑)。

在这篇文章中,我们将深入剖析大模型时代最主流的 Tokenization 算法:BPE(Byte-Pair Encoding)WordPiece 以及 SentencePiece。我们将探讨它们的核心思想、数学原理、代码实现,并横向对比它们在现代大模型中的应用。


一、 Tokenization 的演进:从词到子词

在深入算法之前,我们需要理解为什么需要这些复杂的算法。

1. 传统方法的困境

在 NLP 的早期,分词方法非常直观:

  • 词级别: 以空格和标点为界限切分(如 I love AI -> ["I", "love", "AI"])。
    • 痛点: 词表会无限膨胀,遇到生僻词只能用 <UNK>(未知词)代替;词与词之间的形态学关系(如 runrunning)被完全割裂。
  • 字符级别: 将每个字母/汉字单独切分(如 apple -> ["a", "p", "p", "l", "e"])。
    • 痛点: 序列变得极其漫长,模型难以捕捉长距离的语义依赖;且单个字符往往没有实际意义(如字母 a 无法表达完整含义)。

2. 子词分词的崛起

为了平衡“词级别”和“字符级别”的优缺点,子词分词 成为了现代 LLM 的绝对主流。

其核心思想是:高频词保留为完整词,低频词拆分为有意义的子词或字符。
例如,单词 unbelievable 可能会被切分为 ["un", "believ", "able"]。这样做的好处是:

  1. 彻底解决 OOV(Out-Of-Vocabulary)问题:任何新词都可以用有限的字符集拼凑出来。
  2. 大幅缩小词表规模:从词级别的几十万,缩减到 3 万到 10 万左右,降低模型 Embedding 层的计算压力。

目前,主流大模型(如 GPT-4、BERT、LLaMA)都在使用基于“子词”的 Tokenizer。


二、 BPE (Byte-Pair Encoding):GPT 系列的“魔法”

BPE 最初是一种简单的数据压缩算法,用来将经常一起出现的字节对进行合并。后来被 Alan DJ 等人引入到 NLP 领域,成为了现代大模型(特别是 OpenAI 的 GPT 系列)最广泛使用的 Tokenization 算法。

1. BPE 的核心思想

BPE 是一种自底向上的贪心算法。
给定一个语料库,BPE 的步骤如下:

  1. 在词的末尾添加一个特殊的结束符(如 </w>),以区分词内的字符和词尾的字符。
  2. 将所有词拆分为最基本的字符序列(英文就是 a-z)。
  3. 统计语料中相邻字符对的出现频率。
  4. 找出频率最高的字符对,将它们合并成一个新的单一 Token。
  5. 重复步骤 3 和 4,直到达到预设的词表大小或设定的迭代次数。

2. BPE 算法示例推导

假设我们的语料非常简单,只有四个单词(已经统计了频率):
low (5), lower (2), newest (6), widest (3)

第一步:拆分并添加结束符

  • 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

第二步:统计相邻字符对的频率

  • esnewestwidest 中相邻,总频率为 6 + 3 = 9。
  • st 的频率也是 9。
    (假设我们选择合并 es)

第三步:合并最高频字符对 (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
以此类推,最终 est 甚至 lowest 可能会被合并成一个完整的 Token。

3. BPE 代码实现(Python 简易版)

为了真正理解 BPE,没有什么比自己手写一遍代码更好了。以下是一个极其简化但核心逻辑完备的 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
import re
from collections import defaultdict

def get_stats(vocab):
"""统计相邻符号对的频率"""
pairs = defaultdict(int)
for word, freq in vocab.items():
symbols = word.split()
for i in range(len(symbols) - 1):
pairs[(symbols[i], symbols[i + 1])] += freq
return pairs

def merge_vocab(pair, v_in):
"""将词汇表中的特定字符对合并为一个新的Token"""
v_out = {}
bigram = re.escape(' '.join(pair))
# 正则表达式:匹配被空白字符包围的特定字符对,或者位于行首/行尾的字符对
p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
for word in v_in:
w_out = p.sub(''.join(pair), word)
v_out[w_out] = v_in[word]
return v_out

# 原始语料 (包含频率)
vocab = {
'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_stats(vocab)
if not pairs:
break
# 找出频率最高的字符对
best = max(pairs, key=pairs.get)
# 合并
vocab = merge_vocab(best, vocab)
print(f"Step {i+1}: Merged {best} -> {''.join(best)}")

# print(vocab)

4. GPT 系列的终极武器:Byte-level BPE (BBPE)

传统的 BPE 有一个致命弱点:它依赖于基础的字符集(如英文字母)。如果遇到中文、日文、Emoji 表情,传统 BPE 会把它们当成极其罕见的字符,导致词表爆炸或乱码。

OpenAI 在 GPT-2 中引入了 Byte-level BPE (BBPE)
其核心哲学非常硬核:放弃所有 Unicode 字符集的概念,一切皆字节。

在计算机底层,所有的文本最终都是以 UTF-8 编码的字节形式存储的。BBPE 直接将基础的 256 个 Byte(字节)作为初始词表。

  • 对于英文,基础字节就是 ASCII 码。
  • 对于中文,一个汉字会被编码为 3 个字节(如 -> \xe4\xb8\xad),BBPE 会直接以这 3 个字节为单位进行学习和合并。

BBPE 的优势:

  1. 绝对的多语言支持:无论是日文、韩文还是颜文字,底层都是字节,模型可以通用。
  2. 极其稳定的基础词表:基础词表大小被永远固定为 256,不会因为语料的语言变化而改变。

三、 WordPiece:BERT 的选择

WordPiece 算法最早在 2012 年由微软提出,并在著名的 BERT 模型中大放异彩。从宏观上看,它和 BPE 非常相似,都是通过迭代合并字符对来构建词表。但它们的合并策略(核心评分函数)截然不同

1. BPE vs WordPiece

  • BPE: 选择频率最高的字符对进行合并。
  • WordPiece: 选择结合后使得语言模型似然概率(Likelihood)提升最大的字符对进行合并。

2. 似然概率的数学解释

假设我们将字符 A 和字符 B 合并为新 Token AB。WordPiece 并不单纯看它们出现的次数,而是评估合并它们对整个语料库的“合理性”提升有多大。

其评分公式可以简化理解为:

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

这其实是计算 A 和 B 的点互信息

  • 如果 A 和 B 经常一起出现,而在其他地方很少单独出现,那么 Score(A,B)Score(A, B) 就会很高。
  • 相比于 BPE 的纯频率统计,WordPiece 更倾向于合并那些“内部凝聚力强”的字符。

3. 前缀标记 ##

在使用 WordPiece 时,最明显的视觉特征是它使用了 ## 前缀来表示子词的延续。
例如,单词 unwanted 可能被分词为:["un", "##want", "##ed"]

  • un 是词的开头。
  • ##want 表示这不是一个独立的词,而是接在 un 后面的子词。

这种设计的目的是为了让模型在输入层面就能区分:一个 Token 是作为词的开头,还是作为词的中间/结尾部分。

4. WordPiece 的推理(反向最大匹配)

训练 WordPiece 词表后,在实际推理(对新文本进行分词)时,算法与 BPE 略有不同。
BPE 是按照训练时学到的合并规则从前往后应用。而 WordPiece 通常采用贪婪最长匹配策略:
从句子的第一个字符开始,在词表中寻找匹配到的最长子词,然后继续处理剩余部分。


四、 SentencePiece:多语言大模型的“瑞士军刀”

SentencePiece 是 Google 开源的一个极其强大的语言无关的 Tokenizer 框架。如今,绝大多数开源大模型(如 LLaMA、ChatGLM、BLOOM、Mistral 等)都在使用 SentencePiece 或基于它的改进版。

1. 为什么需要 SentencePiece?

无论是基础的 BPE 还是 WordPiece,它们都有一个隐含的假设:文本已经有空格作为预分词的边界。
这在英文等西方语言中没问题,但在中文、日文等 CJK(中日韩)语言中,词与词之间是没有空格的。

如果不做特殊处理,传统的 Tokenizer 通常有两种粗暴的做法:

  1. 按字切分: 中文词表巨大,且丧失了词级别的语义。
  2. 前置分词器: 在 Tokenization 前,先引入 Jieba 等分词工具。这会导致 NLP 流程极其复杂,且严重依赖外部工具。

SentencePiece 的核心革命在于:它将语言视为纯粹的字符流,将空格也视为一种特殊的字符(通常被编码为 ,即 Unicode 字符 U+2581),从而彻底摆脱了对预分词的依赖。

2. SentencePiece 的两大核心算法

SentencePiece 本质上是一个框架,它主要支持两种底层算法:

Unigram Language Model (ULM)

与 BPE 自底向上的贪心合并不同,Unigram 是一种自顶向下的算法。

  1. 初始化:从一个极其庞大的候选子词词表开始(例如所有可能的子串,或者用 BPE 先跑出一个大词表)。
  2. 建模:假设每个子词都是相互独立的,通过 EM 算法(期望最大化)估计出每个子词在语料库中出现的概率。
  3. 剪枝:计算移除每个子词后,对整个语料库似然函数的损失。移除那些“对整体似然贡献最小”的子词。
  4. 迭代:不断重复 2 和 3,直到词表缩减到目标大小。

ULM 的最大优势是概率化。 它不是硬性规定一种分词方式,而是对一句话可以给出多种分词路径及其概率。在推理时,通常采用 Viterbi 算法寻找概率最大的分词路径。

Character BPE

SentencePiece 也支持 BPE,但由于它剥离了语言特性(空格处理),这里的 BPE 实现更为纯粹。

3. 代码示例:使用 SentencePiece 训练自己的 Tokenizer

在实际的 LLM 开发中,SentencePiece 极其容易上手。

首先安装库:pip install sentencepiece

假设我们有一个纯文本的训练语料 train.txt(其中包含中文和英文混合):

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
import sentencepiece as spm

# 训练 SentencePiece 模型
# model_type 可以选择 'bpe' 或 'unigram'
spm.SentencePieceTrainer.train(
input='train.txt', # 训练语料路径
model_prefix='my_llm_tok', # 输出模型前缀
vocab_size=32000, # 目标词表大小
model_type='bpe', # 使用 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_tok.model')

text = "大模型正在改变世界!Large Language Models are amazing."
tokens = sp.encode_as_pieces(text)
ids = sp.encode_as_ids(text)

print(f"原文本: {text}")
print(f"Tokens: {tokens}")
print(f"IDs: {ids}")
print(f"解码: {sp.decode_pieces(tokens)}")

注意 SentencePiece 输出中的 符号,它代表了原始文本中的空格或词的起始位置。


五、 横向对比与实战考量

为了方便大家在阅读论文或选择技术栈时快速判断,我们将上述三种主流方案进行总结和对比。

1. 算法特性对比总结

特性 BPE / BBPE WordPiece SentencePiece (Unigram/BPE)
核心策略 频率统计,贪心合并相邻对 语言模型似然概率,PMI 最大化 纯粹的字符流处理,支持 BPE 和 Unigram 剪枝
合并方向 自底向上(从字符合并到词) 自底向上 Unigram 是自顶向下(从大词表剪枝)
多语言支持 GPT-2 的 BBPE 彻底支持多语言 需要复杂的预分词处理,对中文不友好 极佳,原生支持所有语言,无空格依赖
标志性代表 GPT 系列, LLaMA (基于 BBPE) BERT, DistilBERT T5, ChatGLM, LLaMA (基于其底层框架)
词表标记 无特殊标记 使用 ## 标记非词首子词 使用 标记空格/词首

2. 大模型实战中的考量点

当我们在训练一个全新的大型语言模型时,Tokenizer 的设计至关重要。以下是几个必须考虑的工程实战问题:

A. 词表大小的权衡

目前开源大模型的词表大小通常在 32k 到 128k 之间(GPT-4 的词表增加到了约 100k,LLaMA-2 为 32k,LLaMA-3 则剧增到了 128k)。

  • 太小(如 30k): 导致常见长单词或中文短语被切碎,序列变长,增加 Transformer 解码的计算负担(推理变慢,计算成本变高)。
  • 太大(如 200k): Embedding 层参数量激增,占用极大的显存,且容易导致欠拟合。

B. Tokenizer 的“公平性”问题

这是一个经常被忽略但极其重要的问题。如果你的 Tokenizer 主要在英文语料上训练,它处理英文时一个单词可能就是一个 Token;但处理中文时,一个汉字可能要 2 到 3 个 Token(因为 UTF-8 编码下中文被视作少见字符)。
这就导致了:

  1. 成本不公: 同样的意思,中文消耗的 Token 数是英文的好几倍,使用 OpenAI API 时中国人要付出更高的成本。
  2. 上下文缩减: 如果模型上下文长度是 4096 Tokens,中文能承载的实际信息量大打折扣。
    这也是为什么后来很多国产大模型(如 ChatGLM、百川、Qwen)必须使用海量中文语料重新训练 Tokenizer,以保证中文的高效编码。

C. 分词中的“数字与代码”陷阱

早期的 Tokenizer 对数字和代码处理极差。比如 123456 可能被切分为 ["12", "34", "56"],导致模型学习数理逻辑异常困难。
现在的大模型(如 GPT-4、LLaMA)通常会在 Tokenization 前加入正则预处理,强制将所有连续的数字切分为固定长度(如每 3 个数字一组),或者将代码中的缩进、括号进行特殊保护。

我们可以看看 OpenAI GPT 系列(及各类开源复现版)在调用底层 Regex 切分时的经典正则表达式:

1
2
3
import regex
# GPT-4 / Cl100k Base 的切分逻辑
pat = regex.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")

这个正则的巧妙之处在于,它优先保留了英文缩写,剥离了独立的字母/数字块,并把连续的空白符单独剥离,极大提升了代码和数字的分词质量。


六、 HuggingFace 实战:一网打尽 Tokenizer 使用

在现代大模型开发中,我们极少从零手写上述算法,而是直接使用 tokenizerstransformers 库。HuggingFace 重新用 Rust 编写的 tokenizers 库具有极高的性能。

以下是使用 HuggingFace 快速加载和使用各类主流大模型 Tokenizer 的代码示例:

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
from transformers import AutoTokenizer

# 1. 加载 BERT 的 WordPiece Tokenizer
print("--- BERT (WordPiece) ---")
bert_tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
tokens = bert_tokenizer.tokenize("Unbelievably powerful models.")
print(f"Tokens: {tokens}")
# 输出: ['unbelievably', 'powerful', 'models', '.']
# 注意:BERT 会自动 lowercase

# 2. 加载 GPT-2 的 BPE Tokenizer
print("\n--- GPT-2 (Byte-level BPE) ---")
gpt2_tokenizer = AutoTokenizer.from_pretrained("gpt2")
tokens = gpt2_tokenizer.tokenize("Hello, my name is GPT. 这是一个测试")
print(f"Tokens: {tokens}")
# 输出: ['Hello', ',', 'Ġmy', 'Ġname', 'Ġis', 'ĠG', 'PT', '.', 'Ġ追', '¤ħ', '¯æ', 'ĩħ', 'Ġæ', 'µı', 'ĉļ']
# 可以看到,GPT-2 对中文的切分极其碎片化(基于 UTF-8 字节),效率极低。

# 3. 加载中文友好的 LLaMA-3 / ChatGLM (SentencePiece/BPE) Tokenizer
# 以 Qwen (基于 BPE 但针对中文深度优化) 为例
print("\n--- Qwen (Optimized BPE) ---")
qwen_tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
tokens = qwen_tokenizer.tokenize("大模型正在改变世界。")
print(f"Tokens: {tokens}")
# 输出: ['大', '模型', '正在', '改变', '世界', '。']
# 可以看到,针对中文优化后,词组被完整保留,Token 效率极高。

七、 总结

Tokenization 是大模型这座摩天大楼的地基。了解它的前世今生,不仅能帮助我们解决日常大模型调用中遇到的“莫名其妙”的 Bug(如字符串包含错误、字符串截断导致乱码、API 计费异常),更能指导我们在训练垂直领域大模型时做出更优的架构选择。

让我们用几句话总结全文:

  1. 子词分词 是大模型平衡语义与词表大小的最优解。
  2. BPE 采用高频优先合并,结合 Byte-level 技术后成为了 GPT 等闭源模型的基石。
  3. WordPiece 采用概率提升优先合并,借助 ## 标记成为了 BERT 时代的标配。
  4. SentencePiece 是一套强大的工程框架,通过将空格也视作字符,完美解决了多语言(特别是中日韩)的预处理痛点,成为了当代开源大模型(LLaMA 等)的绝对主力。

随着大模型向着“无限上下文”、“多模态”以及“极致推理加速”的方向演进,Tokenizer 也在不断进化。也许在未来的某一天,大模型能够彻底抛弃现有的文本 Tokenization 方案,直接在音频波形或像素级别进行端到端的学习,但就目前而言,熟练掌握和优化 BPE、SentencePiece,依然是每一位大模型算法工程师和自然语言处理研究者的必修课。