"""
LLM客户端封装
统一使用OpenAI格式调用
"""
import json
import re
from typing import Optional, Dict, Any, List
from openai import OpenAI
from ..config import Config
class LLMClient:
"""LLM客户端"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model: Optional[str] = None
):
self.api_key = api_key or Config.LLM_API_KEY
self.base_url = base_url or Config.LLM_BASE_URL
self.model = model or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY 未配置")
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
def chat(
self,
messages: List[Dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 4096,
response_format: Optional[Dict] = None
) -> Optional[str]:
"""
发送聊天请求
Args:
messages: 消息列表
temperature: 温度参数
max_tokens: 最大token数
response_format: 响应格式(如JSON模式)
Returns:
模型响应文本,若模型返回空内容则返回 None
"""
kwargs = {
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if response_format:
kwargs["response_format"] = response_format
response = self.client.chat.completions.create(**kwargs)
message = response.choices[0].message
content = message.content
# 部分推理模型(如 Qwen3-thinking、DeepSeek-R1)在思维链模式下
# 会把思考过程放入 reasoning_content,content 字段可能为 None 或空字符串。
# 此时尝试从 reasoning_content 兜底提取实际回答。
if not content:
reasoning = getattr(message, 'reasoning_content', None)
if reasoning:
# reasoning_content 是思考过程,不是最终答案;
# 说明模型在思考阶段被截断,无有效 content,返回 None
pass
return None
# 移除部分模型在 content 中内联的 ... 思考块
# (如 MiniMax M2.5 / 部分 Qwen3 非流式模式)
content = re.sub(r'[\s\S]*?', '', content).strip()
return content if content else None
def chat_json(
self,
messages: List[Dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 4096
) -> Dict[str, Any]:
"""
发送聊天请求并返回JSON
不使用 response_format=json_object,以兼容思维链推理模型
(Qwen3-thinking、DeepSeek-R1 等不支持该参数)。
改为从模型的文本输出中手动提取 JSON。
Args:
messages: 消息列表
temperature: 温度参数
max_tokens: 最大token数
Returns:
解析后的JSON对象
"""
response = self.chat(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
# 不传 response_format,避免思维链模型返回 content=None
)
if response is None:
raise ValueError(
"LLM 返回内容为空(content=None)。"
"可能原因:模型为推理/思维链模型且内容被截断,或 max_tokens 不足。"
f"当前模型: {self.model}"
)
# 从文本中提取 JSON(兼容 markdown 代码块和裸 JSON 两种格式)
cleaned = response.strip()
# 优先尝试提取 ```json ... ``` 代码块
json_block = re.search(r'```(?:json)?\s*\n?([\s\S]*?)\n?```', cleaned, re.IGNORECASE)
if json_block:
cleaned = json_block.group(1).strip()
else:
# 去除首尾的 markdown 围栏(无语言标记的情况)
cleaned = re.sub(r'^```\s*\n?', '', cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r'\n?```\s*$', '', cleaned)
cleaned = cleaned.strip()
# 尝试从文本中定位第一个 { 到最后一个 },提取 JSON 主体
start = cleaned.find('{')
end = cleaned.rfind('}')
if start != -1 and end != -1 and end > start:
cleaned = cleaned[start:end + 1]
try:
return json.loads(cleaned)
except json.JSONDecodeError:
raise ValueError(f"LLM返回的JSON格式无效: {cleaned[:500]}")