KV 生命周期、回收与驱逐#
前两节已经解释了 waiting queue 怎样塑形 batch,也解释了 prefix reuse 为什么不能被看成简单布尔命中。再往下走,就必须回答一个更物理的问题:这些请求背后的 KV 到底怎样被占用、引用、释放和驱逐。
这一节只处理三件事:
- request 到 token,再到物理 KV 的映射是怎样建立起来的;
- KV 在 extend、decode 和完成之后怎样继续存活或被释放;
- cache 驱逐为什么不是简单的“空间满了就删最旧”。
一张图先看 KV 生命周期#
flowchart TB
A["Req"] --> B["ReqToTokenPool"]
B --> C["TokenToKVPoolAllocator / KVCache"]
C --> D["ForwardBatch / ModelRunner"]
D --> E["reuse / retain / evict"]这张图最值得记住的一点是:KV 生命周期不是单独存在的一层,它和请求对象、调度对象、执行对象都绑在一起。
ReqToTokenPool 解决的是“谁占了哪些位置”#
ReqToTokenPool
的角色,很适合先用一句话固定下来:它负责“某个 request 当前占了哪些 token 位置”。
这件事为什么关键?因为对 runtime 来说:
- 逻辑上看到的是 request 和 token;
- 物理上管理的是 KV 槽位。
如果没有这层中间映射,scheduler 很难知道某个 request 释放时应该收回哪一段资源,cache 也很难知道自己命中的到底是哪组真实位置。
ReqToTokenPool 的数据形状已经说明它不是普通 map,而是一张预分配表:
self.req_to_token = torch.zeros(
(size, max_context_len), dtype=torch.int32, device=device
)这意味着它从一开始就被设计成可批量访问的运行时账本,而不是方便写代码的辅助结构。
ScheduleBatch 把 KV 生命周期带进执行层#
第三章已经见过 Req,但真正让 KV 生命周期进入执行路径的,是 ScheduleBatch
和 ForwardBatch
。
ScheduleBatch 持有:
req_to_token_pooltoken_to_kv_pool_allocatortree_cacheout_cache_loc
这意味着一旦 batch 被创建,KV 生命周期就已经不再是“调度器后面的资源问题”,而是 batch 本身的一部分。
一个请求的 KV 生命周期:从 prefill 到 evict#
以一个 prompt 长度 256、生成上限 128 tokens 的请求为例,追踪它的 KV 全程:
阶段 1:prefill(一次性填满 256 个 slots)
Admission 时,scheduler 通过 ReqToTokenPool 为这个请求分配 256 个 slot:
req.kv_allocated_len = 256 # 预分配
req.kv_committed_len = 0 # 还没有通过 RadixCache.insert 写入prefill 执行后,attention 计算把 Q/K/V 写入这 256 个 slot。完成后,这批 KV 会通过 RadixCache.insert 被登记进 prefix tree:
req.kv_committed_len = 256 # 已提交到 RadixCache,后续请求可以复用阶段 2:decode(每步增加 1 个 slot)
每生成一个 token,scheduler 额外分配 1 个 slot:
step 1: kv_allocated_len = 257
step 2: kv_allocated_len = 258
...decode 阶段新增的 KV slot 通常不立刻 commit 到 RadixCache(因为 decode 中间态不是完整前缀),而是在请求完成后才统一 commit(或放弃,取决于后续是否有相同前缀的请求)。
阶段 3:请求完成
请求 finish 时发生两件事:
- 本次请求的 token pool 条目被释放:
ReqToTokenPool里这个 req 的行被清零,对应的 token 位置不再属于这个请求; - RadixCache 决定是否保留 KV:如果 commit 了的 KV(即 prefill 部分)被 RadixCache 保留在树里,KV 物理 slots 不会立即被释放,等待未来的前缀匹配命中;如果 RadixCache 觉得这段前缀不值得保留,会触发 evict,对应的物理 KV slots 才会真正归还给
TokenToKVPoolAllocator。
阶段 4:eviction(由 RadixCache 触发)
当 KV 内存压力增大时,RadixCache 的 eviction policy(LRU 等)会选择叶节点进行驱逐。被驱逐的节点对应的 KV slots 通过 token_to_kv_pool.free() 归还。
驱逐条件:空闲 KV slots < 阈值
驱逐对象:RadixCache 叶节点(无子节点,引用计数为 0)
驱逐操作:radix_cache.evict(num_tokens) → token_to_kv_pool.free(indices)关键约束:正在被 running batch 引用的节点(引用计数 > 0)不能被驱逐,即使它是叶节点。这防止了”KV 被驱逐但 batch 还在用”的情况。
为什么 KV 生命周期不是”请求结束时统一释放”这么简单#
最容易先入为主的想法是:request 完成后把 KV 释放掉就行。但从 Req 的字段已经可以看出,系统显然不是这么粗糙:
kv_committed_len:已经写入 RadixCache 的 token 数kv_allocated_len:已经从 pool 分配出来的 token 数kv_committed_freed:已经从 RadixCache 侧回收的部分kv_overallocated_freed:超出 committed 的临时分配被回收的部分
这说明 KV 在运行时里至少会区分:
- 已经 committed 的部分(保留在 RadixCache 里,供后续复用);
- 只是临时分配、还可能回收的部分(decode 过程中多分配的 slots);
- 已经释放过的部分;
- 还在被后续路径引用的部分。
所以 KV 生命周期是分阶段的,而不是”请求结束时一把梭”。
RadixCache 决定的是保留和驱逐策略#
RadixCache
的初始化已经说明,它不仅关心匹配,还关心驱逐策略:
page_sizeeviction_policyevictable_leaves
这意味着 cache 驱逐不是某个 allocator 内部的顺手逻辑,而是一条独立策略线。也正因为这样,你会看到:
lrulfufifopriorityslru
这些策略不是为了“写得花”,而是因为不同 workload 下,谁更应该被赶走,本来就不是同一个答案。
为什么驱逐不能只看“谁最旧”#
对工程直觉来说,“空间满了就删最旧”很自然,但对 prefix cache 来说这往往不够。原因至少有三类:
- 某段前缀虽然旧,但命中价值很高;
- 某个 request 虽然结束了,但它的 committed KV 可能还值得被后续请求复用;
- 某些资源虽然现在空闲,但很快还会被下一轮 decode 或 session 路径继续引用。
所以驱逐策略真正解决的不是“怎么腾空间”,而是“哪些状态最不值得继续留”。
调试 KV 问题时先看哪里#
如果你看到的现象是:
- 显存很快打满;
- cache 命中看起来正常,但资源回收不及时;
- 某些请求完成以后,后续 batch 仍然很难接新请求;
更稳的顺序通常是:
- 先看
ReqToTokenPool和 request 侧的占用是否已经释放; - 再看
ScheduleBatch/ForwardBatch当前还持有哪些 KV 位置; - 然后确认
RadixCache的保留与驱逐策略是否还在保护这些位置; - 最后再判断这是调度压力问题,还是 cache 驱逐问题。
这里最常见的误读,是把“资源还没释放”直接当成 allocator bug。实际上,很多时候是更上层仍然认为这些 KV 还有价值。
小结#
这一节真正要稳定下来的,是 KV 生命周期的三层关系:
ReqToTokenPool记录 request 占了哪些 token 位置;ScheduleBatch/ForwardBatch把这些位置带进执行路径;RadixCache和 allocator 决定哪些位置继续保留、哪些位置可以驱逐。
只要这三层关系稳住,后面再看执行模型时,就不会把 KV cache 误看成“模型前向后面顺带的一层优化”。它本来就是运行时主能力的一部分。
叶王 © 2013-2026 版权所有。如果本文档对你有所帮助,可以请作者喝饮料。