手撕 BPE 分词器:不到百行 Python 看懂 Tokenizer

计费按 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

三个观察:

  1. 常见组合自己长成了 token。 前 8 条合并一路把 t+o、to+k、tok+e、toke+n 拼成 token,32 条合并里最终出现了 token、tokens、the、words、model、unit——高频词不需要人工词典,统计天然把「经常一起出现的字节」捏合起来;tokens 由 token+s 得到,常见后缀也是自动长出来的。
  2. 也会长出半个词。 redicts 来自 predicts,但前导的 p 没挤进合并预算——频次相当的高频对很多,而合并条数有限,谁上位取决于频次和平手规则。这是 BPE 的常态而非 bug,工业词表里同样充满这类碎片。
  3. 没见过的内容无损退回字节。 大模型 编码成 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 最便宜的方式。

参考资料

  1. Neural Machine Translation of Rare Words with Subword Units(Sennrich et al., ACL 2016,BPE 进入 NLP 的起点)
  2. Language Models are Unsupervised Multitask Learners(GPT-2 论文,2019,字节级 BPE 配方)
  3. openai/gpt-2 仓库 encoder.py(预分词正则与 BPE 编码的参考实现)
  4. openai/tiktoken(工业级 BPE 实现与各编码的词表定义)
  5. 本站概念篇:一篇读懂 Tokenizer:BPE 如何把文字切成 Token
← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?