一句话结论:所谓「推理调度」,不是一个调度器,而是五层彼此看不见的决策。每一层能做什么,完全由它能看见什么信息决定——把决策放错层,不是效果差一点,而是根本做不成。

  1. K8s 调度器只看得见整数设备:它知道「还剩几张卡」,不知道每张卡的 KV Cache 水位、队列深度、模型是否加载完;
  2. Gang 层保证多卡模型「一起上车」,但它读不到 PodGroup 的调度结果;
  3. 拓扑层约束落点,而 GPU 互联的约束(TP 整除、MIG 不可跨实例通信)是硬约束,不是偏好;
  4. 路由器是唯一能看见「请求」的一层——公共前缀、活动请求数、队列深度只有它知道;
  5. 引擎内部再决定请求进哪个 batch,这一层 K8s 完全看不见。

这篇是一次「责任边界」的梳理。主线是一次推理请求穿过五层调度器时,每一层的可见性与决策权;对照组是 Kthena(Volcano 子项目,v1.0)的源码——它的每一层都做了明确取舍,恰好把边界照了出来。

同时,近期有一篇流传较广的公众号文章《从部署 vLLM 到 AI 基础设施:Kubernetes 推理平台生产级落地、容量设计与性能优化》,整体框架(容量、调度、弹性、可观测)与社区实践方向一致,但其中有六处细节在落地前必须修正——本文以 ⚠️ 更正一 ~ 六 标出并给出官方依据。另外,Kthena 的 README 与代码之间也有三处落差,标为 ⚠️ 边界,因为它同样会误导容量规划。

本文路线

段落 章节 回答什么
① 分层 一 五层各自负责什么、看得见什么
② 逐层 二 ~ 六 K8s 调度器 / Gang / 拓扑 / 路由器 / 引擎内部,逐层拆开
③ 闭环 七 ~ 八 弹性与可观测性分别落在哪一层
④ 收束 九 ~ 十一 建议落层对照表、踩坑清单、结语

站内前置阅读:推理调度器调研报告(推理调度与训练调度的差异、主流方案对比)、Kthena 实战解析(P/D 分离与 KV Cache 路由的实测)、Device Plugin 的十年(设备插件为什么只能表达整数)。


一、先把「调度」拆成五层

一次 LLM 请求从进入集群到吐出第一个 token,会穿过五层决策。它们之间几乎不共享信息:

层 决策粒度 它能看见 它看不见 失效模式
① K8s 调度器 Pod 整数设备、node label、资源请求 KV Cache、队列、模型加载状态、GPU 热度 把副本放到一张「马上要 OOM」的卡上
② Gang 一组 Pod 组成员数与最小资源 组内某个 Pod 之后挂了怎么办 部分调度导致整组僵死
③ 拓扑 一组 Pod 节点 label / 交换机域 / zone 真实互联带宽 TP 组跨了机架,AllReduce 拖死
④ 路由器 单个请求 请求内容、副本队列、KV Cache、前缀 集群还有没有空闲 GPU 把请求发给已过载副本
⑤ 引擎内部 单个 token 本实例内的序列与显存 集群里其他实例的存在 高并发下 KV Cache 抢占、P99 毛刺

核心论点:这五层的分工不是设计者的偏好,而是信息可得性的必然结果。K8s 调度器做不了缓存感知路由,不是因为它不够聪明,而是因为请求还没到,缓存状态也还没产生;反过来,路由器做不了资源放置,因为它不知道集群里还有没有卡。

把这一点记住,后面每一层「为什么不能越界」就都顺了。


二、第一层:K8s 调度器只看得见整数

2.1 设备插件的能力天花板

NVIDIA Device Plugin 把 GPU 作为扩展资源上报,这条链路是:

1
GPU → Device Plugin → kubelet → node allocatable → scheduler → Pod

调度器在这一层看到的是一个整数。所以 nvidia.com/gpu: 1 的准确含义是「占用一张设备」,不是「申请 20GB 显存或 30% 算力」。这一点在 Device Plugin 的十年 里展开过,此处只强调结论:

调度器看得见「还剩几张卡」,看不见「每张卡现在怎么样」。

KV Cache 水位、SM 繁忙度、模型是否已加载、PCIe/NVLink 拓扑——这些全部在调度器视野之外。所以任何形如「把请求路由到缓存命中的副本」的诉求,都不可能在这一层实现,必须往下走到第四层。

2.2 ⚠️ 更正一:DRA 的现状

那篇文章把 DRA(Dynamic Resource Allocation)列在「阶段 6:资源池化」里,当作未来方向。这个定位已经过时了:

Feature state: Stable since Kubernetes v1.35

This is a stable feature in Kubernetes, and has been since version v1.35. It was first available in the v1.30 release. You can no longer disable or opt out of this feature or behavior (it is locked).

