系列导航|这是三篇系列的第二篇。第一篇讲现象与初判,结尾留了一句:“我们以为是被驱逐,但代码说不是。”本文兑现这句话。第三篇讲 detached registry 的实现与证据。
TL;DR
- 不是驱逐,是请求自毁。 C=1 时准入第一趟扫描就返回,第二趟(唯一会设置驱逐标志的地方)根本不可达;
FullReset 自己执行 kv.reset(),页当场回池。
- 两个独立销毁点。 销毁点①:入口
FullReset → 释放页;销毁点②:Drop 或 CaptureNew 写在同一个清空条件里 → checkpoint 被清空、身份指纹被替换。页存活 ≠ 支点存活,只修一个都不成立。
- 提高并发单独无效,因为 tie-break 永远选 lane 0。 准入第一趟用严格大于比较,两个 lane 的
reusable_prompt_tokens 都是 0 时相等不换人,lane 从 0 递增 ⇒ 永远选 lane 0。杀死缓存的是“下标最小”这个 tie-break,不是 lane 数不够。它是方案 4 的前提,不是方案本身。
- 四条路线各有适用域。 postfix(应用层,只改形状)、
PreserveExisting(~30 行,只在“页未释放 + postfix 形状”成立)、detached registry(C=1 下唯一独立有效)、lane 选择键(仅 C≥2,~10–50 行)。
- 社区对照的差距不在调度器,在缓存所有权与回收方式。 vLLM 用块 refcount + LRU,SGLang 用 radix 树节点 + COW,TensorRT-LLM 用 block + radix 树 + 优先级 LRU,llama.cpp 每 slot 一份 KV + 32 个 context checkpoint。没有一家靠“改调度”解决这个问题。
- C=1 下只剩脱钩一条路,代价是三条不变量要重新证明:detach 后状态槽不被 lane 复用、页不被二次释放、attach 时 frontier 与状态一致。
1. 不是驱逐,是请求自毁
第一篇结尾我们留下了一个反直觉的观察:在 --max-concurrency 1 下,准入层的驱逐函数一次都不会被调用。那缓存到底是怎么没的?
答案藏在准入扫描的执行路径里。准入选择函数分两趟扫描:
// 示意,非原文
第一趟:遍历所有 lane,找可原地准入的
for lane in 0..max_concurrency:
if slot[lane] 非空: continue
计算该 lane 上的复用计划 plan
if can_admit_lane(lane, plan) 且 (未选中 或 plan.reuse > 已选中的 reuse):
选中该 lane
if 选中: return 选中结果 ← C=1 时在这里就返回了
第二趟:兜底驱逐
for lane in 0..max_concurrency:
if 可以驱逐 retained lane:
设置 evict_retained = true ← 唯一设置驱逐标志的地方
选中该 lane
return
C=1 时唯一那条 lane 在上一轮结束时就已空闲(slot[0] == nullptr),标题请求又很短、配额充裕,第一趟必然成功并 return。第二趟——唯一会设置 evict_retained = true 的地方——在 C=1 下不可达。没有任何东西“驱逐”了缓存。
真正的销毁发生在别处:请求自己的复用计划被判成 FullReset,而 FullReset 执行的第一件事就是释放本 lane 的 KV 分配:
// 示意,非原文
if reuse == FullReset:
sequence.kv.reset() ← 页在这里回池
清空 ledger
重置 text_kv_valid / mtp_kv_valid
按新的配额重新分配
kv.reset() 析构 PagedKVAllocation,析构链路是 ~PagedKVAllocation() → release() → unbind_row() + pool_->return_pages(page_ids_)。页立即回池。这是唯一一处“驱逐”字面成立的地方,但执行者是请求自己的 plan,不是准入策略。
这个区分不是措辞洁癖——它直接决定了修复方向。如果你以为是被驱逐,你会去调驱逐策略、或者加并发;而 C=1 下这两条路连代码都进不去。
但要说准:C≥2 时准入层确实有一个可用杠杆,且不用改所有权模型——是 tie-break(见 §4 方案 4),不是驱逐策略。C=1 时它退化掉,才只剩脱钩一条路。
1.1 页和行本来就是两种资源
理解销毁点之前,先要理解为什么“页跟着 lane 一起死”不是必然的。
release() 只做两件事:unbind_row()(把行号还回去,行只是一个 int)和 return_pages(page_ids_)(把物理页还回池,页在 host 侧 vector 里)。两者没有耦合。页之所以跟着 lane 一起死,唯一原因是 PagedKVAllocation 挂在 sequence 上,而 sequence 挂在 lane 上,RAII 一析构就全还。
维护文档对此有明确表述:一个 retained bundle 同时只能被一个 active request claim;所谓 pin 是 retained entry 持有各 pool allocation,使相应 ID 不属于各自 free set——不需要 page refcount 或特殊 allocator role。页的耦合没有任何运行时收益:不省时间、不省内存,它省的是证明成本——一个所有者、一处释放,就免于证明“悬垂引用”“双重释放”“attach 时 frontier 与 state 是否一致”这三类不变量。
2. 两个销毁点:页存活 ≠ 支点存活
“缓存被销毁”听起来像一件事,实际是两件可以分别观测的事。
2.1 销毁点①:入口 FullReset 释放页
典型客户端标题请求自带指令模板,例如“请为以下对话生成标题:{首条消息截断}”。这种 prompt 的 token 0 就与会话的 ledger[0] 不同,复用判定的两个候选(AppendAtFrontier 和 restore_turn_checkpoint)同时失败,reuse 停在默认值 FullReset。于是入口处 kv.reset() 执行,页当场回池。
2.2 销毁点②:Drop / CaptureNew 清空 checkpoint
即使标题 prompt 恰好以整段会话为前缀——入口命中 AppendAtFrontier,页不被析构、不回池——checkpoint 依然会被清空。因为执行阶段只有一处写:
// 示意,非原文
if checkpoint_action == Drop 或 checkpoint_action == CaptureNew:
sequence.rewrite_checkpoint = {} // 清空
sequence.ledger.assign(prompt.token_ids) // 身份指纹被替换
sequence.prefix_identity.assign(prompt)
Drop 和 CaptureNew 写在同一个清空条件里。最容易被忽略的是 Drop 的语义不是“不动”,是“清空”:标题请求如果不声明 checkpoint,就会落到 Drop;如果它声明了自己的 checkpoint,边界必然大于 reuse_base,走 CaptureNew。两条路出口相同——所以“让标题请求别声明 checkpoint”并不能保住会话的支点。
2.3 A/B/C 三组观测
三组实验把两个销毁点分别钉住(配置:qwen3.8-27b / nvfp4 / TP2 / --max-concurrency 1 / prefix_reuse = true):
| 组 |
请求 |
prompt token |
cached |
prefix_reuse_path |
| A 对照:无旁路 |
turn 2 |
1030 |
961 |
restore_turn_checkpoint |
| B 典型客户端标题(42 token) |
title |
42 |
0 |
full_reset |
|
turn 2 |
1025 |
0 |
full_reset |
| C postfix 标题(1033 token) |
title |
1033 |
962 |
restore_turn_checkpoint |
|
turn 2 |
1031 |
0 |
full_reset |
四条读数:
- 对照组 turn 2 走
restore_turn_checkpoint,不是 append_at_frontier。 正常多轮靠支点恢复,所以“清空 checkpoint”不是次要风险——它就是主销毁路径。
- B 组:标题请求自己是
full_reset + 0。 按 §1.1,kv.reset() 在入口就执行了,页当场回池,支点指向的页不存在。方案 2 无从生效。
- C 组:标题请求
restore_turn_checkpoint + 962,页当时还活着;紧接着一个只短 2 token 的 prompt(1031 vs 1033)却 full_reset + 0。 页存活 ≠ 支点存活,两个销毁点被分别钉住。
- 把同一组实验搬到
--max-concurrency 2,B/C 组的 turn 2 仍是 0。lane 数不是变量。
结论句:页存活 ≠ 支点存活;只修一个都不成立。
3. 为什么提高并发单独无效:严格大于 + 下标 tie-break
第一篇里我们曾把“提高 --max-concurrency”判为无效,理由是“这两条路径与 lane 数无关”——这个理由不成立,必须撤回。C>1 单独无效,是因为 tie-break,不是因为 lane 数无关。
C=2、两 lane 都空闲时,标题请求在两个 lane 上的 reusable_prompt_tokens 都是 0:复用计划按 lane 独立计算,lane 0 上它与 retained 会话首 token 不同,lane 1 上没有 retained。同时可准入判定并不因为“该 lane 有 retained”而拒绝,它只查 lifecycle 与页配额,把 retained 的 entitlement 当作可替换的 old。于是第一趟两 lane 全中:
// 示意,非原文
if can_admit_lane(lane, plan) 且 (!selected 或 reuse > selected_reuse):
选中 = lane
reuse 相等时严格大于不成立,lane 从 0 递增 ⇒ lane 0 先被选中并锁定,lane 1 永远轮不到。所以 C=2 时旁路请求照样落在会话所在的 lane 0 上,照样把它毁了。杀死缓存的是“下标最小”这个 tie-break,不是 lane 数不够。
实验上也是这个结果:同一组实验搬到 --max-concurrency 2 重跑,两种形状的 turn 2 仍是 0(§2.3 第 4 条)。
所以“提并发”是方案 4 的前提,不是方案本身。 这条区分很重要:光把 C 提到 2、不动选择键,什么都不变;只有先改选择键(让旁路请求优先选没有 retained bundle 的 lane),提并发才开始有意义。
4. 四条路线与决策表
修复手段按“标题请求的 prompt 由谁构造”分成两条互斥分支,再按“页是否还活着”细分。下面四条路线,每条都有明确的适用域。
4.1 方案 1:postfix(应用层)
把标题请求改成会话的续写——让它的 prompt 以整段会话为前缀,末尾追加“请为以上对话生成标题”:
User: Hello. AI: Hi.
User: Generate a title for the above conversation. AI: Just Greetings
→ 丢弃最后两条消息,恢复正常对话
- 收益:标题请求自身命中全前缀。实测(C 组):1033 token 只用算 71 个,路径
restore_turn_checkpoint。所谓“多算一段 prefill”在实际调用里不存在。
- 局限:救不了下一轮。同一实测里紧接着的 turn 2 仍是
full_reset + 0。支点仍在销毁点②被清。
- 定位:它单独没有独立价值,唯一意义是把形状从典型搬到 postfix,从而让方案 2 变得可用。
4.2 方案 2:PreserveExisting(~30 行)
现状的 checkpoint 动作五态枚举里缺的正是“不捕获也不清除”这一态。改四处:加枚举值、把 CaptureNew 和 Drop 在旁路标记下都降级为 PreserveExisting、执行阶段对 PreserveExisting 不做任何写操作、用请求级开关透传标记。
注意不能降级为 Drop——Drop 同样执行清空。
生效前提有两个,缺一不可,且两者被分别观测到:
- 标题 prompt 以会话前缀开头,否则前缀匹配会合法地失败(这是正确行为,不是 bug)——典型形状不满足,postfix 形状满足。
- 入口没有走
FullReset,否则 kv.reset() 已经把页还回池,支点指向的页不存在——典型形状 full_reset,postfix 形状 restore_turn_checkpoint 命中 962。
所以本方案只在 postfix 形状下有效,且该形状必须先由方案 1 造出来。
4.3 方案 3:detached registry(C=1 下唯一独立有效)
不需要 refcount(维护文档也明确说 pin 不需要 refcount)。需要的是 single-owner 的 detached registry:让 PagedKVAllocation + 状态槽 + 指纹三样脱离 lane 存活。
- 存活物:
page_ids_(host 数组)、一个状态槽(约 73 MiB/槽,本文只提一次)、prefix_identity、rewrite_checkpoint。
FullReset 时若当前 lane 有 retained 序列,把它移入 registry 而不是 kv.reset()。
- 准入时按指纹匹配 registry;命中则把行 bind 到新 lane,状态槽换过去,跳过一次重算。
- 治理:registry 容量上限 + 淘汰键用
reusable_prompt_tokens。
代价是三条不变量要重新证明:detach 后状态槽不被 lane 复用、页不被二次释放、attach 时 frontier 与状态一致。这三条正是 RAII 把所有东西绑在 lane 上时免费获得的——现在要单独证。实现细节、实测数据、撤回结论留给第三篇。
4.4 方案 4:lane 选择键(仅 C≥2,~10–50 行)
改法两步,缺一不可:
- 第一趟 tie-break:键从
reuse 换成 (reuse, destruction_cost) 的字典序,destruction_cost = sequences[lane].retained ? kv entitlement : 0,即优先选没有 retained bundle 的 lane。
- 第二趟兜底顺序:容量吃紧时的驱逐循环从 lane 0 往上毁,同样先毁会话。要一起改成按
retention_priority 排序,否则第 1 步的收益在容量紧张时被吃掉。
效果:标题请求落 lane 1,只毁 lane 1;turn 2 回 lane 0 全前缀命中,收益上界同方案 3(省一次全量 prefill)。生效条件:仅 C≥2;需要两个 lane 的 KV entitlement 同时放得下(标题请求很短,通常成立,但 256k 配置下必须实测确认)。改动集中在一个选择键 + 一个排序键,不新增不变量。
4.5 决策表
| 标题 prompt 由谁构造 |
页是否还活着 |
走哪条 |
| 客户端 / 服务端可改 prompt |
— |
方案 1 + 方案 2(~30 行),把形状搬到 postfix,不动所有权模型 |
| 前端框架或第三方固定 |
页已回池(典型形状) |
方案 3(C=1)/方案 4(C≥2) |
| 前端框架或第三方固定 |
页还在(postfix 形状) |
若可加旁路标记,方案 2;否则仍走方案 3/4 |
分岔判据是“标题请求的 prompt 由谁构造”,不是改动量。改动量只在选定分支后用来排先后。
5. 社区对照:差距在所有权,不在调度器
免责声明:以下基于各引擎的公开文档与特定版本,不是同环境实测;也不构成“别家没有这个问题”的判断——各家都有自己的取舍与未公开路径。差距在缓存所有权与回收方式,不在调度器。
| 引擎 |
KV 归属 |
旁路请求的影响 |
关键机制 |
| vLLM |
16-token 块,链式哈希(含父块哈希 + 额外键),全局块池 |
只对共享前缀块 refcount +1,另分配自己的块;两段都不搬动 |
块 refcount + LRU;请求结束反向入队,尾块先淘汰 |
| SGLang |
radix 树节点,每节点一组槽 |
分裂节点共享父路径;MAMBA checkpoint 留在原 endpoint,复用者取 COW 私有副本 |
节点 refcount + leaf-first LRU;hybrid 模型拆 FULL / SWA / MAMBA 三组件 |
| TensorRT-LLM |
block + radix 搜索树 |
碰不到高优先级块 |
block reuse;prioritized LRU(priority 0–100);KvCacheRetentionConfig 按 token 范围设保留优先级 |
| llama.cpp |
每 slot 一份 KV |
抢占 slot 时旧状态可进 RAM checkpoint 池 |
按 slot 复用前缀;slot 相似度选择;默认 32 个 context checkpoint,在 user message 边界创建;cache-ram 默认 8 GiB |
| 本引擎 |
每 lane 一份,独占 |
会话缓存被销毁(§2) |
无 refcount、无优先级、无快照池、仅 1 个 checkpoint |
三条值得对照但不构成本文方案的设计:
① llama.cpp 的 32 个 context checkpoint 值得参考,但多槽救不了本文场景:槽再多,只要页在旁路请求入口就被回收,快照就指向已释放的页。它是“页能活下来之后”才谈得上的优化,不是本问题的解。
② TensorRT-LLM 的 retention priority 是“全局最低优先级先淘汰”,不是按 lane 号。本引擎决定“谁被毁”的地方有两处都不看优先级:第一趟准入的 tie-break(严格大于 + 下标优先),第二趟兜底驱逐(从 lane 0 往上清)。给请求加优先级能替掉这两处的顺序,但C=1 用不上——单 lane 时 tie-break 没有第二个候选,兜底路径也不可达。它的价值在 C≥2。
③ SGLang 的 checkpoint 只读语义——“MAMBA checkpoint 留在它原本的 prefix endpoint,复用它的请求通过 COW 获得私有副本,然后继续更新自己的状态”——分支写自己的 state,绝不回头改支点。这正是方案 2(PreserveExisting)的外部依据。
5.1 一个必须澄清的社区方案
open-webui #6677 提出把标题请求改成会话的 postfix 续写。它能让标题请求自身命中全前缀(省掉那一次 prefill),但救不了下一轮:该请求仍然会声明自己的边界(在“请起标题”之后的 assistant header),仍然大于 reuse_base,仍然 CaptureNew,仍然覆盖会话支点。所以 postfix 必须与方案 2 组合才有效,单独使用只是把损失从“两次”降到“一次”。
5.2 一个反面证据
llama.cpp 提供非前缀块的 KV shifting,但这在 hybrid/recurrent 模型上失效,服务端会打印“forcing full prompt re-processing due to lack of cache data”。Qwen3.6 的 GDN 就是 hybrid/recurrent。这与本引擎的取舍一致:recurrent state 是整条序列累积出来的量,KV 页只承载 attention 那一半;没有对应位置的快照,页就算全在也无法继续。 维护文档也写了同一句话:“最后一种情况不是 page-size limitation。缺少的是对应 Linear Attention/backend continuation state,而不是 KV page。”
6. 明确不要做
| 方案 |
为什么不做 |
| LCP / 块级部分前缀匹配 |
对 GDN 无效。缺少的是 continuation state 不是页(§5.2) |
| 延后或去抖标题请求 |
时间点无关。只要它进同一 lane 并释放/覆盖,损失固定;推迟只把 FullReset 从 turn 2 挪到 turn 3 |
只提 --max-concurrency、不动选择键 |
无效。旁路请求的 reuse 在两个 lane 上都是 0,tie-break 严格大于 + 下标优先 ⇒ 仍落 lane 0。提并发是方案 4 的前提,不是方案本身。 |
| 把标题 prompt 拼成整段会话前缀“多复用一点” |
这样它必然 CaptureNew(自己的边界 > reuse_base),照样拆支点,还多算一段 prefill |
让标题请求不声明 checkpoint(走 Drop) |
Drop 语义是清空,与 CaptureNew 同样销毁支点。“不声明”不等于“不动” |
7. C=1 只剩脱钩,三条不变量
把四条路线排开,C=1 下的选择空间其实很窄:
- 方案 1 单独无效——只把损失从两次降到一次,必须配方案 2。
- 方案 2 需要“页还活着 + postfix 形状”两个前提,典型形状入口就
FullReset,页已回池。
- 方案 4 需要 C≥2,单 lane 没有第二个候选,tie-break 这条路不存在。
C=1 下没有第二个 lane 可选,页又在入口回池,只剩脱钩一条路;代价是三条不变量要重新证明。 第三篇会给出 detached registry 的槽位布局、ABAB 四步、以及实测证据——同时会正式撤回第一篇里“与 lane 数无关”的排序:正确的说法是“单独提并发无效,是因为 tie-break;它是方案 4 的前提,不是方案本身”。