这是三篇系列的第一篇。我们在单机推理服务上遇到一个很隐蔽的性能问题:同一会话的下一轮,cached 从 961 变成 0,复用路径从 restore_turn_checkpoint 变成 full_reset。模型没变,主 prompt 也没有大改,变的是客户端在每轮主对话旁边多发了一个会话标题请求。本文只记录现象、排查过程、最小复现、客户端侧证据,以及当时我们据此做出的判断。机制纠正留到第二篇。
系列导航
- 现象与初判:一个标题请求如何顶掉唯一 lane(本文)
- 机制纠正与设计空间:不是驱逐,是自毁;两个销毁点与四条修复路线
- 实现与证据:一次修复的落地、实测与撤回结论
TL;DR
- 客户端是 DeepSeek Harness(下称 DSH),本地 provider 指向本机 OpenAI 兼容端点。DSH 被我开启每轮更新会话标题,标题请求和主对话请求打同一个端点(本机实测)。
- 测试环境是单机、
--max-concurrency 1(即 PARALLEL=1,下文简称 C=1)。同一会话下一轮 cached 从 961 掉到 0,复用路径从 restore_turn_checkpoint 变成 full_reset(本机实测)。
- 一开始我们以为问题出在“每轮更新标题”这个高频动作上;关掉它之后,首次生成标题、标题第一次变化时问题仍然出现。这说明不是频率问题,而是“只要有一次标题请求插在主请求前面”就足够(本机实测)。
- 最小复现是
AAA 主请求 → BBB 无关请求 → AAA 主请求:第三次归零;去掉 BBB,AAA 立刻恢复命中(本机实测)。
- DeepSeek 不受影响,是因为它的缓存是服务端多条目、按前缀块索引、LRU 淘汰的池;本引擎在 C=1 时只有一条 lane 的 retained 状态。这是缓存组织方式差异,不是产品优劣。
- 当时的缓解排序是:辅助请求外置 > 提高
--max-concurrency > 多条目池 > 块级共享。这是当时的判断,后续机制分析推翻了其中一条,见第 2 篇。
1. 场景与四个词
测试环境是一台单机:Qwen3.8-27B,nvfp4 量化,2×V100-16GB,TP2,INT8 KV,--max-context 196608,KV capacity 204800,--max-concurrency 1(即 PARALLEL=1)。以下未特别说明的数字均为本机实测。
客户端是 DSH,通过本地 provider 访问 http://127.0.0.1:8888/v1 这样的本机端点。DSH 有一个默认行为:每轮对话结束后,用同一个本地模型生成或更新会话标题。标题请求 prompt 很短、max_tokens 很小;主请求带着工具列表和完整历史,max_tokens 很大。两者打同一个本地端点。
先对齐四个词:
- lane:引擎里一条可执行请求的常驻执行通道;C=1 时只有一条。
- retained:请求结束后仍留在 lane 上、供后续请求接续的前缀状态。
full_reset:复用路径的结果之一,表示这次没有可接续的常驻前缀,只能从头 prefill。
cached:响应 usage 里的 cached_tokens,表示本次 prompt 有多少 token 命中了已有前缀。
复用路径还有 append_frontier 和 restore_turn_checkpoint,本文只把它们当作“接上了”的名字。命中点只有 frontier 和 checkpoint 两类;本文不展开。
2. 现象:961 变 0
同一会话里,上一轮主请求还能接上 961 个 token,下一轮直接归零。表 1 是本机实测记录。
| 轮次 |
主请求量级 |
cached |
复用路径 |
观察 |
| 上一轮 |
约 2k token |
961 |
restore_turn_checkpoint |
正常接续 |
| 下一轮 |
约 2k token |
0 |
full_reset |
前缀复用没发生 |
表 1 结论:这不是模型算得慢,而是这一轮的前缀复用没有发生。
损失可以折算。按本机约 1110 tok/s 的 prefill 吞吐,1k 量级会话每轮多付约 0.5 s,32k 量级约 27 s(本机实测折算)。单轮看不算致命,但 agent 对话一轮里可能有很多 step,每轮都归零就会累积成肉眼可见的卡顿。
需要先排除“引擎坏了”。同一请求连发两次,第一次 prompt=13 cached=0,第二次 prompt=13 cached=9(本机实测);带历史的多轮请求也能命中,例如 prompt=70 cached=55(本机实测)。这说明引擎的复用链路本身工作正常。问题更像是:该留下的 retained 状态,被别的请求弄没了。
3. 逐步定位:从“每轮更新标题”到“任何一次标题请求”
发现问题时,DSH 开着“每轮更新会话标题”。日志里最显眼的规律就是:每一轮主请求前面,都有一条短小的标题请求。于是第一反应很自然:每轮都发标题请求,每轮都把主请求的前缀覆盖掉。
我们先把“每轮更新标题”关掉。按当时的预期,主请求应该恢复命中。结果并不是。首次给会话生成标题、或者标题第一次变化时,DSH 仍会发一次标题请求;只要这一次标题请求落在两次主请求之间,主请求照样 full_reset。(本机实测)
这一步很关键。它把“频率问题”排除掉了:不是“每轮更新”这个高频动作有问题,而是只要有一次标题请求插在主请求前面,就足够把主请求的前缀弄没。这也解释了为什么有些会话看起来“时好时坏”——取决于标题请求有没有恰好落在两次主请求之间。
顺着这个线索,我们把同一会话的日志逐轮对齐,才看到真正稳定的模式:标题请求先完成并驻留,主请求随后只能 full_reset;下一轮标题请求再把它覆盖,如此循环。表 2 是某次 DSH 日志的本机实测记录,请求名做了泛化。
| 时间(s) |
请求 |
特征 |
prompt |
结果 |
| 33.163 |
标题请求 |
短 prompt、max_tokens=64、无工具 |
190 |
full_reset |
| 33.181 |
主请求 |
带工具、max_tokens=32768 |
2090 |
full_reset |
| 40.228 |
标题请求 |
短 prompt、max_tokens=64、无工具 |
317 |
full_reset |
| 40.241 |
主请求 |
带工具、max_tokens=32768 |
2168 |
full_reset |
表 2 结论:标题请求先完成并驻留,主请求随后只能 full_reset;下一轮标题更新再把它覆盖,如此循环。
第二轮的约 2089 个 token 本可命中 turn checkpoint,但被前面的标题请求覆盖了。用量记录也能对上:同一 DSH 对 DeepSeek 的两个会话命中率分别为 94% 和 95%;对本地模型的会话,一个为 0%,另一个在没有交错时 66%(turn2 2088/2152,turn3 2150/2172)(本机用量记录)。本地只有在没有交错请求时才命中,这和表 2 的结论一致。
4. 最小复现:AAA / BBB / AAA
为了确认“不是标题请求本身特殊,而是任何无关请求都行”,我们把它压到最短:一个主请求 AAA,中间插一个无关请求 BBB,再发 AAA。下面的代码块是全文最有说服力的一张图。
AAA 主请求 prompt=18 cached=0 reuse=full_reset
BBB 无关请求 prompt=20 cached=0 reuse=full_reset
AAA 主请求 prompt=18 cached=0 reuse=full_reset ← 被 BBB 顶掉
AAA 主请求 prompt=18 cached=14 reuse=append_frontier ← 中间没有别人,立刻命中
图结论:BBB 插在两次 AAA 之间,第三次 AAA 归零;去掉 BBB,AAA 立刻恢复命中。 以上为本机实测,短 prompt 便于观察。
这个复现说明两件事。第一,DSH 的标题请求不是特例,任何“别人的 prompt”插进来都会产生同样效果。第二,问题不在主请求自身,而在“主请求之间有没有别的请求”。为了叙述方便,上面说 BBB“顶掉”AAA。这是现象层面的说法。机制上不是驱逐,第 2 篇会纠正。 本文先把“谁覆盖了谁”讲清楚。
5. 为什么 DeepSeek 不受影响
DSH 同时在用 DeepSeek。DeepSeek 那边,标题请求只是新增或命中它自己的前缀块,不会让主对话的块失效。表 3 是机制差异,不是产品优劣比较。
| 维度 |
DeepSeek |
本引擎(C=1) |
| 存储 |
服务端大缓存池 |
显存中每条 lane 一份 retained 状态 |
| 索引 |
前缀块 hash |
lane 扫描 + 逐 token 精确匹配 |
| 并发条目 |
多会话共存,LRU |
PARALLEL 条,后到覆盖 |
| 命中粒度 |
任意块边界 |
frontier / checkpoint |
| 淘汰 |
容量压力 + 时间 |
被新请求覆盖,或容量压力 |
表 3 结论:标题请求在 DeepSeek 上不会让主对话的块失效;本引擎在 C=1 时只有一条 lane 的 retained,后到请求会覆盖它。 这是缓存组织方式差异,不是模型能力差异。
6. 当时的缓解判断
基于现象、逐步定位和最小复现,当时我们给出的排序如表 4。再次强调:这是当时的判断,后续机制分析推翻了其中一条,见第 2 篇。
| 优先级 |
措施 |
当时预期 |
备注 |
| 1 |
辅助请求外置 |
标题、权限审核、压缩等路由到 DeepSeek 或其他 provider,本地只留主对话 |
零代码,收益最大 |
| 2 |
提高 --max-concurrency |
多 lane 后,不同前缀族各自粘住一条 lane |
首次交错仍可能撞车;前缀族数不能超过 lane 数;容量压力仍会淘汰。这条后来被修正 |
| 3 |
多条目 retained 池 |
K 条条目,LRU + 字节预算 |
命中点仍只能是 frontier/checkpoint;条目受 lane 限制;容量在显存 |
| 4 |
块级共享 |
DeepSeek 式任意块边界共享 |
本模型上不划算,不建议 |
表 4 结论:当时最优先的是把辅助请求从本地端点外置;提高并发排在第二,但这一条后续被机制分析修正。
当时的判断可以概括为:C=1 只有一条 lane,DSH 的标题请求(无论是每轮更新,还是首次生成/首次变化)一旦落在两次主请求之间,就会先完成并驻留,主请求随后只能 full_reset;下一轮标题请求再覆盖,循环。这个判断能解释“关掉每轮更新后问题仍在”,也能解释 DeepSeek 对照,但它还不是最终结论。
真正的原因比“被顶掉”更具体,下一篇拆。我们以为是被驱逐,但代码说不是——C=1 时准入层的驱逐函数一次都不会被调用。