KServe 源码拆解系列 · 第 9 篇
前 8 篇你一直在看"一个服务":一个 InferenceService 怎么声明、调和、扩缩容,第 8 篇还讲了单服务内多模型。但真实推理往往是多个服务接力:预处理模型 → 主模型 → 后处理模型。谁决定请求在服务之间怎么流转?KServe 的答案是 InferenceGraph------把编排本身声明成一个 CRD。本篇拆它的 API、调和和两套实现,下一篇看面向 LLM 的 LLMService。
一次推理 = 多个模型接力
以图像分类为例,三个服务排队:
css
请求(图片) ──▶ [预处理模型] ──▶ [主分类模型] ──▶ [后处理模型] ──▶ 响应
isvc-pre isvc-main isvc-post
没有 InferenceGraph 时,这跟"接力棒"得你自己写:要么硬编码进业务代码,要么在网关层手写路由------服务都是 KServe 管着的,编排却要跑到外面解决。InferenceGraph 的思路是编排也声明成 CRD:
yaml
apiVersion: serving.kserve.io/v1alpha1
kind: InferenceGraph
metadata:
name: model-chainer
spec:
nodes:
root:
routerType: Sequence
steps:
- serviceName: isvc-pre
- serviceName: isvc-main
data: $response # 上一步响应作为本步请求
- serviceName: isvc-post
data: $response
三个 InferenceService 一行不改。它也不只是链------节点可以分叉、按条件选路,声明的是一个有向无环图(DAG)。
InferenceGraph API:nodes 里住着四种路由器
API 定义在 pkg/apis/serving/v1alpha1/inference_graph.go。核心结构:spec.nodes 是一个 map[string]InferenceRouter,每个节点是一个路由器,决定请求怎么分发。路由器只有四种类型(InferenceRouterType 枚举):Sequence 串行接力、Splitter 按权重随机分流(金丝雀、A/B 测试)、Ensemble 并行发多个模型再合并响应(多模型投票)、Switch 按请求内容选一条路(不同用户走不同模型)。
节点里每一步是 InferenceStep,关键字段:
name:节点内唯一的 step 名,webhook 查重nodeName/serviceName/serviceUrl:内联的InferenceTarget,三选一 ,多填或不填被 ValidatingWebhook 拦下。nodeName指向图里另一节点------节点能引用节点,图才能分叉、汇聚data:接力棒。$response传上一步响应,缺省传原始请求;mapPredictionsToInstances: true把上一步响应的predictions搬进本步的instancesweight:Splitter 专用,合计必须 = 100condition:Switch/Sequence 的条件,gjson 查询表达式dependency:Soft / Hard。Hard 失败时 Sequence 立即停、Ensemble 返回失败步响应;Soft 失败继续走
节点必须有个叫 root 的入口(GraphRootNodeName = "root",否则报 RootNodeNotFoundError)。图级还能配 router 容器自己的资源、timeout、minReplicas/maxReplicas/scaleTarget/scaleMetric(router 自身扩缩容,语义同第 7 篇 KPA)。CRD 短名 ig。Switch 的 condition 是 gjson 查询而非编程语言,e2e 里的真实写法是 "[@this].#(decision_picker==ERROR)"------一条都不命中返回 404。
Controller 如何把 Graph 变成路由
调和入口 pkg/controller/v1alpha1/inferencegraph/controller.go,Reconcile 主干四步:
markdown
1. 读 ConfigMap → RouterConfig(镜像、资源、透传 header 名单)
2. serviceName → 查 InferenceService → URL 写回 spec;没就绪 → Requeue
3. deploymentMode → raw_ig.go(Standard)/ knative_reconciler.go(Knative)
4. 状态传播:URL + Ready 条件
第 2 步是理解的关键:你写 serviceName,发 HTTP 的 router 容器却不认识 K8s API,所以 controller 把名字翻译成 URL(真实代码):
go
for node, router := range graph.Spec.Nodes {
for i, route := range router.Steps {
if route.ServiceName == "" {
continue
}
err := r.Get(ctx, types.NamespacedName{Namespace: graph.Namespace, Name: route.ServiceName}, &isvc)
if err != nil {
// 服务不存在 → Requeue
} else if graph.Spec.Nodes[node].Steps[i].ServiceURL == "" {
serviceUrl, err := isvcutils.GetPredictorEndpoint(ctx, r.Client, &isvc)
if err == nil {
graph.Spec.Nodes[node].Steps[i].ServiceURL = serviceUrl // ← URL 写回 spec
} else {
// 服务没就绪 → Requeue // ← 依赖就绪才继续
}
}
}
}
GetPredictorEndpoint(pkg/controller/v1beta1/inferenceservice/utils/utils.go)取 isvc.Status.Address.URL 按协议拼 predict 路径;有 transformer 时指向 transformer 端点,和第 6 篇的入口规则一致。
整个 spec 变成容器启动参数。 两种模式最终生成同一个容器,args 就一行:json.Marshal(graph.Spec) 后塞进 "--graph-json"(两个 reconciler 文件重复出现)。序列化的是解析完 URL 之后 的 spec------每个 step 都带着现成的 serviceUrl,router 只管转发,不需要 K8s API,所以容器关了服务账号。
数据面:纯递归的 HTTP 服务。 容器跑 cmd/router(镜像 kserve/router:latest),监听 8080,/readyz 就绪探针。请求进来从 root 递归走图(cmd/router/main.go):
go
func executeStep(step *v1alpha1.InferenceStep, graph v1alpha1.InferenceGraphSpec, input []byte, headers http.Header) ([]byte, int, error) {
if step.NodeName != "" {
return routeStep(step.NodeName, graph, input, headers) // ← 节点间跳转:进程内递归,不出容器
}
return callService(step.ServiceURL, input, headers) // ← 调真正的服务才发 HTTP
}
四种路由器全在 routeStep 的一个 switch 里:Sequence 循环每步,$response 时把上步响应当请求,condition 不命中提前返回,Hard 失败即停;Splitter 生成 [0,100) 随机数按 weight 累加挑路;Switch 用 gjson.GetBytes(input, condition).Exists() 找第一条命中;Ensemble 每步一个 goroutine 并行调,结果按 step 名合并成一个 JSON map。callService 按 PROPAGATE_HEADERS 正则透传请求头;Istio mesh 里 HTTPS 降级明文交给 sidecar。
双模式实现:knative_reconciler.go vs raw_ig.go
有意思的是:同一个 router 容器,坐两种不同的"车" 。选哪辆由 deploymentMode 决定(status → 注解 → ConfigMap 默认,第 3 篇的同一套逻辑)。
| Knative(Serverless)模式 | Raw(Standard)模式 | |
|---|---|---|
| 载体 | knservingv1.Service |
Deployment + Service + HPA |
| 扩缩容 | KPA:scaleTarget/scaleMetric/minReplicas/maxReplicas 写成注解 | HPA:规格塞进 ComponentExtensionSpec |
| 缩到零 | 支持(minReplicas=0) | 不支持 |
Knative 模式 (knative_reconciler.go):createKnativeService 构造 Ksvc,Pod 模板就是那个 router 容器。两点:router 无状态、CPU 轻、并发敏感,扩缩容配 concurrency metric 比 CPU 合理;reconcileKsvc 更新前先 dry-run update ,让 Knative defaulter 把默认值填进本地对象再 diff,避免"没变化却永远有 diff"的抖动。Knative 没装却解析成 Knative 时直接 TerminalError 终止,报 ServerlessModeRejected 事件------和第 1 篇的可插拔一脉相承。
Raw 模式 (raw_ig.go)精简得多:createInferenceGraphPodSpec 构造同一个 PodSpec,丢给 raw.NewRawKubeReconciler------直接复用 InferenceService Standard 模式那套 raw reconciler (pkg/controller/v1beta1/inferenceservice/reconcilers/raw),Deployment、Service、HPA 一次调和完;Deployment 出现 DeploymentAvailable 即 Ready。
所以双模式差异很小:数据面(router 镜像 + graph JSON + 递归路由)完全一致,差异只在"车"------Ksvc 缩到零、按并发扩;Deployment+HPA 简单可控。
适用场景与限制
| 场景 | 路由器 | 例子 |
|---|---|---|
| 预处理 → 主模型 → 后处理 | Sequence | 图像编码 → 分类 → 细分品种(官方 dog-breed 示例) |
| 金丝雀 / A/B 分流 | Splitter | weight 20/80 分流两个版本 |
| 多模型投票 / 融合 | Ensemble | sklearn + xgboost 各打一次分 |
| 按内容条件路由 | Switch | 按 userId 或请求体字段选模型 |
限制也说透,大多能从源码找到依据:
- HTTP 层编排,每跳一次网络往返。 Sequence 三步就是三次串行调用。服务级编排没问题;算子级流水线是 Triton ensemble 的地盘。
- graph JSON 烘焙进容器启动参数。 改一次 spec = 新 Revision(Knative)或滚动重启(Raw),router 进程不感知图变化。
- 校验器不查环。 webhook 只查:名字格式、root 存在、step 名唯一、目标三选一、Splitter 权重和 100------没有环检测。数据面是纯递归,nodeName 指回祖先节点 apply 能通过,环在运行时才爆栈。
- URL 解析是一次性的。 controller 只在调和时解析 serviceUrl,且只 watch 自己的 Deployment/Ksvc------目标 InferenceService 地址变了不会触发重调。
- 无状态。 适合"一次请求走完一张图";需要跨请求记忆的 agent 式长流程不适合。
一句话总结
InferenceGraph 把多服务接力声明成一个 CRD:spec.nodes 里用 Sequence/Splitter/Ensemble/Switch 四种路由器拼出任意 DAG,controller 把每个 serviceName 解析成真实 URL,再把整个 spec JSON 塞进 router 容器的 --graph-json 参数------数据面就是一个进程内递归走图、跨进程 HTTP 调服务的转发程序,Knative 模式下坐 Ksvc 缩到零,Raw 模式下坐 Deployment+HPA。
下一篇预告:第 10 篇 LLMService------KServe 面向 LLM 推理的全新 API。InferenceGraph 还在编排"已有的服务",LLMService 把多实例 GPU、LoRA adapter、prompt cache 这些 LLM 原生概念直接装进 CRD,它和 InferenceService 是什么关系?