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?
相关文章
|
2天前
|
定位技术 Python
0.8MB 跑通 Qwen|第 3-2 篇:推理引擎的字节序与十六进制纪律——一个字节序错误,引擎当场翻脸
本文以RK3588真机实测为基础,深入剖析字节序陷阱:揭示VQF格式中小端落盘导致魔数“VQFW”在磁盘呈现为“FQFW”,并用篡改version字段的实验直观展示——小端机器误读大端数据会直接拒载(报错33554432≠2)。强调“先问字节序,再读数字”的十六进制读法铁律。(239字)
0.8MB 跑通 Qwen|第 3-2 篇:推理引擎的字节序与十六进制纪律——一个字节序错误,引擎当场翻脸
|
1天前
|
缓存 C语言 C++
0.8MB 跑通 Qwen|第 9-3 篇:推理引擎的 q8 KV 精度对照——量化进注意力,输出差多少
本系列《0.8MB跑通Qwen》手搓零依赖纯C推理引擎,适配Qwen3-VL多尺寸模型,在RK3588上实测q8 KV量化:输出误差仅≈0.01,端到端PPL损失<1%,带宽降为52%,精度与效率达成教科书级平衡。(239字)
|
1天前
|
NoSQL 调度 C++
0.8MB 跑通 Qwen|第 14-3 篇:推理引擎的批处理正确性边界——margin 0.71 vs 0.032
本篇精读Qwen3-VL系列推理引擎的“位级一致性”本质:批量与串行输出大多逐字节一致,但因浮点累加顺序差异,在near-tie(近平局)场景下存在翻盘风险——margin(如0.71 vs 0.032)决定是否一致。这是工程结果,非数学承诺。
|
1天前
|
缓存
0.8MB 跑通 Qwen|第 8-1 篇:大模型推理的自注意力数学——Q·K^T / softmax / V
本文精讲自注意力三步核心:Q·K^T打分、softmax归一化、加权V求和,逐行对照纯C引擎源码(vllm_transformer.c),剖析√d缩放、因果掩码与数值稳定性等工程地雷,聚焦Qwen3-VL系列在RK3588上的零依赖推理实现。(239字)
|
1天前
|
C++ 芯片
0.8MB 跑通 Qwen|第 6-2 篇:推理引擎的 8x8 布局重排——把"取数"提前到转换期
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多模态模型,在RK3588芯片实测落地。核心创新是Q8权重的8×8内存重排,使宽矩阵GEMM提速约1.7倍,且位级结果一致,兼顾性能与精度。(239字)
|
1天前
|
编解码 应用服务中间件 API
# 0.8MB 跑通 Qwen|第 19-3 篇:视频帧与媒体模块——推理引擎默认不启用的 H.264 独立模块
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多模态模型(2B/8B/30B),在RK3588上实测。视频支持分两层:帧序列路径已落地;MP4/H.264解码为独立未完成模块,API清晰、默认不构建、不演示未验证功能,体现严谨工程边界。(239字)
|
1天前
0.8MB 跑通 Qwen|第 13-1 篇:推理引擎的 decode 为什么慢——逐词、带宽、不可并行
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多模态模型,在RK3588上实测decode瓶颈——逐词生成、内存带宽受限、无法并行。直击每词92ms硬地板,为推测解码(speculative decode)铺路,实现“一次前向多产出”。
|
1天前
|
Java Linux 调度
0.8MB 跑通 Qwen|第 4-3 篇:核绑定实验——推理引擎在 ARM 大小核上为什么叮嘱"勿设 8 线程"
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,实测RK3588(4×A76+4×A55)上适配Qwen3-VL-2B/8B及30B-A3B模型。通过三组真机实验揭示“核多≠快”本质:仅绑4大核最优,设8线程反降效12%,验证大小核架构下线程配置需严守硬件特性。(239字)
|
1天前
|
JSON C语言 数据安全/隐私保护
0.8MB 跑通 Qwen|第 16-1 篇:VQF 的野心——推理引擎如何把量化与布局固化到文件
本系列手搓零依赖纯C推理引擎,仅0.8MB即可在RK3588上跑通Qwen3-VL多模态大模型。核心创新VQF格式将量化类型、内核布局与模型属性固化于2.17GB单文件中,实现mmap直挂、零转换加载,真正达成“加载即用”。
|
1天前
|
缓存 安全 API
0.8MB 跑通 Qwen|第 11-2 篇:大模型推理的前缀缓存键——怎么知道"两轮一样"?
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,实测RK3588上高效运行Qwen3-VL多模态模型。本文详解前缀缓存核心机制:以token序列最长公共前缀(LCP)为键,实现KV复用;并揭示“完全相同反不复用”的安全设计——确保prefill刷新logits,杜绝空响应。

热门文章

最新文章