Kthena 实战解析:让 Kubernetes 真正理解 Prefill、Decode 和 KV Cache

如果想在 Kubernetes 中运行一个 Qwen 模型,只需准备好模型文件和 vLLM 镜像,写一个 vLLM 的 Deployment,申请 GPU,再配一个 Service,很快就能拿到 OpenAI 的 /v1/chat/completions 接口。

但当模型从一个变成一组、GPU 从一张变成多机多卡,问题就不再是"Pod 能否启动"了。Kthena 可以在 Kubernetes 上解决这些问题:vLLM 继续负责把模型跑快,Kubernetes 继续管理 Pod 和 Service,Kthena 则将 LLM 的角色、组、GPU 位置、KV Cache 和 Token 负载带进调度、路由与扩缩容过程。

本文基于微信公众号文章《Kthena:让 Kubernetes 真正理解 Prefill、Decode 和 KV Cache》整理,保留了全部实验数据与 YAML 配置。


一、为什么要有 Kthena:模型在 K8s 部署的痛点

Deployment 对于普通 Web/API 非常合适:每个 Pod 建立副本,少一个副本只是容量下降,Service 用 Round Robin 把请求分散出去即可。

LLM 推理的资源形态更复杂。一个模型可能用张量并行(TP)分布到多张 GPU,也可能将处理 Prompt 和生成 Token 拆成 Prefill、Decode 两类实例。每个 HTTP 请求也不再等价:一条 100 Token 的问答与一条 8,000 Token 的长文档摘要,即使都记为一次 HTTP 请求,对 GPU 的压力也完全不同。传统 QPS、CPU 和 GPU 利用率不能体现 LLM 的性能指标。

所以 Kthena 解决的是模型在 K8s 集群启动后的几个关键问题:多卡模型怎么一起调度、长上下文怎么感知拓扑、Prefill/Decode 怎么分池、KV Cache 怎么被路由感知、扩缩容怎么按 Token 负载而不是 GPU 利用率。


二、Kthena 是什么

Kthena 是 Volcano 社区面向 LLM 推理的 Kubernetes 原生子项目。它并不自己完成模型计算,而是站在 vLLM、SGLang 等推理引擎之上,管理三件事:模型工作负载的声明与放置、请求级智能路由、基于 Token 负载的扩缩容。核心 CRD 是 ModelServing/ModelBooster/ModelRoute/ModelServer/AutoscalingPolicy

Kthena v1.0.0 还将 P/D 角色扩缩容、路由观测、多轮会话的 Cache 亲和性、Gateway API 与 CLI 改进纳入统一版本。从定位上看,它更像"LLM 推理工作负载和请求的调度层"。


三、Kubernetes 原生调度为什么不适合复杂 LLM 推理

Kubernetes Scheduler 通过 Device Plugin 将 nvidia.com/gpu 发布到 Node 后,Scheduler 会过滤资源不足的节点,再为 Pod 选择位置。对一个独立推理 Pod,这是一个默认的调度链路。

难点在于,原生 Scheduler 看到的基本单位仍是一个 Pod:它不知道这几个 Pod 属于同一个模型的 TP 组、不知道哪些请求该发给已缓存前缀的 Pod、不知道 Prefill 和 Decode 的资源画像完全不同。这不是 Kubernetes 调度器"不行",而是它的边界原本就是通用 Pod 调度。Kthena 与 Volcano 在上层增加了 LLM 工作负载才需要的语义


四、Kthena 与 Volcano 的关系

Kthena 是 Volcano 的子项目。两者的关系可以概括为:Kthena 理解模型,Volcano 完成资源放置

1
2
3
4
5
6
7
请求 → ModelServing(模型、组与角色) ──► Kthena Controller ──► 生成 PodGroup 和 Pod

Volcano PodGroup(Gang/Topology) ◄──┘

Volcano Scheduler ──► 选节点与 GPU

vLLM Pods 执行推理 ◄── Kthena Router 选后端

