bobofamilyNIU CHUN
31/36

编号已经有了:minbpe 怎么恢复规则,又怎么处理特殊 Token

2026-10-099 分钟—阅读

前一篇的 GPT-2 要先把字节换成字符别名,做完字符串 BPE 再查 encoder 得到编号。 minbpe 的 GPT4Tokenizer 直接在整数 ID上合并,复现的是 cl100k_base,不用再训练一份自己的词表。

它多处理了两件事:

  • <|endoftext|> 这样的已注册标记,要由调用方决定识别、当普通文字还是拒绝。
  • 已有词表只给出完整字节内容和 rank / ID;整数 BPE 需要的「左 ID、右 ID → 新 ID」,得恢复出来。
minbpe 的位置与完整编码路径:特殊片段直接取 ID,普通片段经历正则、UTF-8、基础重编号与 BPE

特殊标记:文字、注册编号和停止行为

"<|endoftext|>" 首先只是一个长 13 个字符的 Python 字符串。它恰好也是已注册特殊 token 的拼写: GPT-2 的专用 ID 是 50256,cl100k_base 的是 100257。编号属于各自词表,不能跨词表使用。

同一个字面字符串,在 tiktoken 0.14.0 下:

enc = tiktoken.get_encoding("cl100k_base")
enc.encode("<|endoftext|>") # ValueError:默认禁止
enc.encode("<|endoftext|>",
allowed_special={"<|endoftext|>"}) # [100257]
enc.encode("<|endoftext|>",
disallowed_special=()) # [27,91,8862,728,428,91,29]

默认报错来自编码入口的特殊标记检查,不是 BPE 合并失败。 允许时先识别整段标记,直接取注册 ID;作为普通文字时,照样正则切块、转字节、做 BPE。

第三层是生成循环如何解释这个 ID。分词器认识一个特殊 ID,不等于模型生成它就自动停止; 停止 ID 列表、消息边界和调用协议由上层决定。这里不把某个标记的拼写当成一条自动执行的指令。

cl100k_base 注册的五个标记:

标记ID
<|endoftext|>100257
<|fim_prefix|>100258
<|fim_middle|>100259
<|fim_suffix|>100260
<|endofprompt|>100276

普通词表有 100,256 项:256 个单字节项,加 100,000 个合并项。 特殊 ID 中间留有空位;n_vocab = 100277 是最大 ID 加一,不能据此说有这么多实际条目。


官方表保存的是完整内容,不是左右关系

.tiktoken 文件一行一个条目:Base64 编码的原始字节串,加一个 rank / ID。

IQ== 0 → b'!'
ZQ== 68 → b'e'
aA== 71 → b'h'
bA== 75 → b'l'
bw== 78 → b'o'
aGVsbG8= 15339 → b'hello'

读取后是 mergeable_ranks: dict[bytes, int]。 知道 b'hello' → 15339,只知道它的完整内容和已有编号,还不知道最后一次是 h+ello,还是 he+llo。 recover_merges 恢复的就是这一层组成关系。

byte_shuffle 只重编号,不换位置

第 28 篇自己的 BPE 让基础 ID 等于字节值。这里官方基础 ID 是一份排列:

byte_shuffle = {
b: mergeable_ranks[bytes([b])]
for b in range(256)
}
inverse_byte_shuffle = {idx: b for b, idx in byte_shuffle.items()}
hello 的原始字节: [104,101,108,108,111]
重编号后的基础 ID:[ 71, 68, 75, 75, 78]
ab:[97,98] → [64,65] → BPE → [370]

shuffle 这个名字容易让人想到打乱顺序,实际上它只逐元素换编号,序列位置不变。 也不能把任意 token ID 丢进 inverse_byte_shuffle:反表只覆盖 256 个基础编号,不覆盖 15339。


★ 恢复 hello:为什么停在 15339 之前

恢复的目标是 b'hello',它的 rank r = 15339 一开始就有。先拆成单字节:

[h] [e] [l] [l] [o]

辅助 bpe 查询相邻两块拼接后的已有 rank,每轮挑最小的一个相邻位置合并。 同 rank 时取最左位置;只允许 rank < r。

