Merge PR #98/#100 (feat #57 InferenceBackend 抽象契约 + #58 GPU Triton 后端,桥接 #44/#45 本地与云端实现)

This commit is contained in:
2026-08-05 08:31:30 +08:00
parent 6036e5e151
commit 6d0b2976bd
5 changed files with 1192 additions and 57 deletions
+301
View File
@@ -0,0 +1,301 @@
# -*- coding: utf-8 -*-
"""推理后端抽象接口(backends,Issue #57,PRD 5.6)单元测试。
覆盖:
- 抽象基类不可直接实例化(必须由子类实现四个生命周期方法);
- 值对象 BackendCapabilities / BackendHealth / InferResult 的字段与序列化;
- LocalBackend / CloudBackend 占位实现的生命周期(load/infer/health/unload)与幂等;
- 向后兼容:``generate`` 转发到 ``infer`` 并返回 ``text``;
- 能力声明差异(本地出厂内闭环 / 云端出厂外);
- 注册表与 ``build_backend`` 的配置驱动构造 + 未知后端报错。
"""
import os
import sys
import unittest
from abc import ABC
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _bootstrap # noqa: F401
from llm_gateway.backends import ( # noqa: E402
BackendCapabilities,
BackendHealth,
CloudBackend,
InferResult,
InferenceBackend,
LocalBackend,
_PlaceholderBackend,
build_backend,
default_registry,
)
# ---------------------------------------------------------------------------
# 抽象基类契约
# ---------------------------------------------------------------------------
class AbstractionContractTest(unittest.TestCase):
"""PRD 5.6:InferenceBackend 是抽象接口,业务代码只依赖它。"""
def test_cannot_instantiate_abstract_base(self):
# 缺少四个抽象方法 → 不能实例化
with self.assertRaises(TypeError):
InferenceBackend() # noqa: E721
def test_is_abc_subclass(self):
self.assertTrue(issubclass(InferenceBackend, ABC))
def test_required_abstract_methods(self):
# PRD 5.6 明列的生命周期动作
abstract = InferenceBackend.__abstractmethods__
for name in ("load_model", "infer", "health_check", "unload"):
self.assertIn(name, abstract)
def test_concrete_backends_are_inference_backends(self):
for cls in (LocalBackend, CloudBackend):
self.assertTrue(issubclass(cls, InferenceBackend),
f"{cls.__name__} 必须实现 InferenceBackend")
# ---------------------------------------------------------------------------
# 值对象
# ---------------------------------------------------------------------------
class BackendCapabilitiesTest(unittest.TestCase):
def test_defaults(self):
cap = BackendCapabilities()
self.assertFalse(cap.streaming)
self.assertIsNone(cap.max_concurrency)
self.assertFalse(cap.on_premises)
self.assertEqual(cap.modalities, ("text",))
def test_supports_modality(self):
cap = BackendCapabilities(modalities=("text", "image"))
self.assertTrue(cap.supports("text"))
self.assertTrue(cap.supports("image"))
self.assertFalse(cap.supports("audio"))
def test_to_dict_roundtrip(self):
cap = BackendCapabilities(streaming=True, max_concurrency=4,
on_premises=False, modalities=("text",))
d = cap.to_dict()
self.assertEqual(d["streaming"], True)
self.assertEqual(d["max_concurrency"], 4)
self.assertEqual(d["modalities"], ["text"])
class BackendHealthTest(unittest.TestCase):
def test_fields(self):
h = BackendHealth(healthy=True, detail="ok")
self.assertTrue(h.healthy)
self.assertEqual(h.detail, "ok")
self.assertTrue(h.checked_at) # 自动生成时间戳
def test_to_dict(self):
d = BackendHealth(healthy=False, detail="down").to_dict()
self.assertEqual(d["healthy"], False)
self.assertIn("checked_at", d)
class InferResultTest(unittest.TestCase):
def test_required_fields(self):
r = InferResult(text="hello", backend_name="local-70b")
self.assertEqual(r.text, "hello")
self.assertEqual(r.backend_name, "local-70b")
self.assertIsNone(r.prompt_tokens)
def test_to_dict(self):
r = InferResult(text="a", backend_name="b", model_id="m",
prompt_tokens=3, completion_tokens=5)
d = r.to_dict()
self.assertEqual(d["text"], "a")
self.assertEqual(d["prompt_tokens"], 3)
self.assertEqual(d["completion_tokens"], 5)
# ---------------------------------------------------------------------------
# 占位实现生命周期
# ---------------------------------------------------------------------------
class PlaceholderLifecycleTest(unittest.TestCase):
def setUp(self):
self.b = LocalBackend()
def test_health_reflects_load_state(self):
# 未加载 → 不健康
self.assertFalse(self.b.health_check().healthy)
self.b.load_model("local-70b-base")
self.assertTrue(self.b.health_check().healthy)
def test_load_is_idempotent(self):
self.b.load_model("local-70b-base")
# 重复加载同一 model_id 不报错
self.b.load_model("local-70b-base")
self.assertTrue(self.b.health_check().healthy)
def test_infer_lazy_loads_when_not_loaded(self):
# 演示态:未显式 load_model 也能 infer(惰性自加载)
r = self.b.infer("炉温是多少", context=["SOP-炉温"])
self.assertIsInstance(r, InferResult)
self.assertEqual(r.backend_name, "local-70b")
self.assertIn("炉温是多少", r.text)
self.assertIn("[来源: SOP-炉温]", r.text)
def test_infer_after_explicit_load(self):
self.b.load_model("local-70b-base")
r = self.b.infer("hello")
self.assertEqual(r.model_id, "local-70b-base")
self.assertIn("hello", r.text)
def test_unload_is_idempotent(self):
self.b.load_model("local-70b-base")
self.b.unload()
self.assertFalse(self.b.health_check().healthy)
# 未加载再 unload 也不报错
self.b.unload()
def test_echo_context_disabled(self):
b = LocalBackend(echo_context=False)
b.load_model("m")
r = b.infer("q", context=["src1", "src2"])
self.assertNotIn("[来源:", r.text)
# ---------------------------------------------------------------------------
# 向后兼容:generate 转发到 infer
# ---------------------------------------------------------------------------
class BackwardCompatGenerateTest(unittest.TestCase):
def test_generate_returns_text_of_infer(self):
b = CloudBackend()
b.load_model("cloud-qwen-plus")
txt = b.generate("海绵钛是什么", context=["科普手册"])
# 与 infer().text 一致
self.assertEqual(txt, b.infer("海绵钛是什么", context=["科普手册"]).text)
self.assertIn("云端API占位", txt)
self.assertIn("[来源: 科普手册]", txt)
def test_gateway_still_works_with_new_backends(self):
# 集成校验:LLMGateway.ask() 经 generate 路径仍正常(不导入失败)。
# 复用 test_gateway.py 的模板配置加载 prompts,避免默认空注册表 KeyError。
from llm_gateway.dlp import DlpEngine
from llm_gateway.gateway import LLMGateway
from llm_gateway.prompts import PromptRegistry
from llm_gateway.router import SensitivityRouter
cfg_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
prompts = PromptRegistry.from_template_config(
os.path.join(cfg_dir, "config", "prompts.template.yaml"))
router = SensitivityRouter.from_template_config(
os.path.join(cfg_dir, "config", "router.template.yaml"))
gw = LLMGateway(
dlp=DlpEngine(), router=router, prompts=prompts,
local=LocalBackend(), cloud=CloudBackend())
result = gw.ask("海绵钛是什么", rag_context=["科普手册"])
self.assertTrue(result.answer)
# 后端占位回显特征仍在(证明走的是新 backends 的 generate 路径)
self.assertIn("云端API占位", result.answer)
# ---------------------------------------------------------------------------
# 能力声明差异(本地 vs 云端)
# ---------------------------------------------------------------------------
class CapabilitiesDifferenceTest(unittest.TestCase):
def test_local_is_on_premises(self):
cap = LocalBackend().capabilities
self.assertTrue(cap.on_premises)
self.assertTrue(cap.streaming)
self.assertGreater(cap.max_concurrency, 0)
def test_cloud_is_off_premises(self):
cap = CloudBackend().capabilities
self.assertFalse(cap.on_premises)
self.assertTrue(cap.streaming)
def test_local_and_cloud_differ_on_premises(self):
# 关键差异:本地出厂内闭环,云端数据出厂
self.assertNotEqual(
LocalBackend().capabilities.on_premises,
CloudBackend().capabilities.on_premises,
)
# ---------------------------------------------------------------------------
# 注册表与配置驱动构造
# ---------------------------------------------------------------------------
class RegistryTest(unittest.TestCase):
def test_default_registry_has_known_backends(self):
reg = default_registry()
self.assertIn("local-70b", reg)
self.assertIn("cloud-api", reg)
self.assertIs(reg["local-70b"], LocalBackend)
self.assertIs(reg["cloud-api"], CloudBackend)
def test_build_backend_by_name(self):
b = build_backend("local-70b")
self.assertIsInstance(b, LocalBackend)
self.assertIsInstance(b, InferenceBackend)
self.assertEqual(b.name, "local-70b")
def test_build_unknown_backend_raises_with_hint(self):
with self.assertRaises(ValueError) as ctx:
build_backend("npu-cann") # 尚未实现(#59 才接入)
self.assertIn("npu-cann", str(ctx.exception))
self.assertIn("local-70b", str(ctx.exception)) # 提示已知项
def test_build_passes_kwargs(self):
b = build_backend("cloud-api", echo_context=False)
self.assertIsInstance(b, CloudBackend)
self.assertFalse(b.echo_context)
# ---------------------------------------------------------------------------
# 自定义后端通过实现接口接入(证明「业务代码不感知硬件」)
# ---------------------------------------------------------------------------
class CustomBackendImplementationTest(unittest.TestCase):
"""模拟 #59 昇腾后端:只需实现四个方法即可被当作 InferenceBackend 使用。"""
def test_custom_backend_satisfies_interface(self):
class NpuCannBackend(InferenceBackend):
name = "npu-cann"
def __init__(self):
self._loaded = False
def load_model(self, model_id):
self._loaded = True
def infer(self, prompt, context=None):
if not self._loaded:
self.load_model("ascend-cann")
return InferResult(text=f"[NPU] {prompt}", backend_name=self.name)
def health_check(self):
return BackendHealth(healthy=self._loaded)
def unload(self):
self._loaded = False
b = NpuCannBackend()
self.assertIsInstance(b, InferenceBackend)
self.assertFalse(b.health_check().healthy)
b.load_model("ascend-cann")
self.assertTrue(b.health_check().healthy)
self.assertEqual(b.infer("q").text, "[NPU] q")
# generate 兼容路径
self.assertEqual(b.generate("q", context=[]), "[NPU] q")
b.unload()
self.assertFalse(b.health_check().healthy)
if __name__ == "__main__":
unittest.main()