自包含分词器:byte-level BPE + PCRE2
对拍验证了前向,但那时 nuthatch 还得借 llama-tokenize 把文本变 id。要完全自包含(文本进、文本出,不借 llama.cpp 做任何运行时),得自己实现分词器。OLMoE 用 GPT-2 的 byte-level BPE + OLMo 预分词。
1. 解码:byte↔unicode 反查
tokenizer.cc 先做解码(id → 文本),因为它简单、且能立刻让生成结果可读。
GPT-2 的 byte-level 技巧:把 256 个字节映射到一组”可打印 unicode 码点”(空格 0x20 → Ġ、换行 → Ċ 等),词表里存的就是这些码点串。解码就是反查:token 串里的每个码点 → 原始字节,拼起来就是 UTF-8 文本。char_to_byte_ 就是这张反查表。
2. 编码:预分词 + BPE
编码(文本 → id)是硬骨头,分三步:
- 预分词:用 OLMo 的正则把文本切成 chunk。正则是:
's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)——带\p{L}/\p{N}的 Unicode 属性类,和(?!\S)的前瞻。 - byte-level 编码:每个 chunk 的每个字节 → 一个 byte-level 符号;
- BPE 合并:按
tokenizer.ggml.merges里的合并顺序(rank),反复合并 rank 最小的相邻对,直到合无可合;最后查词表得 id。
3. 依赖抉择:RE2 → PCRE2
预分词那条正则要 Unicode 属性 + 前瞻,标准库 <regex> 跑不动,得引一个正则引擎——这触发了”引入依赖前先讨论”的规矩。
一开始选了 RE2。但 RE2 新版会拖进 abseil——一个庞大而模块众多的库——在 macOS 上链接时撞上 absl::...cctz::local_time_zone() 找不到、需要 CoreFoundation 框架的坑。查下去发现:老版 RE2 不依赖 abseil,新版才拖进来;而且 RE2 根本不支持前瞻,那条正则的 \s+(?!\S) 分支得手动特判。
换成 PCRE2:自包含(不拖 abseil)、同时支持 \p{L}/\p{N} 和前瞻,能一次跑完整正则。用 PCRE2_UTF | PCRE2_UCP 编译,迭代匹配出 chunk 即可。切换后一次通过,链接干净。
这次是”引入依赖前先讨论”这条规矩省了大麻烦的活教材。 如果不先评估、直接把 RE2 焊死进去,后面为 abseil 的链接问题和缺失的前瞻会掉进更深的坑。依赖账本记在
docs/dependencies.md。
4. 结果:token-exact
自包含之后,一条命令就是完整推理:文本 → 自己分词 → KV cache 生成 → 自己解码 → 文本。编码结果与 llama-tokenize 逐 id 一致:
$ olmoe_generate <model> 6 The capital of France is
prompt ids: 510 5347 273 6181 310 ← 自己编码,与 llama 一致
text: The capital of France is Paris.
至此,分词、KV cache、MoE 前向、(后来的)流式缓存都是自己的代码,正确性靠对拍/token-exact 锚住。
下一章:KV cache 与图排序陷阱。