状态当前合法候选选哪一处合并后
h | e | l | l | ohe:383, el:301, ll:657, lo:385el:301h | el | l | o
h | el | l | ohel:50222, ell:616, lo:385lo:385h | el | lo
h | el | lohel:50222, ello:4896ello:4896h | ello
h | ellohello:1533915339 >= r,停止保留两块

这样重放了目标形成前的旧规则,恰好留下它的左右两侧:

left_id = mergeable_ranks[b"h"] # 71
right_id = mergeable_ranks[b"ello"] # 4896
merges[(71, 4896)] = 15339

如果允许等于目标 rank 的最后一次合并,parts 就只剩 [hello],拿不到两个组成部分。 因此停止条件是 min_rank >= max_rank,允许条件是严格的 <,不是 <=。

这不是在当前文本上重新统计频次、选最常见的对,也没有创造新编号。它利用已有 rank,恢复每个已有合并项的最后一次连接。

下面把关键控制流写在一起;依据 karpathy/minbpe 的 gpt4.py(MIT 许可),为便于对照做了精简:

def earlier_parts(ranks, token, max_rank):
parts = [bytes([b]) for b in token]
while len(parts) > 1:
i = min(range(len(parts) - 1),
key=lambda i: ranks.get(parts[i] + parts[i + 1], float("inf")))
rank = ranks.get(parts[i] + parts[i + 1], float("inf"))
if rank >= max_rank:
break
parts[i:i + 2] = [parts[i] + parts[i + 1]]
return parts
merges = {}
for token, rank in mergeable_ranks.items():
if len(token) == 1: # 跳过 256 个基础项
continue
parts = earlier_parts(mergeable_ranks, token, max_rank=rank)
assert len(parts) == 2
left, right = (mergeable_ranks[p] for p in parts)
merges[(left, right)] = rank

这个精简函数用于有限的目标 rank;原辅助函数还支持 max_rank=None,以便不设截止。 如果恢复结果不是两块,就应当报错,不该默默保存一条不完整的规则。

外层遍历词表,内层重放早期规则;hello 的 301、385、4896 三步,以及在 15339 处停止的原因

恢复辅助函数与普通 encode 不完全一样

恢复时的辅助 bpe普通整数 BPE
输入一个已有 token 的原始字节一块新文本的基础 ID
一轮合并一个最小 rank 的相邻位置一种最小 rank 的对,替换所有不重叠出现
截止不许达到目标 rank不设目标 rank 截止
产物恰好两块,恢复最后一次关系最终 token ID 序列

两个函数都利用固定规则,目的不同;不能把恢复阶段的截止条件带进正常编码。


两张表:merges 选规则,vocab 存完整内容

恢复完有 100,000 条 merges。随后提前展开词表:

vocab = {idx: bytes([idx]) for idx in range(256)}
for (left, right), new_id in sorted(merges.items(), key=lambda item: item[1]):
vocab[new_id] = vocab[left] + vocab[right]

排序按合并编号,也就是这里的 rank,保证子项先存在。原实现依赖官方表的迭代顺序;这里显式排好,便于看清依赖。

注意:初始 bytes([idx]) 装的是内部基础 ID,不一定是原始字节值。

vocab[71] == bytes([71])
vocab[4896] == bytes([68,75,75,78])
vocab[15339] == bytes([71,68,75,75,78]) # 直接打印是 b'GDKKN'

b'GDKKN' 不是把 hello 解错了,而是这五个内部编号恰好在 ASCII 下能打印成那些字符。 逐元素逆映射成 [104,101,108,108,111],才是原始 b'hello'。

表保存的例子用途
merges(71,4896) → 15339编码时判断哪对能合、先合谁
vocab15339 → bytes([71,68,75,75,78])解码时直接取完整展开内容

解码不递归拆合并树。 初始化已经把每个条目完全展开并存好,之后拿到 ID 就查完整内容。

同一个 hello 的三阶段:初始化建两张表;encode 走四次合并;decode 查完整内容并逐基础编号逆映射

实际 encode:最后一步继续合成 hello

[71,68,75,75,78]
el:301 → [71,301,75,78]
lo:385 → [71,301,385]
ello:4896 → [71,4896]
hello:15339 → [15339]

