vLLM系统拆解-15-调度、KV Cache与Prefix Caching:为什么必须三层协同

Scheduler、KV Cache、Prefix Cache——很多人学 vLLM 时是分开学这三块的。调度讲完讲 PagedAttention,PagedAttention 讲完讲 prefix caching。每块都"挺厉害",但就是讲不清楚它们为什么必须一起存在,以及它们怎么协同工作。

这篇文章换一个叙述顺序:不按功能分章,而是按一次请求的完整生命周期把三块串起来,展示在真实 serving 场景里它们是怎么相互依赖的。读完应该能回答:prefix caching 为什么不是"小优化"、unified scheduler 为什么比"prefill 一套逻辑 decode 一套逻辑"更强,以及 block 化 KV Cache 为什么是前两者的基础设施。


三层分工,一条依赖链

先把结论放在最前面:

  • Scheduler 是全局调度者——决定本轮 token 预算下哪些请求推进、推进多少
  • KV Cache Manager 是内存管理者——把推进动作落到实际的 block 分配、共享、回收
  • Prefix Cache 是跨请求复用机制——把已计算的 full blocks 复用给其他请求,减少重复 prefill

三者不是并列关系,是有明确方向的依赖:

flowchart LR
    subgraph 调度层
        S[Scheduler\n决定推进策略]
    end
    subgraph 存储层
        K[KV Cache Manager\n提供推进所需存储资源]
    end
    subgraph 复用层
        P[Prefix Cache\n减少需要推进的工作量]
    end

    S -- 申请 block / 查可用量 --> K
    P -- 直接改变请求起跑位置 --> S
    P -- 复用已有 full blocks --> K

更准确的表达:scheduler 决定推进策略,KV cache manager 提供推进所需的存储资源,prefix cache 减少推进前必须重新计算的部分。


为什么三块必须一起存在

把任何一块单独拿出来,系统都会有明显的短板。

只有 scheduler,没有 block 化 KV Cache。 假设 continuous batching 已经做得很好,能不断把新请求塞进 batch。但如果 KV Cache 还是传统的"连续大段分配",请求长度不同、结束时间不同,显存就会快速碎片化——scheduler 想放更多请求进来,显存层腾不出足够整块空间。调度能力必须建立在可持续的 KV 内存管理之上。

只有 block 化 KV Cache,没有 scheduler。 有漂亮的 PagedAttention 和 block pool,但调度策略弱:长 prompt 一下子把预算吃满,decode 请求迟迟排不上,TTFT/ITL 很差。高效内存管理不自动等于高效服务,最终还要靠 scheduler 决定资源怎么花。

只有 KV Cache,没有跨请求复用。 即便 block 化做得很好,每个请求都把相同的 system prompt、相同工具描述再 prefill 一遍,仍然是在浪费 GPU 算力、TTFT 和 batch 预算。


一次请求的生命周期

这部分比较长,先看整体流程图,再展开每个阶段。

flowchart TD
    A[请求进入系统] --> B[Scheduler: 决定本轮推进策略\n查 token budget / 请求优先级]
    B --> C{prefix cache 命中?}
    C -- 命中 --> D[直接挂载已缓存的 full blocks\n起跑线前移,跳过已计算部分]
    C -- 未命中 --> E[从 token 0 开始推进]
    D --> F[KV Cache Manager: 申请剩余新增 blocks]
    E --> F
    F --> G[形成本轮 batch → Worker 执行]
    G --> H[新 KV 写入 blocks\nfull blocks 可进入 prefix cache]
    H --> I{请求完成?}
    I -- 否 → decode 继续 --> B
    I -- 是 --> J[引用计数减少\nfull blocks 按 LRU 保留或驱逐]

阶段一:scheduler 接管,统一抽象的起点

新请求进入后,系统首先面对的不是"怎么算 attention",而是:当前 token 预算还有多少、哪些请求优先、这个请求的当前进度在哪里。

V1 unified scheduler 有一个关键的统一视角:它不把 prefill 和 decode 当成两套完全不同的调度问题,而是把"让请求向前推进若干 token"视为统一动作。每个请求都有"已计算到哪里"的位置,scheduler 的工作是在本轮预算内让每个请求尽量往目标前进。

这点的意义在后面会展开。

阶段二:prefix cache 查询,起跑线前移

请求不一定要从 token 0 开始 prefill。如果它的前缀和之前某个请求相同——相同 system prompt、相同 few-shot 示例、相同工具 schema——prefix cache 可以直接命中已有 blocks。

这里有一个细节很关键:prefix cache 的影响不是"prefill 之后多了个小缓存",而是发生在调度之前——命中了前缀缓存,scheduler 在分配 token budget 时就不需要为这部分历史 token 花预算,KV cache manager 也不需要为这部分已存在的 full blocks 重新分配。

