init
This commit is contained in:
287
backend/app/services/llm_client.py
Normal file
287
backend/app/services/llm_client.py
Normal file
@@ -0,0 +1,287 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
import anthropic
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""LLM 客户端,支持 OpenAI 和 Anthropic SDK"""
|
||||
|
||||
def __init__(self):
|
||||
self._openai_client = None
|
||||
self._anthropic_client = None
|
||||
self._api_key = settings.LLM_API_KEY
|
||||
self._base_url = settings.LLM_BASE_URL
|
||||
self._model = settings.LLM_MODEL
|
||||
self._provider = settings.LLM_PROVIDER
|
||||
|
||||
def _get_openai_client(self) -> AsyncOpenAI:
|
||||
if self._openai_client is None:
|
||||
self._openai_client = AsyncOpenAI(
|
||||
api_key=self._api_key,
|
||||
base_url=self._base_url,
|
||||
max_retries=0,
|
||||
timeout=300.0,
|
||||
)
|
||||
return self._openai_client
|
||||
|
||||
def _get_anthropic_client(self) -> anthropic.AsyncAnthropic:
|
||||
if self._anthropic_client is None:
|
||||
# MiniMax Anthropic 端点
|
||||
base_url = self._base_url.replace("/v1", "/anthropic").replace("api.minimax.chat", "api.minimaxi.com")
|
||||
if not base_url.endswith("/anthropic"):
|
||||
# 如果 base_url 不是标准格式,手动拼接
|
||||
base_url = "https://api.minimaxi.com/anthropic"
|
||||
self._anthropic_client = anthropic.AsyncAnthropic(
|
||||
api_key=self._api_key,
|
||||
base_url=base_url,
|
||||
timeout=300.0,
|
||||
)
|
||||
return self._anthropic_client
|
||||
|
||||
def _use_anthropic(self) -> bool:
|
||||
"""判断是否使用 Anthropic SDK"""
|
||||
return False # MiniMax thinking 占用太多 token,统一用 OpenAI SDK
|
||||
|
||||
def update_config(self, base_url: str = None, api_key: str = None, model: str = None):
|
||||
"""更新配置(设置页面保存后调用)"""
|
||||
if base_url:
|
||||
self._base_url = base_url
|
||||
if api_key:
|
||||
self._api_key = api_key
|
||||
if model:
|
||||
self._model = model
|
||||
# 重置客户端
|
||||
self._openai_client = None
|
||||
self._anthropic_client = None
|
||||
# 更新 provider 判断
|
||||
if base_url:
|
||||
self._provider = "minimax" if "minimax" in base_url.lower() else "openai"
|
||||
|
||||
async def chat(self, messages: list[dict], temperature: float = None, max_tokens: int = 16384, timeout: float = 300.0) -> str:
|
||||
"""发送对话请求,返回文本内容(不含思考过程)"""
|
||||
if temperature is None:
|
||||
temperature = settings.TEMPERATURE
|
||||
|
||||
if self._use_anthropic():
|
||||
return await self._chat_anthropic(messages, temperature, max_tokens, timeout)
|
||||
else:
|
||||
result = await self._chat_openai(messages, temperature, max_tokens, timeout)
|
||||
return result["content"]
|
||||
|
||||
async def chat_detail(self, messages: list[dict], temperature: float = None, max_tokens: int = 16384, timeout: float = 300.0) -> dict:
|
||||
"""发送对话请求,返回详细信息(content + reasoning + finish_reason + usage + elapsed)"""
|
||||
if temperature is None:
|
||||
temperature = settings.TEMPERATURE
|
||||
|
||||
if self._use_anthropic():
|
||||
# Anthropic 暂不支持 detail
|
||||
content = await self._chat_anthropic(messages, temperature, max_tokens, timeout)
|
||||
return {"content": content, "reasoning_content": "", "finish_reason": "stop", "usage": {}, "elapsed": 0}
|
||||
else:
|
||||
return await self._chat_openai(messages, temperature, max_tokens, timeout)
|
||||
|
||||
async def _chat_openai(self, messages: list[dict], temperature: float, max_tokens: int, timeout: float = 300.0) -> dict:
|
||||
"""OpenAI SDK 调用,返回详细信息 dict"""
|
||||
client = self._get_openai_client()
|
||||
t0 = time.time()
|
||||
response = await client.chat.completions.create(
|
||||
model=self._model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
content = response.choices[0].message.content or ""
|
||||
# 提取 reasoning_content(thinking)
|
||||
reasoning = ""
|
||||
msg = response.choices[0].message
|
||||
if hasattr(msg, 'reasoning_content') and msg.reasoning_content:
|
||||
reasoning = msg.reasoning_content
|
||||
# 有些 SDK 把 reasoning 放在 model_extra 里
|
||||
if not reasoning and hasattr(msg, 'model_extra') and isinstance(msg.model_extra, dict):
|
||||
reasoning = msg.model_extra.get("reasoning_content", "")
|
||||
|
||||
# 检查是否有多个 choice
|
||||
if len(response.choices) > 1:
|
||||
print(f"[LLM] 警告: 返回了 {len(response.choices)} 个 choices")
|
||||
# 检查 finish_reason
|
||||
reason = response.choices[0].finish_reason if response.choices else "unknown"
|
||||
if reason != "stop":
|
||||
print(f"[LLM] 警告: finish_reason={reason}, 可能被截断")
|
||||
|
||||
# usage 信息
|
||||
usage = {}
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
usage = {
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
}
|
||||
if hasattr(response.usage, 'completion_tokens_details') and response.usage.completion_tokens_details:
|
||||
details = response.usage.completion_tokens_details
|
||||
usage["reasoning_tokens"] = getattr(details, 'reasoning_tokens', 0) or 0
|
||||
|
||||
# 保存完整响应到文件,供调试
|
||||
try:
|
||||
resp_dict = response.model_dump() if hasattr(response, 'model_dump') else str(response)
|
||||
debug_path = f"/tmp/llm_response_{id(response) % 100000}.json"
|
||||
with open(debug_path, "w", encoding="utf-8") as _f:
|
||||
json.dump(resp_dict, _f, ensure_ascii=False, indent=2, default=str)
|
||||
print(f"[LLM] 完整响应已保存: {debug_path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"reasoning_content": reasoning,
|
||||
"finish_reason": reason,
|
||||
"usage": usage,
|
||||
"elapsed": round(elapsed, 2),
|
||||
}
|
||||
|
||||
async def _chat_anthropic(self, messages: list[dict], temperature: float, max_tokens: int, timeout: float = 300.0) -> str:
|
||||
"""Anthropic SDK 调用 - thinking 和 text 自动分离"""
|
||||
client = self._get_anthropic_client()
|
||||
|
||||
# 转换消息格式:OpenAI -> Anthropic
|
||||
system_msg = ""
|
||||
anthropic_messages = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
if role == "system":
|
||||
system_msg = content
|
||||
elif role in ("user", "assistant"):
|
||||
anthropic_messages.append({"role": role, "content": content})
|
||||
|
||||
kwargs = {
|
||||
"model": self._model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": anthropic_messages,
|
||||
"thinking": {"type": "disabled"}, # 禁用 thinking,避免占用 token
|
||||
}
|
||||
if system_msg:
|
||||
kwargs["system"] = system_msg
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
response = await client.messages.create(**kwargs)
|
||||
|
||||
# 从 response 中提取文本内容,thinking 自动分离在 block.thinking 中
|
||||
text_parts = []
|
||||
for block in response.content:
|
||||
if hasattr(block, 'text') and block.type == 'text':
|
||||
text_parts.append(block.text)
|
||||
# thinking 内容自动忽略
|
||||
|
||||
result = "\n".join(text_parts)
|
||||
return result
|
||||
|
||||
async def chat_json(self, messages: list[dict], temperature: float = None, max_tokens: int = 16384) -> dict:
|
||||
"""发送对话请求,期望返回 JSON"""
|
||||
raw = await self.chat(messages, temperature, max_tokens=max_tokens)
|
||||
return _parse_json(raw)
|
||||
|
||||
async def test_connection(self) -> dict:
|
||||
"""测试 LLM 连接"""
|
||||
try:
|
||||
response = await self.chat([{"role": "user", "content": "请回复:连接成功"}])
|
||||
return {"success": True, "message": f"连接成功,模型回复:{response[:50]}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"连接失败:{str(e)}"}
|
||||
|
||||
|
||||
def _parse_json(raw: str) -> dict:
|
||||
"""健壮的 JSON 解析,处理 LLM 返回的各种格式问题"""
|
||||
print(f"[_parse_json] raw长度={len(raw)}, 前100字={raw[:100]}")
|
||||
print(f"[_parse_json] raw后100字={raw[-100:]}")
|
||||
raw = raw.strip()
|
||||
|
||||
# 去掉 markdown 代码块
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r'^```\w*\n?', '', raw)
|
||||
raw = re.sub(r'\n?```$', '', raw)
|
||||
raw = raw.strip()
|
||||
|
||||
# 找 JSON 结构的位置
|
||||
# 优先找第一个 { }(JSON 对象),用括号匹配确保完整
|
||||
first_brace = raw.find('{')
|
||||
last_bracket = raw.rfind('[')
|
||||
|
||||
start = -1
|
||||
if first_brace >= 0:
|
||||
start = first_brace
|
||||
# 从第一个 { 开始,找匹配的 }
|
||||
depth = 0
|
||||
end = -1
|
||||
for i in range(start, len(raw)):
|
||||
if raw[i] == '{':
|
||||
depth += 1
|
||||
elif raw[i] == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
if end <= start:
|
||||
start = -1
|
||||
elif last_bracket >= 0:
|
||||
start = last_bracket
|
||||
end = raw.rfind(']') + 1
|
||||
if end <= start:
|
||||
start = -1
|
||||
|
||||
if start < 0:
|
||||
start = raw.find('{')
|
||||
end = raw.rfind('}') + 1
|
||||
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError(f"无法找到 JSON: {raw[:200]}")
|
||||
|
||||
json_str = raw[start:end]
|
||||
|
||||
# 修复常见问题:中文引号替换为英文引号(在JSON值内部的需要转义)
|
||||
# 先把 JSON 结构中的英文引号保护起来,再替换中文引号
|
||||
json_str = json_str.replace('\u201c', '\u201c').replace('\u201d', '\u201d') # 先保持不变
|
||||
json_str = json_str.replace('\u2018', "'").replace('\u2019', "'")
|
||||
json_str = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', json_str)
|
||||
json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
|
||||
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError as e:
|
||||
# 如果失败,尝试将中文引号替换为转义的英文引号
|
||||
json_str2 = json_str.replace('\u201c', '\\"').replace('\u201d', '\\"')
|
||||
try:
|
||||
return json.loads(json_str2)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试把换行替换为 \n
|
||||
json_str2 = json_str.replace('\n', '\\n').replace('\r', '\\r')
|
||||
json_str2 = json_str2.replace('\\\\n', '\\n')
|
||||
try:
|
||||
return json.loads(json_str2)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试自动补全缺失的闭合括号(模型输出被截断时常见)
|
||||
for extra in ['}', '}}', '}}}', '}]', '}]}']:
|
||||
try:
|
||||
return json.loads(json_str + extra)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
raise ValueError(f"无法解析 JSON: {json_str[:300]}")
|
||||
|
||||
|
||||
llm_client = LLMClient()
|
||||
Reference in New Issue
Block a user