Tokenizer,即分词器,说到底只有这两个功能:
- 切分句子为单词
- 将单词映射为其对应的ID

计算机读不懂单词、字母、汉字,所以要把字符映射为数字 这就是 Tokenizer 诞生的初衷。它的任务不仅仅是把句子切开,而是要建立一套映射标准。 它要告诉模型:在这个世界里,
- “我” 不是一个汉字,而是编号 1001。
- “爱” 不是一种情感,而是编号 2345。
- “你” 不是一个对象,而是编号 5678。
一旦完成了这一步转换,原本无法计算的文字处理任务,就变成了数学上的序列预测问题。模型不再是在“写作文”,而是在计算:当数列 [1001, 2345] 出现时,下一个数字是 5678 的概率是多少?
字符切分(Character-based)方案:
“h e l l o” ↓ [104, 101, 108, 108, 111]
一个大优点是不会OOV(Out of Vocabulary) 一个大坏处是难以学到”词意”,比如apple变成了a p p l e,它很难拼出来这就是apple,这就导致训练需要更长的链条:字符 → 子词 → 单词 → 语义(举个例子:c+a+t → “cat” → 动物 → 语义);而不是现代的:子词(token) → 语义 另一个好处是字符会很少,整套英文只需要存26个字母,加上常用的各种符号数字,也不过256个左右。坏处是哪怕是apple这个单词,都会需要五个维度去储存,非常稀疏,而且似乎这种方法无法表示汉字(可以只存常用的5k个汉字,但这样其实存储字符少的优点比起BPE也不大了)
单词切分 (Word-based)方案:
“hello world” ↓ [1234, 5678]
优点:
- 语义直接对齐,dog就是狗的意思
- 序列很短,hello一个ID即可表示
- 训练更容易,token = 语义单位
缺点:
- OOV,比如ChatGPT-6.0,单词表里没有,直接不理解
- 词表爆炸,60w英文单词,中文更是灾难,各种词语,成语,俚语都要收录,不然就是一个OOV
- run,running,runner,是完全不同的ID,根本理解不到他们的语义相关性
BPE(Byte Pair Encoding)方案:
核心思想 从字符开始,反复合并最常见的“相邻字符对”
举个例子
训练数据:
low lowest newer wider
初始:
l o w l o w e s t
第一步:统计频率
“l o” 出现最多 → 合并
👉 新 token:
lo
不断重复:
lo + w → low
** 最终得到:**
[“low”, “est”, “new”, “er”]
我们可以使用下面的代码尝试一下最简单的BPE:
from collections import Counter
def get_stats(words):
pairs = Counter()
for word in words:
for i in range(len(word) - 1):
pairs[(word[i], word[i + 1])] += 1
return pairs
def merge_pair(words, pair):
a, b = pair
merged = []
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
merged.append(new_word)
return merged
def train_bpe(corpus, num_merges=10):
words = [list(word) for word in corpus]
merges = []
for _ in range(num_merges):
stats = get_stats(words)
if not stats:
break
best_pair = stats.most_common(1)[0][0]
merges.append(best_pair)
words = merge_pair(words, best_pair)
return merges
def encode(word, merges):
tokens = list(word)
for pair in merges:
tokens = merge_pair([tokens], pair)[0]
return tokens
# demo
corpus = ["low", "lowest", "newer", "wider"]
merges = train_bpe(corpus, num_merges=10)
print("merges:", merges)
print("encode('lowest'):", encode("lowest", merges))
print("encode('newer'):", encode("newer", merges))
上面的代码运行结果是:
merges: [('l', 'o'), ('lo', 'w'), ('e', 'r'), ('low', 'e'), ('lowe', 's'), ('lowes', 't'), ('n', 'e'), ('ne', 'w'), ('new', 'er'), ('w', 'i')]
encode('lowest'): ['lowest']
encode('newer'): ['newer']
一.先把每个单词切分为Character-based级别,这个操作只需要下面一行代码:
words = [list(word) for word in corpus]
例如:
["low", "lowest"] -> [['l', 'o', 'w'], ['l', 'o', 'w', 'e', 's', 't']]
二.统计每个单词里的相邻两个字符的出现频率
这就是 get_stats 函数做的事情。
最开始一轮,学到的 pairs 大致有:
Counter({
('l', 'o'): 2,
('o', 'w'): 2,
('w', 'e'): 2,
('e', 'r'): 2,
('e', 's'): 1,
('s', 't'): 1,
('n', 'e'): 1,
('e', 'w'): 1,
('w', 'i'): 1,
('i', 'd'): 1,
('d', 'e'): 1
})
这里要注意,BPE 不是把所有字符打乱之后去统计“谁出现得多”,而是统计谁和谁经常相邻出现。它依赖的是顺序信息,而不是单个字符的全局频率。
三.从这些 pairs 里找到频率最大的那个,然后在每个 word 里按照这个 pair 合并
具体合并就是每个 word 里,从这个 list 头到尾扫描,每次看看当前两个元素是不是 best_pair,
是的话就合并,不是就继续往下扫描。
所以这个过程的本质很像一种贪心合并。
四.执行合并规则之后,重新开始新一轮循环 每执行完一次合并,整个语料的 token 序列就都会发生变化。 于是下一轮统计出来的“最高频相邻对”也会跟着变化,BPE 就这样一轮一轮把更大的子词拼出来。
比如一开始可能先学到:
('l', 'o') -> 'lo'
('lo', 'w') -> 'low'
('e', 'r') -> 'er'
所以,BPE 真正学到的并不是“某个单词长什么样”,而是一套 merge rules(合并规则)。 后面碰到新单词时,也不是去查完整单词字典,而是按照这些规则一步一步合并。
五.到这里为止,上面的代码其实还只做了一半 它学会了 怎么合并字符、怎么把字符串切成 token,但还没有完成 Tokenizer 最关键的另一半:
encode:把文本变成 token,再变成 iddecode:把 id 还原成 token,再还原成文本
也就是说,上面的代码更像一个最小 BPE 训练器,而不是一个完整的 Tokenizer。
六.Tokenizer 还需要一张 vocab 表 BPE 训练完之后,我们已经拿到了很多 token。 一开始的 token 来自最小单位,也就是字符本身;后续每做一次 merge,又会得到一个新的 token。
所以 vocab 不是“训练结束之后凭空冒出来”的,而是:
- 先有一套初始 token
- 再把 BPE 合并出来的新 token 继续加进去
在工业级 Tokenizer 里,初始 vocab 往往一开始就有内容,比如常用字符,或者更常见地,直接是 256 个 byte。 但在我们这个最小例子里,可以直接从训练语料里收集字符,再把 merge 出来的 token 加进去:
def build_vocab(corpus, merges):
base_tokens = sorted(set("".join(corpus)))
vocab = {}
for token in base_tokens:
vocab[token] = len(vocab)
for a, b in merges:
token = a + b
if token not in vocab:
vocab[token] = len(vocab)
return vocab
这样,vocab 就是一个最简单的 token -> id 映射。
比如它可能长这样:
{
'd': 0,
'e': 1,
'i': 2,
'l': 3,
'n': 4,
'o': 5,
'r': 6,
's': 7,
't': 8,
'w': 9,
'lo': 10,
'low': 11,
'er': 12
}
七.encode:文本 -> token -> id 这时我们才算真正拥有了一个可以编码文本的 Tokenizer。
def encode_to_ids(word, merges, vocab):
tokens = encode(word, merges)
ids = [vocab[token] for token in tokens]
return tokens, ids
把前面的代码接起来:
corpus = ["low", "lowest", "newer", "wider"]
merges = train_bpe(corpus, num_merges=10)
vocab = build_vocab(corpus, merges)
tokens, ids = encode_to_ids("lowest", merges, vocab)
print("tokens:", tokens)
print("ids:", ids)
tokens, ids = encode_to_ids("newer", merges, vocab)
print("tokens:", tokens)
print("ids:", ids)
如果 lowest 被切成 ['low', 'est'],那它后面就会再变成类似 [11, 15] 这样的 id 序列;
如果在这个玩具例子里它最终被直接合并成了 ['lowest'],那也会对应成一个单独的 id。
这时候就能看出:BPE 负责的是“怎么切”,vocab 负责的是“切完之后怎么编号”。
八.decode:id -> token -> 文本
既然有 encode,就一定要有 decode。
否则模型吐出来一串 id,人类还是看不懂。
def decode_from_ids(ids, vocab):
id_to_token = {idx: token for token, idx in vocab.items()}
tokens = [id_to_token[i] for i in ids]
text = "".join(tokens)
return tokens, text
继续测试:
tokens, ids = encode_to_ids("lowest", merges, vocab)
decoded_tokens, decoded_text = decode_from_ids(ids, vocab)
print("decoded_tokens:", decoded_tokens)
print("decoded_text:", decoded_text)
这时整个闭环就完整了:
text -> tokens -> ids -> tokens -> text
Tokenizer 真正做的事情,其实就是这一整套双向翻译。
九.这里要注意一个小细节 由于我们前面设置的是:
num_merges = 10
这个数字对这么小的语料来说已经比较大了,所以最后 lowest 和 newer 很可能会被直接合并成一个完整 token。
于是你会看到:
encode("lowest", merges) -> ['lowest']
encode("newer", merges) -> ['newer']
这并不影响理解,因为 BPE 的核心思想没有变: 先从最小单位出发,不断合并高频相邻对,再把最终得到的 token 映射为 id。
如果想更明显地看到 low + est、new + er 这种“子词效果”,可以把 num_merges 调小一些。
十.所以,BPE 和 Tokenizer 其实不是一回事 严格来说:
- BPE 负责学习“怎么合并、怎么切分”
- vocab 负责记录“每个 token 对应哪个 id”
- encode / decode 负责完成“文本世界”和“数字世界”之间的双向转换
三者合起来,才是一个完整的 Tokenizer。
参考文献:
全网最全的大模型分词器(Tokenizer)总结 - 干的就是大模型的文章 - 知乎 https://zhuanlan.zhihu.com/p/1947688136684574286
大模型回归基本功之1——Tokenizer:万物皆可被数字化 - Ryann的文章 - 知乎 https://zhuanlan.zhihu.com/p/1991625129969615976
【LLM拆了再装】 Tokenizer篇 - coreyzhong的文章 - 知乎 https://zhuanlan.zhihu.com/p/700283095
交流讨论