feat: 完成 issue #6 LLM 网关 + RAG 模板化(混合网关主编排)
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
# -*- 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`(LocalBackend / CloudBackend):推理后端抽象(可注入)。
|
||||
|
||||
设计说明:
|
||||
- 本版提供**编排闭环 + 后端抽象接口**,本地 70B / 云端 API 的具体接入
|
||||
由子任务 #44 / #45 实现;`LocalBackend` / `CloudBackend` 默认内置一个
|
||||
最小实现(返回固定占位答案 + 回显引用),供端到端测试与演示。
|
||||
|
||||
测试:`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 Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from .dlp import DlpEngine
|
||||
from .router import RouteDecision, RouteTarget, SensitivityRouter
|
||||
from .prompts import PromptRegistry
|
||||
from .hallucination import GuardVerdict, HallucinationGuard
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 推理后端抽象(Issue #44 / #45 将实现具体后端,业务代码只依赖本接口)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InferenceBackend:
|
||||
"""推理后端接口抽象(对齐 PRD 5.6 InferenceBackend 思想)。
|
||||
|
||||
业务代码只依赖本接口,不感知具体硬件/厂商;切换后端 = 换实现。
|
||||
子任务 #44(本地 70B)、#45(云端 Qwen/DeepSeek)将各自实现本接口。
|
||||
"""
|
||||
|
||||
name: str = "base"
|
||||
|
||||
def generate(self, prompt: str, context: Sequence[str]) -> str:
|
||||
"""根据 prompt 与 RAG 上下文生成回答。子类实现。"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LocalBackend(InferenceBackend):
|
||||
"""本地 70B 后端占位实现:数据不出厂(敏感/核心走此通道)。
|
||||
|
||||
子任务 #44 将替换为真实本地模型推理封装(vLLM/TGI 等)。
|
||||
"""
|
||||
|
||||
name = "local-70b"
|
||||
|
||||
def __init__(self, echo_context: bool = True) -> None:
|
||||
self.echo_context = echo_context
|
||||
|
||||
def generate(self, prompt: str, context: Sequence[str]) -> str:
|
||||
head = f"[本地70B占位] {prompt[:40]}"
|
||||
refs = ""
|
||||
if self.echo_context:
|
||||
for i, src in enumerate(context[:3], 1):
|
||||
refs += f"\n[来源: {src}]"
|
||||
return head + refs
|
||||
|
||||
|
||||
class CloudBackend(InferenceBackend):
|
||||
"""云端 API 后端占位实现:仅接收 DLP 放行的脱敏/通用内容。
|
||||
|
||||
子任务 #45 将替换为 Qwen/DeepSeek API 接入 + 安全网关。
|
||||
"""
|
||||
|
||||
name = "cloud-api"
|
||||
|
||||
def __init__(self, echo_context: bool = True) -> None:
|
||||
self.echo_context = echo_context
|
||||
|
||||
def generate(self, prompt: str, context: Sequence[str]) -> str:
|
||||
head = f"[云端API占位] {prompt[:40]}"
|
||||
refs = ""
|
||||
if self.echo_context:
|
||||
for i, src in enumerate(context[:3], 1):
|
||||
refs += f"\n[来源: {src}]"
|
||||
return head + refs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 网关输出
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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}>")
|
||||
Reference in New Issue
Block a user