ModelServing 中有一个 ServingGroup、两个 server 角色副本时,Kthena Controller 会创建 PodGroup,把 minMember 和网络拓扑要求写进去。Volcano Scheduler 读取 PodGroup,确认整组资源都能满足后才绑定 Pod。模型运行后,Kthena Router 在请求链路上选择后端——它不会把 HTTP 请求送回 Volcano Scheduler,Volcano 也不会检查 Prompt 和 KV Cache。


五、Kthena 整体架构

核心组件可以按"模型、请求、指标"来区分:

Controller Manager:把声明变成运行资源。 监听 ModelBoosterModelServingModelServerModelRouteAutoscalingPolicy 等资源,将声明中的 ServingGroup、Role、Pod 模板和策略转换为 PodGroup、Pod 与路由关系,并持续对比期望状态与实际状态。Controller 不加载模型,也不在每次 Token 生成的数据路径上。

Router:将一次请求发给更合适的副本。 接收 OpenAI 兼容请求,先通过 ModelRoute 匹配模型,再从 ModelServer 管理的 Pod 中过滤不健康实例。后续的 Score Plugin 可按 least-request、least-latency、prefix-cache、kvcache-aware 等信号打分。

Runtime Sidecar:将推理引擎状态交给路由层。 与 vLLM Pod 一起运行,不执行模型计算,而是适配不同引擎的指标和 KV Cache 事件,将哪些 Token Block 位于哪个 Pod 写入 Redis。没有这一层,Router 就只能看到 Pod 存活,看不到引擎内部的 Cache 状态。

Autoscaler:将 Prometheus 指标转换为副本数。 读取 AutoscalingPolicy 中的查询、目标值、最小/最大副本与稳定窗口。它可以对普通 ServingGroup 扩缩容,也可以对 Prefill、Decode 角色分别计算,再用比例约束避免一侧增长、另一侧仍然堵塞。


六、从一个模型声明到 vLLM 提供服务

Kthena 提供两种常用声明方式:ModelBooster 是高层 API,适合平台将模型、引擎、路由和扩缩容用一个对象交付;ModelServing + ModelServer + ModelRoute 更细,适合自定义 vLLM 参数、GPU 资源和路由策略。本次实验使用后一种方式(模型 Qwen/Qwen3-0.6B)。

三者不是三个并列的 Deployment:ModelServing 先生成 PodGroup 和 vLLM Pod,ModelServer 再把这组 Pod 登记为可选的模型后端,ModelRoute 最后把 Router 收到的模型名连到这个后端。少了任何一个对象,模型要么没有运行实例,要么 Router 不知道应该把请求发给谁。

1. ModelServing:把 Qwen 和 vLLM 交给 Volcano 调度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
apiVersion: workload.serving.volcano.sh/v1alpha1
kind: ModelServing
metadata:
name: qwen-baseline
namespace: kthena-vllm-demo
spec:
schedulerName: volcano
replicas: 1
recoveryPolicy: ServingGroupRecreate
template:
roles:
- name: server
replicas: 1
workerReplicas: 0
entryTemplate:
metadata:
annotations:
volcano.sh/vgpu-mode: hami-core
spec:
runtimeClassName: nvidia-legacy
containers:
- name: server
image: <实际 vLLM 镜像>
command: ["python3", "-m", "vllm.entrypoints.openai.api_server"]
args:
- --host
- 0.0.0.0
- --model
- /models/Qwen3-0.6B
- --served-model-name
- Qwen/Qwen3-0.6B
- --port
- "8000"
- --enable-prefix-caching
readinessProbe:
httpGet: {path: /health, port: 8000}
resources:
requests:
volcano.sh/vgpu-number: "1"
volcano.sh/vgpu-memory: "6144"
volcano.sh/vgpu-cores: "60"
limits:
volcano.sh/vgpu-number: "1"
volcano.sh/vgpu-memory: "6144"
volcano.sh/vgpu-cores: "60"

schedulerName: volcano 让这组 Pod 进入 Volcano。roles.server 表示这是一个聚合式推理角色;后面的 Gang、P/D 分离会在这里增加多个角色和副本。三项 volcano.sh/vgpu-* 约束的是这次 vGPU 份额:一张逻辑 GPU、6144MiB 显存和 60% Core。

