中文资讯 掘金 AI Thu, 27 Aug 2026 正文 ok 651 words

InferenceGraph:将多个推理服务编排为 DAG

英文标题:InferenceGraph:把多个推理服务编排成 DAG

KServe 的 InferenceGraph 将多服务推理流程声明为 CRD,可通过 Sequence、Splitter、Ensemble 和 Switch 四类路由器构建有向无环图(DAG)。Controller 会将 InferenceService 的 serviceName 解析为实际服务 URL,并把完整图配置以 `--graph-json` 参数注入 router 容器。该能力同时支持 Knative Serverless 与 Raw Standard 两种部署模式,数据面路由逻辑相同,主要区别在于底层扩缩容载体。对于开发者而言,InferenceGraph 适合预处理、模型推理、后处理、A/B 测试和多模型融合,但需注意其网络跳数、静态 URL 解析、无环校验缺失及无状态等限制。
打开原文相关 2 · 机会 0
media3.2 KB media589 B media11.4 KB

中文

KServe 源码拆解系列 · 第 9 篇

前 8 篇一直在讨论“一个服务”:一个 InferenceService 如何声明、调和与扩缩容,第 8 篇还介绍了单服务内的多模型。但真实推理通常是多个服务接力:预处理模型 → 主模型 → 后处理模型。谁来决定请求如何在服务之间流转?KServe 的答案是 InferenceGraph——将编排本身声明为一个 CRD。本篇拆解其 API、调和机制与两套实现;下一篇将介绍面向 LLM 的 LLMService。


一次推理 = 多个模型接力

以图像分类为例,三个服务依次处理请求:

请求(图片) ──▶ [预处理模型] ──▶ [主分类模型] ──▶ [后处理模型] ──▶ 响应
               isvc-pre        isvc-main        isvc-post

没有 InferenceGraph 时,这根“接力棒”需要自行编写:要么硬编码到业务代码中,要么在网关层手写路由。服务由 KServe 管理,编排却不得不在外部完成。InferenceGraph 的思路是:编排也声明为 CRD。

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 容器自身的资源、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 主干可分为四步:

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 容器并不认识 Kubernetes API。因此,controller 会将名称翻译为 URL,真实代码如下:

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 {
            
        } 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   
            } else {
                
            }
        }
    }
}

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 只负责转发而无需访问 Kubernetes API,因此容器关闭了服务账号。

数据面是一个纯递归的 HTTP 服务。 容器运行 cmd/router,镜像为 kserve/router:latest,监听 8080 端口,并使用 /readyz 作为就绪探针。请求进入后,会从 root 节点开始递归执行图中的路由(cmd/router/main.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)  
}

四种路由器全部实现在 routeStep 的一个 switch 中:

  • Sequence 会循环执行各步骤;当指定 $response 时,将上一步响应作为下一步请求。条件未命中会提前返回,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 与 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 开销较轻且对并发较敏感的组件,因此相比 CPU 指标,使用 concurrency metric 进行扩缩容更合理;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 时,InferenceGraph 即被标记为 Ready。

因此,两种模式的差异实际上很小:数据面完全一致,即 router 镜像 + graph JSON + 递归路由;差异只在底层“载体”。Ksvc 可缩容到零,并按并发扩缩;Deployment + HPA 则更简单、更易于控制。


适用场景与限制

场景 路由器 示例
预处理 → 主模型 → 后处理 Sequence 图像编码 → 分类 → 细分品种(官方 dog-breed 示例)
金丝雀 / A/B 分流 Splitter 使用 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 是什么关系?

English / 原文

KServe 源码拆解系列 · 第 9 篇

前 8 篇你一直在看"一个服务":一个 InferenceService 怎么声明、调和、扩缩容,第 8 篇还讲了单服务内多模型。但真实推理往往是多个服务接力 :预处理模型 → 主模型 → 后处理模型。谁决定请求在服务之间怎么流转?KServe 的答案是 InferenceGraph——把编排本身声明成一个 CRD。本篇拆它的 API、调和和两套实现,下一篇看面向 LLM 的 LLMService。


一次推理 = 多个模型接力

以图像分类为例,三个服务排队:

请求(图片) ──▶ [预处理模型] ──▶ [主分类模型] ──▶ [后处理模型] ──▶ 响应
               isvc-pre        isvc-main        isvc-post

没有 InferenceGraph 时,这跟"接力棒"得你自己写:要么硬编码进业务代码,要么在网关层手写路由——服务都是 KServe 管着的,编排却要跑到外面解决。InferenceGraph 的思路是编排也声明成 CRD :

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 容器自己的资源、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 主干四步:

  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 (真实代码):

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 {
            
        } 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   
            } else {
                
            }
        }
    }
}

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):

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)  
}

四种路由器全在 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 或请求体字段选模型

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

  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 是什么关系?