大模型背后的“黑魔法”:深入剖析 Tokenization(BPE、WordPiece 与 SentencePiece)

当我们惊叹于 ChatGPT 的对答如流,或者沉醉于 Llama 3 生成的优美代码时,我们通常会将功劳归结于模型庞大的参数量、精妙的 Transformer 架构或是海量的训练数据。然而,在这些宏大的叙事之下,隐藏着一个经常被忽视、却至关重要的基础组件:Tokenization(分词)

大模型并不直接阅读由 26 个字母组成的单词,也看不懂成千上万个汉字。它们的世界里只有数字。如何将人类的自然语言完美地转换为模型能够理解的数字序列?这就是 Tokenization 的使命。

如果把大模型比作一台高性能的发动机,那么 Tokenizer 就是那台精密的燃油喷射系统。如果燃油(文本)没有被打散、雾化成恰到好处的颗粒(Token),发动机就无法正常运转,甚至会直接熄火(报错超出最大长度)。

在这篇文章中,我们将深入探讨大模型中 Tokenization 的核心原理,并全面拆解目前业界最主流的三种分词算法:BPE、WordPiece 与 SentencePiece。同时,我们将结合实际的 Python 代码,带你揭开这层“黑魔法”的面纱。


一、 为什么我们需要复杂的 Tokenization?

在深入算法之前,我们先来聊聊背景。为什么不让模型直接按“字符”或按“单词”来学习呢?

1. 按单词分词的困境

最直观的想法是按照空格和标点符号,将文本切分成一个个完整的单词。

  • 缺点 1:词表过大。 英语中虽然有十几万个单词,但加上各种词形变化(如 play, playing, played)和专业术语,词汇量几乎是无限的。如果词表无限大,模型的 Embedding 层和输出层(Softmax)就会变得无比庞大,导致显存爆炸。
  • 缺点 2:OOV(Out-of-Vocabulary,未登录词)问题。 无论词表多大,总会遇到没见过的词。如果模型在推理时遇到了一个不在词表里的词,它就会彻底“懵逼”,只能输出一个 <UNK>(Unknown)标记。大量出现 <UNK> 会严重破坏模型的理解能力。
  • 缺点 3:对多语言不友好。 中文、日文等亚洲语言本身就没有明显的空格分隔,按单词切分根本行不通。

2. 按字符分词的困境

既然按单词不行,那我们按字母(a, b, c)或者单个汉字(你, 好)来切分呢?

  • 优点: 词表极小(英文只需 26 个字母加一些符号),完全没有 OOV 问题。
  • 缺点: 序列太长!一句 “I love programming” 按单词只有 3 个 Token,按字符则有 18 个 Token。由于 Transformer 模型的计算复杂度是序列长度的平方(O(n2)O(n^2)),过长的序列会导致计算量呈指数级爆炸。此外,单个字母或汉字往往缺乏独立的语义(比如字母 ‘a’ 本身没有具体含义)。

3. 完美的平衡点:子词分词

为了解决上述问题,业界引入了子词分词算法。它的核心思想是:高频词保留,低频词拆解

  • 对于像 apple, love 这样常见的完整单词,模型将它们作为一个整体的 Token。
  • 对于像 unfriend 这样的低频词或未见过的词,模型将其拆解为有意义的子词:unfriend
  • 这样一来,既控制了词表的大小(通常在 3万 到 15万 之间),又彻底解决了 OOV 问题,同时还大幅缩短了序列长度。

目前主流的大模型(GPT 系列、BERT、Llama 等)无一例外地采用了子词分词技术。


二、 核心算法深度解析

接下来,我们将深入剖析三大主流子词分词算法。

1. 字节对编码—— GPT 系列的基石

BPE 最早是一种数据压缩算法,后来被引入到 NLP 领域。它的核心逻辑非常简单:寻找高频出现的字符对,并将它们合并为一个新的 Token

训练过程(如何构建词表):

  1. 准备语料: 将训练文本拆分为字符级别的初始序列,并在单词结尾添加特殊的结束符 </w>(用来区分词内和词尾)。
  2. 统计频率: 统计语料中相邻字符对的出现频率。
  3. 合并: 将出现频率最高的字符对合并为一个新的字符(子词)。
  4. 迭代: 重复步骤 2 和 3,直到达到预设的词表大小。

举个直观的例子:
假设我们的语料库中只有两个单词:low (出现了 5 次),lower (出现了 2 次)。

  • 初始状态(字符级别):
    l o w </w> (5次)
    l o w e r </w> (2次)

  • 统计字符对频率:
    lo 相邻出现了 7 次 (5+2)
    ow 相邻出现了 7 次 (5+2)
    w</w> 相邻出现了 5 次
    … 等等。

  • 第一次合并: 假设 lo 频率最高,合并为 lo
    语料变成:
    lo w </w> (5次)
    lo w e r </w> (2次)

  • 第二次合并: low 频率最高,合并为 low
    语料变成:
    low </w> (5次)
    low e r </w> (2次)

以此类推,最终 low 就成了一个不可分割的 Token 存入词表中。

