系列:《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 篇 · 总纲(阿里云社区)
上一篇:20-2《路由与自研最小 JSON》| 下一篇:21-1《/v1/chat/completions:兼容协议的最小面》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 SSE 流式响应:状态行与头先发,事件流按 token 逐条推送直到 [DONE],板端实测首事件 130ms、此后每 token 约 50ms,看响应怎么写一半就发给客户端。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、SSE、流式响应、HTTP、逐 token、[DONE]
导语:模型边生成、客户端边看到字,靠的是 SSE:状态行与响应头先发,事件流按 token 逐条推,直到 [DONE] 关流。本篇拆开流式三件套,并在 RK3588 上用带时间戳的客户端视角,看首事件与逐 token 到达的节拍。
对话体验的最后一公里是"打字机效果":模型边生成,客户端边看到字。HTTP 本身是请求-响应,要"分片到达"得靠 SSE(Server-Sent Events)。这篇拆 vllm_http.c 的流式三件套,并贴板端实测:stream:true 请求在 130ms 收到首事件、此后每个 token 约 50ms 一个 chunk,直到 [DONE]。
1. 知识点:SSE 到底是什么
SSE 不是协议魔法,它是"HTTP 响应体按约定格式分片写":
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {json 片段1}\n\n
data: {json 片段2}\n\n
data: [DONE]\n\n
规则就三条:状态行+头先发出去;之后每个事件是 data: … 行、以空行结尾;连接保持打开直到流结束。客户端(fetch/EventSource/curl)按"空行切事件"解析即可——不需要任何 WebSocket。对本引擎,SSE 与推理的配合方式是(Day 14 单机守则的延续):一次只服务一个生成流,流里逐 token 调前向、逐 token 推事件,worker 不并行抢推理。
2. 对应代码:流式三件套
vllm_http.c 的接口就三个函数(666–690 行):
int vhttp_stream_begin(VHttpConn *conn, int status, const char *content_type); // 666
int vhttp_stream_write(VHttpConn *conn, const char *data, size_t len); // 678
int vhttp_stream_flush(VHttpConn *conn); // 681
int vhttp_stream_done(VHttpConn *conn); // 685
实现要点:begin 先发状态行与头;write 逐事件写;flush 是空实现(683 行注释:socket 无缓冲,每次 write 都到线缆)——这是"最小实现"里少有的"因为不做缓冲所以天然实时"的巧合,值得在源码里体会。流式端点(/v1/chat/completions?stream=true)在生成循环里每出一个 token 就 vhttp_stream_write 一个 data: 事件,末尾补 data: [DONE]。SSE 事件里还夹了两个"非 OpenAI 标准"的自定义事件:chat.completion.metrics(逐段时序)与 chat.completion.tokens(本轮完整 token 列表)——标准之外的扩展要标清楚,客户端不认识就跳过(框架的宽容度在这里是特性)。
3. 改动后果:curl 视角的逐 token 到达
实测口径:板端 serve + x86 客户端,
POST /v1/chat/completions {"stream":true, "messages":[...从1数到20...], "max_tokens":30},2026-09-07。客户端按行读事件并打时间戳。
t= 130.3ms data: {...delta role:"assistant"...} ← 首事件(角色帧)
t= 930.4ms data: {…delta content 第 1 个 token…}
t= 1061.1ms data: {…第 2 个 token…}
t= 1191.8ms data: {…第 3 个 token…}
…(此后每个事件间隔 ≈ 50–60ms,稳定)
t= 2726.5ms data: {…第 N 个 token…}
t= 2727.3ms data: {"object":"chat.completion.metrics","metrics":{…}}
t= 2727.6ms data: {"object":"chat.completion.tokens","tokens":[…]}
t= 2727.7ms data: [DONE]
→ first_chunk=130.3ms total_chunks=35 elapsed=2728ms
可以量化的三点:
- 首事件 130ms 就到:在 decode 出第一个字之前,客户端已经拿到"流开始了 + role 帧"——这让前端能立刻把光标切到打字状态,体感延迟被隐藏;
- 逐 token ≈ 50–60ms:与 Day 19-2 的
tpot_ms≈55一致——SSE 的节拍就是 decode 的节拍,流式没有给推理额外加可见延迟(write空缓冲是原因之一); - 结束三连:
metrics→tokens→[DONE]在 1ms 内收尾,[DONE]是客户端"关流"的信号。
诚实备注:SSE 的实时性在局域网里测是干净的;跨公网时 TCP 拥塞/代理缓冲会破坏"逐 token"观感(有些中间代理会攒包),这是 SSE 的已知边界,不属于引擎能解决的范畴。
4. 学员调试任务
- A 档(本地动手):
- 用
curl -N -X POST … -d '{…"stream":true…}'观察逐行事件到达(-N关缓冲是关键),记录每两个data:的间隔; - 把
max_tokens调到 200,观察事件间隔是否仍稳定在 ~50ms(推理慢时 SSE 节拍会拉长——把"流式节拍 = 解码速度"这个等式亲自验证一遍); - 找一个会吞 SSE 的代理场景(如 Python
http.server反代或 nginx 默认缓冲)复现"攒包",理解X-Accel-Buffering: no这类头存在的意义。
- 用
- B 档(纯读源码):读
vllm_http.c666–690 与生成循环里写事件的调用点,回答:① 为什么flush可以空实现而write不能省?(提示:谁负责把字节推到线缆)②[DONE]之后连接是关还是复用,读vhttp_stream_done与调用方的收尾逻辑;③ 若某天改成"攒 4 个 token 再推一次",对ttft观感与metrics里的ttft_ms各有什么影响?
预期输出:一张你自己板子的"流式事件时间轴"(含首包与逐 token 间隔),并说明 [DONE] 前的三个自定义事件各是什么用途。
收尾
- 本篇源码点名:vllm_http.c(vhttpstream* 666–690)、vllm_server.c(流式对话 handler、metrics/tokens 自定义事件)
- 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:传输层全会了。Day 21 进"业务层":
/v1/chat/completions的请求字段怎么映射成引擎内部的 prompt 与参数,OpenAI 兼容的"最小面"到底有多大。