InferenceGraph:把多个推理服务编排成 DAG

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 搬进本步的 instances
  • weight:Splitter 专用,合计必须 = 100
  • condition:Switch/Sequence 的条件,gjson 查询表达式
  • dependency:Soft / Hard。Hard 失败时 Sequence 立即停、Ensemble 返回失败步响应;Soft 失败继续走

节点必须有个叫 root 的入口(GraphRootNodeName = "root",否则报 RootNodeNotFoundError)。图级还能配 router 容器自己的资源、timeoutminReplicas/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                 // ← 依赖就绪才继续
            }
        }
    }
}

GetPredictorEndpointpkg/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。callServicePROPAGATE_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 reconcilerpkg/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 或请求体字段选模型

限制也说透,大多能从源码找到依据:

  1. HTTP 层编排,每跳一次网络往返。 Sequence 三步就是三次串行调用。服务级编排没问题;算子级流水线是 Triton ensemble 的地盘。
  2. graph JSON 烘焙进容器启动参数。 改一次 spec = 新 Revision(Knative)或滚动重启(Raw),router 进程不感知图变化。
  3. 校验器不查环。 webhook 只查:名字格式、root 存在、step 名唯一、目标三选一、Splitter 权重和 100------没有环检测。数据面是纯递归,nodeName 指回祖先节点 apply 能通过,环在运行时才爆栈。
  4. URL 解析是一次性的。 controller 只在调和时解析 serviceUrl,且只 watch 自己的 Deployment/Ksvc------目标 InferenceService 地址变了不会触发重调。
  5. 无状态。 适合"一次请求走完一张图";需要跨请求记忆的 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 是什么关系?

相关推荐
众人皆醒我独醉2 小时前
LLMService:KServe 面向 LLM 的下一步
面试·kubernetes·gpu
学习星球2 小时前
DSA 面试精讲 · Trie 前缀树:一文掌握字符串前缀匹配
面试·职场和发展
笨笨饿5 小时前
#111_关于FreeRTOS面试的一些题目
linux·ubuntu·面试·职场和发展·centos·rtos
蒸蒸yyyyzwd7 小时前
AI软件开发面试gpt模拟学习笔记
人工智能·gpt·面试
niyesd7 小时前
Kubernetes Pod 管理
云原生·容器·kubernetes
高卧怡怡8 小时前
Kubernetes的控制器实验
云原生·容器·kubernetes
腾讯数据架构师9 小时前
摩尔线程 GPU 怎么接入 Kubernetes 跑 DeepSeek?CubeStudio 摩尔线程(MUSA)适配实操
人工智能·云原生·容器·kubernetes·开源·mlops·maas
码匠许师傅9 小时前
【C++ 面试真题】35. 聊聊 C++ 的万能引用(T&&)和完美转发(std::forward)
java·c++·面试
明王明王9 小时前
从零搭建一个单节点 K8S 可观测实验室(十三):总结——这个实验室如何服务工作、学习和开源项目
学习·kubernetes·开源