2. ModelServer:把生成的 Pod 登记为一个模型后端

ModelServing 负责创建 Pod,却没有声明 Router 如何识别它们。ModelServer.workloadSelector 按 Kthena 自动带上的 modelserving.volcano.sh/name 标签选择工作负载;workloadPort 与 vLLM 的监听端口对应,model 则是 Router 后续匹配的真实模型名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: networking.serving.volcano.sh/v1alpha1
kind: ModelServer
metadata:
name: qwen-baseline
namespace: kthena-vllm-demo
spec:
workloadSelector:
matchLabels:
modelserving.volcano.sh/name: qwen-baseline
workloadPort:
port: 8000
model: Qwen/Qwen3-0.6B
inferenceEngine: vLLM
trafficPolicy:
timeout: 60s

这里的 ModelServer 不是再启动一个 vLLM。它更像一份后端登记:哪些 Pod 可以处理 Qwen/Qwen3-0.6B、要访问哪个端口、它们使用什么推理引擎。Pod 被重建、扩缩容或被 Gang 调度时,Router 依据 Selector 更新候选实例,不需要应用重新发现 Pod IP。

3. ModelRoute:让请求中的模型名落到正确后端

最后一层是 ModelRoute。Router 收到 OpenAI 兼容请求后读取请求体的 model,用 modelName 找到路由,再把请求发给 targetModels 中登记的 ModelServer:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: networking.serving.volcano.sh/v1alpha1
kind: ModelRoute
metadata:
name: qwen-baseline
namespace: kthena-vllm-demo
spec:
modelName: Qwen/Qwen3-0.6B
rules:
- name: default
targetModels:
- modelServerName: qwen-baseline

因此,应用请求 model: Qwen/Qwen3-0.6B 时,不是直接请求某个 Service 或 Pod,而是先命中 ModelRoute,再由 ModelServer 找到由 ModelServing 创建的就绪实例。后续接入多个副本、P/D 角色或 KV Cache-aware 路由时,应用请求格式不变,变化留在这三类 Kthena 对象及其策略中。

创建资源后,Kthena 很快就生成了 PodGroup 与 vLLM Pod:

1
2
3
4
5
6
7
8
9
$ kubectl -n kthena-vllm-demo get modelserving,podgroup,pod -o wide
NAME AGE
modelserving.workload.serving.volcano.sh/qwen-baseline 51s

NAME STATUS MINMEMBER RUNNINGS AGE QUEUE
podgroup.scheduling.volcano.sh/qwen-baseline-0 Running 1 1 51s default

NAME READY STATUS RESTARTS AGE IP NODE
pod/qwen-baseline-0-server-0-0 1/1 Running 0 50s 10.244.0.84 gpu

请求经 Kthena Router 到达 vLLM,返回了真实的 OpenAI 兼容结果:

1
2
3
4
5
6
7
8
9
10
11
$ curl http://127.0.0.1:18080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"Qwen/Qwen3-0.6B","messages":[{"role":"user","content":"用一句话介绍 Kubernetes。"}],"max_tokens":64}'

{
"id": "chatcmpl-d02d0820-bf9e-4f67-b599-4078328ab5e2",
"model": "Qwen/Qwen3-0.6B",
"choices": [{"finish_reason": "length"}],
"usage": {"prompt_tokens": 13, "completion_tokens": 64, "total_tokens": 77},
"system_fingerprint": "vllm-0.24.0-2cba5a37"
}

这条结果同时证明了三层关系:Kthena 生成并管理模型工作负载,Volcano 将 Pod 放到 GPU,vLLM 才是真正返回 Token 的进程


七、Topology-aware:掌控 GPU 拓扑

一张 GPU 不是孤立存在的。GPU 先通过 PCIe 连接 CPU,一台服务器可能有多个 NUMA 节点,GPU 之间可能有 NVLink,多台服务器之间还要经过机架 ToR 交换机。

