2026-09-11 17:31:16 +08:00
|
|
|
|
"""统一 LLM 客户端:本地 Ollama / OpenAI 兼容 API(api_key)双后端,按优先级降级。
|
2026-09-08 19:17:35 +08:00
|
|
|
|
|
2026-09-11 17:31:16 +08:00
|
|
|
|
设计:两个后端的底层都是 OpenAI 兼容 /v1 端点,业务代码一律调用 `chat()` / `embed()`,
|
|
|
|
|
|
不感知底层。后端链由 .env 决定(LLM_MODE):
|
|
|
|
|
|
- auto(默认):本地 Ollama 参数齐全则优先本地,LLM_API_KEY 齐全则作为其后的降级后端;
|
|
|
|
|
|
- ollama / api:强制只用该后端。
|
|
|
|
|
|
chat 降级链:后端内 主模型 → fallback_chat_model(每模型指数退避重试)→ 下一后端 → 全部失败抛
|
|
|
|
|
|
LLMFailError(§1001),调用方兜底话术见 FALLBACK_REPLY。
|
|
|
|
|
|
embed 只走首选后端、不跨后端降级:不同模型的向量空间不兼容,混入 Milvus 会污染检索。
|
2026-09-08 19:17:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
2026-09-11 17:31:16 +08:00
|
|
|
|
from dataclasses import dataclass
|
2026-09-08 19:17:35 +08:00
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
from config.settings import LLMCfg, settings
|
|
|
|
|
|
from utils.exceptions import LLMFailError
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("tool.llm")
|
|
|
|
|
|
|
2026-09-11 17:31:16 +08:00
|
|
|
|
# 兜底话术:所有后端 + 备用模型均失败时返回
|
2026-09-08 19:17:35 +08:00
|
|
|
|
FALLBACK_REPLY = "抱歉,服务暂时不可用,请稍后再试,或拨打客服热线 400-XXX-XXXX。"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 17:31:16 +08:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class Backend:
|
|
|
|
|
|
"""一个 OpenAI 兼容端点及其模型;name 为 ollama / api。"""
|
|
|
|
|
|
|
|
|
|
|
|
name: str
|
|
|
|
|
|
base_url: str # 以 /v1 结尾
|
|
|
|
|
|
chat_model: str
|
|
|
|
|
|
embed_model: str
|
|
|
|
|
|
api_key: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def is_ollama(self) -> bool:
|
|
|
|
|
|
return self.name == "ollama"
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def headers(self) -> dict:
|
|
|
|
|
|
h = {"Content-Type": "application/json"}
|
|
|
|
|
|
if self.api_key:
|
|
|
|
|
|
h["Authorization"] = f"Bearer {self.api_key}"
|
|
|
|
|
|
return h
|
|
|
|
|
|
|
|
|
|
|
|
def client(self, timeout: float) -> httpx.AsyncClient:
|
|
|
|
|
|
"""ollama 走本机端点,必须绕过系统代理:httpx 会读取 Windows 系统代理,
|
|
|
|
|
|
但不识别其 ProxyOverride 白名单,localhost 请求会被转发到代理并返回 502。
|
|
|
|
|
|
API 后端保留 trust_env,远端接口可能依赖代理。"""
|
|
|
|
|
|
return httpx.AsyncClient(timeout=timeout, trust_env=not self.is_ollama)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_backends(cfg: LLMCfg) -> list[Backend]:
|
|
|
|
|
|
"""按 LLM_MODE 与 .env 完整度解析后端链,首个为首选后端。"""
|
|
|
|
|
|
ollama = (
|
|
|
|
|
|
Backend(
|
|
|
|
|
|
name="ollama",
|
|
|
|
|
|
base_url=cfg.ollama_base.rstrip("/") + "/v1",
|
|
|
|
|
|
chat_model=cfg.ollama_chat_model,
|
|
|
|
|
|
embed_model=cfg.ollama_embed_model,
|
|
|
|
|
|
)
|
|
|
|
|
|
if cfg.ollama_base and cfg.ollama_chat_model
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
|
|
|
|
|
api = (
|
|
|
|
|
|
Backend(
|
|
|
|
|
|
name="api",
|
|
|
|
|
|
base_url=cfg.api_base.rstrip("/"),
|
|
|
|
|
|
chat_model=cfg.api_chat_model,
|
|
|
|
|
|
embed_model=cfg.api_embed_model,
|
|
|
|
|
|
api_key=cfg.api_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
if cfg.api_key and cfg.api_base and cfg.api_chat_model
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
|
|
|
|
|
mode = cfg.mode.lower()
|
|
|
|
|
|
if mode == "ollama":
|
|
|
|
|
|
chain = [ollama]
|
|
|
|
|
|
elif mode == "api":
|
|
|
|
|
|
chain = [api]
|
|
|
|
|
|
elif mode == "auto":
|
|
|
|
|
|
chain = [ollama, api] # 本地优先,API 兜底
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(f"LLM_MODE 无效: {cfg.mode!r},应为 auto / ollama / api")
|
|
|
|
|
|
chain = [b for b in chain if b is not None]
|
|
|
|
|
|
if not chain:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"LLM_MODE={cfg.mode} 下没有可用后端:本地需 LLM_OLLAMA_BASE + LLM_OLLAMA_CHAT_MODEL,"
|
|
|
|
|
|
"API 需 LLM_API_KEY + LLM_API_BASE + LLM_API_CHAT_MODEL"
|
|
|
|
|
|
)
|
|
|
|
|
|
return chain
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 19:17:35 +08:00
|
|
|
|
class LLMClient:
|
|
|
|
|
|
def __init__(self, cfg: LLMCfg | None = None):
|
|
|
|
|
|
self.cfg = cfg or settings.llm
|
2026-09-11 17:31:16 +08:00
|
|
|
|
self.backends = resolve_backends(self.cfg)
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 首选后端(健康检查/日志/embed 使用) -----------------------------
|
|
|
|
|
|
@property
|
|
|
|
|
|
def primary(self) -> Backend:
|
|
|
|
|
|
return self.backends[0]
|
2026-09-08 19:17:35 +08:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def is_ollama(self) -> bool:
|
2026-09-11 17:31:16 +08:00
|
|
|
|
return self.primary.is_ollama
|
2026-09-08 19:17:35 +08:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def base_url(self) -> str:
|
2026-09-11 17:31:16 +08:00
|
|
|
|
return self.primary.base_url
|
2026-09-08 19:17:35 +08:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def chat_model(self) -> str:
|
2026-09-11 17:31:16 +08:00
|
|
|
|
return self.primary.chat_model
|
2026-09-08 19:17:35 +08:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def embed_model(self) -> str:
|
2026-09-11 17:31:16 +08:00
|
|
|
|
return self.primary.embed_model
|
2026-09-08 19:17:35 +08:00
|
|
|
|
|
|
|
|
|
|
def describe(self) -> str:
|
2026-09-11 17:31:16 +08:00
|
|
|
|
"""健康检查/日志用:后端链(首选在前)+ 各自模型。"""
|
|
|
|
|
|
chain = " -> ".join(
|
|
|
|
|
|
f"{b.name}[{b.base_url}] chat={b.chat_model} embed={b.embed_model or '-'}"
|
|
|
|
|
|
for b in self.backends
|
|
|
|
|
|
)
|
|
|
|
|
|
return f"mode={self.cfg.mode} {chain}"
|
2026-09-08 19:17:35 +08:00
|
|
|
|
|
|
|
|
|
|
# ---- 对话 -----------------------------------------------------------
|
|
|
|
|
|
async def chat(
|
|
|
|
|
|
self,
|
|
|
|
|
|
messages: list[dict],
|
|
|
|
|
|
*,
|
|
|
|
|
|
temperature: float | None = None,
|
|
|
|
|
|
max_tokens: int | None = None,
|
|
|
|
|
|
model: str | None = None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""chat/completions,返回 assistant 文本。
|
|
|
|
|
|
|
2026-09-11 17:31:16 +08:00
|
|
|
|
降级链:首选后端 [model > chat_model > fallback_chat_model] → 后续后端 [chat_model >
|
|
|
|
|
|
fallback_chat_model]。显式 model 只作用于首选后端——模型名与后端绑定,跨后端无意义。
|
2026-09-08 19:17:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
last_err: Exception | None = None
|
2026-09-11 17:31:16 +08:00
|
|
|
|
for index, backend in enumerate(self.backends):
|
|
|
|
|
|
models = [(model or backend.chat_model) if index == 0 else backend.chat_model]
|
|
|
|
|
|
if self.cfg.fallback_chat_model and self.cfg.fallback_chat_model not in models:
|
|
|
|
|
|
models.append(self.cfg.fallback_chat_model)
|
|
|
|
|
|
for m in models:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await self._chat_once(backend, m, messages, temperature, max_tokens)
|
|
|
|
|
|
except Exception as e: # noqa: BLE001 重试/切换模型或后端,异常向上收敛
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"chat backend=%s model=%r failed: %s: %s",
|
|
|
|
|
|
backend.name, m, type(e).__name__, e,
|
|
|
|
|
|
)
|
|
|
|
|
|
last_err = e
|
|
|
|
|
|
if index < len(self.backends) - 1:
|
|
|
|
|
|
logger.warning("backend=%s exhausted, falling back to %s",
|
|
|
|
|
|
backend.name, self.backends[index + 1].name)
|
2026-09-08 19:17:35 +08:00
|
|
|
|
raise LLMFailError() from last_err
|
|
|
|
|
|
|
|
|
|
|
|
async def _chat_once(
|
|
|
|
|
|
self,
|
2026-09-11 17:31:16 +08:00
|
|
|
|
backend: Backend,
|
2026-09-08 19:17:35 +08:00
|
|
|
|
model: str,
|
|
|
|
|
|
messages: list[dict],
|
|
|
|
|
|
temperature: float | None,
|
|
|
|
|
|
max_tokens: int | None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"messages": messages,
|
|
|
|
|
|
"temperature": self.cfg.temperature if temperature is None else temperature,
|
|
|
|
|
|
"max_tokens": self.cfg.max_tokens if max_tokens is None else max_tokens,
|
|
|
|
|
|
"stream": False,
|
|
|
|
|
|
}
|
2026-09-11 17:31:16 +08:00
|
|
|
|
url = f"{backend.base_url}/chat/completions"
|
2026-09-08 19:17:35 +08:00
|
|
|
|
# 指数退避:retry_backoff_sec → 翻倍 → …,最多 max_retries 次
|
|
|
|
|
|
for attempt in range(self.cfg.max_retries):
|
|
|
|
|
|
try:
|
2026-09-11 17:31:16 +08:00
|
|
|
|
async with backend.client(self.cfg.timeout) as client:
|
|
|
|
|
|
r = await client.post(url, headers=backend.headers, json=payload)
|
2026-09-08 19:17:35 +08:00
|
|
|
|
r.raise_for_status()
|
|
|
|
|
|
return r.json()["choices"][0]["message"]["content"]
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
if attempt == self.cfg.max_retries - 1:
|
|
|
|
|
|
raise
|
|
|
|
|
|
await asyncio.sleep(self.cfg.retry_backoff_sec * (2**attempt))
|
|
|
|
|
|
raise LLMFailError() # 理论不可达,防御
|
|
|
|
|
|
|
|
|
|
|
|
# ---- Embedding(RAG 入口的统一向量化) -------------------------------
|
|
|
|
|
|
async def embed(self, texts: list[str]) -> list[list[float]]:
|
2026-09-11 17:31:16 +08:00
|
|
|
|
"""批量向量化,返回与 input 一一对应的向量列表。
|
|
|
|
|
|
|
|
|
|
|
|
只走首选后端(见模块说明)。按 OpenAI 兼容协议传 `dimensions`(来自
|
|
|
|
|
|
LLM_EMBED_DIMENSIONS),MRL 模型(qwen3-embedding 等)会截断并重新归一化到目标维度。
|
|
|
|
|
|
"""
|
|
|
|
|
|
backend = self.primary
|
2026-09-13 16:19:24 +08:00
|
|
|
|
embed_model = (
|
|
|
|
|
|
getattr(self.cfg, "api_embed_model", "")
|
|
|
|
|
|
if backend.name == "api"
|
|
|
|
|
|
else backend.embed_model
|
|
|
|
|
|
) or backend.embed_model
|
|
|
|
|
|
if not embed_model:
|
2026-09-11 17:31:16 +08:00
|
|
|
|
raise LLMFailError(f"backend={backend.name} 未配置 embed 模型")
|
2026-09-13 16:19:24 +08:00
|
|
|
|
is_dashscope_compatible = "/compatible-mode/" in backend.base_url.lower()
|
|
|
|
|
|
if is_dashscope_compatible:
|
|
|
|
|
|
base_url = backend.base_url.rstrip("/")
|
|
|
|
|
|
marker = "/compatible-mode/v1"
|
|
|
|
|
|
if base_url.lower().endswith(marker):
|
|
|
|
|
|
base_url = base_url[: -len(marker)]
|
|
|
|
|
|
url = f"{base_url}/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding"
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"model": embed_model,
|
|
|
|
|
|
"input": {"contents": [{"text": value} for value in texts]},
|
|
|
|
|
|
}
|
|
|
|
|
|
else:
|
|
|
|
|
|
url = f"{backend.base_url}/embeddings"
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"model": embed_model,
|
|
|
|
|
|
"input": texts,
|
2026-09-13 16:53:53 +08:00
|
|
|
|
"dimensions": self.cfg.embed_dimensions,
|
2026-09-13 16:19:24 +08:00
|
|
|
|
}
|
2026-09-11 17:31:16 +08:00
|
|
|
|
async with backend.client(self.cfg.timeout) as client:
|
|
|
|
|
|
r = await client.post(url, headers=backend.headers, json=payload)
|
2026-09-08 19:17:35 +08:00
|
|
|
|
r.raise_for_status()
|
|
|
|
|
|
data = r.json()
|
2026-09-13 16:19:24 +08:00
|
|
|
|
if is_dashscope_compatible:
|
|
|
|
|
|
embeddings = data["output"]["embeddings"]
|
|
|
|
|
|
return [
|
|
|
|
|
|
item["embedding"]
|
|
|
|
|
|
for item in sorted(embeddings, key=lambda item: item["index"])
|
|
|
|
|
|
]
|
2026-09-08 19:17:35 +08:00
|
|
|
|
return [item["embedding"] for item in data["data"]]
|
|
|
|
|
|
|
|
|
|
|
|
async def embed_one(self, text: str) -> list[float]:
|
|
|
|
|
|
return (await self.embed([text]))[0]
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 就绪探测(启动/健康检查可选接入) --------------------------------
|
|
|
|
|
|
async def check_health(self) -> None:
|
2026-09-11 17:31:16 +08:00
|
|
|
|
"""验证首选后端端点可达。ollama 走 /api/tags,API 走 /models(需鉴权,401 也算可达)。"""
|
|
|
|
|
|
backend = self.primary
|
|
|
|
|
|
if backend.is_ollama:
|
|
|
|
|
|
url = backend.base_url.removesuffix("/v1") + "/api/tags"
|
|
|
|
|
|
else:
|
|
|
|
|
|
url = f"{backend.base_url}/models"
|
|
|
|
|
|
async with backend.client(min(self.cfg.timeout, 10)) as client:
|
|
|
|
|
|
r = await client.get(url, headers=backend.headers)
|
2026-09-08 19:17:35 +08:00
|
|
|
|
if r.status_code >= 500:
|
|
|
|
|
|
r.raise_for_status()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 全局单例:Agent 统一引入
|
2026-09-11 17:31:16 +08:00
|
|
|
|
llm = LLMClient()
|