编码过程(如何切分新文本):

当遇到一个新句子时,我们从左到右,按照训练时生成的合并规则的优先级依次进行合并。如果遇到词表中不存在的生僻字,就会保留为单个字符。

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
44
45
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, vocab):
"""将最高频的字符对合并"""
new_vocab = {}
bigram = re.escape(' '.join(pair))
p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
for word in vocab:
new_word = p.sub(''.join(pair), word)
new_vocab[new_word] = vocab[word]
return new_vocab

# 初始语料(带有频率)
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 r </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}: Merging {best} -> {best[0]+best[1]}")

# 输出示例:
# Step 1: Merging ('e', 's') -> es
# Step 2: Merging ('es', 't') -> est
# Step 3: Merging ('est', '</w>') -> est</w>
# Step 4: Merging ('l', 'o') -> lo
# Step 5: Merging ('lo', 'w') -> low

2. WordPiece —— BERT 的选择

WordPiece 算法与 BPE 非常相似,它最早由谷歌为语音搜索系统提出,后来被广泛应用于 BERT 及其衍生模型(如 RoBERTa, DistilBERT)中。

核心区别:合并的判断标准

  • BPE: 统计字符对的绝对出现频率,合并频率最高的字符对。
  • WordPiece: 使用似然概率来评估字符对。它不仅考虑了字符对出现的频率,还考虑了组成该字符对的各个部分单独出现的频率。

其打分公式为:

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

为什么这么做?
假设 th 出现了 1000 次,er 出现了 1000 次,但单独的 th 出现的次数极少,而 er 到处都是。
BPE 可能会同等对待 ther。但 WordPiece 会认为:既然 er 单独出现的概率已经很高了,把它们强行绑在一起意义不大;反而 th 结合得如此紧密(因为单独的 th 少),更值得作为一个整体 Token。

特殊的表示方式

如果你仔细看过 BERT 的词表,会发现 WordPiece 在切分子词时,有一个非常明显的特征:

  • 如果是一个词的开头,则正常表示(如 play)。
  • 如果是一个词的中间或结尾部分,则在前面加上 ##(如 ##ing)。

例如单词 unwanted,可能会被切分为:['un', '##want', '##ed']。这种表示方法让模型明确知道哪些部分是属于同一个原始单词的。


3. SentencePiece —— 跨语言的终极解法

无论是 BPE 还是 WordPiece,它们在底层都存在一个巨大的前提假设:文本是由空格分隔的单词组成的。这个假设在英文等印欧语系中成立,但在中文、日文、韩文(CJK)等非空格分隔的语言中,这个假设直接崩塌。

为了解决多语言问题,Google 开源了 SentencePiece 工具。Llama、T5、BLOOM 等现代大模型都采用了 SentencePiece。

核心创新:将文本视为纯字节流

SentencePiece 完全抛弃了“空格即单词边界”的传统观念。它将所有的输入文本(包括空格、标点符号、中英文字符)统统转化为 UTF-8 字节流
在 SentencePiece 的眼中,文本就是一个长长的字符串序列,空格只是一个普通的字符(通常被特殊编码为 _ 或者 ,即下划线)。

SentencePiece 包含的两种主流算法

SentencePiece 是一个工具包,它内部支持多种子词算法,最常用的有两种:

  1. unigram (一元语言模型): 这是 SentencePiece 的默认算法。与 BPE 自底向上的合并不同,Unigram 是自顶向下的。它一开始初始化一个巨大的词表,然后通过评估移除某个 Token 对整体语言模型似然度(Loss)的影响,逐步修剪、删除不重要的 Token,直到达到目标词表大小。
  2. bpe SentencePiece 也实现了经典的 BPE 算法,但处理粒度从字符变成了字节。

为什么现代大模型偏爱 SentencePiece?

  1. 真正的语言无关性: 无论输入的是中英混杂、代码块还是特殊符号,它都能统一处理,不需要针对不同语言写复杂的预处理脚本。
  2. 纯端到端: 从原始文本到 Token IDs 的映射完全自动化,无需手动分词。
  3. 极度的灵活性: 能够有效压缩如中文这样以字符为主的语言的序列长度。

三、 大模型实战:Tokenizer 的使用与对比

理论讲完了,让我们来看看在实际的大模型开发中,我们是如何使用 Tokenizer 的。目前最主流的工具库是 HuggingFace 的 transformerstokenizers

我们将对比 GPT-2 (BPE)BERT (WordPiece) 在处理中英文混合文本时的表现。

环境准备

1
pip install transformers

实战代码

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

text = "大语言模型(LLM)正在改变世界!Let's explore the future."

# ==========================================
# 1. GPT-2 的 Tokenizer (基于 BPE)
# ==========================================
print("=== GPT-2 Tokenizer (BPE) ===")
gpt2_tokenizer = AutoTokenizer.from_pretrained("gpt2")
gpt2_tokens = gpt2_tokenizer.tokenize(text)
gpt2_ids = gpt2_tokenizer.encode(text)