对单卡模型,这些差异并不明显。对 TP=8 的模型,每一层计算都可能需要 GPU 之间 AllReduce。如果 8 张卡分散在两个机架,数据要穿过更多网络设备,延迟和带宽就会进入每一轮 Token 生成。此时"所有 GPU 都已分配"并不等于"分配是高效的"。

Volcano 使用 HyperNode 描述节点之间的网络层级(同一个交换机下的节点、同一机架等拓扑域)。Kthena 在 ModelServing 中提供两层策略:groupPolicyrolePolicymode: hard 表示拓扑不满足就不调度,适合没有目标网络性能就无法工作的多卡模型;soft 则尽量靠近,资源紧张时允许退化放置。

本次单节点实验中,创建了一个 tier 1 HyperNode,并在 ModelServing 中写入:

1
2
3
4
5
6
7
networkTopology:
rolePolicy:
mode: hard
highestTierAllowed: 1
groupPolicy:
mode: hard
highestTierAllowed: 1

Kthena 会将这组字段同步到 PodGroup,Volcano 再用 HyperNode 检查候选节点。实验中 Qwen Pod 最终落在 gpu 节点。

注意:单节点只能证明 Kthena 已将拓扑声明转换为 Volcano 约束,不能证明跨节点通信性能有所提升。真正验证时,应在多节点、多交换机环境分别运行 NCCL Test 和模型压测,对比跨节点带宽、P95 ITL 和总吞吐。


八、Gang Scheduling:为什么多卡模型必须"一起上车"

假设一个模型需要 4 个 Pod,每个 Pod 申请 1 张 GPU。集群只剩 2 张卡时,普通逐 Pod 调度可能先启动 2 个 Pod——它们无法完成模型并行,却已占住 2 张 GPU,另一个原本可以完整运行的任务也可能被堵住。

Gang Scheduling 的核心是 All-or-Nothing:必须成员都能获得资源才一起调度;如果整组不满足,就一个都不提前占用。

本次实验只有一张 RTX 3060 12GiB,Volcano vGPU Device Plugin 将它发布为 VGPU=10, MEM=12288, CORE=100:

第一轮,两个 server 副本放入同一个 ServingGroup,每个副本要 7168MiB + 60% Core(合计 14GiB + 120% Core,超过物理卡容量):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
gangPolicy:
minRoleReplicas:
server: 2
roles:
- name: server
replicas: 2
entryTemplate:
spec:
containers:
- name: resource-holder
resources:
limits:
volcano.sh/vgpu-number: "1"
volcano.sh/vgpu-memory: "7168"
volcano.sh/vgpu-cores: "60"

结果:

1
2
3
podgroup.scheduling.volcano.sh/qwen-gang-0   Pending   2         0
pod/qwen-gang-0-server-0-0 0/1 Pending <none> <none>
pod/qwen-gang-0-server-1-0 0/1 Pending <none> <none>

Event 记得很直接:FailedScheduling: pod group is not ready, 2 Pending, 2 minAvailable,Unschedulable: queue resource quota insufficient: insufficient volcano.sh/vgpu-memory, insufficient volcano.sh/vgpu-cores——PodGroup 要求两个成员,Volcano 没有先启动其中一个

第二轮,每个副本改为 5GiB 显存 + 50% Core(合计 10GiB/100% Core),两个 vLLM 副本都达到 Ready 后,PodGroup 才成为 Running:

1
2
3
podgroup.scheduling.volcano.sh/qwen-gang-0   Running   2         2
pod/qwen-gang-0-server-0-0 1/1 Running 10.244.0.96 gpu
pod/qwen-gang-0-server-1-0 1/1 Running 10.244.0.57 gpu

这个实验验证的是 Kthena 将 ServingGroup 转换为 PodGroup,Volcano 执行整组调度。两个 vGPU Pod 仍然共享同一张 RTX 3060,不能将它写成真正的两卡 TP 模型。生产中的 Gang 验证还应检查 NCCL 通信、模型分片和整组失败后的重建时间。


九、Prefill / Decode 分离:为什么要把一次推理拆成两个资源池

一次 LLM 推理通常可以分为两个计算特征明显不同的阶段。

