日期:2026-10-21
环境:Qwen3.8-27B nvfp4,2×V100-16GB,TP2,INT8 KV,--max-context 196608,KV capacity 204800,PARALLEL=1,MTP3。
客户端:DSH(本地 provider unsloth → http://127.0.0.1:8888/v1)。
TL;DR
reuse=full_reset 本身不是错误:它是"这次请求没有可接续的常驻前缀"的标记。新会话第一条、前缀真变了、或常驻状态被别的请求顶掉,都会是它。
- 同一会话的后续轮本应命中(
append_frontier 或 restore_*),NInfer 的复用链路本身工作正常(实测同一请求第二次 cached=9/13)。
- 实际观察到的"每轮都 full_reset"是客户端辅助请求交错造成的:DSH 每轮在主线请求旁边再发一个会话标题请求,二者都打同一个 8888,标题请求把唯一的常驻 lane 覆盖了。
- DeepSeek 不受影响,因为它的缓存是服务端多条目、按前缀块索引、LRU 淘汰的池,不是一条常驻 lane。
- 性能修复优先级:辅助请求外置到 DeepSeek / 加"不驻留"标志 > 提高
--max-concurrency > 多条目池 > 块级共享(最后一项在本模型上不可行也不划算)。
1. 机制
NInfer 的复用路径(GenerationResult::prefix_reuse_path):
| 路径 |
含义 |
full_reset |
从头 prefill |
append_frontier |
prompt 完整包含当前 resume frontier,只 prefill 后缀 |
restore_turn_checkpoint |
恢复到 turn 闭包快照,重算 assistant 正文 |
restore_response_checkpoint |
恢复到响应 replay 快照 |
命中条件(request_plan_impl.h):
allow_prefix_reuse 且 prompt identity 可复用、lane 有 retained 状态;
- 要么 prompt 在当前 execution frontier 上逐 token 匹配(append),
- 要么在已捕获的 rewrite checkpoint 上匹配(restore)。
关键约束:
- 只有 frontier 和 checkpoint 两个命中点,不是任意公共前缀。公共前缀结束在没有快照的位置 = miss,因为缺的是 Linear Attention/backend continuation state,不是 KV 页(paged-kv-cache.md)。
- retained 状态是 lane-affine 的,不能自由搬到另一条 lane(paged-kv-cache.md)。
- 一个 lane 只保留一份 retained sequence;
PARALLEL=1 时后到的请求直接覆盖它。
- 缓存是进程内的,重启即清空;
--no-prefix-reuse 是服务端级开关,没有单请求粒度。
preserve_thinking 默认 false:thinking 模式下快照落在 <|im_start|>assistant\n 之后、<think> 之前,所以下一轮丢弃 reasoning 时只能复用到这里,正文 + 新回合重算(chat_template.cpp)。
2. 什么时候 full_reset 是正确的
- 新会话 / 服务重启后的第一条;
- prompt 前缀真的变了:system prompt、工具列表、
reasoning_effort、runtime-context 内容、历史被裁剪/改写、媒体变化;
- 另一个无关请求先占用了唯一的 lane(本次问题);
- 长上下文把 KV capacity 挤满,admission 淘汰了 retained lane;
- 服务端带了
--no-prefix-reuse。
3. 实测数据(同一台运行中的 server)
3.1 同一请求连发两次
$body = '{"model":"qwen3.8-27b","messages":[{"role":"user","content":"你好"}],"max_tokens":8,"temperature":0,"stream":false,"enable_thinking":false}'
1..2 | % { $r = Invoke-RestMethod http:
-ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body))
"prompt=$($r.usage.prompt_tokens) cached=$($r.usage.prompt_tokens_details.cached_tokens)" }
结果:prompt=13 cached=0 → prompt=13 cached=9,wall 249ms → 112ms。
3.2 多轮(带历史)请求
第二轮 prompt=70、cached=55(复用 turn checkpoint,正文重算)→ 正常。
3.3 交错请求会顶掉常驻前缀
AAA 主请求 prompt=18 cached=0
BBB 无关请求 prompt=20 cached=0
AAA 主请求 prompt=18 cached=0 ← 被 BBB 顶掉
AAA 主请求 prompt=18 cached=14 ← 中间没有别人,立刻命中
这就是"每轮 full_reset"的最小复现。
3.4 DSH 日志里的交错
| 时间 |
请求 |
特征 |
prompt |
结果 |
| 33.163 |
req 31 |
msgs=2, tools=0, max_tokens=64 |
190 |
full_reset |
| 33.181 |
req 30 |
msgs=3, tools=10, max_tokens=32768 |
2090 |
full_reset |
| 40.228 |
req 33 |
msgs=2, tools=0, max_tokens=64 |
317 |
full_reset |
| 40.241 |
req 32 |
msgs=5, tools=10, max_tokens=32768 |
2168 |
full_reset |
session.v4.jsonl 中对应 session/title-llm-request:DSH 用同一个本地模型生成/更新会话标题。标题请求先完成并驻留,主请求随后只能 full_reset;下一轮标题更新再把它顶掉,如此循环。第二轮的 ~2089 个 token 本可命中 turn checkpoint。
3.5 用量账本(.dsh-usage-ledger.json)
| 会话 |
模型 |
调用 |
输入 |
cacheHit |
命中率 |
| session-7ceb914b |
flash(DeepSeek) |
59 |
4,409,241 |
4,146,816 |
94% |
| session-50bb0ffc |
flash(DeepSeek) |
13 |
345,566 |
328,832 |
95% |
| session-a9d5faea |
qwen-3.8-27b(本地) |
54 |
3,610,286 |
0 |
0% |
| session-50f74277 |
qwen-3.8-27b(本地) |
3 |
6,414 |
4,238 |
66%(turn2 2088/2152,turn3 2150/2172) |
结论:DS 侧即使每轮改标题也稳定高命中;本地只有在没有交错请求时才命中。
4. 干扰源清单
同一本地 server 上的辅助请求(都是"别人的 prompt"):
- 会话标题生成/更新(本次元凶);
- 权限/审批自动审核——如果它把待审内容发给同一个模型,危害更大:它可能发生在一个回合内的多个 step 之间,打断最值钱的 step→step 增量;
- 上下文压缩/摘要(compaction);
- 提示词优化(DSH 的
autoOptimize);
- 子代理(若也配置成同一本地模型);
- 记忆/要点提取、追问建议等。
共享 8888 的其他客户端:网页面板、curl、第二个 DSH 窗口、benchmark 脚本。
真 miss(不是被顶掉):system prompt / 工具列表 / 推理强度变化、runtime-context 变化、历史改写、服务重启、长上下文挤压容量。
5. 为什么 DeepSeek 不受影响
|
DeepSeek |
NInfer |
| 存储 |
服务端大缓存池 |
显存中每条 lane 一份 retained state |
| 索引 |
前缀块 hash(64 token 粒度) |
lane 扫描 + 逐 token 精确匹配 |
| 并发条目 |
多会话共存,LRU |
PARALLEL 条,后到覆盖 |
| 命中粒度 |
任意块边界 |
frontier / checkpoint |
| 淘汰 |
容量压力 + 时间 |
被新请求覆盖,或容量压力 |
标题请求在 DeepSeek 上只是新增/命中它自己的块,不会让主对话的块失效。NInfer 的约束来自 Linear Attention 递归状态和单机显存,属于设计权衡,不是实现缺陷。
6. 测试方法
测引擎:用 3.1 的脚本连发同一请求;第二次 cached>0 即正常。
测客户端:日志里看 [req N] done ... cache=... reuse=...。若每轮都存在 msgs=2 tools=0 max_tokens=64 的请求与主请求交错,且主请求恒 cache=0,即为覆盖。
看面板:GET /metrics 的 live.cache_hit_tokens(累计)、last.cache_hit_tokens(上一请求;会被后续任何请求覆盖,含你自己的)。注意累计值有秒级发布延迟;以响应里的 cached_tokens 为准。
负对照:BBB 无关请求 插在两次 AAA 之间会归零;去掉它立即恢复。
7. 缓解(按成本)
- 辅助请求外置:标题、权限审核、压缩都路由到 DeepSeek(或别的 provider),本地 8888 只留主对话。零代码,收益最大。
PARALLEL≥2:调度器按 reusable_prompt_tokens 最大选 lane(concurrent_executor.h),若干个前缀族在预热后会各自粘住一条 lane。注意:首次交错仍可能撞车;前缀族数量不能超过 lane 数;容量压力下仍会淘汰(concurrent_executor.h)。
- 单请求"不驻留"标志:辅助请求参与计算但不覆盖 retained entry。小改动,直击本次问题。
- 多条目 retained 池:见下节。
- DeepSeek 式块级共享:不建议(见下节)。
默认启动脚本 _scratch/serve-ninfer.bat 里 PARALLEL=1,是本次问题的放大器。
8. 能否做成 DeepSeek 式缓存池
可以,但形态不同。 现有代码已经是一个 PARALLEL 条目的雏形:每个空闲 lane 独立算 reuse,取最大者。
可行的池:
- key = hash(weights_id, KV dtype, rope/max_context, backend/draft window, token ids, positions, token types, media digests),命中后仍用
prefix_matches 逐 token 校验;
- value = 独占 bundle:各 pool 的 page allocations + frontier、linear state slot、hidden tail、typed rewrite checkpoint、backend continuation state;
- K 条条目,LRU + 字节预算,活跃条目 pin,admission 计入 retained 容量;
- 命中时走现有 restore/claim/truncate 路径。
三个硬约束:
- 不能按任意 64-token 块命中——GDN 递归状态无法从 KV 页重建,命中点只能是 frontier/checkpoint;
- 条目 lane-affine——池大小天然受可用 lane 数限制,除非额外存 continuation state 并实现跨 lane 恢复;
- 容量在显存——条目占用 KV capacity,204,800 token 池放不下很多长会话;短 agent 对话可以放不少。
路线:
| 阶段 |
内容 |
量级 |
| 1 |
PARALLEL 提到 4 左右 |
配置 |
| 2 |
请求级 retain_prefix=false |
小改动 |
| 3 |
K 条 keyed retained pool |
中等偏大 |
| 4 |
块级 KV 共享 |
本模型不划算 |