Files
iAOP/core/llm-gateway/tests/test_backends.py
T

302 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()