即:v1.30 首次可用,v1.35 已 GA 且 feature gate 锁定,不是「将来会有的能力」。

但 DRA 对调度博客有一条更要紧的限制,官方文档写得很直白:

The Kubernetes scheduler doesn’t support preemption for DRA resources. This means that an existing Pod that’s running on a node and is using DRA resources can’t be preempted by a higher-priority Pod that also needs DRA resources.

DRA 资源不支持抢占。 对训练/批处理混部场景(依赖抢占做优先级)来说,这是个需要提前规划的硬约束。DRA 的拓扑感知细节见 DRA 拓扑感知调度。

2.3 这一层的边界该怎么用

正确的心态是:别指望 K8s 调度器理解模型。它负责的是「把 Pod 放到有卡的节点上」这件通用的事;模型相关的语义,要么往上包一层 CRD(Kthena 的做法),要么往下交给路由器。


三、第二层:Gang —— 多卡必须一起上车

3.1 为什么需要

一个 70B 模型用 TP=4 时,四个 rank 必须同时起来:任何一个 rank 缺席,NCCL 初始化就会挂住,另外三个 rank 白占着卡等。K8s 默认调度是「一个 Pod 一个 Pod 地放」,很容易放进去两个、卡住两个,形成死锁。

Gang 调度解决的就是这个:要么整组放下,要么一个都不放。

3.2 Kthena 怎么表达

Kthena 把 gang 语义放在 ServingGroup 上,gang 单元 = 一个 ServingGroup = 一个 PodGroup:

1
2
3
4
5
6
7
8
9
10
// pkg/apis/workload/v1alpha1/servinggroup_types.go
type GangPolicy struct {
// MinRoleReplicas defines the minimum number of replicas required for each role
// in gang scheduling, pods in each role are strictly gang required.
// For example if you specify a 2P(prefill) 4D(decode) serving group and set:
// minRoleReplicas: {prefill: 1, decode: 1}
// At least one prefill and one decode must be scheduled before any of the pods
// in the serving group can run. And pods within a role must be scheduled together.
MinRoleReplicas map[string]int32 `json:"minRoleReplicas,omitempty"`
}

关键在最后一句:「pods within a role must be scheduled together」。这比「整组一起」更细——它允许 P/D 之间分别满足最小副本数,而不是要求全部角色都齐。

PodGroup 的尺寸按角色聚合算:

1
2
minMember = Σ_roles (1 + workerReplicas) × minRoleReplicas(role)
minRoleMember[role] = 1 + workerReplicas // 作为 subGroup 的尺寸

每个角色再通过 Volcano 的 subGroupPolicy 单独约束:

1
2
3
4
5
6
7
8
9
10
subGroupPolicy = append(subGroupPolicy, schedulingv1beta1.SubGroupPolicySpec{
Name: role.Name,
LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{
workloadv1alpha1.ModelServingNameLabelKey: ms.Name,
workloadv1alpha1.RoleLabelKey: role.Name,
}},
MatchLabelKeys: []string{workloadv1alpha1.RoleIDKey},
SubGroupSize: &minSubgroupSize,
MinSubGroups: ptr.To(int32(minReplicas)),
})

3.3 一个诚实的边界:gang 是可选的

Kthena 的 gang 集成是特性探测 + 显式声明才启用的:

1
2
3
4
5
6
7
8
func (m *Manager) shouldCreatePodGroup(ms *workloadv1alpha1.ModelServing) bool {
// If PodGroup CRD is not present, gang scheduling is not supported.
if !m.hasPodGroupCRD.Load() {
return false
}
// Check if scheduler is volcano
return ms.Spec.SchedulerName == "volcano"
}

没有装 Volcano 的 PodGroup CRD,或者没写 schedulerName: volcano,就完全不启用 gang——退回普通 K8s 调度。这对单卡小模型是合理的(不需要 gang),但对 TP>1 的部署是个必须显式打开的开关,忘了写就是静默降级。

3.4 ⚠️ 边界一:gang 之后的「部分调度」没人管

这是我在读 Kthena 源码时发现的一处文档没写、但会影响线上行为的细节:

controller 从不读取 PodGroup 的 phase/unschedulable 状态。 它的流程是「先创建/更新 PodGroup,再创建 Pod」,然后由 Volcano 自己决定何时放行。代码里留着一句 TODO:

1
// TODO: add podGroup update after gang scheduler finished

后果是:当一组 Pod 因为资源不足而卡在 gang 阶段时,Kthena 侧没有专门的补偿逻辑——恢复依赖重建,并由 restartGracePeriodSeconds 做节流:

