Files

221 lines
9.0 KiB
Python
Raw Permalink 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`(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}>")