# -*- coding: utf-8 -*- """推理后端实现 —— 本地 70B 模型接入与推理封装(issue #44)。 在 `gateway.InferenceBackend` 抽象之上交付**真实可用的本地后端**: - OpenAI 兼容接口(vLLM / TGI 等本地推理服务,`/v1/chat/completions`), 仅用标准库 urllib,无第三方依赖; - 参数化:endpoint / model / timeout / max_tokens / temperature / context 引用注入; - **数据不出厂**(PRD 5.4):敏感/核心内容走本地后端,云端仅接收脱敏内容; - 未配置 endpoint 时进入 dry-run 占位模式(保持与旧 LocalBackend 一致的 可测试行为,供端到端演示与联调)。 业务代码只依赖 `gateway.InferenceBackend.generate(prompt, context)`, 切换后端 = 换实现(见 `LLMGateway(local=...)`)。 """ from __future__ import annotations import json import time import urllib.request from typing import Optional, Sequence from .gateway import InferenceBackend class Local70BBackend(InferenceBackend): """本地 70B 推理后端(OpenAI 兼容 vLLM/TGI,参数化)。""" name = "local-70b" def __init__( self, endpoint: str = "", model: str = "iaop-local-70b", timeout_seconds: float = 60.0, max_tokens: int = 1024, temperature: float = 0.1, echo_context: bool = True, ) -> None: self.endpoint = (endpoint or "").rstrip("/") self.model = model self.timeout = float(timeout_seconds) self.max_tokens = int(max_tokens) self.temperature = float(temperature) self.echo_context = echo_context # ------------------------------------------------------------------ def generate(self, prompt: str, context: Sequence[str]) -> str: """根据 prompt 与 RAG 上下文生成回答。 - 未配置 endpoint:dry-run 占位(回显 prompt 前 40 字符 + 来源引用); - 已配置:调用本地 OpenAI 兼容服务(/v1/chat/completions)。 """ if not self.endpoint: return self._dry_run(prompt, context) payload = { "model": self.model, "messages": [ {"role": "system", "content": self._system_prompt(context)}, {"role": "user", "content": prompt}, ], "max_tokens": self.max_tokens, "temperature": self.temperature, } body = self._post_json("/v1/chat/completions", payload) try: return body["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError): raise RuntimeError( f"本地推理服务响应格式异常: {str(body)[:200]}") # ------------------------------------------------------------------ def _system_prompt(self, context: Sequence[str]) -> str: """把 RAG 引用注入 system 提示(引用溯源,PRD 5.4)。""" refs = "\n".join(f"- {c}" for c in (context or [])) base = "你是工业 AI 助手。回答须基于给定资料并标注来源。" return f"{base}\n参考资料:\n{refs}" if refs else base def _dry_run(self, prompt: str, context: Sequence[str]) -> str: head = f"[本地70B占位] {prompt[:40]}" if self.echo_context: for i, src in enumerate(context[:3], 1): head += f"\n[来源: {src}]" return head def _post_json(self, path: str, payload: dict) -> dict: """向后端推理服务发起 JSON POST(标准库 urllib)。""" url = self.endpoint + path data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( url, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=self.timeout) as resp: raw = resp.read().decode("utf-8") return json.loads(raw) if raw else {} def health(self) -> dict: """后端健康信息(本地推理服务可探测 /health)。""" base = { "backend": self.name, "model": self.model, "endpoint": self.endpoint or "(dry-run)", } if not self.endpoint: base["status"] = "dry-run" return base try: started = time.monotonic() with urllib.request.urlopen( self.endpoint + "/health", timeout=self.timeout) as resp: base["status"] = "ok" if resp.status == 200 else f"http-{resp.status}" base["latency_ms"] = round((time.monotonic() - started) * 1000, 2) except Exception as exc: # noqa: BLE001 - 健康探测失败仅记录 base["status"] = f"error: {exc}" return base