prefix cache 本质上直接改变了请求的起跑位置。

阶段三:KV Cache Manager,逻辑到物理的映射

scheduler 决定推进多少 token 之后,现实问题是:这些 token 对应的 KV 要存在哪里?

KV cache manager 的核心职责不是"算 attention",而是维护逻辑到物理的映射:这个请求当前持有哪些 blocks、哪些来自 prefix cache 复用、哪些是本请求新增的、block pool 还有多少空闲、不够时能否回收。

请求上下文在逻辑上连续,但在物理上不要求连续——这就是 PagedAttention 的价值所在。没有这一层,请求长度一增长就必须申请更大的连续显存区域,碎片化和搬迁开销都很重。Block 化之后,需要更多上下文时多拿几个 block,请求结束时把 block 还回去,两个请求共享前缀时共同引用相同的 cached blocks。

阶段四:Worker 执行后,新 full blocks 才进入 prefix cache

一轮 batch 执行后,新的 KV 写入 block,但不是所有新写入内容都会立刻成为 prefix cache 候选

vLLM 只缓存 full blocks——只有 block 被完整填满后,才成为稳定、可哈希、可复用的缓存单元。原因:

  • 边界稳定:不会因为后续 token 到来而改变 block 边界含义
  • 哈希身份明确:prefix caching 基于哈希,哈希成分包括 parent hash、block tokens、额外区分信息(如 LoRA adapter、tenant salt);半满 block 的哈希身份不够稳定
  • 管理标准化:full block 可以当成独立的标准复用单元,LRU 驱逐逻辑也更简单

所以 prefix cache 复用的是"已形成稳定边界的历史块",不是任意长度的任意前缀。

阶段五:Decode 继续,三层持续协同

请求不会因为完成一次 prefill 就脱离 scheduler。每轮 decode 仍然是同一条链:scheduler 决定本轮谁继续前进、KV cache manager 确认历史 blocks 和新增 blocks、worker 执行、请求状态更新。

Decode 和 prefill 的区别在于特征不同:每轮新增 token 少、频繁依赖历史 KV、更容易受显存带宽影响、对 ITL 更敏感。这就是 chunked prefill 开启时调度策略优先 decode 的原因——保证 decode 响应性,同时把 compute-bound 的 prefill 和 memory-bound 的 decode 混在同一个 batch 里提高 GPU 利用率。

阶段六:请求结束,Block 解除引用而非直接删除

请求完成后,block 不是"全部删掉",而是引用计数减少。如果某些 full blocks 作为 prefix cache 仍有复用价值,可以暂时保留;cache 压力上来了,再按 LRU 驱逐。

这说明 prefix cache 是真正意义上的缓存系统,有完整的生命周期管理:block pool、free block queue、cache block map、request block map、LRU eviction。不是"写个 dict 存一下"这么简单。


三请求场景:共享、分叉、部分命中

这个例子把三层协同的真实效果展示得最清楚。

1
2
3
4
5
6
7
8
9
10
11
12
13
场景:三个请求同时在系统中

请求 A: [system prompt + 工具定义] + 用户问题1
请求 B: [system prompt + 工具定义] + 用户问题2 ← 与 A 共享前缀
请求 C: [system prompt + 工具定义] + RAG 证据 + 用户问题3 ← 部分共享

┌──────────────────────────────┐
shared blocks │ block1 │ block2 │ block3 │ ← A、B、C 共享
└──────────────────────────────┘
/ | \
┌─────────────┐ ┌──────────┘ ┌──────────────────┐
A only │ block4-A │ │ block4-B │ block4-C ... │ C only
└─────────────┘ └─────────────┘ └──────────────────┘

A 先到,完成 prefill,shared blocks 成为 prefix cache 候选。

B 后到,prefix cache 命中 system prompt + 工具定义 对应的 blocks,scheduler 看到它不需要从 token 0 开始,直接从分叉点之后继续新增 blocks。B 的"少算",不是 worker 在 forward 里临时聪明一下,而是 scheduler + KV cache + prefix cache 三层提前协作的结果。

A 和 B 同时 decode 时,shared blocks 继续共用,各自后续分叉出来的 blocks 独立。Block 化设计最适合做的事就是这样:前面共用,后面分叉。

C 到来,只命中前半段。后半段 RAG 证据不同,必须重新 prefill。如果 RAG 内容很长,chunked prefill 会把它切成小块,避免拖死 A 和 B 的 decode。这时三层分工最清晰:prefix cache 解决"能不能少算前半段",scheduler 解决"后半段什么时候算",KV cache manager 解决"共享块和新增块如何同时存在于 C 的请求上下文里"。


Chunked Prefill 依赖这三层,不只是调度技巧

