系列:《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 篇 · 总纲(阿里云社区)
上一篇:15-2《prefill 与 decode:两条路径为何分开》 | 下一篇:第 16 天《VQF 格式深潜 I:布局》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:单步走一次生成:推理引擎把 N 次 decode 一次一次走给你看——每一步的 token id 是什么、KV 行长到哪、何时停下,本篇用 gdb 在板端录一段真实生成全过程,把"采样—前向—KV 追加"闭环钉在胶片上。
关键词:手搓 Qwen 推理引擎、千问大模型推理、token 循环、KV 追加、decode、GDB、Qwen3-VL、零依赖纯 C
导语:prefill 之后,真正的一个词一个词是怎么走出来的?本篇用 gdb 在 RK3588 板端录下一整段生成:每一次循环里 token id 如何变化、KV 缓存怎样一行行追加、又是何时因 EOS 停表——把「采样 → 前向 → KV 追加 → 判断停」这条闭环钉在胶片上。
15-2 用 gdb 抓到了"1 次 prefill + N 次 decode"的分叉。今天把 N 次 decode 一次一次走给你看:每一步 token id 是什么、KV 行长到哪、什么时候停——用 gdb 在板端录一段真实生成的全过程。
1. 知识点:decode 循环 = 采样 → 前向 → KV 追加 → 判断停
一次 decode 步在引擎里做四件事:
- 采样:从当前 logits 挑下一个 token(贪婪 = argmax,Day 13 的采样路径);
- 前向:把该 token 喂进去,跑 28 层(
st_qwen_model_forward,Day 15-2); - KV 追加:前向把这一位的 K/V 写进缓存并
cache_len++——KV 是"只追加"的; - 判断停:若是 EOS/
<|im_end|>或到达max_tokens,停止;否则回到 1。
所以 decode 的循环变量有两个:token 序号(输出里第几个词)与 KV 长度(缓存里第几行)——它们同步增长,因为每一个生成 token 都会在 KV 缓存里留下一行(它要成为后续 token 的"历史")。这正是 Day 8-3 说的"KV 是模型对已说内容的记忆",今天用数字把它钉死。
2. 对应代码:循环体三件套
decode_loop 的循环体(vllm_server.c 第 1105–1110 行):
gen++; /* ① token 计数 +1 */
if (met && gen == 1) t_first = ...; /* ② 首个 token 计时(TTFT 终点) */
st_qwen_model_forward(st, id); /* ③ 前向 + KV 追加 */
st_qwen_model_forward 内部在层循环末尾做 KV 写入与 cache_len[l]++(vllm_safetensors.c 第 9336 行起);回到循环头,第 989 行前的采样代码从 st->logits 取 argmax——于是"采样→前向→追加→再采样"闭环成立。停止判断在循环体里(命中 eos_id / im_end 即 break,Day 13 的 emit_token 第 825 行)。
3. 改动后果:gdb 实录——从第 25 行 KV 走到第 31 行前停表
口径:RK3588 / 板端 Debug 构建 / 2026-09 / gdb 断在
st_qwen_model_forward,请求:"List the first six letters of the alphabet"。prefill 后缓存已有 25 行(prompt 25 token),随后 gdb 逐次打印每次 decode 入口的token_id与seq_len/cache0。
TEXT>>>a b c d e f<<< ← 模型最终输出
DEC token_id=64 seq_before=25 cache0=25 ← 第 1 个生成词 'a',从第 25 位开始
DEC token_id=293 seq_before=26 cache0=26 ← 'b'
DEC token_id=272 seq_before=27 cache0=27 ← 'c'
DEC token_id=294 seq_before=28 cache0=28 ← 'd'
DEC token_id=384 seq_before=29 cache0=29 ← 'e'
DEC token_id=282 seq_before=30 cache0=30 ← 'f'
判读(这是"单步走一次生成"的完整胶片):
- token id 每次都在变(64/293/272/294/384/282)——每一步采样的是上一步前向的新 logits,不是重复;它们对应输出
a b c d e f; seq_len与cache0逐次 +1 且始终相等——KV 追加与 token 前进严格同步:第 25 行 KV 在写第 1 个词时被追加,第 26 行写第 2 个词……生成 6 个词 = KV 从 25 行长到 31 行(第 31 行正是 'f' 的位置,写完即停);- 停在 6 个字母后:输出没有第 7 个词——模型在第 6 个词后的下一步采到了结束符(EOS/im_end),
decode_loopbreak。这解释了"为什么每次请求 decode 的次数不固定"(15-2 任务 A 的答案:EOS 说了算,max_tokens只是上限); - 对照 Day 8-3 的 KV 布局:这 6 步每步的 K/V 行按 (token, 头) 落进缓存;注意力在每一步都会读这 25+step 行(Day 10 的稀疏在长上下文才划算,就在这层读上)。
诚实标注:这是 -O0 Debug 构建逐词打断的记录,只为展示结构与状态流;Release 下同样 6 步 decode 是几十毫秒(Day 10 表 B),Debug 下每步因 gdb 停表明显变慢——gdb 单步是显微镜,不是秒表。
4. 学员调试任务
- A 档(板端动手):复刻第 3 节:Debug 构建 + gdb 断
st_qwen_model_forward,发"List the first four letters"(或任意短句),抄出每次命中的token_id/seq/cache0与最终输出,验证"KV 长度 = 生成词数"的关系;再把max_tokens设成 2,观察循环是不是真的只走 ≤2 步。 - B 档(纯读源码):读
st_qwen_model_forward尾部的 KV 写入(vllm_safetensors.c 第 9336 行起,层循环末cache_len[l]++附近),回答:① KV 追加发生在层循环内还是外?为什么每层都要加?② 若某步采样到 EOS,为什么还要"已经写下的那行 KV"留在缓存里(提示:seq 已推进,客户端下一轮要 LCP)?③seq_len与cache_len[0]不一致可能意味着什么 bug?
预期输出:你能不看文档,用一段 gdb 输出复述"一次生成 = 1 次 prefill + N 次 decode,KV 行长 N 行",并说出停止条件的两个来源。
收尾
- 本篇源码点名:vllm_server.c(decode_loop 循环体 1105–1110、停止判断 989/825)、vllm_safetensors.c(
st_qwen_model_forward9336、KV 追加)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:生成跑通了,回头看"权重长什么样"——Day 16 进入 VQF 格式深潜:那个 432B 的头部、页对齐 4096、
dir_off=448,为什么字节要摆得这么讲究。