BBPE 只改了 BPE 的一件事:把合并的原子单元从 Unicode 字符换成 UTF-8 字节。代价是词表里塞进了 256 个无语义的字节符,换来的是 OOV 在数学上不可能发生——任何语言、任何 emoji、任何二进制垃圾都有确定的编码结果。

字节作为基表

字符级 BPE 的基表是训练语料里出现过的字符集合。这带来两个绕不开的问题:

  • 基表膨胀:要覆盖 CJK 就得放进上万个汉字,基表本身吃掉大半词表预算,留给 merge 的名额所剩无几
  • OOV 无法根除:无论基表多大,总有没见过的字符(生僻字、新 emoji、Unicode 新增区块),只能落到 <unk>,信息直接丢失

BBPE(byte-level BPE,Wang et al., 2019;GPT-2 将其带入主流)把基表钉死在 256 个字节上。UTF-8 是完备编码,任意 Unicode 码点都能表示成 1–4 字节序列,于是基表覆盖全 Unicode 且大小恒定。所有 GPT 系词表都严格保留这 256 个单字节 token 作为兜底层:

import tiktoken
 
# 四代词表都完整保留 256 个单字节 token —— 字节级兜底的结构性保证
for name in ["r50k_base", "p50k_base", "cl100k_base", "o200k_base"]:
    ranks = tiktoken.get_encoding(name)._mergeable_ranks
    single = sum(1 for tok in ranks if len(tok) == 1)  # tok 是 bytes
    print(f"{name:14s} 单字节 token 数 = {single}")

输出全为 256。任何未在词表中出现的字符,最坏情况按字节逐个落地,不存在 unk 路径。

bytes_to_unicode:把字节伪装成字符

原生 BPE 实现在字符串上跑正则与合并,直接喂裸字节会撞上控制字符与空格——不可打印、易被正则吞掉、序列化后无法还原。GPT-2 的解法是一层双射代理映射:

  • 188 个本身可打印的字节(!–~、¡–¬、®–ÿ)保持原样
  • 剩余 68 个不可打印字节整体搬到 U+0100(Ā)起始的空区
def bytes_to_unicode():
    """GPT-2 的字节到可打印字符双射:188 个保留 + 68 个平移到 U+0100 起。"""
    bs = (list(range(ord("!"), ord("~") + 1))
          + list(range(ord("¡"), ord("¬") + 1))
          + list(range(ord("®"), ord("ÿ") + 1)))
    cs = bs[:]
    n = 0
    for b in range(256):
        if b not in bs:          # 不可打印字节,平移到私用空区
            bs.append(b)
            cs.append(256 + n)
            n += 1
    return dict(zip(bs, [chr(c) for c in cs]))
 
enc = bytes_to_unicode()
print(len(enc), enc[32], enc[0])          # 256  'Ġ'  'Ā'
print("".join(enc[b] for b in " 好".encode("utf-8")))  # Ġ好

这解释了 HF 词表文件里那些诡异字符串:Ġ 是空格的代理符,好 是「好」的三个 UTF-8 字节各自的代理符。它是纯表示层技巧,不参与任何统计。

编解码管线

编码:文本 → UTF-8 字节 → 代理字符 → 正则预切分 → 贪心 merge → token id。解码严格逆行,最后一步 bytes.decode("utf-8")。

关键推论:token 边界与字符边界无关。一个汉字占 3 字节,merge 完全可能只吃掉其中 2 个:

enc = tiktoken.get_encoding("cl100k_base")
ids = enc.encode("词")
print([enc.decode_single_token_bytes(i) for i in ids])
# [b'\xe8\xaf', b'\x8d'] —— 一个汉字被切成两个非法 UTF-8 片段

由此派生两类工程后果:

  • 流式解码必须缓冲:逐 token 解码会在半个汉字上抛 UnicodeDecodeError,或用 errors="replace" 吐出 �。正确做法是累积字节、只在能完整解码时才输出
  • 单 token 不可独立渲染,可视化工具里的乱码往往是这个原因,而非编码 bug

更深层的影响(prompt 边界敏感、glitch token)见 Tokenizer 对推理的影响。

GPT 词表路线

词表n_vocab主要使用者关键变化
r50k_base50257GPT-2 / GPT-3BBPE 起点:256 字节 + 50000 merge + 1 特殊 token
p50k_base50281Codex、早期 ChatGPT加入连续空格 token(实测最长 25 空格),Python 缩进从 9 token 压到 3;p50k_edit 追加 FIM 特殊 token
cl100k_base100277GPT-3.5 / GPT-4、embedding-3词表翻倍,多语言与代码覆盖大幅提升;连续空白 token 延伸到 128
o200k_base200019GPT-4o 及后续再翻倍,重心在非拉丁语系与生僻符号

同步演进的是正则预切分规则,它决定 merge 不许跨越哪些边界:

  • r50k/p50k 共用同一模式,仅英文缩写 's|'t|... 且大小写敏感——'S 切法与 's 不同
  • cl100k 起数字被限制为 \p{N}{1,3},长数字强制按至多 3 位切块(对算术行为影响显著);缩写匹配改为大小写无关;换行单独成组
  • o200k 进一步按大小写类别(\p{Lu}/\p{Ll})细分字母串

也就是说,同为 BBPE,四代词表的差异既在 merge 表也在正则,两者都不向后兼容。OpenAI 侧的实现细节见 Tiktoken。

多语言覆盖:保证不等于高效

字节级只保证「编得出」,不保证「编得短」。同一句话在不同词表下的实测(chars/token 越高越省):

语料cl100ko200k
英文句4.674.67
中文句1.351.52
缅甸语短句0.511.82

三点值得注意:

  • 英文纹丝不动——词表扩容的名额几乎全给了非拉丁语系
  • 中文在 cl100k 下约 1.35 字/token,o200k 提升有限,因为汉字本身信息密度已高
  • 缅甸语在 cl100k 下 chars/token < 1,意味着几乎退化为逐字节编码:一个 3 字节字符耗掉 2–3 个 token。o200k 提升 3.6 倍,正是把这类语言的常见字节组合补进了 merge 表

这就是字节级的公平性代价:低资源语言承担数倍的 token 成本与等比例的 API 费用,且在固定上下文窗口里能装的内容更少。系统性讨论见 中文与多语言分词。

与字符级 BPE 的差异

维度字符级 BPEBBPE
基表语料字符集,可达上万固定 256 字节
OOV存在,落 <unk>结构性不可能
词表预算基表挤占 merge 名额基表仅占 256 席
单 token 可读性恒为合法字符串可能是非法 UTF-8 片段
生僻文字代价丢失(unk)保留但极度膨胀
流式解码逐 token 直接输出必须按字节缓冲

折中路线是 SentencePiece 的 byte_fallback:以字符为基表训练,仅对未覆盖字符回退成字节 token。它保住了常见 token 的可读性,同时消除 OOV——LLaMA 系列走的正是这条路。至于「字节级 vs 字符级」在整条编解码管线中的位置,参见 Tokenizer 概述。