很多人把 chunked prefill 理解为纯调度特性,但它和 KV Cache、Prefix Cache 强相关。

Chunked prefill 是 scheduler 对单请求推进量的再切分——本轮 token 预算里优先放 decode,剩余空间里放 prefill 的一部分,放不下就自动切块。

一旦切块,请求的历史状态会在多个轮次中逐渐增长,KV cache manager 需要支持这种逐块延展。Chunked prefill 不是"与 KV cache 无关的纯调度技巧",它要求底层 KV 表示天然支持分段增长。

同时,如果请求前半段已经命中 prefix cache,它的 chunked prefill 不是从 token 0 开始切,而是从"未命中的分叉点"开始切。Prefix cache 改变了 scheduler 看到的剩余工作量,进而改变 chunked prefill 的起点。


Unified Scheduler 的核心价值

V1 unified scheduler 的设计思想是:把 prefill、decode、chunked prefill、prefix cache 命中这些不同现象,统一成同一个问题——本轮预算下,这个请求还能往前走多远

具体实现上,它按 {request_id: num_tokens} 风格,在固定 token budget 下动态分配,而不是把 prefill/decode 分裂成两套处理通道。

这个统一抽象的好处是可扩展性:如果系统把这些能力写成互相独立、彼此特判的分支(prefix cache 走一套逻辑、chunked prefill 走一套、speculative decode 再走一套),每加一个功能调度器复杂度就爆炸一次。V1 重构的核心价值之一,就是先抽象出"请求推进"这个统一问题,再把各种优化当成对推进起点、推进上限、预算占用方式的修正。


几个容易混淆的问题

Prefix Cache 和普通 KV Cache 是一回事吗?

不是。普通 KV Cache 是请求内复用——自己这个请求的历史上下文在后续 decode 中被复用。Prefix Cache 是请求间复用——这个请求已经计算过的前缀,被其他请求复用。两者都复用 KV blocks,但复用的对象层级不同。

Prefix Cache 只影响 prefill 时间吗?

主要收益体现在减少 prefill 重算,但影响不止于此。它还减少 token budget 占用,改变本轮 batch 能容纳多少其他请求,最终影响整体 TTFT、吞吐和并发能力。效果会传导到整个服务系统。

Scheduler 是不是不需要知道 KV Cache 细节?

它不需要知道每个底层张量怎么排布,但必须知道资源边界——可用 block 数量、cache 压力、是否需要 preemption / 延后。Scheduler 和 KV cache manager 职责不同,但绝不是完全隔离的。

有了 Prefix Cache,是不是就不需要 Chunked Prefill 了?

不是。Prefix Cache 只解决"共享前缀不必重算"的问题。但不是所有请求都共享长前缀,即使共享也常常只共享前半段,分叉后的剩余 prompt 可能仍然很长。这时 chunked prefill 仍然决定剩余 prefill 如何和 decode 共存。


模块地图:源码位置

不需要逐行啃源码,但知道"大概在哪"有助于建立系统感:

模块 核心位置 关注点
Scheduler vllm/v1/engine/core.py
vllm/v1/core/sched/
请求如何进入调度循环、如何形成每轮 batch、如何与 token budget 绑定
KV Cache Manager vllm/v1/core/kv_cache_manager.py 逻辑请求状态如何映射到 physical blocks
Prefix Cache 同上(集成在 KV cache 层) hash-based block identity、block pool、free block queue、LRU eviction

Prefix caching 的核心数据结构:block pool(全局 block 池)、free block queue(可分配的空闲 block)、cache block map(哈希 → cached block 的映射)、request block map(请求 → 其持有 block 的映射)、LRU eviction(驱逐策略)。


五个核心结论

  1. 三层协同链:Scheduler 管推进,KV cache manager 管存储,prefix cache 管跨请求复用——三者是依赖链,不是独立特性的堆砌。

  2. Prefix cache 改变起跑线:它不是"prefill 后的小缓存",而是在调度之前就改变了 scheduler 看到的剩余工作量,影响 token budget 分配和整体吞吐。

  3. Block 化是前提:prefix cache 之所以能做跨请求复用,根基是 block 化 KV cache 提供了可共享、可回收、有明确边界的存储单元。没有 block 化,prefix cache 无法高效实现。

  4. 只缓存 full blocks:这是设计上的明智选择——边界稳定、哈希身份明确、驱逐管理标准化,使 prefix cache 更像标准缓存系统,而不是维护一棵不断变化的复杂树结构。

  5. Unified scheduler 的统一抽象:把 prefill / decode / chunked prefill / prefix hit 都统一成"请求推进多少 token"的问题,消除了各功能之间的特判分支,是 V1 重构里最重要的架构决策之一。


系列导航