""" 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 _stub_key(self, messages: list[dict]) -> str: user_msg = next((m["content"] for m in reversed(messages) if m.get("role") == "user"), "") sys_msg = next((m["content"] for m in messages if m.get("role") == "system"), "") # Allow callers to embed an explicit stub_key=... token for chunk in user_msg.split(): if chunk.startswith("stub_key="): return chunk[len("stub_key="):] import hashlib return hashlib.sha256((sys_msg + "|" + user_msg).encode("utf-8")).hexdigest()[:12] def _stub_response(self, messages: list[dict]) -> str: import json as _json return _json.dumps(self._stub_response_json(messages), ensure_ascii=False) def _stub_response_json(self, messages: list[dict]) -> dict: key = self._stub_key(messages) # Deterministic centered Likert + plausible open text digit = sum(ord(c) for c in key) % 5 + 1 return { "stub_key": key, "responses": {"item_001": digit, "item_002": digit, "item_003": (digit % 5) + 1}, "confidence": {"item_001": 0.7, "item_002": 0.7, "item_003": 0.6}, "open_comment": f"stub:{key}", } def chat( self, messages: List[Dict[str, str]], temperature: float = 0.7, max_tokens: int = 4096, response_format: Optional[Dict] = None ) -> str: """ 发送聊天请求 Args: messages: 消息列表 temperature: 温度参数 max_tokens: 最大token数 response_format: 响应格式(如JSON模式) Returns: 模型响应文本 """ from app.config import Config if getattr(Config, "LLM_STUB_MODE", False): return self._stub_response(messages) 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) content = response.choices[0].message.content # 部分模型(如MiniMax M2.5)会在content中包含思考内容,需要移除 content = re.sub(r'[\s\S]*?', '', content).strip() return content def chat_json( self, messages: List[Dict[str, str]], temperature: float = 0.3, max_tokens: int = 4096 ) -> Dict[str, Any]: """ 发送聊天请求并返回JSON Args: messages: 消息列表 temperature: 温度参数 max_tokens: 最大token数 Returns: 解析后的JSON对象 """ from app.config import Config if getattr(Config, "LLM_STUB_MODE", False): return self._stub_response_json(messages) response = self.chat( messages=messages, temperature=temperature, max_tokens=max_tokens, response_format={"type": "json_object"} ) # 清理markdown代码块标记 cleaned_response = response.strip() cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE) cleaned_response = re.sub(r'\n?```\s*$', '', cleaned_response) cleaned_response = cleaned_response.strip() try: return json.loads(cleaned_response) except json.JSONDecodeError: raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}")