系列:《0.8MB 跑通 Qwen:从零实现 ARM 零依赖纯 C 推理引擎》(30 天 × 90 篇) | 适配模型:Qwen3-VL-8B-Instruct(千问3_VL_8B_Instruct)· Qwen3-VL-2B-Instruct · Qwen3-30B-A3B | 测试设备:RK3588(4×Cortex-A76 + 4×Cortex-A55,aarch64)
系列总纲:《0.8MB 跑通 Qwen》30 天 90 篇 · 总纲(阿里云社区)
上一篇:17-3《位级一致性实验——两条转换路产物逐字节对拍》| 下一篇:18-2《tokenizer.json → vocab.bin:build_vocab_bin.py 在干嘛》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:BPE 分词与 tokenizer.json:推理引擎不逐条跑官方 merge,改用"字节流最长前缀+空格标记"的工程近似;板端三方对拍 15 条语料、一致 9 条,把 6 条差异归因成两类机制。
关键词:手搓 Qwen 推理引擎、千问大模型推理、BPE、tokenizer.json、字节级分词、vocab.bin、RK3588、Qwen3-VL、零依赖纯 C
导语:文本进入引擎的第一步是分词,而这里的答案可能出乎意料:引擎并不逐条复刻官方 BPE 的 merge 过程,而采用"字节流最长前缀加空格标记"的工程近似。这篇用 15 条探测语料在板端做三方对拍,实测一致 9 条,并把 6 条差异逐一归因,把近似的代价摊开讲。
Day 16–17 把"权重怎么进内存"讲完了。现在回答 Day 18 的第一个问题:推理的第一步——文本到 token id——在我们的引擎里到底怎么做的? 一个会让不少读者意外的事实先说在前面:
引擎的分词器不是 HuggingFace 官方 BPE 的逐位克隆,而是一套"字节流最长前缀 + 空格标记"的工程近似。本篇用 15 条探测语料在板端做三方对拍(官方 HF 分词器 / 引擎 / 纯 Python 字节级 BPE 参考实现),实测一致率 9/15,并把 6 条差异逐一归因成两类机制。这正是本项目一贯的诚实口径:性能优化从不免费,代价是什么、发生在哪,要用对拍数据摊开讲。
1. 知识点:byte-level BPE 与 tokenizer.json
1.1 什么是 byte-level BPE
BPE(Byte Pair Encoding)最初是一种数据压缩算法,词表时代它的玩法是:
- 把文本拆成字节级初始 token;
- 反复扫描:找出出现频率最高(或官方给定序)的相邻 token 对,合并成一个新 token;
- 合并顺序(
merges表)固化下来,训练集里的"词根/子词"就变成了词表里的长 token。
GPT-2 起流行 byte-level BPE:token 串直接建立在 256 个字节之上,天然处理任意 Unicode、没有 OOV 的"???"。字节是"看不见"的,所以 OpenAI 发明了一张 bytes_to_unicode 映射表,把 256 个字节翻译成可打印字符存进 JSON,其中最关键的三条:
| 字节 | 映射字符 | 含义 |
|---|---|---|
0x20(空格) |
Ġ(U+0120) |
词与词之间的空格 |
0x0A(换行) |
Ċ(U+010A) |
换行 |
0xAD(软连字符) |
Ń(U+0143) |
一个普通字节 |
Qwen3-VL 沿用 GPT-2 的字节序。所以打开 tokenizer.json 你会看到形如 "Ġapple": 23268 的键——它代表"空格 + apple"这一整块字节。
1.2 tokenizer.json 里装了什么
Qwen3-VL-2B 的 tokenizer.json(7,032,403 字节)里:
model.vocab:151,643 条基础词条(id 0..151642)。注意 id 排序也是 GPT-2 式:id 0 = '!'、id 220 = 'Ġ'(单字符空格标记)……不是从特殊 token 开始;model.merges:151,387 条合并规则(这就是纯 BPE 的"知识"所在,也占了 JSON 体积的大头);- 特殊 token(
<|endoftext|>151643、<|im_start|>151644、<|im_end|>151645 等 26 个)另置。
1.3 官方分词器的完整流水线
HuggingFace 的 fast tokenizer 跑 BPE 前还有两步预处理:normalizer(可选的 Unicode 归一)与 pre-tokenizer(正则按词切分、给每个词补一个虚拟前置空格,即"Ġ 加在词首")。之后才在词内做按序 merge。所以"空格"在官方语义里是一个会参与 merge 的普通字节:" apple" 这个词会一路 merge 成 Ġapple(id 23268)。
2. 对应代码:引擎的近似方案
引擎没有 151,643 条字符串 + 15 万条 merge 规则同时在线做 merge,而是把词表编译成二进制 vocab.bin(18-2 详讲),编码时用最长前缀直接匹配字节流(vllm_tokenizer_qwen.c 的 qwen_tokenizer_encode,195–272 行)。逐字节看这个函数,你会看到三个关键设计(219–236 行附近):
/* Skip spaces (but preserve them as position markers) */
int had_space = 0;
while (pos < text_len && text[pos] == ' ') {
pos++; had_space = 1; }
...
/* For Qwen2 tokenizer, word continuations typically use "Ġ" prefix (0xC4 0xA0).
* Try matching with this prefix first if we had a space before this word. */
if (had_space && num > 0) {
// ← 门槛 num > 0
char spaced_buf[512];
spaced_buf[0] = '\xC4'; spaced_buf[1] = '\xA0'; // "Ġ" 的字面 UTF-8
memcpy(spaced_buf + 2, rem, ...);
token_id = find_longest_match_qwen(tok, spaced_buf, buf_len, &match_len);
if (token_id >= 0 && match_len > 2) {
...; continue; } // 只收"长度 > 2"(≥Ġ+1字符)的匹配
}
/* Try matching without space prefix */
要点拆开讲:
- 真实空格字节(0x20)不进匹配流,先被跳掉、只留下
had_space标记; - 词表里词首带空格的那些 token(如
Ġapple),其字节串以字面C4 A0开头(build_vocab_bin.py特意保留Ġ的 UTF-8 编码不反转,18-2 有证据)。所以跳过空格后,引擎把C4 A0前缀拼回剩余文本再匹配,就能命中Ġapple; num > 0门槛:文本开头的空格不会触发上面的前缀尝试(num==0),直接走"无前缀匹配";match_len > 2门槛:只有Ġ单字本身、后面接不上任何字符的匹配(长度恰为 2)不采用——这条与第 3 条共同制造了 18-1 实测里的一类差异(见 3.3)。
也就是说,引擎用"字面 C4 A0 词首标记"模拟了官方"词首加 Ġ"的约定,但丢掉了 merge 排序,用贪心最长前缀代替。有代价吗?下面就是对拍。
3. 改动后果:三方对拍实测
实测口径:板端 RK3588(Orange Pi 5 Plus)/ aarch64 / Release 构建 / 2026-09-07;
tokprobe(main_tok.c独立 harness,加载vocab.bin后逐行 encode)。参考实现:官方 HF fast tokenizer(tokenizers==0.22.2,x86 主机跑);纯 Python 字节级 BPE(bpe_ref.py,无 pre-tokenizer,按merges排序逐对合并)。语料probes.txt15 条,覆盖中英混合、全角标点、emoji、行首空格、数字词。
3.1 引擎 vs 官方:base = 9/15
15 条语料中 9 条逐字节一致(Hello, world!、英文整句、纯中文、emoji、a b c d e f、北京 上海 广州 深圳 等),6 条不一致,先亮总账:
| # | 语料(显示) | base | A | AB |
|---|---|---|---|---|
| 5 | 回答:中国的首都是北京 | ✗ | ✗ | ✗ |
| 7 | apple pie(行首空格) |
✗ | ✓ | ✓ |
| 8 | !@#$%^&*() 1234567890 |
✗ | ✗ | ✗ |
| 9 | Kestrel 红隼 30 天教程 | ✗ | ✗ | ✗ |
| 13 | The 5th 版本 of 学习 | ✗ | ✗ | ✓ |
| 15 | Red Kestrel: vLLM on RK3588 | ✗ | ✗ | ✗ |
(A、AB 是 3.4 的修复变体,先卖个关子。)
引擎 base 与官方的差异全部来自两类机制,下面分别用真实 id 序列解剖。
3.2 差异族 F1:merge 排序 vs 贪心最长前缀
看 #5 回答:中国的首都是北京 的中间一段"首都是":
官方 : … 59975 100132 … 59975 = 首 100132 = 都是
引擎 : … 106114 20412 … 106114 = 首都 20412 = 是
官方词表里同时存在 首都(106114)与 都是(100132)。官方 BPE 在"首 都 是"三字上按 merge 顺序先合了 都+是 → 都是,所以剩下 首 | 都是;引擎从左边贪心取最长前缀,取到 首都,剩下 是。两者覆盖的字节完全相同,只是切法不同。
再看 #15 Red Kestrel: vLLM on RK3588(红隼是个罕见专名,词表里没有整词):
官方 : 730 477 3748 730 = ĠK 477 = est 3748 = rel (ĠK|est|rel)
引擎 : 82977 10157 75 82977 = ĠKes 10157 = tre 75 = l (ĠKes|tre|l)
ĠKestrel 不存在,于是两条路各自拆;官方按 merge 排出来的 ĠK|est|rel 与引擎贪心的 ĠKes|tre|l 不是一回事。这类差异是算法的本质分歧——只有把 merge 表真正跑起来才能消除,本篇末尾会说明为什么引擎暂时没这么做。
3.3 差异族 F2:空格语义与"丢空格"
这是 base 版本更值得注意的一族——回环会丢字节。看两条实测:
#7 输入 " apple pie"(行首空格)
引擎 ids: 22377 4349 DEC: appleĠpie ← 行首空格丢了
官方 ids: 23268 4349 (23268 = Ġapple)
#13 输入 "The 5th 版本 of 学习"
引擎 ids: 785 20 339 … DEC: The5thĠ版本ĠofĠ学习 ← The 与 5th 间的空格丢了
官方 ids: 785 220 20 339 … (220 = 孤立"Ġ"空格标记)
机制就是 §2 里那两道门槛:
- 行首空格(#7):
num == 0时跳过Ġ前缀尝试,apple直接以无空格形态 22377 出词,空格永久蒸发; - 数字开头的词(#13 的
5th、#8 的1234567890、#9 的30):词表里没有以Ġ5/Ġ1/Ġ3开头的长 token(训练语料里这类组合从没被 merge 过),所以Ġ前缀尝试匹配长度达不到> 2的门槛、被放弃,空格再次蒸发。而官方 BPE 遇到"merge 不上"时,会把孤立空格作为220(即Ġ)单独出词——空格永远不丢。
顺带记一个字节序事实:id 220 就是孤立的 Ġ(vocab.bin 里存 C4 A0 两个字节),id 20 是字符 5,id 339 是 th——这些都已在板端用 tok_inspect.py 逐字节核实。
回环可测的后果:引擎 decode 是"把 token 的原始字节串拼接"(qwen_tokenizer_decode 278–283 行,不反转 bytes_to_unicode),所以上述空格丢失会真实出现在输出里(The5th…),而所有正常命中 Ġ 前缀的词(红隼、版本、of、学习)空格都在(decode 出来显示为 Ġ 字符,即 C4 A0 字节)。
3.4 两处小改,9/15 → 11/15
在板端对 vllm_tokenizer_qwen.c 的副本做两个最小改动(不改仓库主源,实验用):
// 改动 A:去掉 num > 0 门槛,让行首空格也走 Ġ 前缀尝试
if (had_space) {
... }
// 改动 B:Ġ 前缀尝试失败时,先补发一个孤立空格标记(220)
if (had_space) {
// 插在"无前缀匹配"之前
int ml2; int tid2 = find_longest_match_qwen(tok, "\xC4\xA0", 2, &ml2);
if (tid2 >= 0 && ml2 == 2) token_ids[num++] = tid2; // 空格以 220 保住
}
板端重编三个变体(base / A / AB)跑同一批语料,与官方逐条比对:
base = 9/15 A = 10/15 AB = 11/15
#7 A 修复: apple pie → 23268 4349(= 官方)
#13 AB 修复:The 5th… → 785 220 20 339 …(= 官方)
#8/#9 空格部分同步修复(… 8 220 16 … 中 220 出现位置与官方一致)
残余 #5 #8 #9 #15:全部是 F1 类(贪心 vs merge 排序),改动 A/B 无关
注意 A/B 都是纯增量:原先一致的 9 条在 A、AB 下依然一致,没有任何回退。这说明"丢空格"不是引擎的刻意取舍,而是实现细节漏掉的边界;而 F1 类差异才是贪心算法的固有特性。
3.5 结论:为什么引擎现在不跑真 BPE
引擎的分词目标是板载单核也要快、内存 2MB 内、零运行时解析。跑真 BPE 需要 151,643 条字符串在线 + 151,387 条 merge 规则(rank 表约 2MB+)+ 逐字节归并逻辑,与"最长前缀一次扫描"相比是数量级的复杂度差,且 prefill 的 token 数(几十到几百)远小于权重计算量,分词根本不在热点上。本项目的取舍是:用最坏情况会偏离官方分词的贪心匹配,换来几乎为零的加载与编码开销,并把偏离量化、公开(本文 9/15 基线 + 两族归因就是这份"技术诚实")。若未来需要位级对齐官方分词(如蒸馏/评测需要 token 级对齐),再把这套 vocab.bin 升级为"词表 + merge 表"双文件,就是另一个工程故事了。
4. 学员调试任务
- A 档(板端动手):
- 编译并运行
tokprobe(main_tok.c编译命令见文件头注释),对probes.txt复刻 15 条 encode 输出;用bpe_ref.py跑参考对拍,确认 base 恰为 9/15; - 按 3.4 在板端副本上打 A、AB 两处补丁,重编三个变体,复现 9→10→11 的爬升;
- 用
tok_inspect.py核实本节引用的任一条 id(如 106114=首都、59975=首、23268=Ġapple、220=Ġ、20=5、339=th),养成"凡引用 id 先验字节"的习惯。
- 编译并运行
- B 档(纯读源码):通读
qwen_tokenizer_encode(vllm_tokenizer_qwen.c195–272 行),回答:①num > 0与match_len > 2两道门槛各在保护什么、又各造成了什么可测偏差?② 若把"跳过空格"改成"把空格字节当作普通字节参与匹配",会不会引入新的错位(提示:vocab.bin里没有单字节 0x20 词条,空格只有C4 A0形态)?③ 若要在引擎里实现真 BPE,最小改动需要动哪些数据结构(参考 §3.5)?
预期输出:一张你自己跑出来的 9/15 → 10/15 → 11/15 对拍表,并能对每条残余差异说出"空格族还是 merge 族"。
收尾
- 本篇源码点名:vllm_tokenizer_qwen.c(loader 27–153、encode 195–272、decode 278–283)、build_vocab_bin.py(
Ġ/Ċ保留逻辑 32–49) - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:引擎运行时加载的
vocab.bin(1,950,864 B)是怎么从 7MB 的tokenizer.json编译出来的?build_vocab_bin.py的字节解码表如何修复0xAD这类坑、为什么板端重跑能拿到逐位一致的 sha256?