Prefill 会一次性处理完整 Prompt,对全部输入 Token 进行并行计算,并生成后续推理所需的 KV Cache。Prompt 越长,Prefill 的计算量越大,因此这一阶段更依赖 GPU 的矩阵计算能力,并直接影响 TTFT(Time To First Token)——用户等待第一个 Token 出现的时间。

Decode 则是在已有 KV Cache 的基础上逐 Token 生成结果。每生成一个新 Token,都需要持续读取不断增长的 KV Cache,因此相比 Prefill,它通常更容易受到显存带宽和内存访问效率的影响,并直接决定 ITL(Inter-Token Latency)——连续两个 Token 之间的输出间隔。

传统部署通常让 Prefill 和 Decode 共用同一组 GPU 实例,架构简单,但两类负载会相互争抢资源。尤其在长 Prompt 集中到达时,大量 Prefill 计算可能占用 GPU 算力和显存带宽,使已经进入 Decode 阶段的请求出现 Token 输出变慢、ITL 抖动甚至尾延迟升高。

P/D 分离的核心,就是把 Prefill 和 Decode 拆成两个独立资源池。Prefill Pool 可以面向长 Prompt 和高计算吞吐进行配置,Decode Pool 则面向持续 Token 生成和低 ITL 进行优化;两者可以分别选型、独立扩缩容,避免 Prefill 高峰直接干扰 Decode。

代价也很明确:Prefill 和 Decode 不再运行在同一个实例上,因此 Prefill 生成的 KV Cache 必须高效传递给 Decode。所以真正的 P/D 分离不仅是"把两个 Pod 分开",更关键的是解决 KV Cache 跨实例传输、路由配对和传输开销

Kthena 通过 ModelServing.roles 定义 prefilldecode 两类推理实例,再通过 ModelServer.pdGroup 告诉 Router 哪些 Prefill、Decode Pod 属于同一个 P/D 组。请求进入后,Router 负责完成 Prefill → Decode 的配对与转发,而 Prefill 生成的 KV Cache 则由 NIXL、LMCache、Mooncake 等 Connector 负责在两端之间传递。

实验环境与方案选择

本次实验环境只有一张 RTX 3060 12GiB,因此使用 Volcano vGPU 将其切分为两个逻辑 GPU 实例,分别运行 Prefill 和 Decode。由于 NIXL 更适合在独立 GPU 实例之间通过 UCX、RDMA 或高速网络完成低延迟 KV Cache 传输;本次实验使用 LMCache + Redis 方案验证 PD 分离效果:Prefill 将可复用的 KV Cache(KVCache Chunk Hash 索引)写入共享缓存后端,Decode 再从共享后端读取对应 KV,从而不再依赖 Prefill 与 Decode 之间建立 GPU P2P 传输。

1
2
3
请求链路:客户端 → Router → Prefill → LMCache/Redis → Decode → Router → 客户端
(ModelServing 创建 Prefill/Decode,ModelServer.pdGroup 组成 P/D 链路,
控制器不承载推理流量)

一份 ModelServing,创建 P/D 两个角色

