Files
iAOP/core/llm-gateway/gateway.py
T
bot_dev1 03ae81a217 feat(#57): InferenceBackend 抽象接口定义(PRD 5.6 loadModel/infer/health/unload)
将原先内联在 gateway.py 的薄弱 InferenceBackend 提炼为正式抽象基类(ABC),
对齐 PRD 5.6「⑥ 部署底座」契约,为 #44/#45/#58/#59 各类后端提供统一接入点。

实现内容:
- 新增 core/llm-gateway/backends.py:
  · InferenceBackend(ABC):PRD 要求的四个生命周期方法 load_model / infer /
    health_check / unload(均幂等),能力声明 capabilities,并保留 generate()
    向后兼容(转发到 infer().text)
  · 值对象 BackendCapabilities(streaming/max_concurrency/on_premises/modalities)
    / BackendHealth(healthy/detail/checked_at)/ InferResult(text+审计元信息)
  · LocalBackend / CloudBackend 占位实现迁移至此并继承新 ABC,补齐生命周期
  · default_registry + build_backend:配置驱动切换后端(未知 name 报错并提示已知项)
- gateway.py:删除内联定义,改为从 backends.py 再导出,LLMGateway.ask() 调用路径不变
- __init__.py:再导出新符号(BackendCapabilities/BackendHealth/InferResult/
  build_backend/default_registry),InferenceBackend 现为 ABC

设计原则:业务代码仅依赖接口,不感知硬件;切换后端 = 换实现 + 改配置,业务零改动。

测试:core/llm-gateway 全量 97 个用例通过(新增 27 + 既有 70,零回归)。
运行:python -m unittest discover -s tests -v(在 core/llm-gateway 目录下)
2026-08-04 21:04:00 +08:00

178 lines
7.7 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 -*-
"""iAOP-Core · LLM 网关 —— 混合网关主编排(EPIC #6 主体交付)。
对应 PRD 5.4「④ LLM 网关 + RAG」与 EPIC #6(Issue #48 DLP / #46 RAG 模板化
已完成,本模块为其上层编排):
用户提问 → 敏感度路由 → 本地/云端生成 → RAG 溯源校验 → 返回带引用答案
└──── DLP 出站检查(fail-closed,拦截即转本地/人工)────┘
`LLMGateway.ask()` 串起四个可插拔组件:
- `dlp`(DlpEngine):出站防线,云端通道必经检查;
- `router`(SensitivityRouter):敏感度分级路由(local / cloud / block);
- `prompts`(PromptRegistry):提示词模板版本绑定(可复现);
- `guard`(HallucinationGuard):引用溯源 + 信度阈值 → 人工确认;
- `backends`(InferenceBackend / LocalBackend / CloudBackend):推理后端抽象
(可注入)。接口定义已提炼到 `backends.py`(Issue #57,对齐 PRD 5.6)。
设计说明:
- 本版提供**编排闭环 + 后端抽象接口**,本地 70B / 云端 API 的具体接入
由子任务 #44 / #45 实现;`LocalBackend` / `CloudBackend` 默认内置一个
最小实现(返回固定占位答案 + 回显引用),供端到端测试与演示。
- 推理后端契约(`loadModel / infer / health_check / unload`)见 `backends.py`,
本模块仅消费其 `generate` / `name`,业务代码不感知具体硬件。
测试:`python -m unittest discover -s tests -v`(在 core/llm-gateway 目录下执行)。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Dict, List, Optional, Sequence
from .dlp import DlpEngine
from .router import RouteDecision, RouteTarget, SensitivityRouter
from .prompts import PromptRegistry
from .hallucination import GuardVerdict, HallucinationGuard
# 推理后端抽象(Issue #57):契约定义在 backends.py,这里仅做再导出,
# 保持 ``from .gateway import InferenceBackend/LocalBackend/CloudBackend`` 的
# 向后兼容(既有 import 路径与 ``LLMGateway`` 依赖均不变)。
from .backends import (
BackendCapabilities,
BackendHealth,
CloudBackend,
InferResult,
InferenceBackend,
LocalBackend,
build_backend,
default_registry,
)
# ---------------------------------------------------------------------------
# 网关输出
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class GatewayResult:
"""一次 ask() 的完整结果(含中间决策,便于审计与验收)。"""
query: str
answer: str
route: RouteDecision
verdict: GuardVerdict
answer_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
@property
def needs_human(self) -> bool:
"""是否需转人工确认(路由 block 或校验 human_review/unsupported)。"""
return (self.route.target == RouteTarget.BLOCK
or self.verdict.action in ("human_review", "unsupported"))
def to_dict(self) -> Dict[str, object]:
return {
"answer_id": self.answer_id,
"created_at": self.created_at,
"query": self.query,
"answer": self.answer,
"route": self.route.to_dict(),
"verdict": self.verdict.to_dict(),
"needs_human": self.needs_human,
}
# ---------------------------------------------------------------------------
# 混合网关主编排
# ---------------------------------------------------------------------------
class LLMGateway:
"""混合 LLM 网关主编排:路由 → 生成 → 溯源校验 → DLP 出站防线。
构造参数均可注入(默认带内置 DLP 保底规则 + 空 router/prompts/guard)。
"""
def __init__(self,
dlp: Optional[DlpEngine] = None,
router: Optional[SensitivityRouter] = None,
prompts: Optional[PromptRegistry] = None,
guard: Optional[HallucinationGuard] = None,
local: Optional[InferenceBackend] = None,
cloud: Optional[InferenceBackend] = None,
prompt_name: str = "qa",
prompt_version: Optional[str] = None,
high_stakes_names: Optional[List[str]] = None) -> None:
self.dlp = dlp or DlpEngine()
self.router = router or SensitivityRouter()
self.prompts = prompts or PromptRegistry()
self.guard = guard or HallucinationGuard()
self.local = local or LocalBackend()
self.cloud = cloud or CloudBackend()
self.prompt_name = prompt_name
self.prompt_version = prompt_version
# 高利害提示词:命中即启用信度阈值(处置建议 / 报警解释等)
self.high_stakes_names = set(high_stakes_names or [])
# -- 主编排入口 --------------------------------------------------------
def ask(self, query: str,
rag_context: Optional[Sequence[str]] = None,
confidence: float = 1.0) -> GatewayResult:
"""完整处理一次用户提问。
`rag_context`:RAG 检索命中的文档标题列表(溯源校验用);
`confidence`:模型输出信度(0~1,高利害场景低于阈值转人工)。
"""
rag_context = list(rag_context or [])
# 1) DLP 出站检查:query 敏感即拦截(fail-closed,云端不可达)
dlp_result = self.dlp.check_outbound({"query": query})
# 2) 敏感度路由(含 DLP 结果 → block)
decision = self.router.route(query, dlp_blocked=dlp_result.blocked)
# 3) 选择后端与提示词版本(运行时绑定,可复现)
prompt = self.prompts.get(self.prompt_name, self.prompt_version)
backend = self.local if decision.target != RouteTarget.CLOUD else self.cloud
# 4) 生成(block 时也不调用后端,直接给出人工确认占位答案)
if decision.target == RouteTarget.BLOCK:
answer = "该请求已拦截(敏感度路由/规则触发),请转人工确认处理。"
backend_name = "none"
else:
rendered = prompt.render(query=query)
answer = backend.generate(rendered, rag_context)
backend_name = backend.name
# 5) 幻觉/事实性校验(引用溯源 + 高利害信度阈值)
high_stakes = prompt.name in self.high_stakes_names
verdict = self.guard.check(
answer=answer, sources=rag_context,
confidence=confidence, high_stakes=high_stakes,
)
# 6) 出站前最终 DLP 防线(模型输出若含敏感内容:云端通道拦截)
if decision.target == RouteTarget.CLOUD:
outbound = self.dlp.check_outbound({"output": answer})
if outbound.blocked:
answer = "输出经 DLP 复查拦截,已转本地/人工处理。"
return GatewayResult(
query=query, answer=answer, route=decision, verdict=verdict,
)
# -- 审计汇总 ----------------------------------------------------------
def drain_audits(self) -> Dict[str, List[Dict[str, object]]]:
"""取走各组件审计记录(DLP / 路由 / Prompt / 幻觉校验)。"""
return {
"dlp": self.dlp.drain_audit(),
"router": self.router.drain_audit(),
"prompts": self.prompts.drain_audit(),
"guard": self.guard.drain_audit(),
}
def __repr__(self) -> str: # pragma: no cover - 调试辅助
return (f"<LLMGateway router={self.router!r} prompts={self.prompts!r} "
f"guard={self.guard!r}>")