系列:《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 篇 · 总纲(阿里云社区)
上一篇:19-1《ViT 编码——图片是怎么变成视觉 token 的》| 下一篇:19-3《视频帧与媒体模块(默认不启用的 H.264)》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 vision_tokens 客户端预编码协议:encode_media_item 为多模态请求开 image_url、video_frames、vision_tokens 三条路,板端 serve 实测缓存命中跳过 ViT、特征直注与字节数自校验的拒绝边界。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、vision_tokens、客户端预编码、ViT、视觉 token、多模态
导语:板载 ViT 编一张 64×64 小图要约 295ms,多模态请求若每次都重算便成了瓶颈。本篇拆开千问多模态请求的三条路——image_url、video_frames 与 vision_tokens,看客户端预编码如何跳过 ViT,以及字节数自校验怎样把对不上的特征直接挡在门外。
19-1 实测板载 ViT 编码一张 64×64 要 ~295ms——这还只是 4 个 token 的小图。如果每个请求都在板上重算 ViT,视觉就变成了真瓶颈。这篇讲引擎的折中方案:vllm_server.c 的 encode_media_item 给一条多模态请求开了三条路——image_url(板端 ViT + 128 位图片缓存)、video_frames(帧数组)、以及 vision_tokens(客户端/上位机预编码,直接跳过 ViT)。三条路在板端 serve(model.vqf 部署形态)全部实测跑通,日志锚点 [VIS-CACHE] hit … skip ViT 与 [VIS-TOKENS] client-preprocess … 都拿到了。
1. 知识点:图片上行,还是特征上行
多模态请求有两条截然不同的"带宽账":
| 方案 | 上行内容 | 上行体积 | 板端算力 |
|---|---|---|---|
image_url(板上 ViT) |
压缩图片(base64) | 几十 KB 级 | 每次 ~295ms(64×64)起步 |
vision_tokens(客户端预编码) |
视觉特征 fp32 | n×2048×4B(4 token≈32KB;784 token≈6.4MB) | 0(板端只做注入) |
账要这么算:客户端的视觉算力是"免费的"(浏览器/上位机往往有 GPU),而板上 ViT 是稀缺资源。让客户端把图片编码成特征再上行,板端就退化成"收特征 → 拼接 prompt → prefill"。代价是特征体积随 token 数线性涨(高分辨率大图会到 MB 级),且客户端必须能跑同一套 ViT——这正是协议要定义得极严的原因:grid、fp32 布局、字节数、可选 DeepStack,任何一处对不上就直接拒绝,而不是拿错特征硬跑。
同一套 encode_media_item 里还藏了第三条省钱路:图片缓存。image_url 的原始字节算 128 位 FNV-1a 键,命中就直接回放上次的视觉 token([VIS-CACHE] hit … skip ViT)。聊天里同一张图被引用多次是常态,缓存让"板端 ViT 只在第一次出现时付钱"。
2. 对应代码:encode_media_item 的三路分发
vllm_server.c 的 encode_media_item(1608–1793 行)按 content part 类型分发,读协议注释原文(1618–1620 行):
/* 客户端预处理模式(vision_tokens part):浏览器在本机算好的视觉特征,
* 直接接收(跳过 ViT 编码)。协议 v1:tokens_b64 = base64(fp32 LE, n*d),
* ds_b64(可选)= base64 数组,每条 n*d;grid = [gt, gh, gw] 合并后网格。 */
2.1 分支一:vision_tokens(1621–1688 行)
const VJson *vt = vjson_obj_get(part, "vision_tokens");
if (vt) {
grid 必须为数组 [gt, gh, gw],gt/gh/gw ≥ 1 且 gt*gh*gw ≤ 4096; // 1623-1628
n = gt*gh*gw;
tokens_b64 → base64 解码 → 字节数必须 == n*d*sizeof(float) // 1633-1636
memcpy(vis_all + …); grids[n_regions] = (gt, gh, gw); // 1646-1651
ds_b64 可选(≤3 条,每条也必须 == n*d*4)→ ds_accum_append_rows // 1655-1682
fprintf(stderr, "[VIS-TOKENS] client-preprocess n=%d d=%d ds=%d grid=[…]");
}
要点:协议用字节数自校验。d(LLM 隐宽 2048)是板端说了算的,客户端特征宽度差一维、条数差一条,tlen != need 直接 return -1。DeepStack 是可选字段(1653–1654 行注释说得很直白:缺省则 n_ds=0,prefill 跳过注入,输出与带 ds 路径不一致,客户端须自行负责)——协议允许降级,但把语义责任写在注释里。
2.2 分支二:image_url + 图片缓存(1691–1742 行)
url → decode_data_url(只认 "data:…;base64," 形式) // 1693-1696
h1/h2 = FNV-1a128(原始字节) // 1698-1701(图片键)
rgb = media_load_image_memory(raw, …) // stb 解码
if (vis_cache_apply(命中)) {
…skip ViT… return; } // 1706-1714
n = st_vision_encode_image(vis, rgb, w, h); // 1715(19-1 的八步)
grids = [1, gh = grid_h/vis_merge, gw = grid_w/vis_merge] // 1719-1724
vis_cache_store(…); // 1738(存盘待回放)
缓存键是图片原始字节的 128 位 FNV-1a——同一张图换个尺寸/换个像素就换键,命中即回放上次编码出的 token + grid + DeepStack,完全跳过 ViT。
2.3 分支三:video_frames(1744–1791 行)
video_frames 是 base64 data URL 的数组(每帧一张图),解码后要求全部帧同尺寸(1762–1763),交给 st_vision_encode_video 沿时间维 patch(19-1 的 temporal=2),grid 的 gt = n_frames_eff。这是"帧序列"语义的入口;至于"帧序列从哪来",19-3 会讲它和 H.264 模块的关系。
3. 改动后果:serve 上四条请求实测
实测口径:RK3588 / aarch64 / Release / 2026-09-07。部署形态:
--serve --model <2B 目录>(自动加载model.vqf,含 27 个v_*视觉张量)。客户端从 x86 主机发 HTTP。
3.1 模型就绪与协议日志
[SERVE] vision encoder ready (VQF mmap, ViT 24 layers, hidden=1024)
[SERVE] model ready: …/Qwen3-VL-2B-Instruct (wmode=0, vision=1)
serve 路径的视觉权重来自 VQF mmap(不是 19-1 CLI 的 safetensors 直读),这印证了 17 篇的结论:model.vqf 把 ViT 的 Q8/F32 张量一并固化了。
3.2 图像三条路的实测对比
64×64 BMP 渐变图(base64 data URL,16,456 字节),同一 prompt 连发:
① 首次/缓存未命中(换 1 个像素触发):
ttft_ms ≈ 769 / 797 (ViT 编码 + prefill,串行)
② 同图第二次(缓存命中):
[VIS-CACHE] hit (64x64, 4 vis tokens, skip ViT)
ttft_ms ≈ 297 / 301 (纯 prefill,无编码)
③ vision_tokens 零特征(grid=[1,2,2],n=4, d=2048):
[VIS-TOKENS] client-preprocess n=4 d=2048 ds=0 grid=[1,2,2]
ttft_ms ≈ 617 (无编码,但注入 + prefill 比纯文本略高)
诚实解读三条:
- 缓存命中省掉的是 ViT 整段:①−② 的差值 ≈ 0.47s 是"解码→编码→存储"的净增量。注意它比 19-1 CLI 单测的 295ms 大——serve 里编码发生在请求线程、要叠加上解码/分配/首请求页故障等成本,两次测量没有单独插桩,精确拆分留给 Day 28 性能方法论,这里只报"命中 ~0.30s / 未命中 ~0.77–0.80s"两个可直接复现的数字;
- 返回体带逐段时序:image/vtok 响应含
metrics(ttft_ms/tpot_ms/prefill_ms/total_ms),这是引擎给上层做性能观测的现成钩子; - vision_tokens 只验证了协议与管线:本实验发的特征全是 0,板端照常解析、校验、注入并生成了一段"看图说话"——但那是零特征喂出来的幻觉文本,不代表图像语义。协议链路(base64→字节数校验→grid→DeepStack 可选→prefill)被完整走过并返回 200,这层结论是硬的;"零特征也能出活"反过来恰好证明:如果你把客户端特征传错,模型会一本正经地胡说——这正是 §2.1 里"客户端须自行负责"那条注释的现实意义。
3.3 一个真实回答样本(image_url,ViT 真编码)
"content":"这张渐变图的主色调是紫色。"
metrics: prompt_tokens=28 ttft_ms=301 tpot_ms=55 tok_s≈12.6
文本基线(同模型同板)也正常:关于"红隼鸟"的中文回答完整且正确。注意:同图两次请求(①②)因采样路径不同给出的颜色描述并不一致,本文不做图像理解质量评测,只把"编码/缓存/注入/生成"这条链路是否跑通当作结论。
4. 学员调试任务
- A 档(x86 客户端 + 板端 serve):
- 复刻 3.2:起 serve → 发 image_url(BMP/PNG base64)→ 同图再发一次 → 换像素再发,记录三组 ttft 与 serve 日志的
[VIS-CACHE]/[VIS-TOKENS]行; - 故意把
vision_tokens.tokens_b64的 base64 截短/加长 → 观察返回 4xx 与-1拒绝路径(tlen != need);再把grid改成[1,2,2]但特征字节按 3 个 token 的长度发 → 同样被拒; - 给 vision_tokens 加
ds_b64三条、每条字节数与 tokens_b64 一致 → 日志应出现ds=3;再加一条长度不对的 → 整包被拒。
- 复刻 3.2:起 serve → 发 image_url(BMP/PNG base64)→ 同图再发一次 → 换像素再发,记录三组 ttft 与 serve 日志的
- B 档(纯读源码):读
vllm_server.c1608–1793,回答:① FNV-1a 缓存键为什么是"原始字节"而不是解码后的 RGB 或尺寸?(提示:同一尺寸不同内容、同一内容不同编码各算什么)②video_frames分支为什么强制"全部帧同尺寸"而vision_tokens分支不需要知道任何像素信息?③ 若客户端把grid的gh/gw传成与 tokens 行数不符(如 [1,3,3] 配 4 行特征),哪一行校验会拦下它?
预期输出:一张"请求形态 × serve 日志 × ttft"的对照表,并能徒手构造一个被服务端拒绝的错误 vision_tokens 包。
收尾
- 本篇源码点名:vllm_server.c(encode_media_item 1608–1793、vis_cache 1706–1738、协议注释 1618–1620)、vllm_media.c(base64 解码 59–150、stb 图片解码)
- 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:
video_frames收的是"现成帧"。那"上传一个 mp4"行不行?19-3 讲视频输入的两层现实:帧序列路径已实现实测,而 MP4/H.264 解码是独立模块、默认构建不启用、未完成——这个工程取舍本身值得读代码理解。