Prefill 和 Decode 使用同一个 Qwen3-0.6B 模型,但 kv_role 不同。两边必须设置相同的 PYTHONHASHSEED,否则相同 Token 计算出的 Cache Key 可能不同;LMCACHE_REMOTE_URL 让两个实例连接同一个 Redis:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
apiVersion: workload.serving.volcano.sh/v1alpha1
kind: ModelServing
metadata:
name: qwen-pd-lmcache
namespace: kthena-vllm-demo
spec:
schedulerName: volcano
replicas: 1
template:
gangPolicy:
minRoleReplicas:
prefill: 1
decode: 1
roles:
- name: prefill
replicas: 1
entryTemplate:
spec:
containers:
- name: prefill
args:
- --kv-transfer-config
- '{"kv_connector":"LMCacheConnectorV1","kv_role":"kv_producer"}'
env:
- {name: PYTHONHASHSEED, value: "1047"}
- {name: LMCACHE_REMOTE_URL, value: "redis://redis-server.kthena-system.svc.cluster.local:6379"}
- {name: LMCACHE_LOCAL_CPU, value: "True"}
- {name: LMCACHE_MAX_LOCAL_CPU_SIZE, value: "1"}
resources:
limits:
volcano.sh/vgpu-number: "1"
volcano.sh/vgpu-memory: "5120"
volcano.sh/vgpu-cores: "50"
- name: decode
replicas: 1
entryTemplate:
spec:
containers:
- name: decode
args:
- --kv-transfer-config
- '{"kv_connector":"LMCacheConnectorV1","kv_role":"kv_consumer"}'
env:
- {name: PYTHONHASHSEED, value: "1047"}
- {name: LMCACHE_REMOTE_URL, value: "redis://redis-server.kthena-system.svc.cluster.local:6379"}
- {name: LMCACHE_LOCAL_CPU, value: "True"}
- {name: LMCACHE_MAX_LOCAL_CPU_SIZE, value: "1"}
resources:
limits:
volcano.sh/vgpu-number: "1"
volcano.sh/vgpu-memory: "5120"
volcano.sh/vgpu-cores: "50"

GangPolicy 要求 Prefill 和 Decode 同时获得资源——即使只缺少其中一个角色,Volcano 也不会让另一个 Pod 单独占住 GPU。

ModelServer 负责把两个角色组成一条请求链路

ModelServing 只负责创建工作负载。Router 如何识别两个角色、使用哪种 Connector,需要由 ModelServer 描述;ModelRoute 再根据请求中的模型名找到这个 ModelServer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: networking.serving.volcano.sh/v1alpha1
kind: ModelServer
metadata:
name: qwen-pd-lmcache
namespace: kthena-vllm-demo
spec:
workloadSelector:
matchLabels:
modelserving.volcano.sh/name: qwen-pd-lmcache
pdGroup:
groupKey: modelserving.volcano.sh/group-name
prefillLabels:
modelserving.volcano.sh/role: prefill
decodeLabels:
modelserving.volcano.sh/role: decode
workloadPort:
port: 8000
model: Qwen/Qwen3-0.6B
inferenceEngine: vLLM
kvConnector:
type: lmcache

测试 P/D 分离:构造可复用的长前缀

为验证 KV Cache 是否真的从 Prefill 传到了 Decode,实验构造了一组具有大量共享前缀的请求:固定内容重复 18 次,约 1199 个输入 Token(绝大部分来自三次请求完全相同的长 System Prompt)。这模拟的正是企业 Agent、智能客服和知识库问答中的典型模式:长且稳定的 System Prompt / Tool Definition / 企业上下文 + 少量变化的用户问题

证据链:KV Cache 确实从 Prefill 到了 Decode

第一次请求,Prefill 日志显示 1199 个输入 Token,本地无命中,1024 Token 的 KV Cache 写入共享后端:

1
2
3
Reqid: chatcmpl-lmcache-pd-1-875dfe93
Total tokens 1199, LMCache hit tokens: 0
Stored 1024 out of total 1024 tokens. size: 0.1094 GB, cost 6.9528 ms, throughput: 15.7311 GB/s

随后 Decode 没有重新计算整个 Prompt,而是从共享 Redis 命中并读取了 512 Token:

1
2
3
Reqid: chatcmpl-lmcache-pd-1-91de0ef4
Total tokens 1199, LMCache hit tokens: 512, need to load: 512
Retrieved 512 out of 512 required tokens. size: 0.0547 GB, cost 62.6841 ms, throughput: 0.8724 GB/s

Redis 中出现了实际的 KV 数据和 metadata:

1
2
3
$ kubectl exec deployment/redis-server -- redis-cli --scan | head
/models/Qwen3-0.6B@1@0@85d735ccda9ed2e@bfloat16kv_bytes
/models/Qwen3-0.6B@1@0@85d735ccda9ed2e@bfloat16metadata

