系列:《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 篇 · 总纲(阿里云社区)
上一篇:10-3《诚实的坑:哪些场景稀疏无收益》 | 下一篇:11-2《前缀缓存键:怎么知道"一样"》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:多轮对话的浪费:无状态 API 每轮把全历史重发一遍、推理引擎把已算过的 prefill 重新算一遍,越聊越长白算越多——引擎靠什么让第二轮只补算新增的尾巴,从 reset 分支读前缀缓存命中的秘密。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、多轮对话、前缀缓存、KV cache、prefill
导语:多轮对话里最贵的浪费:无状态 API 每轮把全历史重发一遍,引擎就把已算过的 prefill 又算一遍,越聊越长、白算越多。本篇从 reset 分支切入看清这笔浪费有多大,并引出默认开启的前缀 KV 复用——同进程两轮实测 prefill 从 46.4s 降到 1.7s。
10 天里我们优化了"单次请求"的每个环节——可真实服务是多轮对话:客户端每轮都把全历史重发一遍,引擎每轮都把历史重新 prefill 一遍。今天先看清这笔浪费有多大,再引出引擎的解法:前缀 KV 复用(默认开)。
1. 知识点:TTFT 里最贵的那段,多轮时被重复支付
一次聊天的第一段耗时(TTFT,首 token 时间)大致由四段组成:
- 排队 + 网络(HTTP 请求进来、调度);
- 分词(prompt → token id);
- prefill(对整段历史做一次前向,把 KV 写进缓存)——10-1 讲过,这是大头:2K 上下文在本板就要 ~46s(见第 3 节实测);
- 首词采样。
问题出在"无状态 API"模型上:OpenAI 兼容接口每次请求都要带完整消息历史(messages 数组)。于是第二轮请求的 prefill,会把第一轮已经算过的历史从头再算一遍——第一轮的 KV 明明还躺在内存里,却被当作"新请求"清零重来。
这就是"多轮对话的浪费":越聊越长,每轮白算的 prefill 越多。第二轮白算一轮历史、第三轮白算两轮历史……长会话里,模型大部分时间在重算自己刚算完的东西。
2. 对应代码:浪费发生在 reset,解法是"reset 时保留一段"
先看"每轮请求开头"发生了什么(vllm_server.c 第 405 行起 ist_reset):
static void ist_reset(VLLMServerCtx *ctx, int keep_lcp) {
...
int reuse = (ctx->prefix_kv || ctx->disk_kv) &&
keep_lcp >= PREFIX_KV_MIN_LCP && !g_l3_evict &&
keep_lcp <= st->cache_len[0];
if (reuse) {
for (int l = 0; l < nl; l++) st->cache_len[l] = keep_lcp;
st->seq_len = keep_lcp;
fprintf(stderr, "[KV-PREFIX] reuse %d-token KV prefix, prefill rest\n",
keep_lcp);
...
return; /* 保留前 keep_lcp 个 token 的 KV */
}
for (int l = 0; l < nl; l++) st->cache_len[l] = 0; /* 全清:全部重算 */
...
}
看清两件事:
- 不开复用时(
reuse为假),请求开始就把cache_len全清零——上一轮辛苦写下的 KV 行逻辑上立即作废,新一轮从头 prefill。这是"浪费"的代码级实锤; - 开复用时,引擎允许"保留前
keep_lcp个 token 的 KV 行",只 prefill 尾巴(第 1321 行st_qwen_model_prefill_batch(st, ids + keep, n_ids - keep)只喂了剩余部分)。而g_prefix_kv默认就是 1(main.c 第 1649 行,--no-prefix-kv可关)——默认开,不是需要用户想起来才有的功能。
"怎么知道要保留多长(keep_lcp 从哪来)"是下一篇 11-2 的主角(前缀最长公共子序列 LCP)。本篇只需要接受一个事实:保留多少、清零多少,由"上一轮请求的 token 序列"与"这一轮的"比对决定。
3. 改动后果:同进程两轮,prefill 46.4s → 1.7s
口径:RK3588 / Qwen3-VL-2B / 2026-09 / serve 同进程
/v1/completions贪婪(前缀复用默认开)。第一轮 prompt = 2044-token 事实长文 + 问题(Alice 的颜色);第二轮 = 第一轮全部 + 模型回答 + 新问题(Bob 住哪),即真实多轮客户端的重发形态。上下文 n 与耗时取引擎日志[PREFILL-TIMING]的真实值。
| 轮次 | prefill token 数 | prefill 耗时 | 请求总耗时(含 decode) | 模型输出 |
|---|---|---|---|---|
| 第一轮(全量) | 2044 | 46.36 s | 50.73 s | Alice likes the color blue. |
| 第二轮(复用 2034) | 31 | 1.73 s | 5.89 s | Bob lives in Tokyo. |
引擎日志如实记录了命中的那一刻:
[KV-PREFIX] reuse 2034-token KV prefix, prefill rest
[PREFILL-TIMING] n=2044 total=46355.9ms | GEMM=26224.7ms(56.6%) ATTN=18584.2ms(40.1%)
[PREFILL-TIMING] n=31 total=1726.2ms | GEMM=992.4ms(57.5%) ATTN=716.8ms(41.5%)
判读:
- 第二轮只 prefill 了 31 个新 token(问题增量),却是在 2065 token 的完整上下文上采样回答的——正确作答证明保留的 2034 行 KV 真的参与了注意力,不是摆设;
- prefill 从 46.36s 降到 1.73s(≈27×);请求总耗时(含每轮固定的 decode 几秒)50.73s→5.89s(≈8.6×)。会话越长、历史占比越高,这个倍数越大;
- decode 没有被省:第二轮仍需在完整上下文上逐词生成——复用省的是 prefill,不省 decode(decode 的优化是 Day 10 稀疏与 Day 13 推测解码的主场)。
旁证(仓库文档,x86 基准机 8B/2026-08-28,优化配置与边界说明.md §3.2):前缀复用的进程内验证为 prefill 加速 4.99×(253 token 复用 231 token),且输出与全量逐字节一致。本板这轮 27× 之所以更高,是因为第一轮上下文比例更大(2044 复用 2034)——复用率决定加速比。
4. 学员调试任务
- A 档(板端动手):按第 3 节口径自己跑一轮"同进程两轮":先发一个长 prompt,再发"长 prompt + 回答 + 追问"。记录两轮日志里的
[PREFILL-TIMING] n=与[KV-PREFIX],算你自己板子的 prefill 加速比。把第一轮 prompt 拉长到 4K 再跑一次,观察加速比是否变大(复用率更高)。 - B 档(纯读源码):读
ist_reset(vllm_server.c 第 405 行起),回答:① reuse 成立需要哪 4 个条件同时满足?② reuse 分支里cache_len[l] = keep_lcp与"直接不 reset"有什么不同?(提示:新一轮的尾巴还要继续写 KV,cache_len 必须从 keep 处续写。)
预期输出:你能讲清"多轮浪费 = prefill 被重复支付",并用一组自己的板端数字量化"复用后 prefill 降了 X 倍"。
收尾
- 本篇源码点名:vllm_server.c(
ist_reset第 405 行起、reuse 门 408–410、[KV-PREFIX]415、增量 prefill 1321)、main.c(g_prefix_kv=1默认 1649)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:复用保留了 2034 个 token——引擎怎么知道"这两轮的共同前缀恰好是 2034"?11-2 拆最长公共前缀(LCP)与那条"一模一样就不复用"的反直觉安全规则。