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_base | 50257 | GPT-2 / GPT-3 | BBPE 起点:256 字节 + 50000 merge + 1 特殊 token |
p50k_base | 50281 | Codex、早期 ChatGPT | 加入连续空格 token(实测最长 25 空格),Python 缩进从 9 token 压到 3;p50k_edit 追加 FIM 特殊 token |
cl100k_base | 100277 | GPT-3.5 / GPT-4、embedding-3 | 词表翻倍,多语言与代码覆盖大幅提升;连续空白 token 延伸到 128 |
o200k_base | 200019 | GPT-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 越高越省):
| 语料 | cl100k | o200k |
|---|---|---|
| 英文句 | 4.67 | 4.67 |
| 中文句 | 1.35 | 1.52 |
| 缅甸语短句 | 0.51 | 1.82 |
三点值得注意:
- 英文纹丝不动——词表扩容的名额几乎全给了非拉丁语系
- 中文在 cl100k 下约 1.35 字/token,o200k 提升有限,因为汉字本身信息密度已高
- 缅甸语在 cl100k 下
chars/token < 1,意味着几乎退化为逐字节编码:一个 3 字节字符耗掉 2–3 个 token。o200k 提升 3.6 倍,正是把这类语言的常见字节组合补进了 merge 表
这就是字节级的公平性代价:低资源语言承担数倍的 token 成本与等比例的 API 费用,且在固定上下文窗口里能装的内容更少。系统性讨论见 中文与多语言分词。
与字符级 BPE 的差异
| 维度 | 字符级 BPE | BBPE |
|---|---|---|
| 基表 | 语料字符集,可达上万 | 固定 256 字节 |
| OOV | 存在,落 <unk> | 结构性不可能 |
| 词表预算 | 基表挤占 merge 名额 | 基表仅占 256 席 |
| 单 token 可读性 | 恒为合法字符串 | 可能是非法 UTF-8 片段 |
| 生僻文字代价 | 丢失(unk) | 保留但极度膨胀 |
| 流式解码 | 逐 token 直接输出 | 必须按字节缓冲 |
折中路线是 SentencePiece 的 byte_fallback:以字符为基表训练,仅对未覆盖字符回退成字节 token。它保住了常见 token 的可读性,同时消除 OOV——LLaMA 系列走的正是这条路。至于「字节级 vs 字符级」在整条编解码管线中的位置,参见 Tokenizer 概述。