三次请求全部 HTTP 200,端到端耗时从第一次的 0.515s 降到后续约 0.276s。综合成一条完整证据链:请求经过 Kthena Router → Prefill 完成 Prompt 计算 → LMCache 将 KV Cache 写入 Redis → Decode 从 Redis 非零读取 KV Cache → 请求正常完成

TTFT 和 ITL 的变化

部署方式 TTFT ITL 最大停顿 前台输出 Token/s
非 P/D:单实例 100% GPU 79.7 ms 10.5 ms 698.1 ms 170.55
非 P/D:双实例 50% / 50% 85.7 ms 20.9 ms 1398.4 ms 130.05
P/D:50P / 50D 2387.3 ms 11.9 ms 997.3 ms 135.18

先看两组资源相同的双 vGPU 拓扑。P/D 的 ITL 从 20.9ms 降到 11.9ms,多数 Token 的输出间隔更短;前台吞吐从 130.05 提高到 135.18 Token/s(约 +3.9%)。但 TTFT 变差了——长 Prompt 流量集中到一个 50% Prefill 实例、Prefill 与 Decode 争用同一张物理 GPU、请求增加了 KV 交接路径。

实验瓶颈:单张 RTX 3060 上的两个 vGPU 仍共享同一组 SM、显存带宽和 PCIe 路径。P/D 拆分了角色,却没有获得两组可并行执行的物理算力,还新增了 Router 转发和 Redis KV 搬运。这组实验证明的是 Kthena 能完成 P/D 角色编排和真实 KV Cache 传递,但单卡 vGPU 只适合验证链路,不适合证明 P/D 的性能收益。 真正的生产级 P/D 需要把 Prefill 和 Decode 部署在独立 GPU 池中,再通过 NIXL、Mooncake、RDMA 等高速机制传输 KV Cache。验收时不能只看总 Token/s,必须同时检查 TTFT P50/P95、ITL P50/P95、最大 Token 停顿和 SLO Goodput。


十、KV Cache-aware Routing:为什么 Round Robin 已经过时

企业知识库、智能客服和 Agent 场景通常存在大量重复的长前缀(系统 Prompt、工具定义、企业制度、知识库上下文、历史会话)。vLLM 完成 Prefill 后会将这些前缀对应的中间计算结果保存在 KV Cache 中。后续请求如果能继续落到已经缓存相同前缀的实例,就可以直接复用已有 KV Cache,跳过大量重复的 Prefill 计算,降低 TTFT、减少 GPU 计算开销、提升整体吞吐。

问题在于,传统 K8s Service 的负载均衡并不了解 KV Cache——无论 Round Robin 还是按连接数/请求数均衡,它们关注的都是"哪个 Pod 更空闲",而不是"哪个 Pod 已经拥有这个请求需要的 KV Cache"。KV Cache 感知路由解决的正是这个问题:Router 选择推理实例时,不只考虑 Pod 实时负载,还结合请求前缀与各实例 KV Cache 的分布,优先把请求发给已缓存相关前缀的实例。

Router 如何同时考虑 Cache 和实时负载

Router 的调度策略保存在 kthena-system 命名空间的 ConfigMap/kthena-router-config 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
apiVersion: v1
kind: ConfigMap
metadata:
name: kthena-router-config
namespace: kthena-system
data:
routerConfiguration: |-
scheduler:
pluginConfig:
- name: least-request
args:
maxWaitingRequests: 10
- name: kvcache-aware
args:
blockSizeToHash: 16
maxBlocksToMatch: 128
plugins:
Filter:
enabled:
- least-request
Score:
enabled:
- name: least-request
weight: 1
- name: kvcache-aware
weight: 20

这份配置中,Filter 阶段先由 least-request 剔除等待请求已超过 10 的拥堵实例;Score 阶段再对剩余 Pod 同时计算实时负载和连续前缀缓存命中。实验将 kvcache-aware 权重设为 20,least-request 权重设为 1:健康范围内优先回到已有缓存的 Pod,但候选 Pod 已经拥堵时仍会被 Filter 拦下。

实验结果

两个 vLLM 副本基于 Volcano vGPU 各使用 5120MiB 显存和 50% Core,每个前缀先预热一次,再改变末尾问题发起复用请求:

