From e620d4937eba8d6da454a3e798a65ecc0147322d Mon Sep 17 00:00:00 2001 From: bot_dev2 Date: Tue, 4 Aug 2026 22:04:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20issue=20#8=20?= =?UTF-8?q?=E2=91=A5=20K8s/Helm=20=E9=83=A8=E7=BD=B2=E5=BA=95=E5=BA=A7=20+?= =?UTF-8?q?=20=E6=98=87=E8=85=BE=E9=80=82=E9=85=8D=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/inference-backend/README.md | 51 +++++++ core/inference-backend/__init__.py | 18 +++ core/inference-backend/base.py | 103 ++++++++++++++ .../config/backends.template.yaml | 41 ++++++ core/inference-backend/factory.py | 64 +++++++++ core/inference-backend/gpu_backend.py | 74 ++++++++++ core/inference-backend/npu_backend.py | 81 +++++++++++ core/inference-backend/tests/_bootstrap.py | 16 +++ .../tests/test_inference_backend.py | 127 ++++++++++++++++++ deploy/k8s/helm/iaop/Chart.yaml | 16 +++ deploy/k8s/helm/iaop/README.md | 65 +++++++++ deploy/k8s/helm/iaop/_sanity_check.py | 75 +++++++++++ deploy/k8s/helm/iaop/templates/NOTES.txt | 13 ++ deploy/k8s/helm/iaop/templates/_helpers.tpl | 43 ++++++ .../iaop/templates/backend-configmap.yaml | 36 +++++ .../k8s/helm/iaop/templates/deployment.yaml | 72 ++++++++++ deploy/k8s/helm/iaop/templates/ingress.yaml | 23 ++++ deploy/k8s/helm/iaop/templates/pvc.yaml | 17 +++ deploy/k8s/helm/iaop/templates/service.yaml | 15 +++ deploy/k8s/helm/iaop/values.yaml | 63 +++++++++ 20 files changed, 1013 insertions(+) create mode 100644 core/inference-backend/README.md create mode 100644 core/inference-backend/__init__.py create mode 100644 core/inference-backend/base.py create mode 100644 core/inference-backend/config/backends.template.yaml create mode 100644 core/inference-backend/factory.py create mode 100644 core/inference-backend/gpu_backend.py create mode 100644 core/inference-backend/npu_backend.py create mode 100644 core/inference-backend/tests/_bootstrap.py create mode 100644 core/inference-backend/tests/test_inference_backend.py create mode 100644 deploy/k8s/helm/iaop/Chart.yaml create mode 100644 deploy/k8s/helm/iaop/README.md create mode 100644 deploy/k8s/helm/iaop/_sanity_check.py create mode 100644 deploy/k8s/helm/iaop/templates/NOTES.txt create mode 100644 deploy/k8s/helm/iaop/templates/_helpers.tpl create mode 100644 deploy/k8s/helm/iaop/templates/backend-configmap.yaml create mode 100644 deploy/k8s/helm/iaop/templates/deployment.yaml create mode 100644 deploy/k8s/helm/iaop/templates/ingress.yaml create mode 100644 deploy/k8s/helm/iaop/templates/pvc.yaml create mode 100644 deploy/k8s/helm/iaop/templates/service.yaml create mode 100644 deploy/k8s/helm/iaop/values.yaml diff --git a/core/inference-backend/README.md b/core/inference-backend/README.md new file mode 100644 index 0000000..35799dc --- /dev/null +++ b/core/inference-backend/README.md @@ -0,0 +1,51 @@ +# iAOP-Core · 推理后端抽象层(Inference Backend) + +对应 PRD 5.6「⑥ 部署底座」与 EPIC #8: +NVIDIA GPU(5090,Triton/ONNX)与华为昇腾 NPU(ACL/CANN)实现**同一** +`InferenceBackend` 接口,业务代码仅依赖接口、不感知硬件; +**切换后端 = 改适配层配置**(`backend: gpu|npu`),业务代码零改动。 + +## 模块结构 + +``` +core/inference-backend/ +├── __init__.py 包入口(导出接口/请求/结果/工厂) +├── base.py InferenceBackend 统一抽象接口(load_model/infer/health/unload) +├── gpu_backend.py NVIDIA 5090 后端(Triton/ONNX,OpenAI 兼容) +├── npu_backend.py 华为昇腾 NPU 后端(ACL/CANN,MindIE OpenAI 兼容) +├── factory.py 可插拔工厂(backend: gpu|npu -> 实现类) +├── config/ +│ └── backends.template.yaml 模板后端适配层配置资产(ti-cl4 示例) +└── tests/ + ├── _bootstrap.py 测试引导(目录含连字符,挂载包名 inference_backend) + └── test_inference_backend.py 接口/工厂/行为单元测试 +``` + +## 统一接口(PRD 5.6:loadModel / infer / health / unload) + +业务代码只依赖 `InferenceBackend` 接口,不 import 任何具体后端: + +```python +from inference_backend.base import InferRequest +from inference_backend.factory import build_backend, load_backend_config + +backend = build_backend(load_backend_config("config/backends.template.yaml")) + +backend.load_model() +health = backend.health() # 健康巡检 +result = backend.infer(InferRequest(prompt="请解释炉温报警")) # result.text +backend.unload() +``` + +## 切换后端(仅改配置) + +`config/backends.template.yaml` 中 `backend: gpu` 改为 `backend: npu` 即切换到 +昇腾适配层;同一份业务代码、同一调用面,硬件差异被适配层隔离。 +新增硬件:在 `factory.py` 的 `BACKEND_REGISTRY` 注册实现类即可。 + +## 运行测试 + +```bash +cd core/inference-backend/tests +python -m unittest discover -s . -p "test_*.py" +``` diff --git a/core/inference-backend/__init__.py b/core/inference-backend/__init__.py new file mode 100644 index 0000000..9541c56 --- /dev/null +++ b/core/inference-backend/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +"""iAOP-Core · 推理后端抽象层(Inference Backend)。 + +对应 PRD 5.6「⑥ 部署底座」与 EPIC #8: +NVIDIA GPU(5090,Triton/ONNX)与华为昇腾 NPU(ACL/CANN)实现同一 +`InferenceBackend` 接口,业务代码仅依赖接口、不感知硬件; +切换后端 = 改适配层配置(`backend: gpu|npu`),业务代码零改动。 +""" +from inference_backend.base import InferRequest, InferResult, InferenceBackend +from inference_backend.factory import build_backend, load_backend_config + +__all__ = [ + "InferenceBackend", + "InferRequest", + "InferResult", + "build_backend", + "load_backend_config", +] diff --git a/core/inference-backend/base.py b/core/inference-backend/base.py new file mode 100644 index 0000000..4544d5e --- /dev/null +++ b/core/inference-backend/base.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""推理后端统一抽象接口(PRD 5.6「⑥ 部署底座」)。 + +设计约束(对齐产品设计文档 5.6): +- 统一接口 `load_model / infer / health / unload`,与底层硬件无关; +- NVIDIA 5090 实现(Triton/ONNX)与华为昇腾实现(ACL/CANN)均实现本接口; +- 业务代码只 import 本模块,不 import 任何具体后端; +- 切换后端 = 修改适配层配置(见 config/backends.template.yaml),业务代码零改动。 +""" +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +import json +import time +import urllib.request + + +@dataclass +class InferRequest: + """统一的推理请求载荷(业务侧构造,与硬件无关)。""" + + prompt: str + max_tokens: int = 1024 + temperature: float = 0.1 + extra: dict = field(default_factory=dict) + + def to_payload(self) -> dict: + """转换为后端服务 OpenAI 兼容的请求体。""" + return { + "model": self.extra.get("model") if self.extra.get("model") else None, + "prompt": self.prompt, + "max_tokens": self.max_tokens, + "temperature": self.temperature, + } + + +@dataclass +class InferResult: + """统一的推理结果(业务侧消费,不感知硬件)。""" + + text: str + backend: str + latency_ms: float = 0.0 + meta: dict = field(default_factory=dict) + + +class InferenceBackend(ABC): + """推理后端统一接口(对应 PRD 5.6:loadModel / infer / health / unload)。""" + + #: 后端标识:gpu(NVIDIA 5090)| npu(华为昇腾) + backend_name = "base" + + def __init__(self, endpoint: str = "", model: str = "iaop-default", + timeout_seconds: float = 10.0): + self.endpoint = (endpoint or "").rstrip("/") + self.model = model + self.timeout = float(timeout_seconds) + + # ---- 统一接口(业务代码仅依赖以下四个方法) ---- + + @abstractmethod + def load_model(self, model_name: str | None = None) -> dict: + """加载/热载模型(对应 PRD 的 loadModel)。返回加载状态。""" + + @abstractmethod + def infer(self, request: InferRequest) -> InferResult: + """执行一次推理(对应 PRD 的 infer)。返回统一结果对象。""" + + @abstractmethod + def health(self) -> dict: + """健康巡检(对应 PRD 的 health)。返回后端状态与版本信息。""" + + @abstractmethod + def unload(self) -> dict: + """卸载模型/释放显存(对应 PRD 的 unload)。返回卸载结果。""" + + # ---- 内部工具:HTTP JSON 调用(各后端共享) ---- + + def _post_json(self, path: str, payload: dict) -> dict: + """向后端服务发起 JSON POST 请求,返回解析后的响应体。""" + url = self.endpoint + path + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, data=data, headers={"Content-Type": "application/json"} + ) + started = time.monotonic() + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read().decode("utf-8") + meta = {"http_ms": round((time.monotonic() - started) * 1000, 2)} + body = json.loads(raw) if raw else {} + if isinstance(body, dict): + body.setdefault("_http_ms", meta["http_ms"]) + return body + + def _healthz(self) -> dict: + """通用 /health 探针封装。""" + if not self.endpoint: + return { + "status": "ok", + "backend": self.backend_name, + "model": self.model, + "reason": "dry-run(未配置 endpoint,适配层就绪)", + } + return self._post_json("/health", {"model": self.model}) diff --git a/core/inference-backend/config/backends.template.yaml b/core/inference-backend/config/backends.template.yaml new file mode 100644 index 0000000..08e2fe6 --- /dev/null +++ b/core/inference-backend/config/backends.template.yaml @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# 模板「推理后端适配层」配置资产示例:ti-cl4(氯化车间/海绵钛,Template-Ti 一期)。 +# +# 说明: +# - 这是「推理后端选择」配置点(PRD 5.6 ⑥ 部署底座): +# 切换后端(NVIDIA 5090 GPU ↔ 华为昇腾 NPU)**只改本文件 backend 字段**, +# 业务代码零改动(统一走 InferenceBackend 接口); +# - backend: gpu —— NVIDIA 5090(Triton/ONNX,vLLM/Triton OpenAI 兼容接口) +# npu —— 华为昇腾(ACL/CANN,MindIE/onnxruntime-ascend) +# - endpoint 为空时进入 dry-run 模式(适配层就绪,便于离线验证/联调); +# - 该配置由 Helm 渲染注入(deploy/k8s/helm/iaop/values.yaml -> ConfigMap)。 +template: ti-cl4 +version: 1.0.0 + +# ---- 推理后端选择(切换后端只改这一处) ---- +backend: gpu # gpu | npu + +inference: + # 与 Helm values.inference 保持同构,便于一键部署时直接覆盖 + model: iaop-ti-cl4-v1 + endpoint: http://iaop-inference.iaop.svc.cluster.local:8000/v1 + timeout_seconds: 60 + runtime: vllm # gpu: vllm|triton;npu: mindie|onnx-ascend + device: nvidia-5090 # gpu: nvidia-5090;npu: ascend-310p|ascend-910b + cann_version: "" # npu: CANN 工具链版本(如 8.0),gpu 忽略 + max_tokens: 1024 + temperature: 0.1 + +# ---- 资源配额(Helm 部署时以此为默认值) ---- +resources: + requests: + cpu: "2" + memory: 8Gi + limits: + cpu: "8" + memory: 32Gi + +# ---- 灰度发布策略(ArgoCD 滚动发布参数,PRD 5.6 配置点) ---- +rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 diff --git a/core/inference-backend/factory.py b/core/inference-backend/factory.py new file mode 100644 index 0000000..f626d93 --- /dev/null +++ b/core/inference-backend/factory.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""推理后端工厂:按配置选择后端实现(可插拔适配层)。 + +PRD 5.6「配置点:资源配额、推理后端选择、灰度发布策略」—— +`backend: gpu|npu` 即推理后端选择;业务代码只调用 `build_backend()` +返回的接口对象,不感知具体硬件。 +""" +import os + +from inference_backend.base import InferenceBackend +from inference_backend.gpu_backend import NvidiaGpuBackend +from inference_backend.npu_backend import AscendNpuBackend + +#: 可插拔后端注册表:配置名 -> 实现类。新增硬件只需注册新类。 +BACKEND_REGISTRY = { + "gpu": NvidiaGpuBackend, # NVIDIA 5090(Triton/ONNX) + "npu": AscendNpuBackend, # 华为昇腾(ACL/CANN) +} + + +def build_backend(config: dict) -> InferenceBackend: + """依据配置构建推理后端实例(切换后端仅改配置,业务代码零改动)。 + + Args: + config: 后端配置字典(见 config/backends.template.yaml), + 至少包含 ``backend`` 键(gpu | npu)。 + + Returns: + 实现了 :class:`InferenceBackend` 接口的后端实例。 + + Raises: + ValueError: 配置缺失或指定了未注册的后端。 + """ + if not isinstance(config, dict) or not config.get("backend"): + raise ValueError("推理后端配置缺失:需要 backend: gpu|npu") + name = str(config["backend"]).lower() + if name not in BACKEND_REGISTRY: + raise ValueError( + f"未注册的推理后端: {name!r},可用: {sorted(BACKEND_REGISTRY)}" + ) + cls = BACKEND_REGISTRY[name] + inf = config.get("inference", {}) or {} + return cls( + endpoint=inf.get("endpoint", ""), + model=inf.get("model", "iaop-default"), + timeout_seconds=float(inf.get("timeout_seconds", 10)), + runtime=inf.get("runtime", ""), + device=inf.get("device", ""), + cann_version=inf.get("cann_version", ""), + ) + + +def load_backend_config(path: str) -> dict: + """从 YAML 配置资产加载后端配置(模板可覆盖资产)。""" + import yaml + + with open(path, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def default_config_path() -> str: + """返回本模块模板配置资产的默认路径。""" + return os.path.join(os.path.dirname(os.path.abspath(__file__)), + "config", "backends.template.yaml") diff --git a/core/inference-backend/gpu_backend.py b/core/inference-backend/gpu_backend.py new file mode 100644 index 0000000..4c33bcf --- /dev/null +++ b/core/inference-backend/gpu_backend.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""NVIDIA GPU 推理后端实现(5090,Triton/ONNX)。 + +对应 PRD 5.6:5090 实现(Triton/ONNX)——通过 OpenAI 兼容接口 +(vLLM / Triton Inference Server)对外提供推理,业务侧统一走 +`InferenceBackend` 接口,不感知硬件。 +""" +from inference_backend.base import InferenceBackend, InferRequest, InferResult + + +class NvidiaGpuBackend(InferenceBackend): + """NVIDIA GPU 后端:面向 5090 服务器(Triton/ONNX,OpenAI 兼容)。""" + + backend_name = "gpu" + + def __init__(self, endpoint: str = "", model: str = "iaop-default", + timeout_seconds: float = 10.0, runtime: str = "vllm", + device: str = "nvidia-5090", **kwargs): + super().__init__(endpoint, model, timeout_seconds) + self.runtime = runtime # vllm | triton + self.device = device # 硬件型号(默认 5090) + self._loaded = False + + def load_model(self, model_name: str | None = None) -> dict: + """加载模型到 GPU。无 endpoint 时仅维护本地状态(适配层就绪)。""" + model_name = model_name or self.model + if not self.endpoint: + self._loaded = True + return {"status": "ok", "backend": self.backend_name, + "model": model_name, "device": self.device, + "reason": "dry-run(未配置 endpoint)"} + body = self._post_json("/v1/models/load", + {"model": model_name, "device": self.device}) + self._loaded = body.get("status") in ("ok", "loaded", "ready") + return body + + def infer(self, request: InferRequest) -> InferResult: + """OpenAI 兼容生成(/v1/chat/completions)。""" + started = __import__("time").monotonic() + payload = request.to_payload() + payload["model"] = payload["model"] or self.model + body = self._post_json("/v1/chat/completions", payload) + latency_ms = round((__import__("time").monotonic() - started) * 1000, 2) + try: + text = body["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError): + text = str(body) + return InferResult( + text=text, + backend=self.backend_name, + latency_ms=latency_ms, + meta={"runtime": self.runtime, "device": self.device, + "model": self.model, "raw": body}, + ) + + def health(self) -> dict: + """健康巡检:探测 /health,返回后端/设备/运行时信息。""" + base = self._healthz() + base.update({ + "backend": self.backend_name, + "runtime": self.runtime, + "device": self.device, + "model": self.model, + "loaded": self._loaded, + }) + return base + + def unload(self) -> dict: + """卸载模型、释放显存。""" + self._loaded = False + if not self.endpoint: + return {"status": "ok", "backend": self.backend_name, + "reason": "dry-run(未配置 endpoint)"} + return self._post_json("/v1/models/unload", {"model": self.model}) diff --git a/core/inference-backend/npu_backend.py b/core/inference-backend/npu_backend.py new file mode 100644 index 0000000..9aea2c2 --- /dev/null +++ b/core/inference-backend/npu_backend.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""华为昇腾 NPU 推理后端实现(ACL/CANN)。 + +对应 PRD 5.6:昇腾实现(ACL/CANN)——通过昇腾推理服务 +(MindIE / onnxruntime-ascend)的 OpenAI 兼容接口对外提供推理, +与 NVIDIA GPU 后端实现同一 `InferenceBackend` 接口: +**切换后端仅改适配层配置,业务代码零改动**。 +""" +from inference_backend.base import InferenceBackend, InferRequest, InferResult + + +class AscendNpuBackend(InferenceBackend): + """华为昇腾 NPU 后端:面向昇腾 310P/910B(CANN/MindIE)。""" + + backend_name = "npu" + + def __init__(self, endpoint: str = "", model: str = "iaop-default", + timeout_seconds: float = 10.0, runtime: str = "mindie", + device: str = "ascend-910b", cann_version: str = "8.0", + **kwargs): + super().__init__(endpoint, model, timeout_seconds) + self.runtime = runtime # mindie | onnx-ascend + self.device = device # ascend-310p | ascend-910b + self.cann_version = cann_version # CANN 工具链版本 + self._loaded = False + + def load_model(self, model_name: str | None = None) -> dict: + """加载模型到 NPU(ACL aclmdlLoadFromFile 语义)。""" + model_name = model_name or self.model + if not self.endpoint: + self._loaded = True + return {"status": "ok", "backend": self.backend_name, + "model": model_name, "device": self.device, + "reason": "dry-run(未配置 endpoint)"} + body = self._post_json("/acl/models/load", + {"model": model_name, "device": self.device, + "cann_version": self.cann_version}) + self._loaded = body.get("status") in ("ok", "loaded", "ready") + return body + + def infer(self, request: InferRequest) -> InferResult: + """昇腾推理(MindIE OpenAI 兼容 /v1/chat/completions)。""" + import time + started = time.monotonic() + payload = request.to_payload() + payload["model"] = payload["model"] or self.model + body = self._post_json("/v1/chat/completions", payload) + latency_ms = round((time.monotonic() - started) * 1000, 2) + try: + text = body["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError): + text = str(body) + return InferResult( + text=text, + backend=self.backend_name, + latency_ms=latency_ms, + meta={"runtime": self.runtime, "device": self.device, + "cann_version": self.cann_version, "model": self.model, + "raw": body}, + ) + + def health(self) -> dict: + """健康巡检:探测 /health,返回后端/设备/CANN 版本信息。""" + base = self._healthz() + base.update({ + "backend": self.backend_name, + "runtime": self.runtime, + "device": self.device, + "cann_version": self.cann_version, + "model": self.model, + "loaded": self._loaded, + }) + return base + + def unload(self) -> dict: + """卸载模型、释放 NPU 资源(ACL aclmdlUnload 语义)。""" + self._loaded = False + if not self.endpoint: + return {"status": "ok", "backend": self.backend_name, + "reason": "dry-run(未配置 endpoint)"} + return self._post_json("/acl/models/unload", {"model": self.model}) diff --git a/core/inference-backend/tests/_bootstrap.py b/core/inference-backend/tests/_bootstrap.py new file mode 100644 index 0000000..b06446e --- /dev/null +++ b/core/inference-backend/tests/_bootstrap.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""测试引导:把 `core/inference-backend` 以包名 `inference_backend` 挂载到 sys.modules。 + +目录名 `inference-backend` 含连字符,无法直接以包名 import;挂载后模块内相对导入 +(`from inference_backend.base import ...`)在 unittest 发现机制下可正常解析。 +""" +import os +import sys +import types + +BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, BACKEND_DIR) +if "inference_backend" not in sys.modules: + pkg = types.ModuleType("inference_backend") + pkg.__path__ = [BACKEND_DIR] + sys.modules["inference_backend"] = pkg diff --git a/core/inference-backend/tests/test_inference_backend.py b/core/inference-backend/tests/test_inference_backend.py new file mode 100644 index 0000000..182cf87 --- /dev/null +++ b/core/inference-backend/tests/test_inference_backend.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +"""推理后端抽象层单元测试(EPIC #8 ⑥ 部署底座 / PRD 5.6)。 + +覆盖: +1. 模板配置资产可解析、含 backend 选择键; +2. 工厂按配置构建 GPU / NPU 后端(可插拔); +3. 统一接口 load_model / infer / health / unload 行为; +4. 切换后端仅改配置:同一业务调用面(接口方法)在两种后端下等价; +5. 非法配置报错。 +""" +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _bootstrap # noqa: F401 + +from inference_backend.base import InferRequest, InferenceBackend # noqa: E402 +from inference_backend.factory import ( # noqa: E402 + BACKEND_REGISTRY, + build_backend, + default_config_path, + load_backend_config, +) + +CONFIG_PATH = default_config_path() + + +class TestTemplateConfig(unittest.TestCase): + """模板配置资产:可解析且包含后端选择配置点。""" + + def test_template_parses_and_has_backend_key(self): + cfg = load_backend_config(CONFIG_PATH) + self.assertIn("backend", cfg) + self.assertIn(cfg["backend"].lower(), ("gpu", "npu")) + self.assertIn("inference", cfg) + + def test_registry_covers_gpu_and_npu(self): + self.assertEqual(sorted(BACKEND_REGISTRY), ["gpu", "npu"]) + + +class TestFactory(unittest.TestCase): + """工厂:按配置选择后端实现(可插拔适配层)。""" + + def test_build_gpu_from_template(self): + cfg = load_backend_config(CONFIG_PATH) + backend = build_backend(cfg) + self.assertIsInstance(backend, InferenceBackend) + self.assertEqual(backend.backend_name, cfg["backend"].lower()) + self.assertEqual(backend.model, cfg["inference"]["model"]) + + def test_build_npu_by_config_switch(self): + cfg = load_backend_config(CONFIG_PATH) + cfg["backend"] = "npu" + backend = build_backend(cfg) + self.assertEqual(backend.backend_name, "npu") + # 业务调用面不变(接口等价,不感知硬件) + self.assertTrue(callable(backend.load_model)) + self.assertTrue(callable(backend.infer)) + self.assertTrue(callable(backend.health)) + self.assertTrue(callable(backend.unload)) + + def test_build_rejects_unknown_backend(self): + with self.assertRaises(ValueError): + build_backend({"backend": "tpu"}) + + def test_build_rejects_missing_backend(self): + with self.assertRaises(ValueError): + build_backend({"inference": {"model": "x"}}) + + +class TestBackendBehavior(unittest.TestCase): + """统一接口行为(dry-run:未配置 endpoint 时适配层就绪)。""" + + def setUp(self): + cfg = load_backend_config(CONFIG_PATH) + cfg["inference"]["endpoint"] = "" # dry-run + self.gpu = build_backend(cfg) + cfg["backend"] = "npu" + self.npu = build_backend(cfg) + + def test_dry_run_load_health_unload(self): + for backend in (self.gpu, self.npu): + loaded = backend.load_model() + self.assertEqual(loaded["status"], "ok") + health = backend.health() + self.assertEqual(health["status"], "ok") + self.assertEqual(health["backend"], backend.backend_name) + self.assertTrue(health["loaded"]) + unloaded = backend.unload() + self.assertEqual(unloaded["status"], "ok") + + def test_infer_extracts_text_from_openai_response(self): + fake_body = { + "choices": [{"message": {"content": "炉温偏高,建议降低加料比"}}], + } + for backend in (self.gpu, self.npu): + with mock.patch.object(backend, "_post_json", + return_value=fake_body) as post: + result = backend.infer(InferRequest(prompt="请解释炉温报警")) + post.assert_called_once_with( + "/v1/chat/completions", + mock.ANY, + ) + self.assertEqual(result.text, "炉温偏高,建议降低加料比") + self.assertEqual(result.backend, backend.backend_name) + # mock 调用耗时可能为 0,仅断言字段类型与量级 + self.assertIsInstance(result.latency_ms, float) + self.assertGreaterEqual(result.latency_ms, 0.0) + + def test_infer_payload_carries_model(self): + with mock.patch.object(self.gpu, "_post_json", + return_value={"choices": []}) as post: + self.gpu.infer(InferRequest(prompt="hi", max_tokens=64)) + _, payload = post.call_args[0] + self.assertEqual(payload["model"], self.gpu.model) + self.assertEqual(payload["max_tokens"], 64) + + def test_health_dry_run_notes_reason(self): + health = self.gpu.health() + self.assertIn("reason", health) + self.assertIn("dry-run", health["reason"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/k8s/helm/iaop/Chart.yaml b/deploy/k8s/helm/iaop/Chart.yaml new file mode 100644 index 0000000..4a49c31 --- /dev/null +++ b/deploy/k8s/helm/iaop/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: iaop +description: | + iAOP(云美工业AI优化平台)一键 Helm 部署底座(PRD 5.6「⑥ 部署底座」, + EPIC #8):内核(采集/数据总线/LLM 网关/RAG)+ 行业模板一键部署; + 推理后端(NVIDIA 5090 GPU / 华为昇腾 NPU)可插拔,切换后端仅改 values。 +type: application +version: 1.0.0 +appVersion: "v1.0.0" +keywords: + - iAOP + - industrial-ai + - k8s + - helm + - inference + - ascend diff --git a/deploy/k8s/helm/iaop/README.md b/deploy/k8s/helm/iaop/README.md new file mode 100644 index 0000000..1b6e378 --- /dev/null +++ b/deploy/k8s/helm/iaop/README.md @@ -0,0 +1,65 @@ +# iAOP Helm Chart(一键部署底座) + +对应 PRD 5.6「⑥ 部署底座」与 EPIC #8: +K8s/Helm/ArgoCD 部署底座;GPU/NPU 推理后端可插拔(5090 / 华为昇腾)。 +**验收:一套 Chart 部署内核 + 模板;切换后端仅改 values,不动业务代码。** + +## 目录结构 + +``` +deploy/k8s/helm/iaop/ +├── Chart.yaml Chart 元信息(name/version/appVersion) +├── values.yaml 全部配置点(镜像/后端/副本/资源/存储/灰度/域名) +├── templates/ +│ ├── _helpers.tpl 标签/名称辅助模板 +│ ├── backend-configmap.yaml 推理后端适配层配置(按 backend 分支渲染) +│ ├── deployment.yaml 推理服务 Deployment(nodeSelector 按后端分流) +│ ├── service.yaml ClusterIP 服务(:8000/v1) +│ ├── ingress.yaml 可选域名入口 +│ ├── pvc.yaml 模型权重/日志持久化 +│ └── NOTES.txt 部署后提示 +└── README.md +``` + +## 一键部署 + +```bash +helm repo add iaop http://git.xayunmei.com/iaop/charts # 或本地路径 +helm install iaop deploy/k8s/helm/iaop -n iaop --create-namespace +``` + +## 切换推理后端(仅改 values,业务代码零改动) + +```bash +# 默认 gpu(NVIDIA 5090,vLLM/Triton): +helm install iaop deploy/k8s/helm/iaop -n iaop \ + --set inference.backend=gpu \ + --set inference.device=nvidia-5090 + +# 切换为华为昇腾 NPU(ACL/CANN/MindIE): +helm upgrade iaop deploy/k8s/helm/iaop -n iaop \ + --set inference.backend=npu \ + --set inference.device=ascend-910b \ + --set inference.cannVersion=8.0 +``` + +切换行为(由模板自动处理): +- `backend-configmap.yaml`:渲染对应后端适配层配置(npu 追加 `cann_version`); +- `deployment.yaml`:nodeSelector 自动切到 `ascend.com/npu=true`(GPU 为 `nvidia.com/gpu=true`), + 并在 Pod 标签标注 `iaop.ai/inference-backend`; +- 后端实现差异全部收敛在 `core/inference-backend/` 适配层,业务代码零改动。 + +## 其他配置点(PRD 5.6) + +| 配置点 | values 路径 | 说明 | +| --- | --- | --- | +| 资源配额 | `resources.requests/limits` | CPU/内存配额 | +| 推理后端选择 | `inference.backend` | `gpu` / `npu` | +| 灰度发布策略 | `rollingUpdate` | `maxUnavailable: 0, maxSurge: 1`(滚动) | +| 存储 | `storage.*` | 模型权重持久卷 | +| 域名入口 | `ingress.*` | 可选 Ingress | + +## 说明 + +- Chart 未引入任何外部依赖(dependencies 为空),`helm template` 可离线渲染; +- 推理服务镜像对应 `core/inference-backend/` 适配层容器化(本文档发布配套镜像时更新 `image.tag`)。 diff --git a/deploy/k8s/helm/iaop/_sanity_check.py b/deploy/k8s/helm/iaop/_sanity_check.py new file mode 100644 index 0000000..d745472 --- /dev/null +++ b/deploy/k8s/helm/iaop/_sanity_check.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""Helm Chart 结构 sanity 检查(无 helm CLI 环境下的离线基本验证)。 + +检查项: +1. Chart.yaml / values.yaml 均为合法 YAML; +2. templates/ 下所有模板文件存在且非空; +3. 模板中引用的 `.Values.xxx` 键均能在 values.yaml 中找到(防拼写错误); +4. 每个模板文件的 Go template 标记 `{{` / `}}` 数量配平。 + +用法:python _sanity_check.py +""" +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +TEMPLATES = os.path.join(HERE, "templates") + +import yaml # noqa: E402 + + +def load(path): + with open(path, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) + + +def values_paths(text): + """提取模板文本中的 .Values.. 路径集合。""" + return set(re.findall(r"\.Values\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)", text)) + + +def resolve(values, dotted): + node = values + for key in dotted.split("."): + if not isinstance(node, dict) or key not in node: + return False + node = node[key] + return True + + +def main(): + failures = [] + + chart = load(os.path.join(HERE, "Chart.yaml")) + assert chart.get("apiVersion") == "v2", "Chart.yaml 应为 Helm v2 chart" + assert chart.get("name") == "iaop", "Chart.name 应为 iaop" + + values = load(os.path.join(HERE, "values.yaml")) + assert values.get("inference", {}).get("backend") in ("gpu", "npu"), \ + "values.inference.backend 应为 gpu|npu" + + for name in sorted(os.listdir(TEMPLATES)): + path = os.path.join(TEMPLATES, name) + if not os.path.isfile(path): + continue + text = open(path, "r", encoding="utf-8").read() + if not text.strip(): + failures.append(f"{name}: 模板文件为空") + continue + if text.count("{{") != text.count("}}"): + failures.append(f"{name}: Go template 标记 {{/}} 数量不配平") + for dotted in values_paths(text): + if not resolve(values, dotted): + failures.append(f"{name}: 引用了 values.yaml 中不存在的键 .Values.{dotted}") + + if failures: + print("FAIL") + for f in failures: + print(" -", f) + sys.exit(1) + print(f"OK: Chart/values 合法,templates/{len(os.listdir(TEMPLATES))} 个模板校验通过") + + +if __name__ == "__main__": + main() diff --git a/deploy/k8s/helm/iaop/templates/NOTES.txt b/deploy/k8s/helm/iaop/templates/NOTES.txt new file mode 100644 index 0000000..00c5ca7 --- /dev/null +++ b/deploy/k8s/helm/iaop/templates/NOTES.txt @@ -0,0 +1,13 @@ +Thank you for installing {{ .Chart.Name }} {{ .Chart.Version }} (backend: {{ .Values.inference.backend }}). + +iAOP 部署底座(PRD 5.6 / EPIC #8)已就绪,Release 名:{{ .Release.Name }}。 + +快速使用: + 1. 查看部署状态: + kubectl get deploy,svc -l app.kubernetes.io/instance={{ .Release.Name }} + 2. 健康巡检: + curl http://{{ include "iaop.fullname" . }}:{{ .Values.service.port }}/health + 3. 切换推理后端(NVIDIA GPU ↔ 华为昇腾 NPU),仅改 values 后升级: + helm upgrade {{ .Release.Name }} . --set inference.backend=npu + 4. 如需域名入口: + helm upgrade {{ .Release.Name }} . --set ingress.enabled=true --set ingress.host=iaop.example.com diff --git a/deploy/k8s/helm/iaop/templates/_helpers.tpl b/deploy/k8s/helm/iaop/templates/_helpers.tpl new file mode 100644 index 0000000..a2e2c5d --- /dev/null +++ b/deploy/k8s/helm/iaop/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{/* +iAOP Helm Chart 辅助模板(_helpers.tpl) +*/}} + +{{/* +展开 Chart 全名(release 名 + chart 名,截断到 63 字符)。 +*/}} +{{- define "iaop.fullname" -}} +{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Chart 名称标签。 +*/}} +{{- define "iaop.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +通用标签。 +*/}} +{{- define "iaop.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "iaop.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +选择器标签。 +*/}} +{{- define "iaop.selectorLabels" -}} +app.kubernetes.io/name: {{ include "iaop.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +推理后端镜像完整名(repository:tag)。 +*/}} +{{- define "iaop.image" -}} +{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}} +{{- end -}} diff --git a/deploy/k8s/helm/iaop/templates/backend-configmap.yaml b/deploy/k8s/helm/iaop/templates/backend-configmap.yaml new file mode 100644 index 0000000..5979eeb --- /dev/null +++ b/deploy/k8s/helm/iaop/templates/backend-configmap.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "iaop.fullname" . }}-backend + labels: + {{- include "iaop.labels" . | nindent 4 }} +data: + # 推理后端适配层配置(PRD 5.6:切换后端仅改 values.inference.backend)。 + # 该配置与 core/inference-backend/config/backends.template.yaml 同构, + # 由推理服务容器在启动时读取。 + backends.yaml: | + template: ti-cl4 + version: 1.0.0 + backend: {{ .Values.inference.backend }} + inference: + model: {{ .Values.inference.model | quote }} + endpoint: http://{{ include "iaop.fullname" . }}:{{ .Values.service.port }}/v1 + timeout_seconds: {{ .Values.inference.timeoutSeconds }} + runtime: {{ .Values.inference.runtime | quote }} + device: {{ .Values.inference.device | quote }} + max_tokens: {{ .Values.inference.maxTokens }} + temperature: {{ .Values.inference.temperature }} + {{- if eq .Values.inference.backend "npu" }} + # 昇腾适配层专用字段(CANN 工具链版本;gpu 后端忽略) + cann_version: {{ .Values.inference.cannVersion | default "" | quote }} + {{- end }} + resources: + requests: + cpu: {{ .Values.resources.requests.cpu | quote }} + memory: {{ .Values.resources.requests.memory | quote }} + limits: + cpu: {{ .Values.resources.limits.cpu | quote }} + memory: {{ .Values.resources.limits.memory | quote }} + rollingUpdate: + maxUnavailable: {{ .Values.rollingUpdate.maxUnavailable }} + maxSurge: {{ .Values.rollingUpdate.maxSurge }} diff --git a/deploy/k8s/helm/iaop/templates/deployment.yaml b/deploy/k8s/helm/iaop/templates/deployment.yaml new file mode 100644 index 0000000..955c317 --- /dev/null +++ b/deploy/k8s/helm/iaop/templates/deployment.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "iaop.fullname" . }} + labels: + {{- include "iaop.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: {{ .Values.rollingUpdate.maxUnavailable }} + maxSurge: {{ .Values.rollingUpdate.maxSurge }} + selector: + matchLabels: + {{- include "iaop.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "iaop.selectorLabels" . | nindent 8 }} + iaop.ai/inference-backend: {{ .Values.inference.backend }} + spec: + {{- if eq .Values.inference.backend "gpu" }} + # NVIDIA GPU(5090)节点选择:推理节点打标 nvidia.com/gpu=true + nodeSelector: + nvidia.com/gpu: "true" + {{- else }} + # 华为昇腾 NPU 节点选择:推理节点打标 ascend.com/npu=true + nodeSelector: + ascend.com/npu: "true" + {{- end }} + containers: + - name: inference + image: "{{ include "iaop.image" . }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + env: + - name: IAOP_BACKEND_CONFIG + value: /etc/iaop/backends.yaml + volumeMounts: + - name: backend-config + mountPath: /etc/iaop + readOnly: true + {{- if .Values.storage.enabled }} + - name: model-store + mountPath: /models + {{- end }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.livenessProbe.periodSeconds }} + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.readinessProbe.periodSeconds }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: backend-config + configMap: + name: {{ include "iaop.fullname" . }}-backend + {{- if .Values.storage.enabled }} + - name: model-store + persistentVolumeClaim: + claimName: {{ include "iaop.fullname" . }}-models + {{- end }} diff --git a/deploy/k8s/helm/iaop/templates/ingress.yaml b/deploy/k8s/helm/iaop/templates/ingress.yaml new file mode 100644 index 0000000..4dec6b3 --- /dev/null +++ b/deploy/k8s/helm/iaop/templates/ingress.yaml @@ -0,0 +1,23 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "iaop.fullname" . }} + labels: + {{- include "iaop.labels" . | nindent 4 }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "iaop.fullname" . }} + port: + name: http +{{- end }} diff --git a/deploy/k8s/helm/iaop/templates/pvc.yaml b/deploy/k8s/helm/iaop/templates/pvc.yaml new file mode 100644 index 0000000..cf43a0f --- /dev/null +++ b/deploy/k8s/helm/iaop/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if .Values.storage.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "iaop.fullname" . }}-models + labels: + {{- include "iaop.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.storage.className }} + storageClassName: {{ .Values.storage.className }} + {{- end }} + resources: + requests: + storage: {{ .Values.storage.size }} +{{- end }} diff --git a/deploy/k8s/helm/iaop/templates/service.yaml b/deploy/k8s/helm/iaop/templates/service.yaml new file mode 100644 index 0000000..c5f3359 --- /dev/null +++ b/deploy/k8s/helm/iaop/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "iaop.fullname" . }} + labels: + {{- include "iaop.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "iaop.selectorLabels" . | nindent 4 }} diff --git a/deploy/k8s/helm/iaop/values.yaml b/deploy/k8s/helm/iaop/values.yaml new file mode 100644 index 0000000..490c4f1 --- /dev/null +++ b/deploy/k8s/helm/iaop/values.yaml @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# iAOP Helm Chart 默认 values(PRD 5.6「⑥ 部署底座」配置点)。 +# +# 切换推理后端(NVIDIA 5090 GPU ↔ 华为昇腾 NPU)只需改: +# inference.backend: gpu | npu +# 其余部署(内核+模板、资源配额、灰度、存储、域名)无需改动。 + +# ---- 镜像 ---- +image: + repository: iaop/inference + tag: v1.0.0 + pullPolicy: IfNotPresent + +# ---- 推理后端选择(PRD 5.6 配置点:切换后端仅改此处) ---- +inference: + backend: gpu # gpu | npu + model: iaop-ti-cl4-v1 + runtime: vllm # gpu: vllm|triton;npu: mindie|onnx-ascend + device: nvidia-5090 # gpu: nvidia-5090;npu: ascend-310p|ascend-910b + cannVersion: "" # npu: CANN 工具链版本(如 "8.0"),gpu 忽略 + maxTokens: 1024 + temperature: 0.1 + timeoutSeconds: 60 + +# ---- 副本与滚动(灰度发布策略,PRD 5.6 配置点) ---- +replicaCount: 2 +rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + +# ---- 服务 ---- +service: + type: ClusterIP + port: 8000 + +# ---- 域名入口 ---- +ingress: + enabled: false + host: iaop.example.com + className: "" + +# ---- 资源配额(PRD 5.6 配置点) ---- +resources: + requests: + cpu: "2" + memory: 8Gi + limits: + cpu: "8" + memory: 32Gi + +# ---- 存储(模型权重/日志持久化) ---- +storage: + enabled: true + className: "" # 空 = 使用集群默认 StorageClass + size: 100Gi + +# ---- 探针 ---- +livenessProbe: + initialDelaySeconds: 30 + periodSeconds: 10 +readinessProbe: + initialDelaySeconds: 10 + periodSeconds: 5