LLMService:KServe 面向 LLM 的下一步

KServe 源码拆解系列 · 第 10 篇

第 9 篇拆了 InferenceGraph------把多个推理服务编排成 DAG。可那套世界观是 2019 年定下的:三组件、Istio 分流、按请求数扩缩容。当你部署的不是 sklearn 模型,而是一个 70B 的 vLLM 时,这套抽象哪里漏水?社区的答案是新 CRD LLMInferenceService(口语叫 LLMService)。收官篇拆它,也沉淀这 10 篇的读法。


为什么 InferenceService 不够用了

不是实现差,是抽象层和 LLM 的需求对不上。沿着前 9 篇的机制走一遍,会撞上这些墙:

  1. 模型下载 :第 5 篇的 storage-initializer 对几十 GB 权重没有感知,每个 Pod 冷启动重拉一遍;hf:// 不是一等公民。
  2. vLLM 参数透传 :想开张量并行?spec 里没这字段,只能按第 4 篇的路子把 --tensor-parallel-size 塞进 args/env------全靠约定,LoRA 同样缺席。
  3. 流量与扩缩容:第 6 篇的 Istio/Knative 做不了"按 model 名路由、挑 KV cache 最空的 Pod";第 7 篇的 KPA/HPA 看请求数,而 LLM 的瓶颈是 KV cache 利用率。
  4. 多模型与编排:第 8 篇的 ModelMesh 跟"一个基座 + 多个 LoRA"不是一回事;prefill/decode 分离在 2019 年的 API 里根本没这词。

这些能力都能"绕"出来,但每绕一次就是一堆约定俗成。当"绕"成了常态,就该升抽象了。


LLMInferenceService API:先正名,再看字段

正名:源码 kind 是 LLMInferenceServiceserving.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.urihf:// 一等公民)
组件模型 三组件 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-rankmaxAdapters--max-loras。第 2 篇拆过的"参数透传全靠约定",在这里被提升成了 schema。

同文件还有第二个 CRD:LLMInferenceServiceConfig------与 Service 共用同一个 Spec,充当"配置模板",是下一节合并引擎的原料。


合并引擎:config_merge.go 的流水线

你写 30 行 YAML,控制器实际调和的是几百行的完整 spec------差额由 pkg/controller/v1alpha2/llmisvc/config_merge.gocombineBaseRefsConfig 补齐,六步:

  1. 先合并显式 baseRefs,搞清楚启用了什么。 先合并 spec.baseRefs 得到 resolvedSpec------知道"启用了 scheduler 吗、开了 P/D 吗",才能决定注入哪些默认值。
  2. 按部署形态挑 preset。 源码是一串 switch:P/D 分离时 prefill/decode 各选一种,非 P/D 按单节点/数据并行/流水线并行选,对应 config-llm-template 等 preset 名,前缀默认 kserve-
  3. 按优先级 strategic merge。 preset 最低、显式 baseRefs 中间、你的 spec 最高:MergeSpecs(ctx, append(specs, llmSvc.Spec)...)mergeSpecs 的精妙处:先对 override 调 SetDefaults(ctx),再生成 zero 与 override 的 two-way merge patch 打到 base 上------零值字段不进 patch,空字符串就不会抹掉 base 里的镜像名。
  4. 补丁式修复。 给 InferencePool 填 selector、给 EPP 补 ServiceAccount 名、把 scheduler.config.ref 指向的 ConfigMap 内容读进 config.inline
  5. 把整个 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
  6. 干跑校验。 渲染完先送 API server dry-run:reconcileBaseRefsGenerateName: "{名字}-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.gotoConfigv1beta1.NewIngressConfigGetStorageInitializerConfigscredentials.GetCredentialConfig 全是 v1beta1 现成解析器------两代控制器读同一个 inferenceservice-config ConfigMap。
  • 同一个可插拔套路SetupWithManager 里一串 utils.IsCrdAvailable 条件 watch(HTTPRoute、Gateway、InferencePool、ScaledObject、LeaderWorkerSet......),装了才 watch;ca-bundle 都直接 import v1beta1 的 reconcilers/cabundleconfigmap
  • 同一个调和骨架 :finalizer → knative 的 PreProcessReconcile/PostProcessReconcileRetryOnConflict 状态更新 + Ready/NotReady 事件。

差异在调和主线。承上启下只有一行:llmSvc.Spec = baseCfg.Spec------合并结果直接替换 spec,只写 status 不回写。然后 reconcileWorkloadworkload.go:LeaderWorkerSet/Deployment + Service 8000 + HPA/KEDA)和 reconcileRouter(Gateway API 路由 + EPP)接力。事件过滤更精细:PodStatusPredicate 只关心 InitContainerStatusesPodIPs 变化------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 篇一句话回顾:

  1. 源码导读:仓库地图 + cmd/manager 入口
  2. InferenceService API:一个 CRD 承载全部配置
  3. 调和循环:事件 → Reconcile → 组件 → 状态
  4. 从 Spec 到资源:组件变 Deployment
  5. 模型加载:storage-initializer + LocalModel 节点级缓存
  6. 流量路由:Istio + Knative 的按需装配
  7. 自动扩缩容:KPA/HPA/KEDA 三条路径
  8. 多模型管理:TrainedModel 与 ServingRuntime
  9. InferenceGraph:多服务编排成 DAG
  10. LLMService(本篇):preset 合并 + 模板渲染 + 干跑校验

方法论五条,给同样想啃大型控制器项目的你:

  1. 先读 types.go 再读 controller------YAML 与 Go 结构体一一对应,API 是理解一切的锚。
  2. 从入口沿调用链下潜cmd/SetupWithManagerReconcile → 子 reconciler。
  3. "可插拔"要找源码证据 :文档说支持 Knative/KEDA,源码证据是 utils.IsCrdAvailable 条件 watch------两代控制器一模一样。
  4. 合并与默认值是坑最多的地方:preset → merge → 模板 → 干跑校验,保证"你写的"与"实际跑的"可控。
  5. 读注释里的 why:KServe 注释常直接写设计取舍------"为什么端口固定 8000"、"为什么零值字段不能进 merge patch"。

回看这条线:2019 年的 InferenceService 用"一个 CRD 承载一切"解决通用推理的标准化;2025 年的 LLMInferenceService 用"preset + 模板 + 干跑校验"把 vLLM 时代的复杂性收编进 API。抽象层会老去,控制面的基本功不变------事件、调和、合并、状态。剩下的路,就是去 apply 一个真实的服务,看它跑起来。

系列完。

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