Files
Mutual_Fund/tool/llm.py
T
2026-09-14 10:57:48 +08:00

297 lines
12 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.
"""统一 LLM 客户端:本地 Ollama / OpenAI 兼容 API(api_key)双后端,按优先级降级。
设计:两个后端的底层都是 OpenAI 兼容 /v1 端点,业务代码一律调用 `chat()` / `embed()`,
不感知底层。后端链由 .env 决定(LLM_MODE):
- auto(默认):本地 Ollama 参数齐全则优先本地,LLM_API_KEY 齐全则作为其后的降级后端;
- ollama / api:强制只用该后端。
chat 降级链:后端内 主模型 → fallback_chat_model(每模型指数退避重试)→ 下一后端 → 全部失败抛
LLMFailError(§1001),调用方兜底话术见 FALLBACK_REPLY。
embed 只走首选后端、不跨后端降级:不同模型的向量空间不兼容,混入 Milvus 会污染检索。
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
import httpx
from config.settings import LLMCfg, settings
from utils.exceptions import LLMFailError
logger = logging.getLogger("tool.llm")
# 兜底话术:所有后端 + 备用模型均失败时返回
FALLBACK_REPLY = "抱歉,服务暂时不可用,请稍后再试,或拨打客服热线 400-XXX-XXXX。"
@dataclass(frozen=True)
class Backend:
"""一个 OpenAI 兼容端点及其模型;name 为 ollama / api。"""
name: str
base_url: str # 以 /v1 结尾
chat_model: str
embed_model: str
api_key: str = ""
api_trust_env: bool = True
@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=self.api_trust_env if not self.is_ollama else False)
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,
api_trust_env=False,
)
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,
api_trust_env=cfg.api_trust_env,
)
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
class LLMClient:
def __init__(self, cfg: LLMCfg | None = None):
self.cfg = cfg or settings.llm
self.backends = resolve_backends(self.cfg)
# ---- 首选后端(健康检查/日志/embed 使用) -----------------------------
@property
def primary(self) -> Backend:
return self.backends[0]
@property
def is_ollama(self) -> bool:
return self.primary.is_ollama
@property
def base_url(self) -> str:
return self.primary.base_url
@property
def chat_model(self) -> str:
return self.primary.chat_model
@property
def embed_model(self) -> str:
return self.primary.embed_model
def describe(self) -> str:
"""健康检查/日志用:后端链(首选在前)+ 各自模型。"""
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}"
# ---- 对话 -----------------------------------------------------------
async def chat(
self,
messages: list[dict],
*,
temperature: float | None = None,
max_tokens: int | None = None,
model: str | None = None,
) -> str:
"""chat/completions,返回 assistant 文本。
降级链:首选后端 [model > chat_model > fallback_chat_model] → 后续后端 [chat_model >
fallback_chat_model]。显式 model 只作用于首选后端——模型名与后端绑定,跨后端无意义。
"""
last_err: Exception | None = None
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)
raise LLMFailError() from last_err
async def _chat_once(
self,
backend: Backend,
model: str,
messages: list[dict],
temperature: float | None,
max_tokens: int | None,
) -> str:
request_max_tokens = self.cfg.max_tokens if max_tokens is None else max_tokens
url = f"{backend.base_url}/chat/completions"
# 指数退避:retry_backoff_sec → 翻倍 → …,最多 max_retries 次
for attempt in range(self.cfg.max_retries):
payload = {
"model": model,
"messages": messages,
"temperature": self.cfg.temperature if temperature is None else temperature,
"max_tokens": request_max_tokens,
"stream": False,
}
try:
async with backend.client(self.cfg.timeout) as client:
r = await client.post(url, headers=backend.headers, json=payload)
r.raise_for_status()
choice = r.json()["choices"][0]
message = choice["message"]
content = message.get("content")
if isinstance(content, str) and content.strip():
return content
# DeepSeek reasoning models may consume the entire small token budget
# in reasoning_content, leaving content empty and finish_reason=length.
if choice.get("finish_reason") == "length" and attempt < self.cfg.max_retries - 1:
request_max_tokens = max(request_max_tokens * 4, 256)
logger.warning(
"chat backend=%s model=%r returned empty content after token limit; "
"retrying with max_tokens=%d",
backend.name,
model,
request_max_tokens,
)
continue
raise ValueError("LLM 返回空内容")
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]]:
"""批量向量化,返回与 input 一一对应的向量列表。
只走首选后端(见模块说明)。按 OpenAI 兼容协议传 `dimensions`(来自
LLM_EMBED_DIMENSIONS),MRL 模型(qwen3-embedding 等)会截断并重新归一化到目标维度。
"""
backend = self.primary
embed_model = (
getattr(self.cfg, "api_embed_model", "")
if backend.name == "api"
else backend.embed_model
) or backend.embed_model
if not embed_model:
raise LLMFailError(f"backend={backend.name} 未配置 embed 模型")
is_dashscope_multimodal = "vision" in embed_model.lower() or "multimodal" in embed_model.lower()
if is_dashscope_multimodal:
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,
"dimensions": self.cfg.embed_dimensions,
}
response = None
max_retries = max(1, int(getattr(self.cfg, "max_retries", 1)))
retry_backoff_sec = float(getattr(self.cfg, "retry_backoff_sec", 0))
async with backend.client(self.cfg.timeout) as client:
for attempt in range(max_retries):
try:
response = await client.post(
url,
headers=backend.headers,
json=payload,
)
response.raise_for_status()
break
except Exception:
if attempt == max_retries - 1:
raise
await asyncio.sleep(retry_backoff_sec * (2**attempt))
if response is None: # pragma: no cover - defensive guard
raise LLMFailError("Embedding 请求未返回响应")
data = response.json()
if is_dashscope_multimodal:
embeddings = data["output"]["embeddings"]
return [
item["embedding"]
for item in sorted(embeddings, key=lambda item: item["index"])
]
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:
"""验证首选后端端点可达。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)
if r.status_code >= 500:
r.raise_for_status()
# 全局单例:Agent 统一引入
llm = LLMClient()