大模型基座探秘:深入剖析 Tokenization(BPE、WordPiece 与 SentencePiece)

在浩瀚的大语言模型(LLM)浪潮中,我们常常将目光聚焦于 Transformer 架构、多头注意力机制或是千亿级的参数量。然而,在那些耀眼的智能涌现背后,有一个极其基础却决定模型上限的“幕后功臣”——Tokenization(分词)

当你向 ChatGPT 输入一句问候,或者让 Claude 写下一首长诗时,它们的第一步并不是直接理解语义,而是将人类的自然语言“切”成一个个小块。这个过程就是 Tokenization。

为什么大模型不能直接处理字符?为什么 GPT-4 有时会“数不清”单词里的字母?这一切的答案,都藏在分词算法中。今天,我们将深入探讨大模型时代最核心的三种分词算法:BPE、WordPiece 与 SentencePiece,并通过硬核的代码实战,带你彻底搞懂大模型的“拼音输入法”。


一、 为什么我们需要 Tokenization?

在深入算法细节之前,我们需要明确一个问题:为什么我们要分词?

理想情况下,我们可以把一个完整的词作为一个 Token,这被称为 词级别分词。但它面临两个致命问题:

  1. 词表爆炸:英语中有几十万个单词,加上时态、复数、派生词,词表会无限膨胀。对于没有明显词边界的中文,情况更复杂。
  2. OOV(Out-of-Vocabulary,未登录词)问题:无论词表多大,训练数据中总会出现没见过的词(比如新的网络用语、错别字)。遇到 OOV,模型只能用 <UNK> 代替,导致信息彻底丢失。

另一个极端是 字符级别分词,即把每个字母或汉字作为一个 Token。

  1. 序列过长:一句话 “I love you” 会变成 ['I', ' ', 'l', 'o', 'v', 'e', ' ', 'y', 'o', 'u']。由于 Transformer 的计算复杂度随长度呈平方级增长(O(N2)O(N^2)),这会导致计算资源瞬间爆炸。
  2. 语义表示弱:单独的字母 “a” 或 “b” 很难携带实际的语义信息。

为了在这两者之间取得平衡,子词分词 应运而生。它的核心哲学是:高频词保留,低频词拆解

  • 常见的词(如 “apple”)直接作为一个整体 Token。
  • 罕见的词(如 “chatgptization”)被拆解为有意义的子词,例如 [“chat”, “gpt”, “ization”]。

这样既控制了词表大小(通常在 3万 到 10万 之间),又完美解决了 OOV 问题。接下来,我们来看看实现这一哲学的三大神器。


二、 核心算法详解

2.1 BPE (Byte-Pair Encoding):GPT 系列的标配

BPE 最初是一种数据压缩算法,在 2015 年被引入 NLP 领域。它是目前最流行的分词方法,OpenAI 的 GPT 系列(包括 GPT-2, GPT-3, GPT-4)以及 Meta 的 LLaMA 均采用了 BPE 的变体。

算法核心思想

BPE 的核心逻辑非常朴素:寻找出现频率最高的相邻 Token 对,并将它们合并为一个新的 Token。

训练步骤

  1. 准备语料:将所有单词拆分为字符序列,并在单词结尾添加特殊符号(如 </w>)表示词边界。
  2. 统计共现频率:统计语料中所有相邻字符对的出现频率。
  3. 合并:将频率最高的字符对合并为一个新的字符(子词)。
  4. 重复:重复步骤 2 和 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
44
45
46
47
48
49
50
51
52
import re
from collections import defaultdict

def get_vocab(text):
"""将文本转化为带词频的字典,初始拆分为字符"""
vocab = defaultdict(int)
for word in text.split():
# 在单词末尾加上 </w> 标记
vocab[' '.join(list(word)) + ' </w>'] += 1
return vocab

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 = {}
# 构造正则表达式,匹配需要合并的相邻符号
p = re.compile(r'(?<!\S)' + re.escape(' '.join(pair)) + r'(?!\S)')
for word in vocab:
# 替换为新符号
new_word = p.sub(''.join(pair), word)
new_vocab[new_word] = vocab[word]
return new_vocab

# 1. 准备简单的语料
text = "low lower newest widest low"
vocab = get_vocab(text)

print("初始词表 (字符级别):")
for k, v in vocab.items():
print(f"{k}: {v}")

# 2. 模拟 BPE 合并 5 次
num_merges = 5
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"\n第 {i+1} 步合并: {best} -> {''.join(best)}")
for k, v in vocab.items():
print(f"{k}: {v}")

运行结果解析
你会发现,程序首先合并了 es,因为它们共同出现的频率最高(构成 est)。随后 ne 可能会合并。通过这种自下而上的贪心策略,BPE 成功地从语料中自动挖掘出了常见的词根和词缀。

2.2 WordPiece:BERT 的选择

WordPiece 算法与 BPE 非常相似,同样是从字符级别开始,通过不断合并构建词表。Google 提出的 BERT 模型使用的就是这种算法。

