0.8MB 跑通 Qwen|第 18-2 篇:tokenizer.json → vocab.bin——推理引擎的 build_vocab_bin.py 在干嘛

简介: 本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多模态模型(2B/8B/30B),在RK3588平台实测通过。本文详解词表编译:将7MB tokenizer.json编译为1.95MB可审计vocab.bin,精准处理byte-level解码(如0xAD/0x7F等历史坑),实现板端逐位一致、冷启动仅约2秒。(239字)

系列:《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 四个现实约束

  1. 体积与解析成本:tokenizer.json 7MB,JSON 解析 + 字符串构建 + merge 表加载,板载单核启动要付出不可忽略的固定成本——而本项目冷启动卖点是 ~2s;
  2. 字节语义不能在运行时反复算:官方词条是"字符映射串"(Ġapple 里的 Ġ 代表空格字节)。若运行时再反转 bytes_to_unicode,每次加载都要过一遍 15 万条映射;
  3. merge 表用不上:引擎是贪心最长前缀(18-1),根本不读 merges;
  4. 特殊 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)

三类处理各对应一种工程语义:

  1. 解码回单字节(0x7F、0x80–0xA0、0xAD):这些字节若以字符 UTF-8 形式存进 vocab.bin,会变成 C2 xx/C5 83 这类双字节串,与原文的单字节永不相等——贪心匹配要么错过、要么把两个词条缝成错位,历史上表现为 "ðŁĺ/İ" 类乱码(脚本头注释 2–16 行记录了这轮修复:早先的 fix_vocab_local.py 漏了 0xAD→U+0143 这一条)。
  2. latin-1 原样:¡–¬、®–ÿ(U+00A1..)本身就是"字节即字符",直接 out.append(cp);
  3. 保持 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 档(板端动手):
    1. 复刻 3.1/3.2:重跑 build_vocab_bin.py,核对 151669/57286/94357/26 四个数字与 sha256 一致;
    2. 故意删掉 decode_token_str 的 0x0143 分支(或把 0x0122..0x0142 整段注释掉),重生成后跑 tokprobe 的 15 条探针,观察并记录哪些行开始错、错成什么样(重点看含特殊字节的碎片词条路径);
    3. 用 tok_inspect.py 核对 3.3 中 id 220/198/255/221 的落盘字节,养成"先验字节再谈语义"的习惯。
  • B 档(纯读源码):通读 qwen_tokenizer_load(vllm_tokenizer_qwen.c 27–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"会在旋转上差出多少弧度。
相关文章
|
13天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
7959 15
|
11天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
1761 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
11天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
1822 12
|
9天前
|
人工智能 编解码 并行计算
MiniMax-H3 一键整合包技术文档:8G 显存运行 AI 漫剧制作 —— 角色替换 / 动作迁移 / 文图生视频部署与调参指南
MiniMax H3 是 MiniMax 开源的全模态视频生成模型,支持文/图/音/视多条件输入,输出最高2K、15秒带双声道音频视频。本文档详述其Int8量化版在8GB显存下的本地一键部署、三段式工作流(EDIT/REPLACE/CONTINUE)、参数调优及常见问题排查。(239字)
|
5天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
25天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
3808 10
|
19天前
|
缓存 IDE Java
【保姆级】Android Studio下载、安装和汉化教程(2026最新)
Android Studio 是 Google 官方推出的免费 Android 应用开发集成环境,基于 IntelliJ IDEA,内置模拟器、调试器、性能分析及 Compose 界面工具,功能全面,文档丰富,是安卓开发首选工具。(239字)
2039 1

热门文章

最新文章