一个让人抓狂的 bug
2016 年的某个深夜,一个做聊天机器人的工程师盯着屏幕发呆。
用户输入 “microservice”,模型回答得很好。输入 “micro-service”,模型突然开始胡说八道。输入 “microservices”,又是一堆乱码。
明明是同一个概念,只是写法稍微不同,为什么模型的表现天差地别?
他打开日志,看到了一堆 <unk> 标记。这个标记的意思是:模型说”我不认识这个词”。
问题不在模型,而在更前面——在文本还没喂给模型之前,就已经被切坏了。
这个故事不是虚构的。2010 年代中期,几乎每个做 NLP 的团队都遇到过类似的问题。当时的解决方案很粗暴:不停往词表里加新词,就像往漏水的桶上贴胶带。
直到 2015 年,一篇论文改变了这个局面。论文的标题很朴素:《Neural Machine Translation of Rare Words with Subword Units》。作者们从一个 1994 年的数据压缩算法里找到了灵感,把它改造成了分词算法。
这个算法叫 BPE(Byte Pair Encoding)。
今天,GPT、LLaMA、Claude 这些大模型,用的都是 BPE 或它的变体。你每次和 ChatGPT 对话,每次计算 token 数量,每次遇到上下文长度限制,背后都有 BPE 的影子。
但 BPE 不是什么高深的理论。它的核心思想简单到可以用一句话说清楚:数相邻对,合并最常见的那对,重复到满意为止。
这篇文章会带你从零开始理解 BPE。我们会从那个 microservice 的 bug 出发,看看没有 BPE 之前我们是怎么凑合的,为什么那些方法会失败,BPE 又是如何解决问题的。读完之后,你不仅能理解原理,还能自己写出一个简单的 BPE 实现。
机器不吃文字,它吃数字
在讲 BPE 之前,我们需要先理解一个基本事实:模型从来不处理文字,它处理的是数字。
你在屏幕上看到 “hello”,模型看到的可能是 [15496, 0] 这样的数字序列。这个从文字到数字的转换过程,就是分词(tokenization)。
整个流程大概是这样的:
“hello world”
↓ 分词器切分
[“hello”, “world”]
↓ 映射到 ID
[15496, 995]
↓ 转换成向量
[[0.2, -0.5, ...], [0.1, 0.3, ...]]
↓ 喂给 Transformer
...
BPE 负责的是第一步:怎么把一段文本切成一个个 token。切得好不好,直接影响后面所有的环节。
切得太碎,序列会变得很长,模型要处理更多 token,速度变慢,上下文容易装不下。切得太粗,词表会无限膨胀,新词会被标记成 <unk>(unknown,表示”我不认识”),模型就像被蒙住了眼睛。
这就是分词算法要解决的核心矛盾:词表大小、覆盖率、序列长度,三者不可能同时完美。
1994 年的压缩算法
BPE 这个名字听起来很技术,但它的来历其实很有意思。
1994 年,Philip Gage 在《C Users Journal》上发表了一篇文章,介绍了一种简单的数据压缩算法。思路是这样的:
在一串数据里,反复找出出现最多的相邻字节对(Byte Pair),把它们替换成一个新的字节。这样原始数据可以变短,同时你会得到一份替换表,用来还原数据。
举个例子,假设你有这样一段数据:
aaabdaaabac
统计相邻对的出现次数:
aa出现 4 次(最多)ab出现 2 次bd出现 1 次da出现 2 次ba出现 1 次ac出现 1 次
把出现最多的 aa 替换成一个新符号,比如 Z:
aaabdaaabac → ZabdZabac
继续统计,再合并,重复这个过程,数据就会越来越短。
这个算法在压缩领域没有掀起什么波澜——它太简单了,压缩率也不够好。但 21 年后,它在 NLP 领域找到了新的用途。
2015 年:从压缩到分词的跨界
2015 年,Rico Sennrich 和他的团队在做神经机器翻译。他们遇到了一个老大难问题:稀有词(rare words)。
当时的翻译模型用的是固定词表。常见词翻译得还不错,但一遇到人名、地名、专业术语,模型就傻眼了。要么输出 <unk>,要么胡乱翻译。
他们试过很多方法:扩大词表、用字符级模型、设计复杂的回退机制……都不理想。词表越大,训练越慢;用字符级模型,序列又太长。
直到有一天,Sennrich 想到了那个 1994 年的压缩算法。
他意识到:压缩算法在做的事情,和分词器要做的事情,本质上是一样的——都是在找”常见的模式”。
压缩算法找常见的字节对,是为了减少数据量。分词算法找常见的字符组合,是为了用有限的词表覆盖无限的新词。
于是他们把 BPE 改造成了分词算法。2015 年 8 月,论文发表:《Neural Machine Translation of Rare Words with Subword Units》。
这篇论文改变了 NLP 的游戏规则。
没有 BPE 之前,我们是怎么凑合的
回到开头那个 microservice 的故事。为什么同一个概念的不同写法,会让模型表现天差地别?
让我们先看看没有 BPE 之前,大家是怎么做分词的。
方法一:按空格切分(Word-based)
最朴素的做法,就是把文本按空格切开,每个”词”分配一个 ID。
vocab = {
“<unk>”: 0,
“microservice”: 1,
“deploy”: 2,
“container”: 3,
# ... 更多词
}
text = “deploy microservice”
tokens = text.split() # [“deploy”, “microservice”]
ids = [vocab.get(t, vocab[“<unk>”]) for t in tokens] # [2, 1]
这个方法简单直观,debug 也容易。在 demo 里总是很灵,第二天就能给你一个可用的词表。
但问题很快就来了。
用户开始输入 micro-service(带连字符)、microservices(复数)、MicroService(驼峰命名)、microservice-v2(带版本号)……
这些词在人类看来都是”微服务”,但对分词器来说,它们是完全不同的键。如果词表里没有,就会被标记成 <unk>。
text = “deploy micro-service microservices”
tokens = text.split() # [“deploy”, “micro-service”, “microservices”]
ids = [vocab.get(t, vocab[“<unk>”]) for t in tokens]
print(ids) # [2, 0, 0] <- 两个 <unk>
你当然可以不停往词表里加新词。但这就像往漏水的桶上贴胶带——你永远追不上用户的创造力。
更要命的是,真实业务里的变体远比你想象的疯狂:
# 同一个概念的不同写法
variants = [
“microservice”,
“micro-service”,
“micro_service”,
“microservices”,
“micro-services”,
“MicroService”,
“microService”,
“MICROSERVICE”,
“microservice-v2”,
“microservice.v2”,
“microservice_v2”,
“ms”, # 缩写
“svc”, # 另一种缩写
]
如果你为每一种写法都开一个词条,词表会爆炸。而且这些词在语义上是相关的,但模型看到的是完全不同的 ID,它学不到它们之间的关系。
方法二:写规则做归一化(Normalization)
既然变体太多,那就写规则把它们统一起来。
def normalize(word):
word = word.lower() # 统一小写
word = word.replace(“-”, “”) # 去掉连字符
word = word.replace(“_”, “”) # 去掉下划线
return word
for w in [“micro-service”, “MicroService”, “micro_service”]:
print(f”{w} -> {normalize(w)}”)
输出:
micro-service -> microservice
MicroService -> microservice
micro_service -> microservice
看起来不错,对吧?你救回了一部分样本,<unk> 少了一些。
但新的问题来了。
规则写”狠”了,会把本来不同的词揉成同一个。比如 re-sign(重新签署)和 resign(辞职),如果你把连字符删掉,它们就变成了同一个词。在人类语义上,这两个词差别很大。
而且规则会越写越多,维护成本越来越高。你会发现自己在维护的不是一个算法,而是一套”追着数据跑”的手工艺。
# 你的规则越来越复杂
def normalize_v2(word):
word = word.lower()
word = word.replace(“-”, “”)
word = word.replace(“_”, “”)
# 但是 microservice.v2 怎么办?
# microservice-v2 和 microservice_v2 要不要区分?
# re-sign 和 resign 怎么处理?
# ...
return word
这条路走不通。
方法三:按字符切分(Character-based)
既然按词切分会遇到 OOV(out-of-vocabulary)问题,那干脆走另一个极端:按字符切分。
text = “microservice”
tokens = list(text) # ['m', 'i', 'c', 'r', 'o', 's', 'e', 'r', 'v', 'i', 'c', 'e']
这个方法的好处是:永远不会遇到 OOV。因为字符集是固定的(比如 26 个英文字母 + 一些标点),你可以用一个很小的词表覆盖所有文本。
但代价是:序列会变得很长。
原本 “deploy microservice” 可能只需要 2 个 token,现在变成了 18 个字符。模型要处理的序列长度增加了 9 倍。
序列越长,计算量越大,训练和推理都变慢。而且模型需要从字符级别学习语义,学习难度也增加了。
子词(Subword):一个工程化的折中方案
到了 2010 年代中期,大家逐渐意识到:我们需要一个介于”词”和”字符”之间的粒度。
这就是子词(Subword)的思想:
- 不要赌完整单词(会遇到 OOV)
- 也不要把一切都打散成字符(序列太长)
- 去学一套”常见零件”,让新词可以拼出来
microservice 可以拆成 micro + service。这样:
microservice可以拼出来micro-service可以拼成micro+-+servicemicroservices可以拼成micro+service+s
不同的写法共享了 micro 和 service 这两个零件,词表不用无限长,模型也能学到它们之间的关系。
问题是:怎么找到这些”常见零件”?
你可以用语言学规则(比如词根、词缀),但规则很难覆盖所有语言,维护成本也很高。
BPE 给出了一个更工程化的答案:用统计的方式,让常见零件自己长出来。
BPE 的核心思想:让常见零件自己长出来
现在我们来看 BPE 是怎么工作的。
BPE 的训练过程可以分成三个阶段:
- 初始化:把所有文本拆成最小单位(字符或字节)
- 迭代合并:反复统计相邻对,合并出现最多的那对
- 得到词表和合并规则:用这些规则去切分新文本
让我们用一个具体的例子来演示整个过程。
第一步:准备语料
假设我们有这样一小段语料(为了演示,我们只用几个词):
corpus = [
“microservice”,
“microservices”,
“micro-service”,
]
第二步:初始化(拆成字符)
把每个词拆成字符序列。为了标记词的边界,我们在词尾加一个特殊符号 </w>(end of word):
words = [
['m', 'i', 'c', 'r', 'o', 's', 'e', 'r', 'v', 'i', 'c', 'e', '</w>'],
['m', 'i', 'c', 'r', 'o', 's', 'e', 'r', 'v', 'i', 'c', 'e', 's', '</w>'],
['m', 'i', 'c', 'r', 'o', '-', 's', 'e', 'r', 'v', 'i', 'c', 'e', 's', '</w>'],
]
这时候,我们的”词表”只包含这些字符:m, i, c, r, o, s, e, v, -, </w>。
第三步:统计相邻对
现在我们统计所有相邻两个符号的出现次数。
在第一个词 microservice</w> 中:
(m, i)出现 1 次(i, c)出现 2 次(位置 1-2 和位置 9-10)(c, r)出现 1 次(r, o)出现 1 次(o, s)出现 1 次(s, e)出现 1 次(e, r)出现 1 次(r, v)出现 1 次(v, i)出现 1 次(c, e)出现 1 次(e, </w>)出现 1 次
把三个词的统计结果加起来,我们会发现:
# 相邻对出现次数(部分)
pairs = {
('i', 'c'): 6, # 在三个词中都出现了 2 次
('e', 'r'): 3, # 在三个词中各出现 1 次
('r', 'v'): 3,
('v', 'i'): 3,
('c', 'e'): 3,
('m', 'i'): 3,
('c', 'r'): 3,
# ...
}
出现最多的是 (i, c),出现了 6 次。
第四步:合并最常见的对
我们把 (i, c) 合并成一个新的符号 ic:
words = [
['m', 'ic', 'r', 'o', 's', 'e', 'r', 'v', 'ic', 'e', '</w>'],
['m', 'ic', 'r', 'o', 's', 'e', 'r', 'v', 'ic', 'e', 's', '</w>'],
['m', 'ic', 'r', 'o', '-', 's', 'e', 'r', 'v', 'ic', 'e', 's', '</w>'],
]
同时,我们记录下这次合并:
merges = [
('i', 'c'), # 第 1 次合并
]
词表也更新了:m, ic, r, o, s, e, v, -, </w>(注意 i 和 c 还在,因为它们可能单独出现)。
第五步:重复这个过程
继续统计新的相邻对,合并出现最多的那对。
假设下一轮最常见的是 (m, ic),我们就合并成 mic:
words = [
['mic', 'r', 'o', 's', 'e', 'r', 'v', 'ic', 'e', '</w>'],
['mic', 'r', 'o', 's', 'e', 'r', 'v', 'ic', 'e', 's', '</w>'],
['mic', 'r', 'o', '-', 's', 'e', 'r', 'v', 'ic', 'e', 's', '</w>'],
]
merges = [
('i', 'c'), # 第 1 次合并
('m', 'ic'), # 第 2 次合并
]
再下一轮,可能是 (mic, r) → micr:
words = [
['micr', 'o', 's', 'e', 'r', 'v', 'ic', 'e', '</w>'],
['micr', 'o', 's', 'e', 'r', 'v', 'ic', 'e', 's', '</w>'],
['micr', 'o', '-', 's', 'e', 'r', 'v', 'ic', 'e', 's', '</w>'],
]
merges = [
('i', 'c'),
('m', 'ic'),
('mic', 'r'), # 第 3 次合并
]
继续下去,你会看到 micro、service 这样的片段逐渐长出来:
# 经过多轮合并后
merges = [
('i', 'c'),
('m', 'ic'),
('mic', 'r'),
('micr', 'o'), # micro 出现了
('s', 'e'),
('se', 'r'),
('ser', 'v'),
('serv', 'ic'),
('servic', 'e'), # service 出现了
('micro', 'service'), # microservice 出现了
# ...
]
什么时候停止?
你可以设定一个目标词表大小,比如 10000 个 token。当词表达到这个大小时,就停止合并。
或者你可以设定合并次数,比如合并 5000 次。
这是一个超参数,需要根据你的数据和任务来调整。
训练完成后,我们得到了什么?
训练完成后,我们得到两样东西:
-
词表(vocabulary):所有的 token 和它们的 ID
vocab = { 'm': 0, 'i': 1, 'c': 2, 'ic': 3, 'mic': 4, 'micro': 5, 'service': 6, 'microservice': 7, '-': 8, 's': 9, '</w>': 10, # ... } -
合并规则(merges):按顺序记录了每次合并
merges = [ ('i', 'c'), ('m', 'ic'), ('mic', 'r'), ('micr', 'o'), # ... ]
有了这两样东西,我们就可以对新文本进行编码了。
编码阶段:用合并规则切分新文本
现在假设用户输入了一个新词:microservice-v2。
我们要用训练好的合并规则,把它切成 token。
第一步:初始化
把新词拆成字符:
tokens = ['m', 'i', 'c', 'r', 'o', 's', 'e', 'r', 'v', 'i', 'c', 'e', '-', 'v', '2', '</w>']
第二步:按合并规则依次合并
按照训练时记录的合并顺序,依次尝试合并:
第 1 次合并:('i', 'c') → ic
tokens = ['m', 'ic', 'r', 'o', 's', 'e', 'r', 'v', 'ic', 'e', '-', 'v', '2', '</w>']
第 2 次合并:('m', 'ic') → mic
tokens = ['mic', 'r', 'o', 's', 'e', 'r', 'v', 'ic', 'e', '-', 'v', '2', '</w>']
第 3 次合并:('mic', 'r') → micr
tokens = ['micr', 'o', 's', 'e', 'r', 'v', 'ic', 'e', '-', 'v', '2', '</w>']
继续下去,最终可能得到:
tokens = ['micro', 'service', '-', 'v', '2', '</w>']
第三步:映射到 ID
用词表把 token 转换成 ID:
ids = [vocab[t] for t in tokens]
# [5, 6, 8, 15, 16, 10] # 假设的 ID
这就是模型最终看到的数字序列。
关键观察
注意到了吗?microservice-v2 这个词在训练语料中从未出现过,但我们依然能把它切分成有意义的片段:
micro和service是训练时学到的常见零件-,v,2是基础字符- 它们组合起来,就能表示这个新词
这就是 BPE 的威力:用有限的词表,覆盖无限的新词。
动手实现一个简单的 BPE
理论讲完了,现在让我们动手写代码。我会带你实现一个最简单的 BPE,让你真正理解它的工作原理。
这个实现不是工业级的(工业实现会有很多优化),但它能让你看清 BPE 的本质。
第一步:统计相邻对
首先,我们需要一个函数来统计所有相邻对的出现次数。
from collections import Counter
def count_pairs(words):
“””
统计所有相邻对的出现次数
参数:
words: 列表的列表,每个内层列表是一个词的 token 序列
例如: [['m', 'i', 'c', 'r', 'o'], ['m', 'i', 'c', 'r', 'o', 's']]
返回:
Counter 对象,键是 (token1, token2) 元组,值是出现次数
“””
pairs = Counter()
for word in words:
# 遍历每个词的相邻位置
for i in range(len(word) - 1):
pair = (word[i], word[i + 1])
pairs[pair] += 1
return pairs
让我们测试一下:
words = [
['m', 'i', 'c', 'r', 'o'],
['m', 'i', 'c', 'r', 'o', 's'],
]
pairs = count_pairs(words)
print(pairs)
# Counter({('m', 'i'): 2, ('i', 'c'): 2, ('c', 'r'): 2, ('r', 'o'): 2, ('o', 's'): 1})
可以看到,('m', 'i'), ('i', 'c'), ('c', 'r'), ('r', 'o') 都出现了 2 次,因为它们在两个词中都出现了。
第二步:合并一对符号
接下来,我们需要一个函数来执行合并操作。
def merge_pair(words, pair):
“””
在所有词中合并指定的相邻对
参数:
words: 列表的列表,每个内层列表是一个词的 token 序列
pair: 要合并的相邻对,例如 ('i', 'c')
返回:
合并后的新 words
“””
new_words = []
a, b = pair # 要合并的两个符号
for word in words:
new_word = []
i = 0
while i < len(word):
# 如果当前位置和下一个位置正好是要合并的对
if i < len(word) - 1 and word[i] == a and word[i + 1] == b:
new_word.append(a + b) # 合并成一个新符号
i += 2 # 跳过两个位置
else:
new_word.append(word[i]) # 保持原样
i += 1
new_words.append(new_word)
return new_words
测试一下:
words = [
['m', 'i', 'c', 'r', 'o'],
['m', 'i', 'c', 'r', 'o', 's'],
]
# 合并 ('i', 'c')
words = merge_pair(words, ('i', 'c'))
print(words)
# [['m', 'ic', 'r', 'o'], ['m', 'ic', 'r', 'o', 's']]
# 再合并 ('m', 'ic')
words = merge_pair(words, ('m', 'ic'))
print(words)
# [['mic', 'r', 'o'], ['mic', 'r', 'o', 's']]
第三步:完整的训练过程
现在我们把这两个函数组合起来,实现完整的 BPE 训练。
def train_bpe(corpus, num_merges):
“””
训练 BPE 模型
参数:
corpus: 字符串列表,每个字符串是一个词
num_merges: 要执行的合并次数
返回:
merges: 合并规则列表,按顺序记录每次合并
vocab: 最终的词表(集合)
“””
# 初始化:把每个词拆成字符,并在词尾加上 </w>
words = []
for word in corpus:
tokens = list(word) + ['</w>']
words.append(tokens)
print(f”初始状态: {words[:3]}”) # 打印前 3 个词
merges = [] # 记录合并规则
# 执行 num_merges 次合并
for i in range(num_merges):
# 统计所有相邻对
pairs = count_pairs(words)
if not pairs:
break # 没有可合并的对了
# 找出现次数最多的对
best_pair = pairs.most_common(1)[0][0]
print(f”\n第 {i+1} 次合并: {best_pair} (出现 {pairs[best_pair]} 次)”)
# 执行合并
words = merge_pair(words, best_pair)
# 记录这次合并
merges.append(best_pair)
print(f”合并后: {words[:3]}”) # 打印前 3 个词
# 构建最终词表
vocab = set()
for word in words:
vocab.update(word)
return merges, vocab
让我们用真实的例子来训练:
# 准备语料
corpus = [
“microservice”,
“microservice”,
“microservice”,
“microservices”,
“microservices”,
]
# 训练 BPE,执行 10 次合并
merges, vocab = train_bpe(corpus, num_merges=10)
print(“\n” + “=”*50)
print(“训练完成!”)
print(f”\n合并规则 (merges):”)
for i, merge in enumerate(merges, 1):
print(f” {i}. {merge[0]} + {merge[1]} → {merge[0] + merge[1]}”)
print(f”\n最终词表大小: {len(vocab)}”)
print(f”词表内容: {sorted(vocab)}”)
运行这段代码,你会看到类似这样的输出:
初始状态: [['m', 'i', 'c', 'r', 'o', 's', 'e', 'r', 'v', 'i', 'c', 'e', '</w>'], ...]
第 1 次合并: ('i', 'c') (出现 10 次)
合并后: [['m', 'ic', 'r', 'o', 's', 'e', 'r', 'v', 'ic', 'e', '</w>'], ...]
第 2 次合并: ('m', 'ic') (出现 5 次)
合并后: [['mic', 'r', 'o', 's', 'e', 'r', 'v', 'ic', 'e', '</w>'], ...]
第 3 次合并: ('mic', 'r') (出现 5 次)
合并后: [['micr', 'o', 's', 'e', 'r', 'v', 'ic', 'e', '</w>'], ...]
...
==================================================
训练完成!
合并规则 (merges):
1. i + c → ic
2. m + ic → mic
3. mic + r → micr
4. micr + o → micro
5. s + e → se
6. se + r → ser
7. ser + v → serv
8. serv + ic → servic
9. servic + e → service
10. micro + service → microservice
最终词表大小: 15
词表内容: ['</w>', 'c', 'e', 'i', 'ic', 'm', 'mic', 'micr', 'micro', 'microservice', 'o', 'r', 's', 'se', 'ser', 'serv', 'servic', 'service', 'v']
看到了吗?BPE 自动学到了 micro 和 service 这两个有意义的片段,最后甚至把它们合并成了完整的 microservice。
第四步:编码新文本
训练完成后,我们需要一个函数来对新文本进行编码。
def encode(text, merges):
“””
使用训练好的合并规则对文本进行编码
参数:
text: 要编码的字符串
merges: 训练时得到的合并规则列表
返回:
token 列表
“””
# 初始化:拆成字符
tokens = list(text) + ['</w>']
# 按照合并规则的顺序,依次尝试合并
for pair in merges:
a, b = pair
i = 0
while i < len(tokens) - 1:
if tokens[i] == a and tokens[i + 1] == b:
# 找到了可以合并的对
tokens = tokens[:i] + [a + b] + tokens[i + 2:]
else:
i += 1
return tokens
测试一下:
# 编码训练集中的词
text1 = “microservice”
tokens1 = encode(text1, merges)
print(f”{text1} → {tokens1}”)
# microservice → ['microservice', '</w>']
# 编码训练集中没有的词
text2 = “microservices”
tokens2 = encode(text2, merges)
print(f”{text2} → {tokens2}”)
# microservices → ['microservice', 's', '</w>']
# 编码完全没见过的词
text3 = “service”
tokens3 = encode(text3, merges)
print(f”{text3} → {tokens3}”)
# service → ['service', '</w>']
# 编码带连字符的词
text4 = “micro-service”
tokens4 = encode(text4, merges)
print(f”{text4} → {tokens4}”)
# micro-service → ['micro', '-', 'service', '</w>']
看到了吗?
microservice被编码成一个完整的 token(因为训练时见过)microservices被编码成microservice+s(复用了学到的片段)service被编码成一个 token(因为训练时学到了这个片段)micro-service被编码成micro+-+service(虽然没见过这个写法,但能用学到的片段拼出来)
这就是 BPE 的威力:不同的写法可以共享相同的零件。
完整代码
把上面的代码整合在一起:
from collections import Counter
def count_pairs(words):
pairs = Counter()
for word in words:
for i in range(len(word) - 1):
pair = (word[i], word[i + 1])
pairs[pair] += 1
return pairs
def merge_pair(words, pair):
new_words = []
a, b = pair
for word in words:
new_word = []
i = 0
while i < len(word):
if i < len(word) - 1 and word[i] == a and word[i + 1] == b:
new_word.append(a + b)
i += 2
else:
new_word.append(word[i])
i += 1
new_words.append(new_word)
return new_words
def train_bpe(corpus, num_merges):
words = []
for word in corpus:
tokens = list(word) + ['</w>']
words.append(tokens)
merges = []
for i in range(num_merges):
pairs = count_pairs(words)
if not pairs:
break
best_pair = pairs.most_common(1)[0][0]
words = merge_pair(words, best_pair)
merges.append(best_pair)
vocab = set()
for word in words:
vocab.update(word)
return merges, vocab
def encode(text, merges):
tokens = list(text) + ['</w>']
for pair in merges:
a, b = pair
i = 0
while i < len(tokens) - 1:
if tokens[i] == a and tokens[i + 1] == b:
tokens = tokens[:i] + [a + b] + tokens[i + 2:]
else:
i += 1
return tokens
# 使用示例
if __name__ == “__main__”:
corpus = [“microservice”] * 3 + [“microservices”] * 2
merges, vocab = train_bpe(corpus, num_merges=10)
print(“测试编码:”)
for text in [“microservice”, “microservices”, “micro-service”, “service”]:
tokens = encode(text, merges)
print(f” {text:20} → {tokens}”)
这段代码不到 100 行,但它实现了 BPE 的核心逻辑。你可以复制这段代码,自己跑一跑,改改参数,看看效果。
BPE 的变体和演进
BPE 解决了很多问题,但它也不是完美的。在实际应用中,人们发现了一些问题,并提出了改进方案。
WordPiece:Google 的改进
2016 年,Google 在做语音识别时,提出了 WordPiece 算法。它和 BPE 很像,但合并标准不同。
BPE 选择”出现次数最多”的相邻对来合并。WordPiece 选择”能让语言模型概率提升最大”的相邻对来合并。
简单来说,BPE 是频率驱动的,WordPiece 是概率驱动的。
WordPiece 后来被用在 BERT 中,成为了 NLP 领域的标准之一。
Unigram:反向思维
2018 年,Kudo 提出了 Unigram 算法。它的思路和 BPE 完全相反:
- BPE 是从小到大:从字符开始,不断合并,词表越来越大
- Unigram 是从大到小:从一个很大的候选词表开始,不断删除不重要的 token,词表越来越小
Unigram 用概率模型来评估每个 token 的重要性,删除那些对整体概率贡献最小的 token。
SentencePiece:统一的工具箱
2018 年,Google 发布了 SentencePiece,这是一个完整的分词工具库。
SentencePiece 的核心创新是:把空格也当成普通字符。
传统的分词器会先按空格切分,再对每个词做子词切分。但这对中文、日文这些没有空格的语言不友好。
SentencePiece 直接在原始文本上训练,不做任何预处理。空格被编码成一个特殊符号(通常是 ▁),和其他字符一视同仁。
这样,同一套工具可以处理所有语言,不需要针对不同语言写不同的规则。
SentencePiece 支持 BPE 和 Unigram 两种算法,你可以根据需要选择。
Byte-level BPE:彻底消灭 OOV
传统的 BPE 在字符级别工作。但不同语言的字符集不同,Unicode 字符有几万个,词表会很大。
Byte-level BPE 的思路是:在字节级别工作。
任何文本都可以编码成字节序列(UTF-8),字节只有 256 种。这样,初始词表只需要 256 个 token,就能覆盖所有可能的输入。
GPT-2 和 GPT-3 用的就是 Byte-level BPE。这也是为什么 GPT 可以处理任何语言、任何符号,甚至是乱码——因为它在字节级别工作,永远不会遇到”不认识”的字符。
BPE 在实际应用中的问题
虽然 BPE 很强大,但它也有一些问题。
问题一:对输入格式敏感
BPE 对输入的格式很敏感。同样的内容,格式稍微变一下,切分结果可能完全不同。
# 假设我们用 GPT 的分词器
text1 = “Hello world”
text2 = “Hello world” # 两个空格
text3 = “hello world” # 小写
# 它们的 token 数量可能不同
这在某些场景下会造成问题。比如你在计算 token 数量来估算成本时,多一个空格可能就多一个 token。
问题二:切分结果不直观
BPE 的切分结果有时候很反直觉。
# 常见词可能被切成一个 token
“microservice” → [“microservice”]
# 但稍微不常见的词可能被切得很碎
“microservicing” → [“micro”, “serv”, “icing”]
这对 debug 和理解模型行为造成了困难。
问题三:不同语言的效率差异
BPE 对英文很友好,因为英文有明确的词边界(空格)。但对中文、日文这些没有空格的语言,效率会低一些。
同样的语义内容,中文可能需要更多的 token。这也是为什么用 GPT 处理中文时,感觉”更贵”——因为 token 数量更多。
问题四:训练数据的偏差会被放大
BPE 是数据驱动的。如果训练数据有偏差,这个偏差会被编码到词表里。
比如,如果训练数据主要是英文,那么英文词会被切得很粗(一个词一个 token),而其他语言会被切得很碎(一个字符一个 token)。
这会导致模型在处理不同语言时的效率差异很大。
从 BPE 到 GPT:一个时代的基石
2018 年,OpenAI 发布了 GPT(Generative Pre-trained Transformer)。这个模型用的就是 BPE 分词。
GPT 的成功,很大程度上要归功于 BPE。BPE 让 GPT 可以用一个固定大小的词表(50257 个 token),处理几乎所有的英文文本,而不会遇到 OOV 问题。
后来的 GPT-2、GPT-3、GPT-4,都沿用了 BPE(或 Byte-level BPE)。
今天,当你和 ChatGPT 对话时,你输入的每一个字符,都会经过 BPE 的处理,被切分成 token,然后喂给模型。
那个 1994 年的压缩算法,经过 20 多年的演化,成为了 AI 时代的基石之一。
实用建议:如何用好 BPE
如果你在实际项目中使用大模型,这里有一些实用建议。
建议一:理解 token 的计费方式
大模型通常按 token 计费。理解 BPE 的切分逻辑,可以帮你优化成本。
- 常见词通常是一个 token
- 不常见的词会被切成多个 token
- 标点符号通常是单独的 token
- 空格可能会被合并到前面或后面的词里
你可以用 OpenAI 的 Tokenizer 工具 来查看具体的切分结果。
建议二:注意格式对 token 数量的影响
如果你在做 prompt 工程,要注意格式对 token 数量的影响。
# 这两种写法的 token 数量可能不同
prompt1 = “List: apple, banana, orange”
prompt2 = “List:\napple\nbanana\norange”
有时候,调整一下格式,可以节省不少 token。
建议三:理解上下文长度限制
模型的上下文长度限制(比如 GPT-3.5 的 4096 tokens),指的是 token 数量,不是字符数量。
如果你的文本被切得很碎,可能很快就会超过上下文限制。
建议四:不要过度依赖分词结果
虽然理解 BPE 很重要,但不要过度依赖分词结果来理解模型行为。
模型在训练时学到的是 token 之间的关系,而不是字符之间的关系。即使两个词被切成了不同的 token 序列,模型依然可能理解它们的语义关系。
总结:从 bug 到基石
让我们回到开头那个故事。
2016 年的那个工程师,盯着屏幕上的 <unk> 标记发呆。他不知道,就在同一年,BPE 正在被引入到神经机器翻译中。
两年后,GPT 发布,BPE 成为了大模型的标配。
再过几年,ChatGPT 横空出世,BPE 处理了数十亿次对话。
那个让人抓狂的 bug,最终被一个 1994 年的压缩算法解决了。
BPE 的故事告诉我们:好的工程方案,往往不是最复杂的,而是最合适的。
BPE 不需要语言学知识,不需要复杂的规则,只需要统计和合并。但它解决了一个困扰 NLP 领域多年的问题:如何用有限的词表,覆盖无限的新词。
今天,当你和 AI 对话时,当你计算 token 数量时,当你遇到上下文长度限制时,记得:背后站着的,是那个简单而优雅的算法。
数相邻对,合并最常见的那对,重复到满意为止。
就这么简单。
延伸阅读
如果你想深入了解 BPE 和相关技术,这里有一些推荐资源:
论文:
- Neural Machine Translation of Rare Words with Subword Units (2015) - BPE 在 NLP 中的开创性论文
- SentencePiece: A simple and language independent approach to subword tokenization (2018)
工具:
- OpenAI Tokenizer - 可视化查看 GPT 的分词结果
- SentencePiece - Google 的开源分词工具
- Hugging Face Tokenizers - 快速的分词库,支持多种算法
实践:
- 试着用本文的代码训练一个 BPE 模型,用你自己的数据
- 比较不同分词算法(BPE、WordPiece、Unigram)在你的任务上的效果
- 分析你常用的大模型的分词器,看看它是怎么切分你的输入的
记住:理解分词,就理解了大模型的第一步。