0.8MB 跑通 Qwen|第 18-1 篇:BPE 入门与 tokenizer.json——为什么推理引擎不直接跑 BPE

简介: 本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多尺寸模型,在RK3588上实测通过。本文详解分词器工程近似方案:以“字节流最长前缀+空格标记”替代官方BPE merge,三方对拍15条语料达成9/15一致,并精准归因差异为“贪心vs排序”与“空格丢失”两类机制,践行技术诚实。(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 篇 · 总纲(阿里云社区)

上一篇: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)最初是一种数据压缩算法,词表时代它的玩法是:

  1. 把文本拆成字节级初始 token;
  2. 反复扫描:找出出现频率最高(或官方给定序)的相邻 token 对,合并成一个新 token;
  3. 合并顺序(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 */

要点拆开讲:

  1. 真实空格字节(0x20)不进匹配流,先被跳掉、只留下 had_space 标记;
  2. 词表里词首带空格的那些 token(如 Ġapple),其字节串以字面 C4 A0 开头(build_vocab_bin.py 特意保留 Ġ 的 UTF-8 编码不反转,18-2 有证据)。所以跳过空格后,引擎把 C4 A0 前缀拼回剩余文本再匹配,就能命中 Ġapple;
  3. num > 0 门槛:文本开头的空格不会触发上面的前缀尝试(num==0),直接走"无前缀匹配";
  4. 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.txt 15 条,覆盖中英混合、全角标点、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 档(板端动手):
    1. 编译并运行 tokprobe(main_tok.c 编译命令见文件头注释),对 probes.txt 复刻 15 条 encode 输出;用 bpe_ref.py 跑参考对拍,确认 base 恰为 9/15;
    2. 按 3.4 在板端副本上打 A、AB 两处补丁,重编三个变体,复现 9→10→11 的爬升;
    3. 用 tok_inspect.py 核实本节引用的任一条 id(如 106114=首都、59975=首、23268=Ġapple、220=Ġ、20=5、339=th),养成"凡引用 id 先验字节"的习惯。
  • B 档(纯读源码):通读 qwen_tokenizer_encode(vllm_tokenizer_qwen.c 195–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?
相关文章
|
13天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8001 15
|
11天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
1772 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
12天前
|
人工智能 并行计算 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主流音视频/图像模型,解压即用,无需环境配置。
1923 12
|
10天前
|
人工智能 编解码 并行计算
MiniMax-H3 一键整合包技术文档:8G 显存运行 AI 漫剧制作 —— 角色替换 / 动作迁移 / 文图生视频部署与调参指南
MiniMax H3 是 MiniMax 开源的全模态视频生成模型,支持文/图/音/视多条件输入,输出最高2K、15秒带双声道音频视频。本文档详述其Int8量化版在8GB显存下的本地一键部署、三段式工作流(EDIT/REPLACE/CONTINUE)、参数调优及常见问题排查。(239字)
|
6天前
|
人工智能 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同事"。
3823 10
|
20天前
|
缓存 IDE Java
【保姆级】Android Studio下载、安装和汉化教程(2026最新)
Android Studio 是 Google 官方推出的免费 Android 应用开发集成环境,基于 IntelliJ IDEA,内置模拟器、调试器、性能分析及 Compose 界面工具,功能全面,文档丰富,是安卓开发首选工具。(239字)
2108 1

热门文章

最新文章