路由策略 成功 owner hit ratio TTFT P50 TTFT P95 平均 match ratio Redis lookup 平均耗时
least-request 40/40 57.5% 71.7ms 131.3ms - -
kvcache-aware 40/40 97.5% 24.6ms 95.2ms 25.2% 0.163ms

owner hit ratio 表示复用请求是否回到预热该前缀的 Pod;match ratio 表示请求的连续 Token Block 中,有多少已存在于最佳候选 Pod。本次平均可复用 Block 为 25.2%,却已足以在 40 次中有 39 次识别出原 Owner。

这组结果说明,在两个 vGPU 副本之间,KV Cache-aware 确实减少了重复 Prefill,TTFT P50 从 71.7ms 降到 24.6ms。它不能直接外推到更大模型和生产并发,但至少证明 Router、Runtime、Redis 和 vLLM KV Event 已经形成真实闭环。

Kthena 官方基准使用 DeepSeek-R1-Distill-Qwen-7B、3 副本、4,096 Token 长系统 Prompt、最大并发 300:KVCacheAware + Least Request 相比 Random,吞吐从 11.81 Req/s 提高到 32.22 Req/s(约 2.73 倍),TTFT 从 2.15 秒降到 0.57 秒(下降 73.5%)

注意:官方同一组测试中,256 Token 短前缀下 KVCacheAware 并没有明显优势,Tokenizer 和 Redis 查询还会带来额外成本。因此它适合长系统 Prompt、多轮对话、代码助手和共享 RAG 上下文,不是所有模型都应默认开启。


十一、LLM Autoscaling:从 GPU 利用率走向 Token 负载

GPU 利用率是 LLM 平台必须监控的硬件指标,但并不适合作为扩缩容的唯一依据。

例如,vLLM 持续处理大量短 Prompt 时,GPU 利用率可能已经很高,但请求几乎无需排队;而当大量长 Prompt 同时进入时,Waiting Requests 和 TTFT 可能已经明显上升,GPU 利用率却与前一种场景相差不大。只看 GPU 是 70% 还是 80%,平台很难判断当前是"资源利用充分"还是"服务已经开始拥堵"。

因此 LLM 扩缩容更适合观察与推理负载直接相关的指标。Kthena v1.0.0 将扩缩容目标、指标来源和副本边界统一定义在 AutoscalingPolicy 中。下面的实验策略使用 Prometheus 中的 Prompt Token/s + Generation Token/s 作为负载信号,在 1~2 个 ServingGroup 之间伸缩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: workload.serving.volcano.sh/v1alpha1
kind: AutoscalingPolicy
metadata:
name: qwen-token-load
namespace: kthena-vllm-demo
spec:
homogeneousTarget:
targetRef:
apiVersion: workload.serving.volcano.sh/v1alpha1
kind: ModelServing
name: qwen-autoscale
minReplicas: 1
maxReplicas: 2
metrics:
- name: token_rate
targetValue: "35"
metricSources:
token_rate:
prometheus:
serverURL: http://prometheus.kthena-vllm-demo:9090
query: |
sum(rate(vllm:prompt_tokens_total[1m]))
+ sum(rate(vllm:generation_tokens_total[1m]))

LLM Autoscaling 的重点不是简单判断"GPU 忙不忙",而是判断:当前推理容量是否还能在目标 SLO 下承接新的 Token 负载


总结

Kthena 的价值,并不是再提供一套启动 vLLM 的 YAML,而是把过去分散在调度器、Service、Router、监控系统和运维脚本中的 LLM Serving 能力,统一抽象成 Kubernetes 可以声明、调度和治理的资源。

当推理服务开始进入多卡大模型、长 Prompt、高并发、多轮 Agent、KV Cache 复用、P/D 资源池以及明确 TTFT / ITL SLO 的阶段,仅仅"把 vLLM 跑起来"已经不够。

这时 Kthena 的价值才真正体现出来:它治理的不是一个 vLLM Pod,而是一套面向大模型推理的资源、调度、路由和弹性体系。


参考