KServe 源码拆解系列 · 第 10 篇
第 9 篇拆了 InferenceGraph------把多个推理服务编排成 DAG。可那套世界观是 2019 年定下的:三组件、Istio 分流、按请求数扩缩容。当你部署的不是 sklearn 模型,而是一个 70B 的 vLLM 时,这套抽象哪里漏水?社区的答案是新 CRD LLMInferenceService(口语叫 LLMService)。收官篇拆它,也沉淀这 10 篇的读法。
为什么 InferenceService 不够用了
不是实现差,是抽象层和 LLM 的需求对不上。沿着前 9 篇的机制走一遍,会撞上这些墙:
- 模型下载 :第 5 篇的 storage-initializer 对几十 GB 权重没有感知,每个 Pod 冷启动重拉一遍;
hf://不是一等公民。 - vLLM 参数透传 :想开张量并行?spec 里没这字段,只能按第 4 篇的路子把
--tensor-parallel-size塞进 args/env------全靠约定,LoRA 同样缺席。 - 流量与扩缩容:第 6 篇的 Istio/Knative 做不了"按 model 名路由、挑 KV cache 最空的 Pod";第 7 篇的 KPA/HPA 看请求数,而 LLM 的瓶颈是 KV cache 利用率。
- 多模型与编排:第 8 篇的 ModelMesh 跟"一个基座 + 多个 LoRA"不是一回事;prefill/decode 分离在 2019 年的 API 里根本没这词。
这些能力都能"绕"出来,但每绕一次就是一堆约定俗成。当"绕"成了常态,就该升抽象了。
LLMInferenceService API:先正名,再看字段
正名:源码 kind 是 LLMInferenceService (serving.kserve.io/v1alpha2,shortName llmisvc),类型定义在 pkg/apis/serving/v1alpha2/llm_inference_service_types.go。最小 YAML(出自 docs/samples/llmisvc/single-node-gpu/):
yaml
apiVersion: serving.kserve.io/v1alpha2
kind: LLMInferenceService
metadata: { name: qwen2-7b-instruct-single-no-scheduler }
spec:
model: { uri: hf://Qwen/Qwen2.5-7B-Instruct, name: Qwen/Qwen2.5-7B-Instruct }
replicas: 3
router: { route: { } }
template:
containers: [{ name: main, resources: { limits: { nvidia.com/gpu: "1" } } }]
Spec 全貌(结构体 LLMInferenceServiceSpec):
arduino
LLMInferenceServiceSpec
├─ model uri / name / lora / confidential
├─ storageInitializer enabled 开关(要不要 initContainer 下载)
├─ WorkloadSpec(内联) replicas / scaling / parallelism / template /
│ worker / kvCacheOffloading / rolloutStrategy
├─ prefill P/D 分离的 prefill workload
├─ router route / gateway / ingress / scheduler
├─ tracing OTLP
└─ baseRefs 继承的 LLMInferenceServiceConfig 列表
对照 InferenceService(v1beta1,pkg/apis/serving/v1beta1/inference_service.go):
| 关注点 | InferenceService | LLMInferenceService |
|---|---|---|
| 模型来源 | predictor.model.storageUri |
model.uri(hf:// 一等公民) |
| 组件模型 | 三组件 predictor/transformer/explainer | 单一 workload + 可选 prefill |
| 引擎参数 | 塞 runtime args/env | parallelism(tensor/pipeline/data/expert) |
| LoRA | 无 | model.lora:adapters + maxRank/maxAdapters/maxCpuAdapters |
| 流量/扩缩容 | Istio/Knative + KPA/HPA(第 6、7 篇) | Gateway API + EPP;scaling.wva/keda(推理指标) |
| 多模型/灰度 | ModelMesh + canary(第 8 篇) | 模型级路由 + router.route.group + weight |
model.lora 直译 vLLM 参数:maxRank → --max-lora-rank、maxAdapters → --max-loras。第 2 篇拆过的"参数透传全靠约定",在这里被提升成了 schema。
同文件还有第二个 CRD:LLMInferenceServiceConfig------与 Service 共用同一个 Spec,充当"配置模板",是下一节合并引擎的原料。
合并引擎:config_merge.go 的流水线
你写 30 行 YAML,控制器实际调和的是几百行的完整 spec------差额由 pkg/controller/v1alpha2/llmisvc/config_merge.go 的 combineBaseRefsConfig 补齐,六步:
- 先合并显式 baseRefs,搞清楚启用了什么。 先合并
spec.baseRefs得到resolvedSpec------知道"启用了 scheduler 吗、开了 P/D 吗",才能决定注入哪些默认值。 - 按部署形态挑 preset。 源码是一串 switch:P/D 分离时 prefill/decode 各选一种,非 P/D 按单节点/数据并行/流水线并行选,对应
config-llm-template等 preset 名,前缀默认kserve-。 - 按优先级 strategic merge。 preset 最低、显式 baseRefs 中间、你的 spec 最高:
MergeSpecs(ctx, append(specs, llmSvc.Spec)...)。mergeSpecs的精妙处:先对 override 调SetDefaults(ctx),再生成 zero 与 override 的 two-way merge patch 打到 base 上------零值字段不进 patch,空字符串就不会抹掉 base 里的镜像名。 - 补丁式修复。 给 InferencePool 填 selector、给 EPP 补 ServiceAccount 名、把
scheduler.config.ref指向的 ConfigMap 内容读进config.inline。 - 把整个 spec 当 Go 模板渲染。
ReplaceVariables把合并结果 JSON 序列化后交给text/template渲染,模板上下文是{.Spec, .GlobalConfig},并设Option("missingkey=error")。于是 preset 里能写{{ if .Spec.Parallelism.Tensor }}--tensor-parallel-size {{ .Spec.Parallelism.Tensor }}{{ end }}------你填parallelism.tensor: 4,模板翻译成 vLLM 参数;拼错字段立刻报错。真实 preset 见config/llmisvcconfig/config-llm-template.yaml。 - 干跑校验。 渲染完先送 API server dry-run:
reconcileBaseRefs用GenerateName: "{名字}-validation-"的临时对象走完整 admission 链(CEL + webhook)。被拒(Invalid)返回reconcile.TerminalError------重试无用,等用户改;webhook 不可达则标记ValidationUnavailable并重试。状态里的appliedConfigs按优先级记录每个 preset 的来源(Preset/UserRef),随时可查"这段配置是谁塞进来的"。
Controller:独立进程,复用老兵的零件
反直觉的事实:这个控制器不在 第 1 篇的 cmd/manager 里。它是独立二进制 cmd/llmisvc/main.go------自己的 scheme、三组 webhook、metrics 端口 :8443。理由:LLMISVC 依赖的 CRD(LeaderWorkerSet、InferencePool、WVA)太重,独立部署让普通用户不必背。
但代码大量复用老兵零件:
- 同一个配置源 :
config_loader.go的toConfig里v1beta1.NewIngressConfig、GetStorageInitializerConfigs、credentials.GetCredentialConfig全是 v1beta1 现成解析器------两代控制器读同一个inferenceservice-configConfigMap。 - 同一个可插拔套路 :
SetupWithManager里一串utils.IsCrdAvailable条件 watch(HTTPRoute、Gateway、InferencePool、ScaledObject、LeaderWorkerSet......),装了才 watch;ca-bundle 都直接 import v1beta1 的reconcilers/cabundleconfigmap。 - 同一个调和骨架 :finalizer → knative 的
PreProcessReconcile/PostProcessReconcile→RetryOnConflict状态更新 + Ready/NotReady 事件。
差异在调和主线。承上启下只有一行:llmSvc.Spec = baseCfg.Spec------合并结果直接替换 spec,只写 status 不回写。然后 reconcileWorkload(workload.go:LeaderWorkerSet/Deployment + Service 8000 + HPA/KEDA)和 reconcileRouter(Gateway API 路由 + EPP)接力。事件过滤更精细:PodStatusPredicate 只关心 InitContainerStatuses 和 PodIPs 变化------initContainer 完成意味着模型下载好了,正是 LLM 冷启动最关心的信号。
演进与迁移:两条并行的航线
关系一句话:并存,不是替换 。manager 管 InferenceService(v1beta1,稳定),llmisvc 管 LLMInferenceService(v1alpha2,alpha),共享同一份 inferenceservice-config。
容易误会的一点:cmd/llmisvc/migration.go 的"迁移"不是"从 InferenceService 迁过来",而是 LLMISVC 自己的存储版本迁移(清理遗留 VariantAutoscaling CR)。源码里没有 ISVC → LLMISVC 的自动迁移工具,社区路线是"新 LLM 部署用 LLMISVC,存量 ISVC 继续跑"。
演进方向(源码证据):Gateway API 原生路由 + EPP、WVA、prefill/decode 分离、KV cache 分级卸载、LoRA 一等公民------一条"以推理指标为中心"的 LLM 原生栈。v1alpha2 字段仍会变,生产要跟紧 release note。
系列总结
10 篇一句话回顾:
- 源码导读:仓库地图 +
cmd/manager入口 - InferenceService API:一个 CRD 承载全部配置
- 调和循环:事件 → Reconcile → 组件 → 状态
- 从 Spec 到资源:组件变 Deployment
- 模型加载:storage-initializer + LocalModel 节点级缓存
- 流量路由:Istio + Knative 的按需装配
- 自动扩缩容:KPA/HPA/KEDA 三条路径
- 多模型管理:TrainedModel 与 ServingRuntime
- InferenceGraph:多服务编排成 DAG
- LLMService(本篇):preset 合并 + 模板渲染 + 干跑校验
方法论五条,给同样想啃大型控制器项目的你:
- 先读 types.go 再读 controller------YAML 与 Go 结构体一一对应,API 是理解一切的锚。
- 从入口沿调用链下潜 :
cmd/→SetupWithManager→Reconcile→ 子 reconciler。 - "可插拔"要找源码证据 :文档说支持 Knative/KEDA,源码证据是
utils.IsCrdAvailable条件 watch------两代控制器一模一样。 - 合并与默认值是坑最多的地方:preset → merge → 模板 → 干跑校验,保证"你写的"与"实际跑的"可控。
- 读注释里的 why:KServe 注释常直接写设计取舍------"为什么端口固定 8000"、"为什么零值字段不能进 merge patch"。
回看这条线:2019 年的 InferenceService 用"一个 CRD 承载一切"解决通用推理的标准化;2025 年的 LLMInferenceService 用"preset + 模板 + 干跑校验"把 vLLM 时代的复杂性收编进 API。抽象层会老去,控制面的基本功不变------事件、调和、合并、状态。剩下的路,就是去 apply 一个真实的服务,看它跑起来。
系列完。