1
2
3
if ms.Spec.Template.RestartGracePeriodSeconds != nil && *ms.Spec.Template.RestartGracePeriodSeconds > 0 {
...
time.Sleep(time.Duration(*ms.Spec.Template.RestartGracePeriodSeconds) * time.Second)

所以**「gang 失败之后多久重试」这个参数要显式设**,默认 0 意味着不等待。这不是 bug,但它是那种「不读源码就不会知道」的行为。


四、第三层:拓扑 —— 三种约束,三种表达

拓扑这一层最容易讲糊,因为「拓扑」被用来指三件不同的事。分开看:

4.1 硬约束一:TP size 必须整除注意力头数

vLLM 的报错原文很直白:

1
Total number of attention heads (64) must be divisible by tensor parallel size (3)

TP 是按头切的,所以 64 头的模型用 TP=3 直接起不来。补一个实用结论:非 2 的幂卡数(3、5、6、7)应该改用 PP——PP 按层切,不受头数约束,且可以和 TP 组合(节点内 TP + 跨节点 PP)。

4.2 硬约束二:MIG 实例之间不能通信

那篇文章把「用 MIG 切片承载 70B」列为高危配置,这是对的。NVIDIA 官方 CUDA-X 文档给出了更根本的原因:

Due to the on-chip protection layer that isolates MIG instances, CUDA Inter-Process Communication (IPC) is not supported. This means NVLink and NVSwitch are completely unavailable when using MIG, forcing communication through much slower system (CPU) memory.

也就是说:

  • 70B 的 BF16 权重约 140GB(70 × 2 bytes),单张 80GB 装不下,MIG 2g.10gb 更不可能;
  • 就算用 4 个 MIG 切片凑出 40GB × 4,也做不了 TP=4——TP 需要高频通信,而 MIG 之间没有 P2P、没有 NVLink,只能走 CPU 内存。

MIG 的正确用途是中小模型的多租户隔离(同一张 H100 上跑多个 7B/13B),不是大模型的 TP 替代品。

4.3 Kthena 的两种表达方式

Kthena 自己不实现拓扑算法,而是透传给下游:

(a)Volcano 的 network topology,整组级写到 PodGroup 上:

1
2
3
4
5
6
7
func syncPodGroupNetworkTopology(ms *workloadv1alpha1.ModelServing, spec *schedulingv1beta1.PodGroupSpec) {
if ms.Spec.Template.NetworkTopology != nil && ms.Spec.Template.NetworkTopology.GroupPolicy != nil {
spec.NetworkTopology = ms.Spec.Template.NetworkTopology.GroupPolicy
return
}
spec.NetworkTopology = nil
}

角色级则复制到每一个 subGroupPolicy:

1
2
3
4
5
if ms.Spec.Template.NetworkTopology.RolePolicy != nil {
for i := range subGroupPolicy {
subGroupPolicy[i].NetworkTopology = ms.Spec.Template.NetworkTopology.RolePolicy
}
}

(b)普通 K8s affinity,靠 Kthena 写在每个 Pod 上的 label:

1
2
3
4
5
modelserving.volcano.sh/name
modelserving.volcano.sh/group-name
modelserving.volcano.sh/role
modelserving.volcano.sh/role-id
modelserving.volcano.sh/revision

用户拿这些 label 写 affinity term 即可,例如「同一角色实例落在同一节点」(topologyKey: kubernetes.io/hostname)或「整个 ServingGroup 落在同一 zone」(topologyKey: topology.kubernetes.io/zone)。这条路不需要 Volcano,default-scheduler 就能用。

这一条要说清楚,因为很容易被文档误导:

在 Kthena 的 Go 代码里,NVLink、RDMA、NUMA 三个词只出现在文档和注释中,没有任何落点逻辑。 它做的是「把约束透传给 Volcano / K8s」,而不是「自己计算拓扑」。

同样地:

  • 没有 hostname 反亲和(examples/ 和 docs/ 里 grep podAntiAffinity 无结果);
  • Role 上没有 per-role 的 networkTopology 字段——只有一份 2026-07-06 的提案文档 modelserving-role-scoped-network-topology.md,未实现;
  • 没有 TP 组感知的放置规则——TP 组的落点完全取决于用户声明的 affinity/topology。

这不是批评,而是边界:Kthena 把「模型语义」翻译成「K8s/Volcano 认识的约束」,拓扑计算交给专业调度器。理解这一点,才知道该在哪里写 affinity。

对照:训练侧的自研调度器(如 Sky-Scheduler)是自己做 RDMA zone 发现与 Zone 感知打分的——因为训练任务的生命周期长、通信占比高,值得自建;推理侧则更多复用现成约束。


五、第四层:路由器 —— 唯一能看见「请求」的一层

5.1 为什么 Round-Robin 必然失效

前四层都在决定「Pod 放在哪」,只有这一层决定「这个请求发给谁」。两者需要的信息完全不同:

  • 放置需要知道集群状态(哪里有空卡);
  • 路由需要知道请求内容(这个 prompt 的前缀在哪个副本的缓存里)。

Round-Robin 的假设是「所有后端等价」。在 LLM 推理里这个假设同时被三件事打破:

  1. 前缀缓存:同一个 system prompt / RAG 模板反复出现,命中缓存能省掉整个 Prefill;
  2. KV Cache 占用:副本之间显存水位差异巨大,等权轮询会把请求送给最忙的那个;
  3. P/D 分离:Prefill 和 Decode 的资源画像完全不同,不能当同类后端。

5.2 Kthena 的 router:不走 EPP,自己 scrape

一个容易误解的点:Kthena 的 router 不用 Gateway API Inference Extension 的 Endpoint Picker / ext-proc 协议。它依赖 gateway-api-inference-extension,但只用其中的 InferencePool 类型作为后端选择器来源,负载均衡完全自己实现——一个 gin 写的独立反向代理。

它的指标来源是直接轮询每个副本的 /metrics:

1
2
3
4
metricsName = []string{utils.KVCacheUsage, utils.RequestWaitingNum, utils.RequestRunningNum, utils.TPOT, utils.TTFT}
...
// defaultMetricsScrapeInterval is the default polling interval for pod metrics.
defaultMetricsScrapeInterval = 1 * time.Second

指标名映射(vLLM):

1
2
3
4
5
KVCacheUsage      = "vllm:kv_cache_usage_perc"
RequestWaitingNum = "vllm:num_requests_waiting"
RequestRunningNum = "vllm:num_requests_running"
ITL = "vllm:inter_token_latency_seconds"
TTFT = "vllm:time_to_first_token_seconds"

注意这里的指标名是 vllm: 冒号前缀——下一节会看到,很多文章把它写成下划线,PromQL 里直接查不到。

5.3 filter → score → topN

调度框架是经典三段:

1
2
3
4
5
6
7
8
9
10
type ScorePlugin interface {
Name() string
// Score is a method that is used to rank pods that have passed the filter plugins.
// Note each plugin should generate score for a pod within [0, 100]
Score(ctx *Context, pods []*datastore.PodInfo) map[*datastore.PodInfo]int
}
type FilterPlugin interface {
Name() string
Filter(ctx *Context, pods []*datastore.PodInfo) []*datastore.PodInfo
}

注册的插件:

类型 插件 默认权重
Score least-request、least-latency、prefix-cache 各 1
Score gpu-usage、kvcache-aware、random 未启用
Filter least-request 启用
Filter lora-affinity 未启用

打分是加权和,然后取 top-5(topN = 5),由后续逻辑做故障转移。

5.4 两个值得单独说的细节

(1)least-request 的分数公式解决了一个真实问题。 直接看代码:

1
2
3
4
5
6
7
// Score formula: base = onFlight + 100 * max(onFlight - running, 0)
// - onFlight: router-tracked in-flight count, updated with zero delay.
// ... avoids the ~1 s engine-metrics poll lag.
// - max(onFlight - running, 0): estimates the number of requests that the router has
// dispatched but the engine has not yet started executing (i.e. likely queued inside
// the engine). Weighted x100 ...
base := float64(info.GetOnFlightRequestNum()) + 100*math.Max(float64(info.GetOnFlightRequestNum())-float64(info.GetRequestRunningNum()), 0)

两件事同时解决:

  • 指标有 1 秒的轮询延迟,光看引擎上报的队列会滞后——所以用 router 自己维护的 onFlight(零延迟);
  • 引擎内部还有一层队列,路由器看不见——用 onFlight - running 估算「我发出去了但引擎还没开始跑」的数量,并加权 100 倍优先惩罚。

这是一个很务实的工程判断:能自己精确观测的量,就别依赖有延迟的远端指标。

(2)kvcache-aware 有一个防「僵尸缓存」的守卫。 分布式 KV 位置存在 Redis 里:

1
2
// Redis key format: "matrix:kv:block:{model}@{hash}"
kvCacheKeyPrefix = "matrix:kv:block:"

但副本重启后,它名下的 KV block 其实已经没了。所以打分时会丢弃写入时间早于该 Pod 容器启动时间的条目:

1
freshOwners(...)   // drops entries written before the pod's container start time

这是分布式缓存路由里最容易漏掉的一环:缓存的「归属」必须和进程的生命周期绑定,否则重启之后会持续把请求路由到一个空的缓存上。

5.5 P/D 分离:先选 Decode,再配同组的 Prefill

Kthena 的 P/D 配对不是靠角色名,而是靠 ModelServer 上声明的 label:

1
2
3
4
5
6
7
func getPdGroup(backend workload.ModelBackend) *networking.PDGroup {
return &networking.PDGroup{
GroupKey: workload.GroupNameLabelKey,
PrefillLabels: map[string]string{workload.RoleLabelKey: string(workload.ModelWorkerTypePrefill)},
DecodeLabels: map[string]string{workload.RoleLabelKey: string(workload.ModelWorkerTypeDecode)},
}
}

GroupKey 是 modelserving.volcano.sh/group-name——也就是说,只有同一个 ServingGroup 里的 P 和 D 才能配对。这保证了 KV 传输的两端在 gang 意义上属于同一组,不会跨组乱配。

调度顺序是 decode 优先:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if ctx.PDGroup != nil {
decodePods, err := s.store.GetDecodePods(ctx.ModelServerName)
decodePods, err = s.RunFilterPlugins(decodePods, ctx)
scores := s.RunScorePlugins(decodePods, ctx)
ctx.DecodePods = TopNPodInfos(scores, topN)
...
for i, decodePod := range ctx.DecodePods {
// Get prefill pods for the same PD group as the decode pod (O(1) lookup)
selectedPods, err := s.store.GetPrefillPodsForDecodeGroup(ctx.ModelServerName, decodePodName)
scores = s.RunScorePlugins(selectedPods, ctx)
bestPrefillPod := TopNPodInfos(scores, 1)
prefillPods[i] = bestPrefillPod[0]
validPairs++
}
if validPairs == 0 { return fmt.Errorf("no valid prefill-decode pod pairs found") }
}

先 decode 后 prefill 的理由:Decode 副本是长期驻留 KV Cache 的那一侧,它的健康度决定请求能不能稳定吐 token;Prefill 是一次性计算,可替换性更强。最后拿到最多 5 组候选对,执行时依次故障转移,全部失败才返回 500。

5.6 ⚠️ 边界三:README 说了、代码里没有的能力

Kthena 的 README 在特性列表里写了 「model load aware」 路由策略。代码里没有这个插件:

  • grep model-load-aware|ModelLoadAware 无结果;
  • 真正和「模型是否加载」相关的是两件事:store 每 1 秒轮询每个 Ready Pod 的 /v1/models 存下来,以及 lora-affinity filter 用 info.Contains(ctx.Model) 做判断——而 lora-affinity 默认不启用,源码里写着:
1
// TODO: enable lora affinity when models from metrics are available.
  • 真正兜底的是 Pod readiness:只有 Ready 的 Pod 才会进入 store。所以「权重还在加载的副本不会被路由到」这件事成立,但靠的是 K8s readiness,不是 router 的模型感知。

同样需要澄清的:Kthena 没有 SLO 对象、没有 error budget、没有基于 TTFT 阈值的准入或路由。TTFT/TPOT 只以两种形式参与:路由里的相对打分(per-candidate 归一化,不是阈值),和自动扩缩里的 p95 直方图指标。文档里出现的「SLO」是叙述性表述,不是 API。


六、第五层:引擎内部调度

请求到了副本之后,最后一层决策发生在 vLLM 内部。这一层 K8s 完全看不见,也不需要看见。

6.1 Continuous batching 与显存耦合

vLLM 把 Prefill 和 Decode 混在同一个 batch 里动态增删(continuous batching),于是并发数、序列长度、显存占用三者强耦合:

  • 并发越高 → KV Cache 占用越大;
  • 上下文越长 → 单请求占用越大;
  • 显存不够时 → 触发抢占。

这就是那篇文章说的「KV Cache 通常是并发、吞吐和 P99 的共同约束」。

6.2 ⚠️ 更正二:preemption 现在只剩 recompute 一条路

那篇文章(以及不少二手资料)说 vLLM 的抢占有 recompute 和 swap 两种模式,需要分别评估。这个说法对应的是 V0,而 V1 已经移除了 GPU↔CPU 的 KV Cache swapping:

  • V1 的特性对照表里,「GPU <> CPU KV Cache Swapping」标注为 Removed;
  • --swap-space 参数已被移除;
  • 遗留指标 vllm:num_requests_swapped、vllm:cpu_cache_usage_perc 被官方文档明确标注为「legacy … no longer relevant in v1」;
  • V0 本身已完全废弃。

所以现在讨论抢占,实际讨论的是 recompute:被抢占的请求丢掉 KV Cache、回到等待队列、之后重新 Prefill。这也解释了为什么抢占会直接推高 TTFT——它把已经算过的 Prefill 又算了一遍。


七、弹性:扩容为什么救不了分钟级尖峰

7.1 冷启动的时序账

那篇文章给了一张很准确的时序图,值得原样保留:

1
2
3
4
5
6
7
8
traffic        ____/^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\____
queue ________/^^^^^^^^^^\_____________________
KEDA poll |----|----|----|----|----|----|----|----|
scale action [ create new Pod ]
image pull [====]
model load [==========]
readiness [Ready]
new capacity |+++++++

从「检测到排队」到「新容量能接流量」,中间串了五段延迟:KEDA 轮询 → Pod 创建调度 → 镜像拉取 → 模型加载 → readiness 通过。后两段是分钟级的。

结论:弹性扩容无法应对分钟级突发。 能救当前的只有两件事——准入控制(快速失败 / 降级)和预留 warm capacity。

必须做的一件事是拆开测量:scheduled → image pulled → model loaded → readiness passed,否则你只知道「慢」,不知道慢在哪一段。

7.2 容量模型

若单副本在目标 SLO 下稳定吞吐为 R requests/s,峰值到达率 λ,安全系数 h:

1
replicas = ceil(λ × h / R)

这里 R 不是 GPU 100% 时的最大 QPS,而是「TTFT、TPOT 与错误率仍达标时」的吞吐。输入/输出 token 分布一变,R 必须重测——没有稳定的单副本容量曲线,任何扩容阈值都没有意义。

7.3 Kthena 的自动扩缩:三种目标模式

Kthena 把扩缩策略建模成 AutoscalingPolicy,三种互斥的目标模式:

1
2
3
4
5
6
7
8
9
10
type AutoscalingPolicySpec struct {
// Scaling operations are performed only when |current - target| >= current * TolerancePercent / 100.
// +kubebuilder:default=10
TolerancePercent int32
Metrics []AutoscalingPolicyMetric
Behavior AutoscalingPolicyBehavior
HomogeneousTarget *HomogeneousTarget // 普通指标扩缩
HeterogeneousTarget *HeterogeneousTarget // 成本/预算驱动
DisaggregatedTarget *DisaggregatedTarget // P/D 角色分别扩缩
}

几个设计上值得注意的点:

(1)指标可以来自 Pod 或 Prometheus。 Pod 源默认打 :8100(runtime sidecar),Prometheus 源支持自定义 query。直方图指标会被转成60 秒滑窗的 p95:

1
2
3
SloQuantileSlidingWindowSeconds = 60
SloQuantileDataKeepSeconds = 300
SloQuantilePercentile = 95

(2)算法是「比值 + 容差」,多指标取 max。 核心就几行:

1
2
3
4
5
6
func getDesiredInstancesForSingleExternalMetric(currentCount int32, tolerance, target, metric float64) int32 {
desired := metric / target
ratio := desired / float64(currentCount)
if math.Abs(ratio-1.0) <= tolerance { return currentCount }
return getCeilDesiredInstances(desired)
}

多指标时取各指标建议值的最大值,结果再夹到 [min, max]。tolerancePercent 默认 10%,作用是抑制抖动。

(3)P/D 分别扩缩,但有比例约束,且修复是「向上偏置」的。 这是我觉得最有意思的一处:

1
2
3
4
// EnforceRoleRatio projects replicas into the feasible region described by the
// role ratio constraint. It follows the scale-up-biased repair from the P/D
// disaggregated autoscaling proposal: raise the deficient role first, and only
// reduce the other side if raising would exceed that role's max bound.

先抬不足的那一侧,只有抬到上限才去压另一侧。 这个方向性很重要:P/D 分离下,任何一侧不足都会导致整条链路堵塞,所以「宁可多花一点,不要卡住」是更安全的默认。

还支持固定角色(min == max 时不参与扩缩),用于「只扩 D、P 保持不变」这类策略。

(4)成本优化走「最便宜优先」的阶梯。 HeterogeneousTarget 里 Cost 是相对因子(例如 H100=100、A100=60),CostExpansionRatePercent 默认 200——允许优化器最多花 2 倍最低成本去满足性能目标,超过就拒绝继续扩。

7.4 ⚠️ 更正五:KEDA 没有内置 vLLM scaler

那篇文章用 KEDA + Prometheus scaler 是对的,但要澄清一点:KEDA 并没有原生的 vLLM trigger。通行做法就是用 Prometheus scaler 打 vLLM 的指标。llm-d 那边是 Workload Variant Autoscaler 驱动 KEDA,且 Red Hat 标注为 Developer Preview,不是生产可用。

顺带一提:Kthena 走的是自研 autoscaler(默认 15 秒 reconcile),不依赖 KEDA。


八、可观测性:指标名、探针语义与告警

8.1 ⚠️ 更正三:那个 preemption 指标不存在

这是本次核对里最实际的一条。那篇文章给了一条告警:

1
2
- alert: LLMPreemptionStorm
expr: sum(rate(vllm_num_preemptions_total{model_name="chat-8b"}[5m])) > 0.1

这个指标不存在。 我把 vLLM 官方 metrics 文档的指标名列了一遍,没有任何 preemption 计数器——PREEMPTED 只是引擎内部事件,用于计算区间,不对外暴露计数。

同样需要注意的还有指标名前缀。vLLM 用的是冒号:

文章里写的 实际指标名
vllm_num_requests_waiting vllm:num_requests_waiting
vllm_num_preemptions_total 不存在
(TPOT) vllm:inter_token_latency_seconds 与 vllm:request_time_per_output_token_seconds

真正存在、且适合做告警的指标:

用途 指标
队列积压 vllm:num_requests_waiting
并发水位 vllm:num_requests_running
KV Cache 水位 vllm:kv_cache_usage_perc
前缀命中 vllm:prefix_cache_hits / vllm:prefix_cache_queries
TTFT vllm:time_to_first_token_seconds
ITL / TPOT vllm:inter_token_latency_seconds
拒绝 vllm:admission_rejections_total

想观测抢占,只能间接推:num_requests_waiting 尖峰 + kv_cache_usage_perc 打满 + TTFT 抬升,三者同时出现基本就是抢占在发生。或者从引擎日志里数。

一个通用教训:那篇文章自己在示例前写了「指标名需根据实际 exporter 验证」——这句话是对的,但示例里全用了错的名字。凡是照抄的 YAML,上线前一定 curl /metrics 核对一遍。

8.2 ⚠️ 更正四:/health 是进程存活语义,不是就绪语义

那篇文章把 /health 同时用作 startupProbe、readinessProbe 和 livenessProbe,并建议它「必须是常数时间且不依赖 GPU 队列的生命周期接口」。这个建议本身是对的,但由此推出的就绪判断不成立:

  • vLLM 官方文档明确:引擎在 asleep 状态下 /health 仍然返回 200——即它反映的是进程存活,不是「模型可服务」;
  • 上游 vLLM 没有 readiness 端点:官方 Online Serving 文档里只列了 /health、/metrics、/ping,不存在 /health/ready 或 /health/live;
  • vLLM 自己的 K8s 部署文档给 livenessProbe 和 readinessProbe 都指向 /health,并用 startupProbe(gRPC 例子里的 failureThreshold: 120,注释就写着 # Allow for model loading.)来覆盖加载窗口。

⚠️ 这里还要更正一个流传较广的二手说法:「vLLM v0.9+ 已引入 /health/ready」。/health/ready 那套是 NVIDIA Dynamo / NIM 和 llm-d(Model-Aware Readiness Probes)的,不是上游 vLLM 的。把它们的端点名写进 vLLM 的探针配置,容器会一直 readiness 失败。

实践结论:用 startupProbe 覆盖模型加载窗口(periodSeconds × failureThreshold 要大于最坏加载时间),readinessProbe 若要严格反映「模型可服务」,得自己加一层(查 /v1/models 或引入 llm-d 那类 model-aware 探针)。

8.3 ⚠️ 更正六:increase() 用在了 gauge 上

那篇文章的 XID 告警:

1
2
- alert: GPUXidError
expr: increase(DCGM_FI_DEV_XID_ERRORS[5m]) > 0

DCGM_FI_DEV_XID_ERRORS 是 dcgm-exporter 的默认 gauge,存放最近一次 XID 错误码——对 gauge 用 increase() 语义不对(它算的是时间序列的增量,而这个值只在「又发生了一次 XID」时才变化,且可能从 79 变成 0)。

正确写法有两种:

  • DCGM_FI_DEV_XID_ERRORS > 0(简单直接,但会持续告警);
  • 用 dcgm-exporter opt-in 的 counter DCGM_EXP_XID_ERRORS_TOTAL(按 xid label 分组,正是为计数设计的)。

九、把建议落回对应的层

那篇文章给了一套很完整的工程建议。它们的价值取决于落在哪一层——放错层就是空转。逐条对照:

建议 应落在哪一层 为什么不能放在别处
按隔离需求划分 GPU 池(taint / label / PriorityClass) ① K8s 调度器 这一层才有节点与优先级的概念
多卡模型 gang 调度 ② Gang 需要「整组原子性」,单 Pod 调度表达不了
TP 组同节点 / 同交换机域 ③ 拓扑 需要拓扑数据,前两层没有
前缀缓存 / KV Cache 感知路由 ④ 路由器 只有这一层看得见请求内容
最少队列 / 最短延迟路由 ④ 路由器 需要实时副本状态
P/D 分离与配对 ④ 路由器 + ② Gang 配对是请求级,成组是放置级,跨两层
max-num-seqs / max-num-batched-tokens 调参 ⑤ 引擎内部 这两个参数只影响引擎内批处理
队列深度驱动扩容 ③ 之上的控制面 需要「副本数」这个自由度,前四层都没有
模型缓存 / NVMe 预热 数据面(调度之外) 它影响的是冷启动时长,不是放置决策

这张表的用法:当你发现某个优化「做了但没效果」,先确认它是不是被放到了错误的层。最常见的错位有两种:

  1. 想在 K8s 调度器里做缓存感知——信息根本不在这一层,只能靠自定义调度器或 CRD 扩展(这也是 Kthena 存在的理由);
  2. 想在路由器里做资源放置——路由器不知道集群还有没有卡,它只能在已就绪的副本里挑。

十、踩坑清单

  • 「nvidia.com/gpu: 1 能不能只给一半显存?」 不能。扩展资源是整数设备声明。要切分得走 MIG / 时间片 / MPS,或 HAMi 这类方案(见 K8s GPU 复用与 HAMi)。
  • 「TP=3 起不来」:num_attention_heads 必须被 TP size 整除。非 2 的幂卡数改用 PP。
  • 「用 MIG 跑 70B」:装不下(BF16 约 140GB),且 MIG 之间不能 P2P / 不能 NVLink / 不能 CUDA IPC,做不了 TP。MIG 留给中小模型多租户。
  • 「照抄的告警从来不触发」:先 curl /metrics 核对指标名——vLLM 用 vllm: 冒号前缀,且没有 preemption 指标。
  • 「readiness 指向 /health 却把流量导到了未就绪副本」:vLLM 的 /health 是进程存活语义(引擎 asleep 时仍返回 200),上游 vLLM 没有 readiness 端点。需要模型级就绪判断时,用 startupProbe 覆盖加载窗口,或引入 llm-d 那类 model-aware readiness。
  • 「gang 打开了但没生效」:确认两件事——Volcano 的 PodGroup CRD 存在,且 ModelServing.spec.schedulerName == "volcano"。少一个就静默退回普通调度。
  • 「gang 卡住之后一直不重试」:restartGracePeriodSeconds 默认 0,需要显式设置。
  • 「重启后缓存路由一直打到空缓存」:检查缓存归属有没有和进程启动时间绑定(Kthena 的 freshOwners 就是干这个的)。
  • 「扩容了但还是超时」:把冷启动拆成 scheduled → image pulled → model loaded → readiness 四段分别测。分钟级突发靠扩容救不了,靠准入控制和 warm capacity。

十一、结语

把「推理调度」当一个整体谈,一定会谈糊;拆成五层之后,每层的能与不能都变得很清楚,而且判断标准只有一条:这一层看得见所需的信息吗?

  • K8s 调度器看不见缓存 → 所以缓存感知路由必须在路由器;
  • 路由器看不见空闲 GPU → 所以扩容和放置必须在控制面;
  • 引擎看不见集群 → 所以批处理参数只能在引擎里调;
  • Gang 看不见请求 → 所以它只负责原子性,不负责配对。

Kthena 的取舍也正好落在这条线上:它不自己算拓扑(透传给 Volcano)、不实现 EPP(自己 scrape /metrics 做负载均衡)、不承诺 SLO API(只把 TTFT/TPOT 作为打分与扩缩的输入)。这些「不做」和它「做」的部分一样重要——它们划出了这一层的边界。

至于那篇文章,整体框架是对的,六处更正都是细节层面的:DRA 的成熟度(更正一)、preemption 只剩 recompute(更正二)、preemption 指标根本不存在(更正三)、/health 的语义(更正四)、KEDA 没有内置 vLLM scaler(更正五)、increase() 用在了 gauge 上(更正六)。另外它的 YAML 示例里 preStop 与 emptyDir 两段有复制粘贴损坏(preStransform: translateY(、em)ptyDir),照抄前需自行修正。

这些恰恰是最容易照抄出错的地方——方向可以信,YAML 必须核。


参考来源

Kthena 源码(github.com/volcano-sh/kthena)

  • pkg/apis/workload/v1alpha1/{model_serving_types.go, servinggroup_types.go, autoscalingpolicy_types.go} — ServingGroup / GangPolicy / AutoscalingPolicy
  • pkg/apis/networking/v1alpha1/modelserver_types.go — PDGroup / KVConnector
  • pkg/model-serving-controller/podgroupmanager/manager.go — gang 单元与 subGroupPolicy
  • pkg/kthena-router/scheduler/{scheduler_impl.go, framework/interface.go, factory.go} — filter/score/topN
  • pkg/kthena-router/scheduler/plugins/{least_request.go, least_latency.go, prefix_cache.go, kvcache_aware.go}
  • pkg/kthena-router/backend/vllm/metrics.go — 指标名映射
  • pkg/kthena-router/datastore/store.go — 1s 轮询与 Redis on-flight 同步
  • pkg/autoscaler/algorithm/{recommendation.go, disaggregated_ratio.go} — 扩缩算法与比例修复
  • README.md、docs/proposal/*(含未实现的 role-scoped network topology 提案)

官方文档

站内相关