前三步和恢复过程一样;正常 encode 没有 max_rank=15339 的截止,所以第四步也执行。 规则已经恢复到 merges,每轮挑当前合法候选里最小的 rank,合并全部不重叠出现,直到没有规则或只剩一个 ID。

混合文本也走同一条路径:

输入:Hi 你! 😊
正则块:['Hi', ' 你', '!', ' 😊']
最终 ID:[13347,220,57668,0,27623,232]

emoji 仍可跨 token;不能因为中文在这个词表里已经合成了一个 token,就推断所有字符都完整。

minbpe 三阶段:初始化、编码、解码,以及五个实际输入新窗口打开 ↗

动画初始化段始终用 hello 展示一条恢复记录;它属于Tokenizer 实例的初始化,不是随着选中的输入重新训练。 输入可切换 hello、internationalization、重复 ab、中英加 emoji、带特殊标记的文字。 每步候选表里的次数只是辅助显示;编码选择依据仍然是 rank。


特殊策略与解码:区分原实现和学习实现

RegexTokenizer 的 allowed_special 接口:

策略已注册特殊标记怎么处理
"none_raise"(默认)发现标记就报错
"none"全当普通文字
"all"全部识别成专用 ID
指定集合集合里的识别,其余按普通文字

这个「其余按普通文字」和 tiktoken 默认禁止其余标记不同。做一致性对照时必须同时设置:

enc.encode(text, allowed_special=chosen, disallowed_special=())

否则可能在比较 ID 之前,就因禁止标记而报错。 原 RegexTokenizer 的默认拒绝用 assert,抛 AssertionError;本地学习实现改成显式 ValueError,不能把两个异常混写。

原 GPT4Tokenizer 的 decode 有一处缺口

原 gpt4.py 的解码只查 self.vocab[idx],再统一逆映射。注册特殊 token 并不会把它加进这张普通词表。 因此原 GPT4Tokenizer.decode([100257]) 会 KeyError。 笔记中的 tokenizer_lab 学习实现补了分支:

parts = []
for idx in ids:
if idx in vocab:
internal = vocab[idx]
parts.append(bytes(inverse_byte_shuffle[b] for b in internal))
elif idx in inverse_special_tokens:
parts.append(inverse_special_tokens[idx].encode("utf-8"))
else:
raise ValueError(f"unknown token ID: {idx}")
return b"".join(parts).decode("utf-8", errors="replace")

普通 token 先取内部完整内容,再逐基础编号逆映射。 特殊 token 直接把注册文字变成 UTF-8 字节,不走基础编号逆映射;最后把所有片段拼齐,再解码一次。

tokenizer.encode("<|endoftext|>hello", allowed_special="all")
# [100257,15339]
tokenizer.decode([100257,15339])
# '<|endoftext|>hello'

这里的往返由补齐特殊解码分支的学习实现完成,不是原 gpt4.py 已经具备的行为。


验证了什么,以及没由测试证明什么

本地 tokenizer_lab/verify.py 的五项测试重新执行通过:

范围数量核对内容
普通输入123 例与 tiktoken 的完整 ID 序列相同,解码回原文
特殊策略66 例默认拒绝、普通文字、全部允许、指定集合,使用相应的 tiktoken 参数
普通词表100,256 项展开内部内容并逆映射后,与官方原始字节逐项相同

普通例子含中文、组合字符、emoji、零字节、Tab、换行、重复片段,以及固定随机种子的生成字符串; 另有未知 ID 和跨 token UTF-8 片段的边界检查。比较的是 ID 与原始字节,不能只看最终文字一样。

这些结果说明本次环境、词表和测试范围一致,不是靠 123 个例子证明任意字符串都已经覆盖。 实现也直接读取 _mergeable_ranks 等私有属性;复用实验时要保留版本,并在依赖变化后重新核对。

这三篇把 BPE 周围的几层约定补齐了:正则管边界,固定 rank 管合并顺序,ID 管词表成员,特殊策略管标记识别, 完整词表管解码内容。初始化做的工作可以多,之后编码与解码各查自己需要的表;混用这些职责,才会出现「编号对了,内容却还原不出来」。