print(f"原始文本: {text}")
print(f"切分结果: {gpt2_tokens}")
print(f"Token 数量: {len(gpt2_tokens)}")
print(f"Token IDs: {gpt2_ids}\n")

# ==========================================
# 2. BERT 的 Tokenizer (基于 WordPiece)
# ==========================================
print("=== BERT Tokenizer (WordPiece) ===")
bert_tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
bert_tokens = bert_tokenizer.tokenize(text)
bert_ids = bert_tokenizer.encode(text)

print(f"原始文本: {text}")
print(f"切分结果: {bert_tokens}")
print(f"Token 数量: {len(bert_tokens)}")
print(f"Token IDs: {bert_ids}\n")

# ==========================================
# 3. Llama 2 的 Tokenizer (基于 SentencePiece BPE)
# ==========================================
# 注意:Llama2 需要在 HuggingFace 上申请许可,这里用等效的 open模型演示
print("=== Open-Llama Tokenizer (SentencePiece) ===")
llama_tokenizer = AutoTokenizer.from_pretrained("openlm-research/open_llama_3b")
llama_tokens = llama_tokenizer.tokenize(text)
llama_ids = llama_tokenizer.encode(text)

print(f"原始文本: {text}")
print(f"切分结果: {llama_tokens}")
print(f"Token 数量: {len(llama_tokens)}")
print(f"Token IDs: {llama_ids}")

结果分析与对比

对于句子:"大语言模型(LLM)正在改变世界!Let's explore the future.",各大模型的切分差异非常大:

GPT-2 的表现(经典 BPE)

GPT-2 的词表主要是英文,对中文支持较差。它的切分结果会像这样:

  • -> '大', '§' (中文字符被强行拆解为 UTF-8 字节,乱码状)
  • LLM -> 'LL', 'M'
  • ! -> '!'
    评价: GPT-2 处理中文的效率极低,一个汉字往往需要消耗 3 个甚至更多的 Token,这极大地浪费了模型的上下文长度(Context Window)。

BERT 的表现

BERT 的词表中包含中文字符,它的切分结果:

  • 大语言模型 -> ['大', '语', '言', '模', '型'] (逐字切分,因为中文成语在词表中可能不是高频词组)
  • LLM -> ['ll', '##m'] (转小写,并使用了 ## 前缀表示子词)
  • Let's -> ['let', "'", '##s']
    评价: BERT 能够正确处理中文,但为了控制词表大小,主要以单字为主。其标志性的 ## 前缀清晰地标明了词的边界。

Llama / 现代大模型的表现

现代大模型(如 Llama 3, ChatGLM)为了提高多语言效率,在词表中加入了大量的中文词组和字节级 Token。切分结果可能如下:

  • 大语言模型 -> ['大语言', '模型'] (将高频词组作为完整 Token)
  • 正在 -> ['正在']
  • ! -> '!' 或可能合并为 世界!
    评价: 极大地压缩了非英语文本的 Token 数量。这意味着同样的一段中文,在新模型中可以比在 GPT-2 中容纳更多的信息,这也是为什么现代大模型上下文窗口越来越长的原因之一(不仅是模型结构的优化,也是 Token 压缩率的提升)。

四、 总结与大模型的未来

Tokenization 是自然语言处理中一座连接人类语言与机器数字的桥梁。从传统的基于规则的分词,到如今主导大模型架构的三大核心算法,我们见证了文本处理方式的进化:

  1. BPE (GPT系列): 通过最高频的字符对自底向上合并,直观高效。
  2. WordPiece (BERT系列): 同样是合并,但基于语言模型的似然概率,并使用 ## 标记子词,更注重词边界的语义。
  3. SentencePiece (Llama/T5系列): 将文本视为纯字节流,剥离了对空格等语言的先验假设,真正实现了语言无关的跨语言大一统分词。

未来的发展:我们还需要 Tokenization 吗?

尽管现在的 Tokenization 技术已经非常成熟,但它依然存在痛点。比如:

  • 反直觉的切分: 模型可能把 " (引号) 和前面的字母切在一起,导致你很难通过字符串匹配来精准删除文本中的某一段敏感信息。
  • 推理时的性能瓶颈: Tokenization 过程通常在 CPU 上运行,涉及大量的字符串匹配和正则表达式,这在实时流式输出时往往成为速度的瓶颈。

在业界,一种新的趋势正在悄然兴起:Byte-Level Models(字节级模型)
例如,2023 年底 Meta 提出的 MegaByte 架构,或者最新的研究直接尝试让模型读取原始的 UTF-8 字节(总共只有 256 个 Token)。这种方案彻底抛弃了庞大的词表和复杂的 Tokenization 训练过程。

虽然目前字节级模型在性能和长序列处理上还不如基于 Transformer 的传统大模型,但这也许才是真正通向 AGI(通用人工智能)的终极道路——让模型像人类一样,直接看清构成文本的每一个最基础的字节。

Tokenization 也许在未来某一天会退出历史舞台,但理解它的原理,对于我们理解今天的大模型为何如此强大,以及如何优化大模型的 Prompt、控制 API 调用成本(毕竟按 Token 计费),都有着不可替代的价值。