Files
iAOP/core/llm-gateway/backends.py
T

115 lines
4.6 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 -*-
"""推理后端实现 —— 本地 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