# VLLM 多副本如何复用 KV Cache？llm-D 缓存感知路由实战


![KV Cache 感知路由实战：llm-d 与 vLLM 多副本实测](https://img.lixueduan.com/ai/cover/llm-kv-cache-aware-routing.jpg)

上一篇 [PD 分离详解：Prefill 与 Decode 的瓶颈、拆分与代价](/posts/ai/27-why-pd-disaggregation/) 介绍了为什么要把 Prefill 和 Decode 拆开， [vLLM PD 分离实战：从请求流程理解 Prefill、Decode 与 KV Cache 传输](/posts/ai/28-vllm-pd-demo/) 则用一个 P、一个 D 和轻量 Proxy，跑通了请求编排与 KV Cache 传输。

完成 Prefill 和 Decode 解耦之后，可以根据场景单独扩缩容 P、D 实例。

不过多副本推理还需要考虑缓存局部性：**当多个实例都开启了前缀缓存，每次请求应该交给谁，才能复用已经计算过的 KV Cache**？

本文便基于这个缓存局部性问题展开，通过 llm-d Router 对比**随机路由**与**前缀感知路由**。

<!--more-->

本文通过两个 vLLM 副本，对比随机路由与前缀感知路由，观察请求选点对缓存命中、Prefill 计算量和首字延迟的影响。

## 1. 为什么需要缓存感知路由？

### Prefix Cache

vLLM 的 **Automatic Prefix Caching（APC，自动前缀缓存**）可以减少重复计算。两次请求包含相同的上下文前缀，就可以复用前缀对应的 KV Cache，减少 Prefill 计算。

> 但这里有一个前提，两次请求要落到相同的实例，才能用到缓存。

假设第一次请求分给实例 A，A 完成 Prefill 并保留了该上下文前缀对应的 KV Cache。第二次请求如果仍然分给 A，就有机会复用；如果分给 B，B 没有这份缓存，还是需要重新计算。

![相同上下文前缀的后续请求落到不同 vLLM 副本时的缓存复用差异](https://img.lixueduan.com/ai/kv-cache-routing/prefix-cache-reuse-across-replicas.jpg)

### 多副本路由策略

**APC 负责单个实例内的缓存复用，多副本之间还需要路由配合。**

常见的路由方式有以下几种：

* **轮询或随机路由**：不看请求内容，后续请求可能分到没有对应缓存的副本。
* **会话粘滞**：让同一会话尽量回到原副本，但不同会话也可能共享相同的系统提示词或其他上下文前缀，而且同一会话的缓存也可能已经被驱逐。
* **前缀感知路由**：根据请求前缀的匹配情况，优先选择可能已有缓存的副本。

本文主要验证第三种方式。为了先看清缓存复用的效果，测试时全局并发设为 1，暂时不叠加负载评分。

> 为了把路由效果单独看清，我们使用两个普通 vLLM 副本做实验，不把它与 PD 分离的传输开销混在一起。

### 与 PD 分离的关系

前面的 PD Demo 只有一个 P 和一个 D，实例地址确定后，请求按顺序执行即可。扩展到多副本，Router 就需要分别考虑 Prefill 与 Decode 的选择。

| 选择对象 | 需要考虑的问题 |
| --- | --- |
| Prefill 实例 | 哪个实例保有可复用前缀，可以少做多少 Prefill，以及需要排队多久 |
| Decode 实例 | 哪个实例有足够的 KV 容量、负载是否合适，以及取得所需 KV 的传输成本 |

例如，上下文前缀对应的 KV Cache 已保留在 P1 上，下一次请求如果分给没有对应缓存的 P2，就可能重新计算整个输入；如果只命中部分前缀，则只需要计算剩余部分。
> **PD 分离不会自动让所有 P 实例共享前缀缓存。**

Decode 侧则不能简单理解为“需要拉取缓存就是选错了 D”。P → D 的 KV 传输本来就是分离后的正常步骤。若某个 D 已保有可复用前缀，并且当前缓存与 Connector 实现支持相应复用，选点可能影响需要补齐的 KV 数量；同时还要考虑排队和显存容量。

**PD 分离拆开计算阶段，缓存感知路由则在同类候选实例之间选点。** 两者可以组合，也可以分别使用。

## 2. KV Cache 感知路由原理

### llm-d Router 的请求处理流程

这里使用 llm-d-router 的 Standalone 模式，主要包含两个组件：

* **Envoy**：接收客户端请求，并转发到选中的 vLLM 实例。
* **Endpoint Picker（EPP）**：根据请求内容和候选实例信息，决定本次请求发给哪个实例。

请求处理流程如下：

![llm-d Router 请求处理流程：客户端、Envoy、EPP 与 vLLM](https://img.lixueduan.com/ai/kv-cache-routing/llm-d-request-flow.jpg)

EPP 中和前缀感知相关的处理，可以分成三步：

1. **准备前缀信息**：`approx-prefix-cache-producer` 根据请求前缀和历史路由记录，查找哪些副本可能缓存了相同内容。
2. **计算评分**：`prefix-cache-scorer` 根据前缀匹配信息，为候选副本打分。v1.0.0 默认使用匹配块占比。
3. **选择实例**：`max-score-picker` 选择评分更高的副本；分数相同时，使用轮转方式处理同分候选。

**Router 优先选择可能已有缓存的实例，实际能否复用仍然由 vLLM 决定**。这里没有跨实例传输 KV Cache，也不涉及 PD 分离。

### 缓存感知路由的实现思路

不同项目的实现各有差异，但都需要解决：**Router 如何知道缓存在哪里**这个问题。下面以 llm-d 为例介绍。

常见思路包括**根据历史请求估算**、**根据引擎上报的缓存事件维护索引**以及**将突发到达、共享前缀的请求集中分配到同一实例**。

#### Approximate：根据历史路由记录估算

`approx-prefix-cache-producer` 会将请求的上下文前缀划分成块，维护这些前缀块与实例之间的对应关系。

例如，第一次请求被分配给实例 A 后，Router 就记录该请求的上下文前缀与 A 的关联。后续请求包含相同前缀时，插件便能查询这些记录，推测 A 上可能保留着对应的 KV Cache，并将匹配信息交给后续评分和选点插件，让 A 更容易被选中。

> 这里记录的是请求的历史分配情况，没有向推理引擎确认缓存是否仍然存在，因此属于近似估算。

这种方式部署比较简单，不依赖引擎上报 KV Events。但记录的是历史的路由选择，不是推理引擎的实际缓存清单；如果缓存已经被驱逐，或者实例重启了，估算就可能与实际状态不符。

![Approximate 根据历史路由记录估算前缀缓存位置并选择 vLLM 副本的流程](https://img.lixueduan.com/ai/kv-cache-routing/approximate-prefix-routing-flow.jpg)

#### Precise：根据引擎事件跟踪缓存状态

[`precise-prefix-cache-producer`](https://github.com/llm-d/llm-d-router/blob/v1.0.0/pkg/epp/framework/plugins/requestcontrol/dataproducer/preciseprefixcache/README.md) 则是订阅引擎的 KV Events，根据缓存块保存、驱逐等事件维护索引。收到新请求后，使用请求上下文对应的真实 token 序列查询索引，判断各实例持有哪些可复用的前缀块。

例如，实例 A 上某段上下文前缀的 KV Cache 被驱逐后， Router 收到缓存驱逐事件，然后去更新索引，移除对应的缓存记录。

> 注意：Precise 也需要处理事件延迟、丢失以及索引重建等问题。

#### Burst Prefix：将共享前缀的请求集中起来

还有一种情况：突然有许多共享相同上下文前缀的请求到达，但所有实例都还没有缓存。此时就没有可供优先选择的热实例，那么这些请求可能会被分散到多个副本，分别计算同一段前缀。

[`burst-prefix-cache-producer`](https://github.com/llm-d/llm-d-router/blob/v1.0.0/pkg/epp/framework/plugins/requestcontrol/dataproducer/burstprefix/README.md) 会在一个短时间窗口内收集请求，将上下文相同的请求分组，并根据配置条件，尽量把共享上下文前缀的请求分配到同一实例，减少重复的前缀计算。

> 它解决的是冷缓存场景下的请求调度问题，收集请求的窗口会增加等待时间，也不保证所有并发请求只计算一次前缀。

前缀匹配信息是选点的依据之一，实际路由还可以结合实例负载、缓存容量等因素。

本文先只考虑前缀复用：由 Approximate 估算各实例的缓存匹配情况，交给 prefix-cache-scorer 评分，再对比随机选择和按评分选择的效果。

## 3. 实验环境与部署配置

### 实验环境

本次测试环境如下：

| 项目 | 配置 |
| --- | --- |
| Kubernetes | v1.36.1，单节点，8 vCPU、约 15 GiB RAM |
| 模型 | Qwen2.5-0.5B-Instruct，同一份本地模型文件 |
| vLLM | v0.31.0，FP16、eager、APC 开启 |
| Router | llm-d-router v1.0.0，Standalone EPP + Envoy v1.33.2 |
| 请求 | 约 5200 token 上下文前缀，输出上限 32 token，全局并发 1 |

### 配置两个 vLLM 副本

两个副本使用相同的模型和推理参数，各自维护独立的 KV Cache。

将模型挂载到 `/model`，两个 vLLM 使用以下参数启动：

```bash
vllm serve /model \
  --served-model-name kv-demo \
  --dtype half \
  --max-model-len 8192 \
  --max-num-seqs 1 \
  --max-num-batched-tokens 8192 \
  --enforce-eager \
  --enable-prefix-caching \
  --kv-cache-memory-bytes 268435456 \
  --port 8000
```

其中，`--enable-prefix-caching` 用于开启 APC，`--kv-cache-memory-bytes` 将每个实例的 KV Cache 预算固定为 256 MiB。本次启动日志显示，每个实例的 GPU KV Cache 容量为 21840 token。

> KV Cache 预算和进程总显存不是一回事，模型权重等内容还需要额外占用显存。

### 配置 Router 后端发现与请求入口

两个 vLLM Pod 都带上 `app: kv-demo` 标签，Standalone Router 根据标签发现后端，直接访问 Pod 的 8000 端口。核心 Helm values 如下：

```yaml
router:
  modelServers:
    type: vllm
    protocol: http
    matchLabels:
      app: kv-demo
    targetPorts:
    - number: 8000
  inferencePool:
    create: true
    failureMode: FailClose
  epp:
    replicas: 1
    flags:
      refresh-metrics-interval: 1s
  proxy:
    enabled: true
    proxyType: envoy
    mode: sidecar
    failOpen: false
  tokenizer:
    enabled: false
```

### 配置前缀评分与实例选择

对比两种路由策略：

* **随机路由**：使用 `random-picker`，忽略缓存评分，随机选择实例。
* **前缀感知路由**：使用 `max-score-picker`，按照前缀缓存评分选择实例。

前缀感知路由的 EPP 配置如下：

```yaml
apiVersion: llm-d.ai/v1
kind: EndpointPickerConfig
plugins:
- type: utilization-detector
  parameters:
    metricsStalenessThreshold: 5s
- type: single-profile-handler
- type: approx-prefix-cache-producer
  parameters:
    autoTune: false
    blockSizeTokens: 64
    maxPrefixTokensToMatch: 8192
    lruCapacityPerServer: 4096
- type: prefix-cache-scorer
- type: max-score-picker
schedulingProfiles:
- name: default
  plugins:
  - pluginRef: prefix-cache-scorer
    weight: 1.0
  - pluginRef: max-score-picker
```

测试随机路由时，将配置中的两处 `max-score-picker` 替换为 `random-picker` 即可。

另外，需要避免正常实例被意外排除。`utilization-detector` 会检查实例指标的更新时间：如果指标太旧，就可能认为实例状态不可靠，暂时不让它参与选点。

> 本次指标每 1 秒刷新一次，但默认过期阈值为 200 ms，这里将过期阈值调整为 5 秒。

## 4. 实验设计与结果分析

### 测试请求与执行方法

如果一直重复发送相同的长上下文前缀，随机路由也可能把两个副本都预热。等到两个副本上都有缓存，无论请求分给谁都能命中，自然就看不出差异了。

因此，这里基于每段新的上下文前缀构造一对请求：

1. 构造上下文前缀 C，约 5200 token，开头带独立 ID，内容由多条不同的运维观察记录组成。
2. 发出 `C + Summarize the observations.`，等待完整响应。
3. 保持上下文前缀 C 完全不变，末尾换成 `Which measurements should engineers collect next?`，再次请求同一个 Router。
4. 换一段新的上下文前缀，重复上述步骤。

两次请求的结构如下。`context_prefix` 表示两次请求共享的上下文前缀，实验脚本会先把它展开成实际文本，再发送 JSON；这里省略约 5200 token 的正文：

```text
第一次 messages[0].content:
  {context_prefix}\nQuestion: Summarize the observations.

第二次 messages[0].content:
  {context_prefix}\nQuestion: Which measurements should engineers collect next?
```

两次都请求 `/v1/chat/completions`，并使用相同的生成参数：

```json
{
  "model": "kv-demo",
  "messages": [{"role": "user", "content": "这里填入展开后的上下文前缀与问题"}],
  "max_tokens": 32,
  "temperature": 0,
  "stream": true,
  "stream_options": {"include_usage": true}
}
```

每组测试包含两次请求：第一次发送一段新的上下文前缀和问题 1；响应完成后，保持前缀不变，只把末尾换成问题 2。重点观察第二次请求是否回到原来的实例，以及是否真正复用了缓存。

> 本实验未启用会话亲和性插件。每条请求使用独立的 `X-Request-Id`，用于关联 Router 日志与请求记录。

同一轮中，两种策略使用完全相同的 10 组上下文前缀；不同轮使用新的前缀集合。三轮执行顺序依次为“随机 → 前缀感知”“前缀感知 → 随机”“随机 → 前缀感知”。
每次切换策略前，都清空两个 vLLM 实例的前缀缓存，并重建 Router 的缓存索引，避免受上一组测试留下的记录影响。

### 观察指标与统计口径

我们先通过 `request_id` 关联 EPP 选中的 endpoint，再找到对应的 vLLM Pod 的请求成功计数和缓存指标增量。

主要观察以下指标：

```text
vllm:request_success_total
vllm:prefix_cache_hits_total
vllm:prefix_cache_queries_total
vllm:request_prefill_kv_computed_tokens_sum
vllm:request_prefill_kv_computed_tokens_count
```

这些值都是引擎的累计指标，需要取请求前后的增量。全局并发为 1 时，只有一个副本的 `request_success_total` 增加，便可据此核对本次实际处理请求的实例。

前缀 token 命中率使用 `Δhits / Δqueries`；多条请求汇总时，先分别累加 hits 和 queries，再计算比例。它与“第二次请求回到首次副本的比例”是两个指标：回到同一副本不保证缓存仍在，换了副本也可能命中通用模板。

实际 Prefill token 数通过 histogram 的 `_sum` 增量观察。Router 的评分只说明选点依据，vLLM 的命中与实际计算量才说明缓存有没有被复用。

**TTFT（Time To First Token）** 表示从客户端发起请求，到收到第一个非空 `delta.content` 的时间。流式响应开头的 role 或空事件不算首字。

### 测试结果

每种策略执行 3 轮，每轮 10 对请求，共 120 条主实验请求。下表汇总每对请求中的第二次请求，每种策略各 30 条：

| 指标 | 随机路由 | 前缀感知路由 |
| --- | --- | --- |
| 回到首次处理该前缀的副本 | 17/30（56.7%） | 30/30（100%） |
| 前缀 token 命中率 | 56.56% | 99.60% |
| TTFT P50 | 155.1 ms | 127.3 ms |
| TTFT P95 | 2018.0 ms | 159.2 ms |
| 实际 Prefill token 均值 | 2261.5 | 21.0 |

随机组有 17 次请求回到了首次处理该前缀的副本，这部分请求同样很快，TTFT 中位数为 132.2 ms。另外 13 次落到冷副本，中位数则为 2005.3 ms。

**前缀感知路由的主要收益来源于减少请求落到冷副本后产生的重复 Prefill。**

随机路由中仍有一半左右的后续请求回到了首次处理该前缀的副本，这部分请求同样有机会复用缓存，因此两种策略的 TTFT P50 仅相差约 28 ms。更明显的变化在 P95，从 2018.0 ms 降到了 159.2 ms，而不是所有请求都快了十几倍。

### 结果的适用范围

**缓存命中率高，不一定就代表请求延迟更低。**

实际场景可能出现另一种情况，实例 A 有缓存，但队列已经很长；实例 B 没有缓存，却可以马上开始计算。此时就需要比较，复用缓存省下的 Prefill 时间，能不能抵消额外排队的时间。

在这类场景下，前缀评分还需要和负载感知配合使用。会话粘滞可以保持同一会话的亲和性，前缀匹配可以处理跨会话复用，而热点和并发场景还需要结合实例队列等负载信息做判断。

## 5. 小结

**APC 和前缀感知路由解决的是两个层次的问题**：

- APC 让单个 vLLM 实例复用已经计算过的前缀。
- 前缀感知路由让请求尽量找到已经有缓存的实例。

本次通过两个 vLLM 副本验证了这个过程。随机路由的 30 次后续请求中，有 17 次回到首次处理该前缀的副本；前缀感知路由则是 30 次全部回到首次处理该前缀的副本，前缀 token 命中率从 56.56% 提升到了 99.60%。

从实际 Prefill token 和 TTFT 也可以看到，请求回到已有缓存的实例后，确实减少了重复计算。对于长上下文前缀、共享提示词等存在前缀复用的场景，可以通过缓存感知路由，提高多副本部署下的缓存复用率，减少重复 Prefill。

---

> 作者: [意琦行](https://github.com/lixd)  
> URL: https://www.lixueduan.com/posts/ai/32-llm-kv-cache-aware-routing/  