与 BPE 的核心区别

BPE 选择合并的依据是频率。而 WordPiece 选择合并的依据是似然概率

在 WordPiece 中,每次合并前,算法会评估所有可能的字符对合并后,对整个语言模型的 likelihood(似然)提升有多大。它倾向于合并那些合并后能让语言模型更好地预测文本的符号对,而不仅仅是看它们是否经常挨在一起。

前缀标记 ##

WordPiece 最显著的标志是它的子词前缀 ##
如果一个词被切分,除了第一个子词外,后面的子词都会加上 ## 前缀,表示它不是一个完整的词,而是词的一部分。

示例

  • 单词 unfriendly 可能会被切分为:['un', '##friend', '##ly']
  • 单词 hugging 可能会被切分为:['hug', '##ging']

这种标记方式让模型在处理时能够清晰地知道哪些 Token 是词的开头,哪些是词的延续。

代码实战:调用 BERT 的 WordPiece

我们可以通过 Hugging Face 的 transformers 库直观感受 WordPiece:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from transformers import BertTokenizer

# 加载 BERT-base 中文分词器
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')

text = "大语言模型正在改变世界"
tokens = tokenizer.tokenize(text)
ids = tokenizer.encode(text)

print(f"原句: {text}")
print(f"Tokens: {tokens}")
print(f"IDs: {ids}")

# 测试一个未登录词(比如拼音或错别字)
text_unk = "大语言模型改变了世jie"
tokens_unk = tokenizer.tokenize(text_unk)
print(f"\n含未登录词原句: {text_unk}")
print(f"Tokens: {tokens_unk}")

输出解析
对于中文,BERT(WordPiece)通常会以为单位进行切分。如果遇到非常罕见的字或未知的符号,它会将其切分成更小的片段甚至单个字符,并使用 [UNK] 标记那些完全无法识别的 Token。相比 BPE,WordPiece 对中文的处理显得有些过于“字级别”,这也是后来中文大模型转向 BPE 的原因之一。

2.3 SentencePiece:打破语言边界的终极武器

无论是 BPE 还是 WordPiece,它们在训练前都有一个隐含的假设:文本已经通过空格进行了预分词。
这对于英文等印欧语系没问题,但对于**中文、日文、韩文(CJK 字符)**等不使用空格分隔单词的语言来说,就需要依赖复杂的上游分词工具(如 Jieba),这会引入额外的误差和系统复杂度。

SentencePiece 应运而生。它是一个开源的、语言无关的分词工具。当前最火的大模型,如 GLM-4、Llama 2/3、T5 等,几乎全部采用了基于 SentencePiece 训练的分词器。

核心创新:把文本当作字节流

SentencePiece 将输入的句子简单地视为一系列原始的字节流,甚至空格也被当作一个特殊的字符(通常被转义为 ,即下划线字符 U+2581)。它将分词问题直接转化为一个无监督的文本分割问题,完全摒弃了语言学的预处理。

核心算法:Unigram Language Model

虽然 SentencePiece 支持 BPE 模式,但它最著名的是引入了 Unigram 语言模型

与 BPE(自下而上不断合并)相反,Unigram 是自上而下不断剪枝的:

  1. 初始化:首先用一个巨大的词表(比如包含所有字符、所有高频子串)。
  2. 计算概率:通过 EM 算法(期望最大化)计算每个子串在当前词表下的出现概率。
  3. 评估损失:计算移除某个子词后,对整体似然函数的影响。
  4. 剪枝:移除那些对似然函数影响最小(即对全局概率贡献极小)的子词。
  5. 重复:直到词表缩减到目标大小。

这种方法的好处是,它可以自然地为一个单词提供多种切分路径,并在解码时通过 Viterbi 算法找到概率最大的切分序列。

代码实战:使用 SentencePiece 训练自定义分词器

我们可以自己准备一段语料,体验 SentencePiece 的强大与便捷。

首先安装库:pip install 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
36
37
38
import sentencepiece as spm

# 1. 准备训练数据
# 假设我们有一段纯文本数据 'train.txt'
with open('train.txt', 'w', encoding='utf-8') as f:
f.write("大语言模型正在改变世界。自然语言处理是人工智能的王冠。\n")
f.write("SentencePiece是一个强大的分词工具。它不依赖空格进行分词。\n")
f.write("ChatGPT和Llama等模型都在使用类似的机制。\n")

# 2. 训练 SentencePiece 模型
# model_type 可以选择 'unigram' (默认) 或 'bpe'
spm.SentencePieceTrainer.train(
input='train.txt',
model_prefix='my_tokenizer', # 输出模型前缀
vocab_size=150, # 目标词表大小
model_type='bpe', # 这里为了演示,使用 BPE 算法
character_coverage=1.0, # 字符覆盖率,中文通常设为 0.9995,这里语料小设为 1.0
pad_id=0, unk_id=1, bos_id=2, eos_id=3 # 特殊 Token 的 ID
)

