系列:《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 篇 · 总纲(阿里云社区)
上一篇:14-1《连续批处理:iteration-level 调度思想》 | 下一篇:14-3《批处理正确性边界:margin 0.71 vs 0.032》
源码精读篇:本文为源码/方法论精读,无独立实测;文中数字均引述仓库 docs 的板端实测记录
一句话导读:单 SSE 流与串行队列:推理引擎的"单机守则"——不开 batch 时同来的请求在推理状态上串行,每个请求的输出走自己独立的一条 SSE 流,本篇讲这条守则为何是对的,以及乱配 HTTP worker 与推理并发会发生什么。
关键词:手搓 Qwen 推理引擎、千问大模型推理、SSE 流、串行队列、线程模型、Qwen3-VL、零依赖纯 C
导语:不开 batch 时,同时进来的请求其实在推理状态上依次排队;每个请求的输出则各走自己的一条 SSE 流。本篇讲这套「单机守则」为什么是对的:推理串行或按步合批、SSE 各管各的,以及把 HTTP worker 与推理并发乱配会踩到什么坑。
14-1 讲了批处理线程怎么把 8 个请求"凑成一次前向"。但真实引擎的默认形态其实更朴素:不开 batch 时,同时进来的请求在推理状态上串行;每个请求自己的输出是一条独立的 SSE 流。今天讲这套"单机守则"为什么是对的——以及"把 HTTP worker 与推理并发乱配"会发生什么。
1. 知识点:一个推理状态,一次只能算一个请求
先回到 Day 4 的教训:引擎的推理状态(KV、hidden、logits)是单份的(vllm_server.c 里 ctx->ist)。于是:
- 不加 batch 时,两个请求同时到:谁先拿到推理状态的锁谁先算,另一个在队列里等——推理天然串行;
- 这不可耻:单机、单模型、单用户级延迟目标下,串行 = 延迟可预测、无共享状态竞争;batch 是把串行换成"按步合并",是有意识地用复杂度换吞吐。
再看 HTTP 侧:每个请求的输出通过自己的 SSE(Server-Sent Events)流发给客户端——8 个并发请求 = 8 条独立的 HTTP 连接 + 8 条 SSE 流。SSE 与推理是两个层次:推理决定"什么时候算出下一个词",SSE 决定"这个词怎么流给谁"。两者不该互相踩踏。
2. 对应代码:serve 的线程分工
引擎的 serve 骨架(vllm_server.c)用几组线程各管一摊:
- HTTP worker(
--threads,默认 8):accept 连接、读请求、路由(/health、/v1/chat/completions、/admin…);每个请求一个处理协程式流程; - 推理状态锁:请求处理进 decode_loop 前要拿到推理状态(14-1 提过 prefill 在引擎 mutex 下)。没有 batch 时这里就是串行闸门;
- batch 引擎线程(
--batch-max≥2时,vllm_batch.c):14-1 的引擎线程,取代"请求自己独占推理状态"的模式——worker 改为提交 token、等 done_epoch; - SSE 流式发送:decode_loop 每算出一个 chunk 就沿当前连接写出去(stream=true 时,vllm_server.c emit_token 第 834 行起拼 SSE 事件)。
"单机守则"落到代码就是三条纪律(文档 技术文档.md §2.2 的表述):
- 推理串行或按步合批,绝不并发读同一份 ist——否则 KV/hidden 互相踩(位级灾难);
- SSE 流每请求一条、写入只属于自己——流之间不共享可变缓冲;
- HTTP worker 只负责搬运,不碰推理内部状态——推理只由持有锁的路径或 batch 引擎线程驱动。
3. 改动后果:把并发乱配会怎样(思维实验 + 板端证据)
改动实验(别在板外真做,看代码推演):假设你在 HTTP worker 里直接调用"自己的一份推理状态"而不经过锁/不经过 batch 引擎——两个 worker 同时在写 KV、同时在改 hidden:后果是两个请求的中间状态互相覆盖,输出变成两个 prompt 的混合垃圾,甚至越界崩掉。引擎注释里那份"batch 与串行必须位级一致"的执念(14-3)正是为杜绝这类状态污染而设。
板端证据(不并发乱配,只看守则本身):同一批 8 个短 chat 问答(14-1 第 3 节),不开 --batch-max 时 8 并发总耗时 10.48s ≈ 同服务串行 11.35s——推理在状态锁上排队,并发没有换来任何聚合提速;开了 --batch-max 8 后这批短问答甚至更慢(20.93s,批内每轮迁就最慢者)。所以:默认形态的"串行"是守则的代价(延迟可预测、状态无竞争),batch 是守则内的提速手段——而且它对"长 decode 请求"才生效。
诚实标注:本文引用的三组耗时(串行 11.35s / 无 batch 并发 10.48s / batch 20.93s)均来自 14-1 的板端同批对照(2B 短问答);batch 在本场景是"更慢",不是"下降"——哪个方向取决于请求的 decode 长度,别把本文当"batch 必胜"的背书。若你在自己板子上观察到无 batch 并发时出现位级不一致,那多半是越过了第 2 节的三条纪律——那也是 14-3 讨论"为什么批量位级一致性不是普遍保证"的入口。
4. 学员调试任务
- A 档(板端动手):对同一批 8 个请求,分别在不带
--batch-max与带--batch-max 8的服务上做 8 并发,记录两种总耗时与全部输出,验证"不开 batch 并发≈串行、开 batch 才降"。 - B 档(纯读源码):在 serve 代码里找出:① 推理状态锁在哪个文件哪一行(提示:找 decode_loop 进入前的 mutex);② SSE 事件在哪个函数拼装、每连接缓冲是否独立;③ batch 模式下 worker 线程"等 done_epoch"的代码在 vllm_batch.c 哪一段。回答:把 ② 的缓冲改成全局共享,会出现什么症状?
预期输出:你能讲清"推理锁 / batch 引擎线程 / SSE 流"三层各管什么,并解释为什么"默认串行"是单机守则而非性能缺陷。
收尾
- 本篇源码点名:vllm_server.c(
ctx->ist、decode_loop 900、SSE emit 820+)、vllm_batch.c(线程注释 1–14)、技术文档.md(§2.2)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:batch 把请求合批后,怎么保证"合批 ≠ 改变结果"?14-3 拆那条最诚实的边界:margin 0.71 vs 0.032——为什么批量位级一致性不是普遍保证。