系列:《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 篇 · 总纲(阿里云社区)
上一篇:18-1《BPE 入门与 tokenizer.json——为什么引擎不直接跑 BPE》| 下一篇:18-3《MRoPE:3D 位置编码给视觉留的席位》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 build_vocab_bin.py 词表编译:把 7MB 的 tokenizer.json 编成 1,950,864 字节的 vocab.bin,decode_token_str 字节解码闸门收拾 0xAD 等三处历史坑,板端重跑逐位一致可审计。
关键词:手搓 Qwen 推理引擎、千问大模型推理、tokenizer.json、vocab.bin、词表编译、build_vocab_bin.py、sha256、Qwen3-VL、零依赖纯 C
导语:运行时加载的为什么是 vocab.bin,而非现成的 tokenizer.json?这篇拆开 build_vocab_bin.py:它把 15 万余条基础词条与特殊 token 编译成二进制,并对 byte-level 词条做字节解码,几个高位区间正是历史踩坑处。板端重跑生成后与线上文件逐位比对,证明这份词表可审计。
18-1 提到引擎运行时加载的是 vocab.bin(1,950,864 字节)而不是 7MB 的 tokenizer.json。这一篇拆开 tools/build_vocab_bin.py:它把 151,643 条基础词条 + 26 个特殊 token 编译成二进制,并对 byte-level 词条做字节解码——其中 0xAD、0x80–0xA0、0x7F 三个区间是历史上反复踩坑的地方。板端重跑一次生成,sha256 与线上 vocab.bin 逐位一致。
1. 知识点:为什么不能直接读 tokenizer.json
1.1 四个现实约束
- 体积与解析成本:
tokenizer.json7MB,JSON 解析 + 字符串构建 + merge 表加载,板载单核启动要付出不可忽略的固定成本——而本项目冷启动卖点是 ~2s; - 字节语义不能在运行时反复算:官方词条是"字符映射串"(
Ġapple里的Ġ代表空格字节)。若运行时再反转bytes_to_unicode,每次加载都要过一遍 15 万条映射; - merge 表用不上:引擎是贪心最长前缀(18-1),根本不读
merges; - 特殊 token 不在 vocab 里:
<|endoftext|>(151643)、<|im_start|>(151644)、<|im_end|>(151645) 等在 tokenizer.json 的added_tokens区,需要另外的来源。
结论:把词表"编译"成一份紧凑、定长语义的二进制,loader 两遍扫描直接落成 strings[151669] 指针数组,编码时零字符串处理、零 JSON。
1.2 vocab.bin 的二进制布局
vllm_tokenizer_qwen.c 的 loader(27–153 行)读的就是这个格式:
<u32 词条数 N = 151669>
重复 N 次:
<u32 token_id> <u16 字节串长度 L>
<L 字节:token 的原始字节串> ← 不是"映射字符串",是解码后的字节
loader 的做法是两遍扫描:第一遍只读 tid/slen,把每个词条的 offset 记下、累加总字节数(93–118 行);第二遍 fseek 回去按 tid 落位到一块连续内存 str_data,strings[tid] 直接指进去(129–142 行)。这样词条顺序无关、内存零拷贝解析,之后 find_longest_match 就是 memcmp 线性扫描。
1.3 词表里到底存什么"字符串"——byte-level 的解码问题
Qwen3 词条在 tokenizer.json 里长这样(model.vocab,id 0..151642):
"!": 0, …, "Ġ": 220, "Ċ": 198, "Ń": 255, "Ġapple": 23268, …
键里的 Ġ/Ċ/Ń 都不是真字符,而是 GPT-2 bytes_to_unicode 给单个字节起的代称。映射规则(16 进制字节):
| 字节区间 | 映射到 | 说明 |
|---|---|---|
0x21–0x7E |
原样(ASCII) | 直接存 |
0xA1–0xAC、0xAE–0xFF |
原样(latin-1 区) | 直接存 |
0x00–0x20 |
U+0100–U+0120 |
控制符 + 空格;其中 0x0A→'Ċ'、0x20→'Ġ' |
0x7F–0xA0 |
U+0121–U+0142 |
删除符与 C2 区字节 |
0xAD |
U+0143('Ń') |
一个游离字节(历史修复点) |
关键问题:引擎 decode 是"把 token 字节串直接拼回输出"(不做反向映射,18-1 §3.3),编码是"拿原始字节匹配词表"。所以 vocab.bin 里存的必须贴近真实字节语义,而不是把 U+0120 之类再 UTF-8 编码一次。build_vocab_bin.py 的 decode_token_str(32–49 行)就是这道转换闸门。
2. 对应代码:decode_token_str 的三种处理
读 build_vocab_bin.py 32–49 行,注意它对"映射字符"分三类:
def decode_token_str(s):
out = bytearray()
for ch in s:
cp = ord(ch)
if cp == 0x0143: # 'Ń' → 字节 0xAD
out.append(0xAD)
elif cp == 0x0121: # 'ġ' → 字节 0x7F
out.append(0x7F)
elif 0x0122 <= cp <= 0x0142:
out.append(cp - 0xA2) # 0x80..0xA0 → 原始单字节
elif 0x00A1 <= cp <= 0x00AC or 0x00AE <= cp <= 0x00FF:
out.append(cp) # latin-1 原样
else:
out.extend(ch.encode("utf-8")) # 'Ġ'/'Ċ'/CJK 等保持原样
return bytes(out)
三类处理各对应一种工程语义:
- 解码回单字节(
0x7F、0x80–0xA0、0xAD):这些字节若以字符 UTF-8 形式存进 vocab.bin,会变成C2 xx/C5 83这类双字节串,与原文的单字节永不相等——贪心匹配要么错过、要么把两个词条缝成错位,历史上表现为 "ðŁĺ/İ" 类乱码(脚本头注释 2–16 行记录了这轮修复:早先的fix_vocab_local.py漏了0xAD→U+0143这一条)。 - latin-1 原样:
¡–¬、®–ÿ(U+00A1..)本身就是"字节即字符",直接out.append(cp); - 保持 UTF-8 原样:
Ġ(U+0120)、Ċ(U+010A) 与所有 CJK。这是故意的——Ġ/Ċ是引擎 encode 的词首/换行标记(18-1 §2 的C4 A0前缀就是这么来的),CJK 是 3 字节真字符,直接 UTF-8 编码即可。
main() 里对每条词条做 rec != raw 统计(69–72 行),最后把 id ≥ 151643 的 26 个特殊 token 从旧 vocab.bin 原样拷回(75–83 行)——因为它们不在 tokenizer.json 的 model.vocab 里,只有既有二进制里才有完整字节。
3. 改动后果:板端重跑一次生成
实测口径:板端 RK3588 / aarch64 / 2026-09-07。命令:
python3 tools/build_vocab_bin.py <tokenizer.json> <旧 vocab.bin> <vocab_regen.bin>,源为Qwen3-VL-2B-Instruct/tokenizer.json、旧件为线上build-rk3588/vocab.bin。
3.1 重生成日志与统计
total entries: 151669
converted: 57286 kept: 94357 special copied: 26
-- common char coverage --
(no MISSING lines above = full coverage)
'回答': 2 hits [102104, 111423]
'你好': 1 hits [108386]
'Ġgood': 6 hits [1661, 11561, 38426]
'Ċ': 2179 hits [198, 271, 280]
DONE -> /mnt/emmc/day18_logs/vocab_regen.bin
数字对得上:151,669 = 151,643(基础)+ 26(特殊)。57,286 条被解码修正(即 rec != raw,主要是含 0x80–0xA0/0xAD/0x7F 的碎片词条与字节词条),94,357 条原样保留。内置校验还顺带打印了常见汉字覆盖(无一 MISSING)与 Ġ/Ċ 的命中示例。
3.2 与线上文件的逐位一致性
299e1c156bff1b85b5b4dc098c1d7f2e33e0b76fe1967d0d21df328985637a73 build-rk3588/vocab.bin
299e1c156bff1b85b5b4dc098c1d7f2e33e0b76fe1967d0d21df328985637a73 vocab_regen.bin
sha256 相等,两个文件都是 1,950,864 字节。这证明线上那份 vocab.bin 正是同一份脚本同一份输入生成的——词表编译是纯函数,可复现、可审计(与 Day 17 的权重转换同理:确定性来自"无随机 + 输入相同")。
3.3 一个"错修"反例,看修复点在数据里长什么样
用本地脚本在 tokenizer.json 里抓几个带映射字符的键,验证 §1.3 的映射:
字节 0x20(空格) : id 220 str 'Ġ' utf-8 字节 c4a0 → 保留(引擎词首标记)
字节 0x0A(换行) : id 198 str 'Ċ' utf-8 字节 c48a → 保留(引擎换行标记)
字节 0xAD : id 255 str 'Ń' utf-8 字节 c583 → 应解码成单字节 ad
字节 0x7F(DEL) : id 221 str 'ġ' utf-8 字节 c4a1 → 应解码成单字节 7f
若把 decode_token_str 里的 0x0143 分支删掉(模拟漏修),id 255 会以 c5 83 落盘,此后凡是文本流中出现字节 0xAD 的位置,贪心匹配都找不到对应的单字节词条——文本与词表在字节维度上错位。loader 侧的安全网只有一层:vocab_size 越界与条目截断检测(loader 74–78、105–110 行),它管不了"语义对错",所以这道闸门必须在生成期把好。
4. 学员调试任务
- A 档(板端动手):
- 复刻 3.1/3.2:重跑
build_vocab_bin.py,核对 151669/57286/94357/26 四个数字与sha256一致; - 故意删掉
decode_token_str的0x0143分支(或把0x0122..0x0142整段注释掉),重生成后跑tokprobe的 15 条探针,观察并记录哪些行开始错、错成什么样(重点看含特殊字节的碎片词条路径); - 用
tok_inspect.py核对 3.3 中 id 220/198/255/221 的落盘字节,养成"先验字节再谈语义"的习惯。
- 复刻 3.1/3.2:重跑
- B 档(纯读源码):通读
qwen_tokenizer_load(vllm_tokenizer_qwen.c27–153 行),回答:① 为什么 loader 要"两遍扫描 + offset 表"而不是顺序读一遍直接塞进strings?②max_str_len(打印为max_len=256)在代码里只出现在 loader 的统计与诊断打印里(104/115/151 行),encode 路径并没有用它剪枝——试从find_longest_match_qwen的实现说明"为什么有最长词条长度却无法用来加速匹配",以及若要加速该换什么数据结构;③ 若某人把vocab.bin的<u32 N>改成 200000,loader 的哪些检查会先拦下它?
预期输出:一次可复现的词表重生成记录(统计 + sha256),加一张"改坏某分支 → 哪条探针哪几个 token 变错"的对照表。
收尾
- 本篇源码点名:build_vocab_bin.py(decode_token_str 32–49、统计 59–72、特殊拷贝 75–83)、vllm_tokenizer_qwen.c(loader 27–153、encode 195–272)
- 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:词表就位,但 Qwen3-VL 有个纯文本模型没有的问题——视觉 token 和文本 token 混在一个序列里,位置编码怎么排?Day 18 收尾篇进 MRoPE:
[24,20,20]的三段式旋转、pos_t/h/w三套坐标,以及"把 3D 改回 1D"会在旋转上差出多少弧度。