计费按 token、上下文窗口按 token、最大输出长度还是按 token;模型训练读进来的第一份数据是 token 序列,推理时每一步预测的也是 token。Tokenizer 就是把人类文本翻译成 token 的那一层,而 BPE(Byte Pair Encoding)是当下几乎所有大模型 tokenizer 的内核。本站前一篇《一篇读懂 Tokenizer:BPE 如何把文字切成 Token》讲过概念,这篇直接动手:用不到百行纯标准库 Python 实现训练与编码,在可复现的小语料上跑通验证,再对照工业级实现,看工程师在哪些地方做了关键取舍。
两个阶段:训练学合并表,编码重放合并
BPE 最容易被讲混的一点,是它有两个完全分开的阶段:
- 训练(learn merges):统计语料里所有相邻符号对,把最高频的一对合并成新符号加入词表,重复直到词表达到目标大小。产物是一张有序的合并表——每条规则记录「哪两个符号并成一个」。
- 编码(encode):面对新文本不再统计,只按合并表的先后顺序(先学到的优先)在块内贪心重放合并,最后把不可再分的符号查表映射成 id。
训练是反复统计的笨功夫,编码是轻量的查表。理解了这个不对称,后面的所有细节都会各归其位。
不到百行的实现
先给出完整实现——55 行,Python 3 纯标准库,只依赖 collections 和 re:
# 手撕 BPE 分词器:训练学合并表,编码重放合并;纯标准库
import collections
import re
def train_bpe(text: str, vocab_size: int) -> tuple[dict[str, int], list[tuple[str, str]]]:
"""阶段一:从语料学出合并表与词表。vocab_size 含 256 个基础字节。"""
words = [tuple(w) for w in text.encode("utf-8").decode("latin-1").split()]
merges: list[tuple[str, str]] = []
for _ in range(vocab_size - 256): # 每轮产出一条合并规则
pairs = collections.Counter()
for w in words:
pairs.update(zip(w, w[1:])) # 统计所有相邻对
if not pairs:
break
best = max(pairs.items(), key=lambda kv: (kv[1], kv[0]))[0]
merges.append(best)
words = [merge_word(w, best) for w in words] # 语料内原地合并
vocab = {chr(i): i for i in range(256)} # 256 个字节是词表地基
for a, b in merges:
vocab[a + b] = len(vocab) # 新 token = 两部分拼接
return vocab, merges
def merge_word(word: tuple[str, ...], pair: tuple[str, str]) -> tuple[str, ...]:
out, i = [], 0
while i < len(word):
if i + 1 < len(word) and (word[i], word[i + 1]) == pair:
out.append(word[i] + word[i + 1]) # 命中规则:两个符号并成一个
i += 2
else:
out.append(word[i])
i += 1
return tuple(out)
def encode(text: str, vocab: dict[str, int], merges: list[tuple[str, str]]) -> list[int]:
"""阶段二:按学到的合并表把任意文本切成 token 并映射为 id。"""
ranks = {pair: r for r, pair in enumerate(merges)} # 越早学到的合并优先级越高
ids = []
for piece in re.findall(r"\S+|\s+", text): # 预分词:词与空白各自成块
word = tuple(piece.encode("utf-8").decode("latin-1"))
while len(word) > 1:
pair = min(zip(word, word[1:]), key=lambda p: ranks.get(p, 1 << 30))
if pair not in ranks:
break # 没有可合并的对,收工
word = merge_word(word, pair)
ids.extend(vocab[s] for s in word) # 不可再分的符号查词表
return ids
def decode(ids: list[int], vocab: dict[str, int]) -> str:
inv = {v: k for k, v in vocab.items()}
# token 边界不必对齐字符边界,半个 UTF-8 字符是合法状态,用 replace 兜底
return "".join(inv[i] for i in ids).encode("latin-1").decode("utf-8", "replace")
逐段拆解:
字节是地基。 train_bpe 第一行把文本转成 UTF-8 字节,再用 latin-1 把每个字节映成一个字符——chr(0) 到 chr(255) 恰好与 256 个字节一一对应且完全可逆。词表的底座就是这 256 个单字节条目,之后合并出的任何 token 都是它们的拼接。这样做的收益是任何文本都有无损表示:没见过的字、emoji、二进制都不存在 OOV(词表外的词),最差退回逐字节编码。
训练循环。 每轮用 collections.Counter 统计所有词内相邻对,max 取计数最高的一对(平手时取字典序更大者以保证可复现;工程实现各有自己的确定性规则),merge_word 在语料里原地执行合并。跑满 vocab_size - 256 轮,合并表与词表同时就绪——注意词表大小是精确控制的:256 个字节加 32 条合并,词表就是 288,一个不多一个不少。
编码是贪心重放。 encode 先做预分词(下一节详谈),每块内反复找出优先级最高——也就是最早学到——的可合并对并合并,直到无对可合。它与训练的本质区别在于不重新统计频率,只是按既定顺序重放,所以同一个词无论出现在什么上下文里,编码结果恒定,tokenizer 才是一个纯函数。
解码要容忍半截字符。 token 边界不必对齐字符边界,一个 token 可能只装着某个汉字 UTF-8 编码的前两个字节。GPT-2 的解码器默认 errors='replace',本文照做:不完整的字节序列显示成 U+FFFD,而不是让解码抛异常。
小语料实测:训练、编码、往返一致
代码跑过才算数。语料是三句关于 tokenizer 的英文重复 8 遍——重复是为了把频次差距拉开:
from bpe import train_bpe, encode, decode
corpus = (
"the token is the basic unit of a language model\n"
"the model reads tokens and predicts the next token\n"
"tokens can be words or parts of words\n"
) * 8
vocab, merges = train_bpe(corpus, vocab_size=288) # 256 个字节 + 32 条合并
print(merges[:8])
print(len(corpus), len(encode(corpus, vocab, merges))) # 1096 -> 560
samples = [
"the model predicts the next token", # 都是语料里的词
"Tokenization 大模型, emoji 🚀, and code: x = f(y) + 1",
" leading spaces and double spaces\n\n", # 空白也不能丢
]
for s in samples:
ids = encode(s, vocab, merges)
assert decode(ids, vocab) == s # 往返一致性断言
print(len(s), "->", len(ids), [decode([i], vocab) for i in ids])
print("all roundtrips OK")
在本机 Python 3.13 上实际运行,输出(大模型 与 emoji 的多字节字符在逐 token 打印时显示为 U+FFFD,属正常现象):
[('t', 'o'), ('to', 'k'), ('tok', 'e'), ('toke', 'n'), ('t', 'h'), ('th', 'e'), ('o', 'r'), ('d', 's')]
1096 560
33 -> 14 ['the', ' ', 'model', ' ', 'p', 'redicts', ' ', 'the', ' ', 'n', 'e', 'xt', ' ', 'token']
49 -> 55 ['T', 'o', 'k', 'e', 'n', 'i', 'z', 'a', 't', 'i', 'o', 'n', ' ', '�', '�', '�', '�', '�', '�', '�', '�', '�', ',', ' ', 'e', 'm', 'o', 'j', 'i', ' ', '�', '�', '�', '�', ',', ' ', 'an', 'd', ' ', 'c', 'ode', ':', ' ', 'x', ' ', '=', ' ', 'f', '(', 'y', ')', ' ', '+', ' ', '1']
38 -> 37 [' ', ' ', 'l', 'e', 'a', 'd', 'i', 'n', 'g', ' ', 's', 'p', 'a', 'c', 'e', 's', ' ', 'an', 'd', ' ', ' ', 'd', 'o', 'u', 'b', 'l', 'e', ' ', ' ', 's', 'p', 'a', 'c', 'e', 's', '\n', '\n']
all roundtrips OK
三个观察:
- 常见组合自己长成了 token。 前 8 条合并一路把
t+o、to+k、tok+e、toke+n拼成token,32 条合并里最终出现了token、tokens、the、words、model、unit——高频词不需要人工词典,统计天然把「经常一起出现的字节」捏合起来;tokens由token+s得到,常见后缀也是自动长出来的。 - 也会长出半个词。
redicts来自 predicts,但前导的p没挤进合并预算——频次相当的高频对很多,而合并条数有限,谁上位取决于频次和平手规则。这是 BPE 的常态而非 bug,工业词表里同样充满这类碎片。 - 没见过的内容无损退回字节。
大模型编码成 9 个 token(每个汉字 UTF-8 占三字节),emoji 同理;即便如此,assert decode(encode(s)) == s对三段样本(含缩进、双空格、连续换行)全部通过——字节级兜底保证往返一致,这就是 GPT-2 论文选择字节级 BPE 的理由。
预分词:不是优化,是正确性设计
把实验改一个参数:训练时不按词切开,把整段语料(含空白)当成一条序列,会怎样?
import collections
from bpe import merge_word, decode
corpus = (
"the token is the basic unit of a language model\n"
"the model reads tokens and predicts the next token\n"
"tokens can be words or parts of words\n"
) * 8
# 反例:不做预分词,把整段语料(含空白)当成一条序列来训练
word = tuple(corpus.encode("utf-8").decode("latin-1"))
merges2 = []
for _ in range(32):
pairs = collections.Counter(zip(word, word[1:]))
best = max(pairs.items(), key=lambda kv: (kv[1], kv[0]))[0]
merges2.append(best)
word = merge_word(word, best)
cross = [a + b for a, b in merges2 if " " in a + b or "\n" in a + b]
print(len(cross), cross[:6])
# 编码同样不做预分词:整句当一条序列
def encode_no_pretok(text, vocab, merges):
ranks = {pair: r for r, pair in enumerate(merges)}
word = tuple(text.encode("utf-8").decode("latin-1"))
while len(word) > 1:
pair = min(zip(word, word[1:]), key=lambda p: ranks.get(p, 1 << 30))
if pair not in ranks:
break
word = merge_word(word, pair)
return [vocab[x] for x in word]
vocab2 = {chr(i): i for i in range(256)}
for a, b in merges2:
vocab2[a + b] = len(vocab2)
for s in ["next token please", "the model"]:
print([decode([i], vocab2) for i in encode_no_pretok(s, vocab2, merges2)])
实际运行输出:
16 ['s ', 'e ', 'the ', 'tokens ', 'ts ', 't ']
['n', 'e', 'xt token', ' p', 'l', 'e', 'a', 's', 'e']
['the ', 'model']
32 条合并里 16 条跨过了词边界:s 、e 、the 、tokens ……(完整列表里还有 p、\nthe 这种把行首也粘进来的)。更刺眼的是编码结果:next token please 被切出了 xt token 这个横跨两个单词的 token——它只是因为语料里 next token 恰好相邻出现 8 次,就被捏成了一体。这种 token 的语义完全取决于训练语料的排版巧合:换一批语料边界就变,同一个词在不同上下文里也会被切成不同样子,词表里塞满了这类「上下文特供」的碎片。
所以现代 BPE 实现都先按正则预分词、再在块内做 BPE。GPT-2 的 encoder.py 里,这行正则可能是整个 tokenizer 最有信息量的一行代码:
's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+
它把文本切成缩写、字母串、数字串、标点串与空白,合并永远不越过这些边界。本文的玩具实现用了极简版 re.findall(r"\S+|\s+", text):空白自成一块、永不参与合并;GPT-2 则更进一步,把词前的一个空格并入词块——所以它的词表里有 the 这类带前导空格的 token(英语里词前空格高频出现,这样切更省 token)。预分词正则不是性能优化,它定义了「合并可以在哪里发生」,直接决定词表的形态。
工业对照:GPT-2 与 tiktoken
把玩具实现放大上百倍就是工业现状。以下数字与行为均为本次在 tiktoken 0.14.0 上实测(截至 2026-10-07 的 PyPI 最新版):
- GPT-2(2019)确立了字节级 BPE 的标准配方:词表 50257 = 256 个字节 + 50000 条合并 + 1 个 special token
<|endoftext|>(id 50256)。tiktoken 源码对gpt2编码显式标注explicit_n_vocab = 50257。 - cl100k_base(GPT-3.5/GPT-4 系列在用):100256 个可合并条目,special token 另行编址——
<|endoftext|>是 100257、<|endofprompt|>是 100276,词表总量 100277。 - o200k_base(GPT-4o 起):199998 个可合并条目 + special token,总量 200019。
- special token 是「保留字」。 默认
enc.encode("hello <|endoftext|>")直接抛ValueError:这类字符串能被用来伪造对话边界(提示词注入的经典手法),tiktoken 要求显式传allowed_special才肯把它编码成一个 id;encode_ordinary则把它当普通文本切成碎片。这是玩具实现完全不会告诉你的一层安全设计。 - 数字被刻意切碎。 cl100k 的预分词正则含
\p{N}{1,3},1234567恒切成123、456、7三段;GPT-2 无此规则,同一串切成123、45、67。等宽数字切分对算术类任务的稳定性更友好(各家动机表述不一,行为本身以实测为准)。 - 工程栈现状。 tiktoken(OpenAI 出品,Rust 核心 + Python 壳)适合编码与计数侧复现;Hugging Face tokenizers(Rust,PyPI 最新 0.23.2)是训练侧与开源模型生态的标配。两边都不用标准库
re:tiktoken 依赖regex库——\p{L}这类 Unicode 属性类标准库正则不支持;tokenizers 的预分词正则则直接在 Rust 侧求值。
边界与坑
- 中文与多语言:英文常见词一两个 token,中文取决于词表覆盖。cl100k 里
大模型恰好每字一个 token,而手撕BPE分词器被切成 9 个 token——部分汉字直接退化到字节级。token 密度差异直接影响中文文本的计费与上下文利用率,跨模型估算成本时必须各自实测。 - 代码:
def train_bpe(text):在 cl100k 里是 6 个 token:def、train、_b、pe、(text、):。缩进与标点各自成 token,token 边界与语法边界毫无关系——模型看到的只是碎片序列,只不过这些碎片见得多了才有了语义。 - 数字的坑:除 1–3 位分组外,token 边界也不保证对齐数值位(GPT-2 把
1234567切成123、45、67)。凡是数字当字符串处理的任务——算术、日期推算、金额核对——都受切分方式影响,做数值类评测前先看一眼 tokenizer 怎么切。 - 换 tokenizer ≈ 换模型接口:token id 只是词表的下标,输入侧的 embedding 矩阵和输出头的形状都与词表绑死。换词表意味着这两块参数全部作废,模型要重训或至少重训 embedding;同一文本的 token 数也会变化,计费与上下文预算全部重来。所以「模型家族」往往长期固定词表——tokenizer 是模型接口契约的一部分,而不只是预处理工具。
小结
BPE 的全部逻辑就两句话:训练时反复把最高频相邻对合成新符号;编码时按学到的顺序贪心重放。再叠加两个工程决策——先预分词再块内合并、字节级兜底——就是从 GPT-2 到今天主流模型仍在用的 tokenizer。全文实现 55 行,验证脚本在本机 Python 3.13 全部跑通、断言全过。建议把语料换成自己领域的文本重训一遍,看看哪些 token 会长出来——这是理解任何模型 tokenizer 最便宜的方式。
参考资料
- Neural Machine Translation of Rare Words with Subword Units(Sennrich et al., ACL 2016,BPE 进入 NLP 的起点)
- Language Models are Unsupervised Multitask Learners(GPT-2 论文,2019,字节级 BPE 配方)
- openai/gpt-2 仓库 encoder.py(预分词正则与 BPE 编码的参考实现)
- openai/tiktoken(工业级 BPE 实现与各编码的词表定义)
- 本站概念篇:一篇读懂 Tokenizer:BPE 如何把文字切成 Token
读者留言
COMMENTS 暂无还没有留言,来说第一句?