"""LLM API 客户端,兼容 OpenAI API 格式"""
import json
import logging
import re
from typing import Optional
from openai import OpenAI, APIError
from config import settings
logger = logging.getLogger(__name__)
# 匹配 reasoning 模型(MiniMax-M3 / DeepSeek-R1 / GLM-Z1 等)的 ... 推理块
_THINK_RE = re.compile(r".*?", re.DOTALL)
def _parse_llm_json(content: str) -> dict:
"""从 LLM 输出中提取 JSON。
兼容 reasoning 模型在 json_object 模式下仍输出 ...
推理块、以及 JSON 前后有多余文本的情况。
"""
if not content or not content.strip():
raise ValueError("LLM 返回空内容,无法解析 JSON")
text = content.strip()
# 1) 去掉闭合的 ... 块
text = _THINK_RE.sub("", text).strip()
# 2) 处理只有 开头但未闭合(content 被截断)的情况
if text.startswith(""):
text = text.split("", 1)[-1].strip()
# 3) 尝试直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 4) 提取首个 { 到最后 } 之间的子串
start = text.find("{")
end = text.rfind("}")
if start != -1 and end > start:
try:
return json.loads(text[start : end + 1])
except json.JSONDecodeError:
pass
# 5) 兜底:尝试数组
start = text.find("[")
end = text.rfind("]")
if start != -1 and end > start:
return json.loads(text[start : end + 1])
logger.error("无法从 LLM 输出提取 JSON: %s", content[:500])
raise ValueError("LLM 输出无法解析为 JSON")
class AIClient:
"""封装 LLM 调用,支持重试和 JSON 输出。
配置以 property 形式运行时从 settings 读取,避免模块 import 时
固化旧值(settings 在 FastAPI lifespan 启动后才会被数据库配置覆盖)。
"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model: Optional[str] = None,
timeout: Optional[int] = None,
max_retries: Optional[int] = None,
):
# 仅保存显式传入的覆盖值;为 None 时运行时回退到 settings
self._api_key = api_key
self._base_url = base_url
self._model = model
self._timeout = timeout
self._max_retries = max_retries
@property
def api_key(self) -> str:
return self._api_key or settings.OPENAI_API_KEY
@property
def base_url(self) -> str:
return self._base_url or settings.OPENAI_BASE_URL
@property
def model(self) -> str:
return self._model or settings.OPENAI_MODEL
@property
def timeout(self) -> int:
return self._timeout or settings.OPENAI_TIMEOUT
@property
def max_retries(self) -> int:
return self._max_retries or settings.OPENAI_MAX_RETRIES
@property
def client(self) -> OpenAI:
# 每次按最新配置创建,确保用到启动后覆盖的真实配置
return OpenAI(
api_key=self.api_key,
base_url=self.base_url,
timeout=self.timeout,
max_retries=self.max_retries,
)
def chat_completion(
self,
system_prompt: str,
user_prompt: str,
temperature: float = 0.3,
json_mode: bool = False,
) -> str:
"""调用 LLM 返回文本"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
kwargs = {
"model": self.model,
"messages": messages,
"temperature": temperature,
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
try:
resp = self.client.chat.completions.create(**kwargs)
content = resp.choices[0].message.content or ""
return content.strip()
except APIError as exc:
logger.error("LLM API 调用失败: %s", exc)
raise
def chat_completion_json(
self,
system_prompt: str,
user_prompt: str,
temperature: float = 0.3,
) -> dict:
"""调用 LLM 并解析返回的 JSON(兼容 reasoning 模型的 块)"""
content = self.chat_completion(
system_prompt=system_prompt,
user_prompt=user_prompt,
temperature=temperature,
json_mode=True,
)
return _parse_llm_json(content)
ai_client = AIClient()