状态:草稿(2026-11-14),实测。 本文中的数据由作者于 2026-11-13 亲自实测得出。环境与请求同 第 1 篇相同。Kubernetes v1.34、三台 GPU 服务器(一张 24GB 级 GPU,两张 8GB 级笔记本 GPU)、节点间 1GbE、Dynamo v1.6.2 的
vllm-runtime:1.6.2镜像(vLLM 0.31.0)、模型为 Qwen3-0.6B。路由器参数全部为默认值。 编写规则:以实测数据为基础,未能测出的标为「未测」,未能确认的标为「未确认」。
轮询与感知 KV 路由
目录
- 1. 本篇要回答的问题
- 2. Manifest(清单)中的变更
- 3. 五种变体
- 4. 结果:轮询与基准线相同,KV 路由则不同
- 5. 首个 Token 时间与吞吐量
- 6. 3 个 Worker 时是否也一样
- 7. 距离上限还有多接近
- 8. 哈希种子与近似模式
- 9. 总结
- 参考
1. 本篇要回答的问题
第 1 篇的结论如下:如果在 Service 背后部署多台 vLLM,前缀缓存的命中率由「单个 Worker 容纳的前缀数 ÷ 前缀种类数」决定,增加 Worker 也无法提高命中率。本篇将相同的请求发送到 Dynamo,以确认以下三点。
- 仅仅通过 Dynamo 是否会有所不同(轮询)。
- 开启感知 KV 路由后,命中率和首个 Token 时间会有多大变化。
- 如果去掉文档中提醒注意的两点(即哈希种子和 KV 事件),结果会怎样。
2. Manifest(清单)中的变更
基准线使用的是 Deployment 和 Service。而在 Dynamo 中,这两者合并成了 DynamoGraphDeployment(DGD)这一个资源。Operator 会读取该资源并创建 Pod 和 Service。引擎参数、镜像、模型和节点分配保持不变,仅修改了以下四项。
| 变更项 | 基准线 | Dynamo |
|---|---|---|
| 资源 | Deployment + Service | 单个 DGD(组成部分:1 个前端,多个 Worker) |
| Worker 的启动命令 | python3 -m vllm.entrypoints.openai.api_server |
python3 -m dynamo.vllm |
| 开放 OpenAI API 的位置 | 每个 Worker 均为 8000 | 仅前端为 8000,Worker 不开放 HTTP |
| 路由 | Service 按连接级别随机分发 | 前端的 DYN_ROUTER_MODE |
启用感知 KV 路由需要两项设置。
# 프런트엔드
env:
- {name: DYN_ROUTER_MODE, value: kv}
# 워커 인자
- --kv-events-config
- '{"publisher":"zmq","topic":"kv-events","endpoint":"tcp://*:20080","enable_kv_cache_events":true}'
每当创建或删除 KV 块时,Worker 都会向该地址发送事件,前端内部的路由器接收这些事件并构建「哪个 Worker 拥有哪个块」的映射地图。当请求到来时,路由器会选择与请求前半部分重叠度最高的 Worker,同时也会考虑 Worker 的负载情况。
在此记录一下迁移过程中确认的事实。
- 在没有 etcd、NATS 和 Grove 的情况下成功启动了。 Worker 将自己的信息写入 Kubernetes API,资源名称为
DynamoWorkerMetadata,然后由前端进行读取。 - 可以部署多个同类型的组件。 由于每个节点的资源限制不同,我们将
type: worker组件按节点进行了划分,而前端则将这些 Worker 组合为同一模型的 Worker 进行了尝试。 - Worker 的指标可以从 9090 端口获取。 这是 Operator 注入的系统端口。像
vllm:prefix_cache_hits_total这样的引擎指标也是从这里抓取的。 cached_tokens默认已包含在响应中,无需单独开启。 在单独运行的 vLLM 服务器中,这是需要--enable-prompt-tokens-details的参数值。- 在 Worker 变为 Ready 状态后,前端的
/v1/models中显示模型也需要几秒钟的时间。在施加负载之前必须先检查此列表。
3. 五种变体
| 名称 | 配置 | 观测目的 |
|---|---|---|
| 基准线 | Service 背后的 vLLM(第 1 篇) | 对比值 |
| 轮询 | DGD, DYN_ROUTER_MODE=round-robin |
仅经过 Dynamo 的效果 |
| KV 路由 | DGD, DYN_ROUTER_MODE=kv, KV 事件, PYTHONHASHSEED=0 |
核心功能 |
| 无种子 | KV 路由中去掉 PYTHONHASHSEED 选项 |
是否确实需要哈希种子 |
| 近似模式 | DYN_ROUTER_USE_KV_EVENTS=false, 同时关闭 Worker 事件 |
在没有事件、仅靠猜测的情况下效果如何 |
请求与第 1 篇相同。共享前缀约为 2,000 个 Token,每个请求附加不同的尾部约 300 个 Token,单个 Worker 的 KV 容量限制为 1GiB(相当于约 3.95 个请求)。前缀种类为 2、7、12、24,并发请求为 4、8、16,每个数据点运行 3 次并取中位数。由于种子相同,所有变体接收到的请求和顺序完全一致。五种变体在所有数据点上的错误数均为 0。
轮询变体中的某个数据点(2 种前缀·并发 4)排除了第一次迭代。因为紧邻其后以相同种子发送了测试请求,导致前缀已经存在于缓存中。该数据点取剩余 2 次运行的中位数。
4. 结果:轮询与基准线相同,KV 路由则不同
请求中命中缓存的比例(6 个 Worker,并发 8)。
| 前缀种类 | Service 背后的 vLLM | 轮询 | KV 路由 |
|---|---|---|---|
| 2 | 95.0% | 95.0% | 97.5% |
| 7 | 55.5% | 53.5% | 83.0% |
| 12 | 32.1% | 29.2% | 67.9% |
| 24 | 17.5% | 17.5% | 43.3% |
从输入 Token 中来自缓存的比例来看结果也是一样的。在此请求中,2,371 个 Token 中有 2,032 个是前缀,因此 0.857 是能够达到的最大值。
| 前缀种类 | 并发数 | Service 背后的 vLLM | 轮询 | KV 路由 |
|---|---|---|---|---|
| 2 | 4 | 0.814 | 0.814 | 0.857 |
| 7 | 4 | 0.431 | 0.385 | 0.788 |
| 12 | 4 | 0.241 | 0.262 | 0.664 |
| 24 | 4 | 0.135 | 0.126 | 0.429 |
| 2 | 16 | 0.814 | 0.814 | 0.835 |
| 7 | 16 | 0.425 | 0.442 | 0.779 |
| 12 | 16 | 0.268 | 0.237 | 0.695 |
| 24 | 16 | 0.141 | 0.148 | 0.431 |
轮询调度与基准线没有区别。 仅仅在 Dynamo 前端前面套一层并不能让缓存复活。从前缀缓存的视角来看,把请求均匀分发给工作节点和随机分发是一回事。
在单台容量无法容纳前缀的区间内,KV 路由将命中率提升了两到三倍。 在 2 种前缀的情况下,差异较小。正如第 1 篇中所写,只要前缀能完全装进单台机器,用路由几乎得不到什么好处。
路由器实际上是否汇聚了请求,是通过“一个前缀发往了多少个工作节点”来确认的。在请求的 nvext.extra_fields 中加入 worker_id 后,响应中就会带有处理该请求的工作节点的标识符。我们就是通过它来计数的。
| 前缀类型 | 轮询调度 | KV 路由 |
|---|---|---|
| 2 | 6.00台 | 1.00台 |
| 7 | 6.00台 | 2.86台 |
| 12 | 6.00台 | 4.00台 |
| 24 | 5.92台 | 5.17台 |
(6个工作节点,并发 4,3 次中位数。)轮询调度将所有前缀发送到了所有工作节点。KV 路由在 2 种前缀时每个前缀只发送到 1 台机器,而随着前缀增加,分布范围也随之扩大。
5. 首Token时间和吞吐量
首Token时间 (TTFT)。每格中为 p50 / p99(6 个工作节点)。
| 前缀类型 | 并发 | Service 后的 vLLM | 轮询调度 | KV 路由 |
|---|---|---|---|---|
| 2 | 4 | 37 / 122ms | 41 / 125ms | 34 / 73ms |
| 7 | 4 | 63 / 184ms | 89 / 142ms | 40 / 129ms |
| 12 | 4 | 94 / 193ms | 106 / 140ms | 43 / 138ms |
| 24 | 4 | 101 / 254ms | 113 / 143ms | 64 / 149ms |
| 2 | 16 | 64 / 325ms | 74 / 332ms | 67 / 206ms |
| 7 | 16 | 142 / 1,353ms | 111 / 576ms | 72 / 240ms |
| 12 | 16 | 193 / 1,441ms | 190 / 646ms | 77 / 193ms |
| 24 | 16 | 205 / 1,508ms | 199 / 774ms | 109 / 242ms |
这里有三点值得一读。
- 经过前端的开销为几毫秒。 在缓存条件相同的 2 种前缀、并发 4 的情况下,轮询调度的 p50 为 41ms,基准线为 37ms。而在并发 8 时,差距拉大到了 64ms 对 47ms。这是因为多了一步由前端进行分词并将请求传递给工作节点的过程。
- 即使命中率相同,轮询调度的尾延迟也更短。 并发 16、24 种前缀的 p99 为 774ms,是基准线 1,508ms 的一半。我们认为,这是由于 Service 会在每次连接时随机选择 Pod,从而导致请求涌向某个工作节点的瞬间,而轮询调度则分发得更均匀。由于未测量每个工作节点的瞬时队列,因此原因属于推测。
- KV 路由进一步缩短了尾延迟。 在并发 16 下,无论前缀数量多少,p99 都在 0.2 秒左右。因为未命中的请求需要计算全部 2,371 个 token,从而挤占同一 GPU 上的其他请求,而这类请求本身减少了。
吞吐量(每秒请求数,6 个工作节点·并发 16)也随之上升。
| 前缀类型 | Service 后的 vLLM | 轮询调度 | KV 路由 |
|---|---|---|---|
| 2 | 18.9 | 19.1 | 21.1 |
| 7 | 14.1 | 16.0 | 20.1 |
| 12 | 13.9 | 14.7 | 19.2 |
| 24 | 13.3 | 14.3 | 17.6 |
GPU 保持不变,但在 24 种前缀下多处理了 32% 的请求。这相当于减少了用于重新计算前缀的时间。
6. 在 3 个工作节点下是否同样如此
这是一个每张 GPU 放置一个工作节点的部署配置(并发 8)。
| 前缀类型 | 缓存 token 比例:基准线 / 轮询调度 / KV | TTFT p99:基准线 / 轮询调度 / KV | 每秒请求数:基准线 / 轮询调度 / KV |
|---|---|---|---|
| 2 | 0.840 / 0.840 / 0.848 | 165 / 171 / 83ms | 17.0 / 17.3 / 20.6 |
| 7 | 0.487 / 0.475 / 0.690 | 853 / 413 / 145ms | 12.4 / 13.6 / 18.5 |
| 12 | 0.283 / 0.291 / 0.474 | 1,125 / 679 / 152ms | 11.2 / 12.5 / 17.2 |
| 24 | 0.143 / 0.141 / 0.252 | 1,216 / 830 / 202ms | 10.7 / 11.8 / 16.6 |
趋势是相同的。在 24 种前缀下,吞吐量增加了 55%。不过,因为只有 3 个工作节点,能够容纳的前缀总量减半,其命中率也低于 6 节点的部署配置。
还有一个负载超出容量的点。在 3 个工作节点·并发 16·24 种前缀的条件下,KV 路由的 TTFT p50 为 279ms,p99 为 962ms。虽然比基准线(563ms、3,060ms)要好,但并没有像其他情况那样降到 0.2 秒以内。路由只能减少浪费,而无法创造出短缺的计算资源。
7. 距离上限还有多接近
第 1 篇中计算的理论上限是“工作节点数 × 单台机器容纳的前缀数 ÷ 前缀种类数”。这是一个假设前缀互不重叠地分配给工作节点的值。
| 部署配置 | 前缀种类 | 上限 | 实际请求单位命中(并发 4 / 8 / 16) |
|---|---|---|---|
| 6个工作节点 | 7 | 100% | 93 / 83 / 91% |
| 6个工作节点 | 12 | 100% | 80 / 68 / 82% |
| 6个工作节点 | 24 | 99% | 52 / 43 / 52% |
| 3个工作节点 | 7 | 100% | 77 / 82 / 90% |
| 3个工作节点 | 12 | 99% | 53 / 58 / 64% |
| 3个工作节点 | 24 | 49% | 29 / 30 / 28% |
默认配置下的 KV 路由在缓存紧张的区间内达到了 上限的一半到三分之二之间的效果。虽然比随机分散好两到三倍,但仍未达到毫无保留榨干容量的部署配置。
原因在第 4 节的表格中有所体现。在 24 种前缀中,单个前缀平均发往了 5 个工作节点。路由器不仅考虑重叠度,还会同时考虑工作节点的负载,因此如果重叠的工作节点很忙,它就会发往其他工作节点。该工作节点会重新计算并缓存前缀,从而挤掉其他前缀。缓存越紧张,这种挤占就会引发连锁反应。
Dynamo 文档中有一个用于调整重叠度与负载权重的路由器参数。本文仅按默认值进行了测量,更改该参数是否能更接近上限尚未进行实际测量。
8. 哈希种子与近似模式
文档和示例中要求为 worker 设置 PYTHONHASHSEED=0 。理由是 vLLM 的块哈希会经过 Python 的哈希,因此如果每个进程的 seed 不同,相同的字首在各个 worker 中就会产生不同的哈希。我们去掉了 seed 进行了重新测试(6 个 worker,并发 8,缓存 token 比例)。
| 字首种类 | 轮询 | KV 路由 | 无 seed | 近似模式 |
|---|---|---|---|---|
| 2 | 0.814 | 0.835 | 0.848 | 0.848 |
| 7 | 0.439 | 0.702 | 0.729 | 0.638 |
| 12 | 0.247 | 0.573 | 0.547 | 0.480 |
| 24 | 0.149 | 0.361 | 0.343 | 0.245 |
即使去掉 seed,结果也完全相同。 这四点的差距在 0.03 以内,方向也互有交错。其幅度无法与重复测试之间的抖动区分开来。至少在此次测试与配置下,没有 seed 并没有导致路由失效。原因尚未确认。我们未能从代码中确认运行时是自行确定 seed,还是路由器存在不依赖 worker 哈希的途径。既然这是文档要求的配置且没有额外成本,我们并不建议仅仅因为结果相同就将其去掉。
近似模式处于中间水平。 在该模式下,worker 不发送事件,路由器通过自己发送的请求记录来推测“那个 worker 上应该还留有这个字首”。在 7 种字首时,它与使用 KV 事件时没有太大差异(0.638 对 0.702),但在 24 种字首时降至 0.245,与轮询(0.149)的差距缩小了一半。这可能是因为缓存紧张导致块频繁被逐出时,路由器的推测与实际情况不符。可以将其理解为在难以开放事件端口的环境中可以使用的一种次优选择。
9. 总结
- 仅在前方部署 Dynamo 并不能使字首缓存生效。轮询的命中率与 Service 后端的 vLLM 相同。
- 当字首总量超过单台 worker 容量时,KV 感知路由就能发挥价值。在 6 台 worker 的情况下,请求级命中率在 7 种字首时从 56% 提升至 83%,12 种字首时从 32% 提升至 68%,24 种字首时从 18% 提升至 43%,并发 16 下的 TTFT p99 从 1.3~1.5 秒降至 0.2 秒级别。
- 如果服务的字首全部能够放入单台机器中,那么几乎没有什么收益。在 2 种字首时,命中率差距仅为 2~4%p。此时付出的代价是前端增加的一层几毫秒延迟以及在 第 2 篇中看到的安装与权限负担。
- 默认情况下,其性能大约是理论上限的一半到三分之二。路由器参数的调整尚未进行实测。
- 去掉哈希 seed 后结果相同(原因未确认)。近似模式下,缓存越紧张,效果越差。
本次测量的局限性与第 1 篇相同。字首是均匀抽取的,请求长度全部相同,且模型较小。对于大模型来说,一个未命中的请求所需的计算时间要长得多,同样的命中率差距会体现为更大的时间差距,但这并非本文所测。当部署多个前端时,路由器之间是否能看到相同的映射表也未经实测。
参考资料
- NVIDIA Dynamo v1.6.2 文档,KV Cache Routing 与 Router 设置。
- 清单、负载工具、网格记录与中位数汇总位于仓库的
docs/experiments/2026-11-13-dynamo-adoption/中。 - KV 事件与路由器的原理请参考 LLM KV 缓存剖析第 4 篇。