# 3. 加载并测试模型
sp = spm.SentencePieceProcessor()
sp.load('my_tokenizer.model')

text = "自然语言处理改变了世界"
print(f"原句: {text}")

# 编码为子词序列
pieces = sp.encode_as_pieces(text)
print(f"子词序列: {pieces}")

# 编码为 ID 序列
ids = sp.encode_as_ids(text)
print(f"ID 序列: {ids}")

# 解码回原句
decoded_text = sp.decode_pieces(pieces)
print(f"解码还原: {decoded_text}")

通过这段代码你会发现,即使完全没有空格,SentencePiece 也能以极高的效率和准确度将中文切分成类似 ['▁自然', '语言', '处理', '改变', '了', '世界'] 的合理序列。


三、 横向对比:BPE vs WordPiece vs SentencePiece

为了便于大家在阅读论文或选择技术栈时快速区分,我们总结如下对比表:

特性 BPE WordPiece SentencePiece (Unigram/BPE)
提出者/代表模型 OpenAI (GPT系列), LLaMA Google (BERT, DistilBERT) Google (T5, ALBERT), GLM
合并/选择策略 最高频率的相邻对 最大提升似然概率的对 自上而下基于概率剪枝
对空格的依赖 依赖(需预先按空格分词) 依赖(需预先按空格分词) 不依赖(空格视为普通字符
切分标记 无特定前缀,依赖字母组合 词尾或词内子词带 ## 前缀 句首或词首常带 标记
多语言支持 较弱(需结合 Byte-level) 较弱 极强(原生跨语言设计)
反向解码难度 较容易 需去除 ## 并拼接 极易(拼接后将 替换为空格)

四、 大模型 Tokenization 的进阶避坑指南

在真实的大模型研发和使用中,Tokenization 并非完美无缺。了解以下“坑点”,有助于你更深刻地理解大模型的行为。

1. 分词粉尘效应 与多语言不公平

BPE 在处理多语言时,如果词表分配不均,会导致严重的“粉尘效应”。
例如,由于英语在训练语料中占主导地位,GPT-3 等模型会将常见的英文单词(如 “apple”)视为 1 个 Token。但同样的模型在处理中文时,可能需要用 2 到 3 个 Token 才能拼出一个汉字(尤其是在某些生僻字或繁体字上,使用 Byte-level BPE 将汉字拆成 UTF-8 字节)。
后果:这导致在相同的上下文窗口下,中文用户能输入的有效信息实际上比英文用户。这也是为什么国产大模型(如 GLM-4)在构建词表时,专门增加了中文字符的覆盖率,使得中文文本的压缩率大幅提升。

2. 大模型为什么连“草莓”里有几个“r”都数不清?

这是一个著名的社区梗。当你在 ChatGPT 中问它 “How many 'r’s in the word ‘strawberry’?” 时,它常常会回答 2 个。
原因就在于 Tokenization
在 GPT 的词表中,“strawberry” 很可能是一个完整的 Token(ID: 38837)。
当模型看到 strawberry 这个 Token 时,它内部看到的是一个高维向量,它根本看不到组成这个词的独立字母 “r”。就像你在看“草莓”这个词时,你不会去想里面有几横几竖一样。
解决方法:如果你让它把单词拆开(例如加上空格 s t r a w b e r r y),或者写出逐个字母检查的 Python 脚本,它就能数对了。

3. 特殊 Token 的注入攻击

在分词器的设计中,通常会有特殊的控制符,例如 <|endoftext|>(表示文本结束)、<|im_start|>(表示指令开始)。
如果开源模型在微调或部署时,没有严格过滤用户输入中的这些特殊字符,攻击者就可以通过输入 <|endoftext|> 来强行截断模型的系统提示,从而实现越狱攻击。


五、 总结

Tokenization 是连接人类语言与神经网络数字世界的桥梁。回顾全文,我们可以看到分词技术的演进路线:

  1. WordPiece 引入了概率学视角,解决了 BERT 时代的语言表示问题。
  2. BPE 以其简单粗暴的统计概率,配合 Byte-level(BBPE)技术,成为了 GPT 系列霸主的基石。
  3. SentencePiece 打破了空格的枷锁,实现了真正意义上的语言无关性,成为当今多语言大模型(如 LLaMA、GLM)的标配。

尽管随着模型规模的扩大,一些研究者开始探索 Byte-level 甚至 无 Tokenizer(如 MegaByte)的架构,试图让模型直接阅读原始字节,但在未来相当长的一段时间内,基于 BPE 和 SentencePiece 的分词器仍将是工业界的主流。

理解了 Tokenization,你才算真正拿到了阅读大模型底层代码的钥匙。下一次,当你在向大模型输入 Prompt 并发现它出现了奇怪的截断或幻觉时,不妨想一想:是不是这里的 Token 边界 在作祟呢?