系列:《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 篇 · 总纲(阿里云社区)
上一篇:11-1《多轮对话的浪费——上一轮 prefill 算完就被扔了》 | 下一篇:11-3《缓存的一生:内存、容量与淘汰》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:前缀缓存键:推理引擎日志那句 reuse 2034-token KV prefix 的 2034 从哪来——以 token 序列的最长公共前缀(LCP)作键,回答两轮请求怎么算"一样",以及"一模一样反而不复用"那条反直觉安全规则。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、前缀缓存、LCP、token 序列、KV cache
导语:引擎日志那句 reuse 2034-token KV prefix,2034 是怎么来的?本篇讲前缀缓存的"键"——不是字符串也不是"话题",而是 token 序列的最长公共前缀(LCP),逐 token 精确比对;并解释那条反直觉的安全规则:两轮完全相同反而不复用,差一个增量尾巴才命中。
11-1 里引擎打了句日志:reuse 2034-token KV prefix。这个 2034 是怎么算出来的,就是今天的主角:最长公共前缀(LCP)。它是前缀缓存的"键"——但这里的键不是字符串,是 token 序列。
1. 知识点:键 = token 序列的 LCP,而不是"话题"或"用户"
缓存要不要命中,只回答一个问题:这一轮请求的 token 序列,和上一轮(留在 KV 里)的序列,从开头数有多少个完全一样?
- 一样到第
L个、之后分叉 → 前 L 个 token 的 KV 可以原样复用,只算L之后的部分; - 从头就不一样(L < 16)→ 缓存键不匹配,全量重算;
- 完全一样、没有分叉 → 故意不复用(见第 2 节的"反直觉安全规则")。
两个关键纪律:
- 逐 token 精确比对(不是近似、不是语义):token id 有一个不同,前缀就到此为止。代价是"错一个词就全量重算",收益是永远不把错误的 KV 复用进来——宁可慢,不可错;
- 复用只对"前缀"生效:哪怕两轮只有一句话顺序不同,前缀也在那里断开。真实对话里,客户端按顺序重发全历史,天然满足"前缀相同",所以无状态 API 也能吃到这个优化。
2. 对应代码:kv_lcp 与那条"一模一样就不复用"的规则
最朴素的实现长这样(vllm_server.c 第 388–396 行):
#define PREFIX_KV_MIN_LCP 16 /* 值得跳过的公共前缀最小长度 */
static int kv_lcp(const int *a, int na, const int *b, int nb) {
int n = na < nb ? na : nb;
for (int i = 0; i < n; i++) if (a[i] != b[i]) return i;
return n; /* 一路全等:返回较短者的长度 */
}
双指针扫一遍、第一个不等就停——O(L),L 是公共前缀长度。16 是"跳过 16 个 token 以上才值得",太小的话省下的 prefill 抵不过流程开销。
请求处理里怎么用它(第 1300–1307 行):
if (ctx->prefix_kv && !g_l3_evict && ctx->last_n > 0) {
int l = kv_lcp(ctx->last_ids, ctx->last_n, ids, n_ids);
/* 必须 l < n_ids: 若新 prompt 被上次 KV 完全覆盖(l == n_ids), prefill
* rest=0 不会刷新 logits, decode 首步会采样到上次请求末尾的陈旧 logits
* (预测 eos) → 立即停止 → 空响应。回退全量 prefill 保证正确。 */
if (l >= PREFIX_KV_MIN_LCP && l < n_ids && l <= st->cache_len[0])
keep = l;
}
注释里是本项目踩过的一个真坑,值得逐字读:
ctx->last_ids是上一轮"prompt + 生成"的完整 token 序列(第 1338–1349 行在请求成功后保存);- 新请求算出与它的 LCP =
l; - 条件
l < n_ids是安全阀:如果新 prompt 恰好是上次序列的前缀(被完全覆盖,l == n_ids),就不复用。为什么?复用意味着"跳过 prefill",而 prefill 的副产品是刷新 logits;如果 rest=0 什么都不算,decode 第一步会用上一轮末尾的陈旧 logits 采样——那通常是 EOS,模型会立刻"闭嘴"返回空响应。注释记录的原话:回退全量 prefill 保证正确。
所以规则是反直觉但正确的:完全相同 → 不复用;长公共前缀但还有增量 → 复用。11-1 第二轮之所以命中,是因为它 = 第一轮 + 新尾巴,l = 2034 < n_ids。
另外第 1262–1295 行还有一条"精确覆盖"路径:客户端若明确回传上一轮的 context_tokens(n_ctx 与缓存完全一致),可直接 keep——那是聊天页面的特权通道,普通 /v1/completions 走上面的 LCP 分支。
3. 改动后果:--no-prefix-kv 关掉复用,第二轮打回原形
口径:RK3588 / Qwen3-VL-2B / 2026-09 / serve 同进程贪婪。第二轮请求文本完全相同,只改引擎开关:默认(前缀复用开)vs
--no-prefix-kv(关)。
| 配置 | prefill token 数 | prefill 耗时 | 请求总耗时 | 引擎日志 | 输出 |
|---|---|---|---|---|---|
| 复用开(默认) | 31 | 1.73 s | 5.89 s | [KV-PREFIX] reuse 2034-token KV prefix |
Bob lives in Tokyo. |
复用关(--no-prefix-kv) |
2065 | 47.72 s | 52.07 s | (无 KV-PREFIX 行) | Bob lives in Tokyo. |
判读:
- 同一个请求文本,只差一个开关:关掉复用,第二轮被迫全量重算 2065 token,prefill 47.72s——27.6× 的差距(同 11-1 的复用率;预fill 46.4→1.7 是同款数字的另一个视角);
- 输出逐字节一致:复用开/关两种路径给出的回答完全相同(都是
Bob lives in Tokyo.)——KV 是"算出来的状态",只要前缀 token 相同,复用的 KV 与重算的 KV 就是同一份状态,所以复用是数学无损的(不像量化/稀疏有近似); --no-prefix-kv开关本体在 main.c 第 4796–4797 行,而g_prefix_kv默认 1(第 1649 行)——工程默认是"开",关它只是为了对照/诊断。
诚实标注:本文的复用率高达 2034/2065(98.5%),因为是"接续同一段长文"的极端口径,加速比 27.6× 是这个口径的上界方向。真实短对话(如"你好→在吗")前缀很短、小于 16 个 token,压根不触发;文档里 x86 8B 的日常验证口径是 4.99×(231/253 复用)。别拿 27.6× 到处说,要连复用率一起报——这正是 Day 28 会教的"口径先行"。
4. 学员调试任务
- A 档(板端动手):复现第 3 节:同进程发"长 prompt+回答+追问",分别跑默认与
--no-prefix-kv,抄两条[PREFILL-TIMING]的n=与耗时,算你自己的复用加速比;再验证两组输出文本是否逐字一致。 - B 档(纯读源码):读第 1262–1295 行(context_tokens 精确通道)与 1300–1307 行(LCP 通道),回答:①
l == n_ids时为什么不复用?② 若新 prompt 与上一轮零共享,keep是多少、走哪个分支?③PREFIX_KV_MIN_LCP=16是写死的宏,若改成 1 会怎样(提示:频繁小命中 vs 流程开销)?
预期输出:你能解释"前缀缓存键 = token 序列 LCP"及"完全相同反而不复用"的安全规则,并复现一组开关对照数字。
收尾
- 本篇源码点名:vllm_server.c(
PREFIX_KV_MIN_LCP388、kv_lcp392–396、last_ids保存 1338–1349、LCP 通道 1300–1307、context_tokens通道 1262–1295)、main.c(默认 1649、--no-prefix-kv4796)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:复用命中的那 2034 行 KV,在内存里占多大、什么时候被清掉、被谁顶掉?11-3 跟完"缓存的一生"——顺带回答"为什么进程内缓存救不了断电"。