""" LLM服务 - 处理各种LLM提供商的调用 """ from typing import Dict, Any, Optional, List, Callable, Awaitable import json import os import re import asyncio import logging import time from openai import AsyncOpenAI from app.core.config import settings from app.services.tool_registry import tool_registry logger = logging.getLogger(__name__) # 允许“无参调用”的内置工具;模型仅给出 invoke name 时也应执行,避免把 DSML 协议文本当最终回复返回。 _DSML_NO_ARG_TOOLS = { "system_info", "datetime", } def _chat_messages( user_content: str, system_prompt: Optional[str] = None, ) -> List[Dict[str, str]]: """构建 chat.completions messages:可选 system + user。""" msgs: List[Dict[str, str]] = [] if system_prompt is not None: sp = system_prompt.strip() if sp: msgs.append({"role": "system", "content": sp}) msgs.append({"role": "user", "content": user_content}) return msgs def _resolve_request_timeout(kwargs: Dict[str, Any]) -> float: """ 统一解析 LLM 请求超时时间(秒): 1) 节点/调用 kwargs.request_timeout 2) 环境变量 LLM_REQUEST_TIMEOUT 3) 默认 120 秒 """ try: if "request_timeout" in kwargs and kwargs.get("request_timeout") is not None: return max(10.0, float(kwargs.get("request_timeout"))) except Exception: pass try: return max(10.0, float(os.getenv("LLM_REQUEST_TIMEOUT", "120"))) except Exception: return 120.0 def _is_retryable_llm_error(exc: Exception) -> bool: msg = str(exc).lower() return ( ("timed out" in msg) or ("timeout" in msg) or ("connection error" in msg) or ("temporarily unavailable" in msg) or ("server disconnected" in msg) ) def _assistant_message_for_tool_history(message: Any, tool_calls_dicts: List[Dict[str, Any]]) -> Dict[str, Any]: """ 构造写入多轮 messages 的 assistant 条目。 DeepSeek V4 思考模式 + 工具调用:下一轮请求必须把本轮返回的 reasoning_content 原样带回, 否则会 400 invalid_request_error。 """ entry: Dict[str, Any] = { "role": "assistant", "content": message.content, } if tool_calls_dicts: entry["tool_calls"] = tool_calls_dicts rc = getattr(message, "reasoning_content", None) if rc is None: extra = getattr(message, "model_extra", None) or {} if isinstance(extra, dict): rc = extra.get("reasoning_content") if rc is not None: entry["reasoning_content"] = rc return entry def _extract_dsml_parameter_args(chunk: str) -> Dict[str, str]: """ DeepSeek 新版 DSML 常用「parameter」而非「invoke_arg」: <|DSML|parameter name="file_path" type="string">D:\\path\\file.md 多行正文会出现在 name="content" 的 > 之后、下一个 parameter 之前。 注意:标签可能使用全角 < (U+FF1C),不能用 ASCII [^<] 截断;file_path/mode 等取首行即可。 """ args: Dict[str, str] = {} pat = re.compile(r'parameter\s+name="([^"]+)"(?:\s+type="[^"]*")?\s*>', re.IGNORECASE) ms = list(pat.finditer(chunk)) for i, m in enumerate(ms): key = m.group(1).strip() start = m.end() end = ms[i + 1].start() if i + 1 < len(ms) else len(chunk) raw = chunk[start:end] if key.lower() == "content": val = raw.strip() # 模型在空 content 后紧接半截 DSML 行,勿写入文件 kept: List[str] = [] for line in val.splitlines(): t = line.strip() if not t: continue if "DSML" in t.upper() and "parameter" not in t.lower(): continue kept.append(line) val = "\n".join(kept).strip() else: val = raw.lstrip().split("\n", 1)[0].strip() for sep in ("<", "<"): if sep in val: val = val.split(sep, 1)[0].strip() if key: args[key] = val return args def _extract_dsml_invoke_content_args(chunk: str) -> Dict[str, str]: """ 部分 DeepSeek 输出使用 invoke_content 块(与 parameter 并列): <|DSML|invoke_content name="file_path"> 123.md <|DSML|invoke_content name="content"> ... """ args: Dict[str, str] = {} pat = re.compile(r'invoke_content\s+name="([^"]+)"[^>]*>', re.IGNORECASE) ms = list(pat.finditer(chunk)) for i, m in enumerate(ms): key = m.group(1).strip() start = m.end() end = ms[i + 1].start() if i + 1 < len(ms) else len(chunk) raw = chunk[start:end] if key.lower() == "content": val = raw.strip() kept: List[str] = [] for line in val.splitlines(): t = line.strip() if not t: continue if "DSML" in t.upper() and "invoke_content" not in t.lower() and "parameter" not in t.lower(): continue kept.append(line) val = "\n".join(kept).strip() else: val = raw.lstrip().split("\n", 1)[0].strip() for sep in ("<", "<"): if sep in val: val = val.split(sep, 1)[0].strip() if key: args[key] = val return args def _merge_dsml_arg_dicts(*dicts: Dict[str, str]) -> Dict[str, str]: """后者仅填补前者缺失或空字符串的键。""" out: Dict[str, str] = {} for d in dicts: for k, v in d.items(): if k not in out or (isinstance(out.get(k), str) and not str(out[k]).strip()): out[k] = v return out def _dedupe_consecutive_dsml_tool_headers(text: str) -> str: """ 模型异常时连续输出多行相同的 invoke name="tool" 头且无参数,导致解析器切成大量空 chunk。 合并「与上一行完全相同的 invoke 行」,保留首次,使后续 invoke_content/parameter 仍与第一次 invoke 同属一块。 空行会打断「连续」,其后的重复头再次保留一行(兼容极少数真需两次调用的草稿)。 """ lines = text.splitlines(keepends=True) out_lines: List[str] = [] prev_sig: Optional[tuple[str, str]] = None for line in lines: m = re.search(r'invoke\s+name="([^"]+)"', line, re.I) if m: tool = m.group(1).strip().lower() sig = (tool, line.strip()) if prev_sig == sig: continue prev_sig = sig else: if not line.strip(): pass prev_sig = None out_lines.append(line) return "".join(out_lines) def _dedupe_consecutive_function_call_headers(text: str) -> str: """同上,针对 function_call name="..." 重复行。""" lines = text.splitlines(keepends=True) out_lines: List[str] = [] prev_sig: Optional[tuple[str, str]] = None for line in lines: m = re.search(r'function_call\s+name="([^"]+)"', line, re.I) if m: tool = m.group(1).strip().lower() sig = (tool, line.strip()) if prev_sig == sig: continue prev_sig = sig else: if not line.strip(): pass prev_sig = None out_lines.append(line) return "".join(out_lines) def _parse_dsml_tool_invocations(content: str) -> List[Dict[str, Any]]: """ 部分模型(如 DeepSeek)会把 function call 以 DSML 形式写在 content 里,而 message.tool_calls 为空。 解析为 [{\"name\": str, \"arguments\": dict}, ...],供与 OpenAI tool_calls 相同的执行路径处理。 兼容: - invoke_arg … v(新,可无闭合标签) - invoke_content name="k">v(DeepSeek 另一种块) - function_call name="tool" 与 invoke name="tool" 交替出现 - Windows 绝对路径 + 反斜杠 user_data """ if not content: return [] if not re.search(r'invoke\s+name="|function_call\s+name="', content): return [] content = _dedupe_consecutive_function_call_headers( _dedupe_consecutive_dsml_tool_headers(content) ) out: List[Dict[str, Any]] = [] signatures: set = set() head_re = re.compile(r'(?:invoke|function_call)\s+name="([^"]+)"') matches = list(head_re.finditer(content)) for i, m in enumerate(matches): tname = m.group(1).strip() rest_start = m.end() nxt_start = matches[i + 1].start() if i + 1 < len(matches) else len(content) chunk = content[rest_start:nxt_start] args: Dict[str, Any] = {} for am in re.finditer( r'invoke_arg\s+name="([^"]+)"[^>]*>\s*(.*?)\s*]*invoke_arg', chunk, re.DOTALL | re.IGNORECASE, ): args[am.group(1).strip()] = am.group(2).strip() param_args = _extract_dsml_parameter_args(chunk) invc_args = _extract_dsml_invoke_content_args(chunk) merged_flat = _merge_dsml_arg_dicts(param_args, invc_args) for k, v in merged_flat.items(): if k not in args or (isinstance(args.get(k), str) and not str(args[k]).strip()): args[k] = v if not args: if tname in _DSML_NO_ARG_TOOLS: args = {} else: continue sig = (tname, json.dumps(args, sort_keys=True, ensure_ascii=False)) if sig in signatures: continue signatures.add(sig) out.append({"name": tname, "arguments": args}) # 仍找不到完整工具块时:从全文兜底抽取 file_write(含仅 parameter、无 invoke_arg) if not out and "file_write" in content: fp = None pm = re.search( r'parameter\s+name="file_path"[^>]*>\s*([^\r\n]+)', content, re.IGNORECASE, ) if pm: fp = pm.group(1).strip() if not fp: pm_ic = re.search( r'invoke_content\s+name="file_path"[^>]*>\s*([^\r\n]+)', content, re.IGNORECASE, ) if pm_ic: fp = pm_ic.group(1).strip() if not fp: pm2 = re.search(r'user_data[/\\][^\s<|"\'\n]+\.[a-zA-Z0-9]+', content) if pm2: fp = pm2.group(0) if not fp: pm3 = re.search( r'([A-Za-z]:\\(?:[^|<\n\r]+\\)+[^|<\n\r]+\.[a-zA-Z0-9]+)', content, ) if pm3: fp = pm3.group(1).strip() if not fp: # 模型只重复 invoke 行、未给 file_path 时:从「《静夜思》」或「静夜思.md」等推断文件名 pm_book = re.search(r'《\s*([\u4e00-\u9fff]{2,24})\s*》', content) if pm_book: fp = pm_book.group(1).strip() + ".md" if not fp: pm_title = re.search( r'(?:《\s*)?([\u4e00-\u9fff]{2,32})(?:\s*》)?\s*\.md\b', content, ) if pm_title: fp = pm_title.group(1).strip() + ".md" body = "" cm = re.search( r'invoke_arg\s+name="content"[^>]*>\s*(.*?)\s*]*invoke_arg', content, re.DOTALL | re.IGNORECASE, ) if cm: body = cm.group(1).strip() if not body: cm2 = re.search( r'parameter\s+name="content"[^>]*>\s*', content, re.IGNORECASE, ) if cm2: after = content[cm2.end() :] nxt = re.search( r'(?:<[^>]*>)?(?:parameter\s+name=|function_call\s+name=|invoke\s+name=)', after, re.IGNORECASE, ) body = (after[: nxt.start()] if nxt else after).strip() if not body: cm_ic = re.search( r'invoke_content\s+name="content"[^>]*>\s*', content, re.IGNORECASE, ) if cm_ic: after = content[cm_ic.end() :] nxt = re.search( r'(?:<[^>]*>)?(?:invoke_content\s+name=|parameter\s+name=|function_call\s+name=|invoke\s+name=)', after, re.IGNORECASE, ) body = (after[: nxt.start()] if nxt else after).strip() if not body: idx = content.find('invoke name="file_write"') if idx == -1: idx = content.find('function_call name="file_write"') if idx >= 0: cm3 = re.search( r"(#\s*\w+.*?)(?=\n\n|\Z)", content[idx:], re.DOTALL, ) if cm3: body = cm3.group(1).strip() mode_m = re.search(r'parameter\s+name="mode"[^>]*>\s*(\w+)', content, re.IGNORECASE) if not mode_m: mode_m = re.search( r'invoke_content\s+name="mode"[^>]*>\s*(\w+)', content, re.IGNORECASE, ) mode = (mode_m.group(1).strip() if mode_m else None) or "w" if fp and not (body or "").strip(): # 古诗类请求常见:仅有标题文件名,无正文块 blob = (fp + content) if "静夜思" in blob: body = ( "# 静夜思\n\n" "床前明月光,疑是地上霜。\n" "举头望明月,低头思故乡。\n" ) if fp: out.append({ "name": "file_write", "arguments": { "file_path": fp, "content": body if body else " ", "mode": mode, }, }) return out class LLMService: """LLM服务类""" def __init__(self): """初始化LLM服务""" self.openai_client = None self.deepseek_client = None # 初始化OpenAI客户端 if settings.OPENAI_API_KEY: self.openai_client = AsyncOpenAI( api_key=settings.OPENAI_API_KEY, base_url=settings.OPENAI_BASE_URL ) # 初始化DeepSeek客户端(兼容OpenAI API) if settings.DEEPSEEK_API_KEY: self.deepseek_client = AsyncOpenAI( api_key=settings.DEEPSEEK_API_KEY, base_url=settings.DEEPSEEK_BASE_URL ) async def call_openai( self, prompt: str, model: str = "gpt-3.5-turbo", temperature: float = 0.7, max_tokens: Optional[int] = None, api_key: Optional[str] = None, base_url: Optional[str] = None, system_prompt: Optional[str] = None, **kwargs ) -> str: """ 调用OpenAI API Args: prompt: 提示词 model: 模型名称,默认gpt-3.5-turbo temperature: 温度参数,默认0.7 max_tokens: 最大token数 api_key: API密钥(可选,如果不提供则使用默认配置) base_url: API地址(可选,如果不提供则使用默认配置) **kwargs: 其他参数 Returns: LLM返回的文本 """ extra_kwargs = dict(kwargs or {}) extra_kwargs.pop("request_timeout", None) # 如果提供了api_key或base_url,创建临时客户端 # 注意:api_key 可能是空字符串,需要检查是否为 None if api_key is not None or base_url is not None: # 如果提供了 api_key,使用它;否则使用系统默认配置 final_api_key = api_key if api_key else settings.OPENAI_API_KEY final_base_url = base_url if base_url else settings.OPENAI_BASE_URL if not final_api_key: raise ValueError("OpenAI API Key未配置,请在节点配置中设置API Key或在环境变量中设置OPENAI_API_KEY") client = AsyncOpenAI( api_key=final_api_key, base_url=final_base_url ) else: # 如果 openai_client 未初始化,尝试从 settings 重新读取并初始化 if not self.openai_client: if settings.OPENAI_API_KEY: self.openai_client = AsyncOpenAI( api_key=settings.OPENAI_API_KEY, base_url=settings.OPENAI_BASE_URL ) else: raise ValueError("OpenAI API Key未配置,请在节点配置中设置API Key或在环境变量中设置OPENAI_API_KEY") client = self.openai_client request_timeout = _resolve_request_timeout(kwargs) timeout_retries = 2 try: response = None last_exc: Optional[Exception] = None for attempt in range(timeout_retries + 1): try: response = await client.chat.completions.create( model=model, messages=_chat_messages(prompt, system_prompt), temperature=temperature, max_tokens=max_tokens, timeout=request_timeout, **extra_kwargs ) break except Exception as e: last_exc = e if not _is_retryable_llm_error(e) or attempt >= timeout_retries: raise logger.warning( "OpenAI 调用超时,重试 attempt=%s/%s timeout=%ss", attempt + 1, timeout_retries + 1, request_timeout, ) if response is None and last_exc is not None: raise last_exc content = response.choices[0].message.content if content is None: raise Exception("OpenAI API返回的内容为空,请检查API配置和模型名称") return content except Exception as e: raise Exception(f"OpenAI API调用失败: {str(e)}") async def call_deepseek( self, prompt: str, model: str = "deepseek-v4-flash", temperature: float = 0.7, max_tokens: Optional[int] = None, api_key: Optional[str] = None, base_url: Optional[str] = None, system_prompt: Optional[str] = None, **kwargs ) -> str: """ 调用DeepSeek API Args: prompt: 提示词 model: 模型名称,默认 deepseek-v4-flash(deepseek-chat / deepseek-reasoner 将于 2026/07/24 弃用) temperature: 温度参数,默认0.7 max_tokens: 最大token数 api_key: API密钥(可选,如果不提供则使用默认配置) base_url: API地址(可选,如果不提供则使用默认配置) **kwargs: 其他参数 Returns: LLM返回的文本 """ extra_kwargs = dict(kwargs or {}) extra_kwargs.pop("request_timeout", None) # 如果提供了api_key或base_url,创建临时客户端 # 注意:api_key 可能是空字符串,需要检查是否为 None if api_key is not None or base_url is not None: # 如果提供了 api_key,使用它;否则使用系统默认配置 final_api_key = api_key if api_key else settings.DEEPSEEK_API_KEY final_base_url = base_url if base_url else settings.DEEPSEEK_BASE_URL if not final_api_key: raise ValueError("DeepSeek API Key未配置,请在节点配置中设置API Key或在环境变量中设置DEEPSEEK_API_KEY") client = AsyncOpenAI( api_key=final_api_key, base_url=final_base_url ) else: # 如果 deepseek_client 未初始化,尝试从 settings 重新读取并初始化 if not self.deepseek_client: if settings.DEEPSEEK_API_KEY: self.deepseek_client = AsyncOpenAI( api_key=settings.DEEPSEEK_API_KEY, base_url=settings.DEEPSEEK_BASE_URL ) else: raise ValueError("DeepSeek API Key未配置,请在节点配置中设置API Key或在环境变量中设置DEEPSEEK_API_KEY") client = self.deepseek_client request_timeout = _resolve_request_timeout(kwargs) timeout_retries = 2 try: # 记录实际发送给LLM的prompt import logging logger = logging.getLogger(__name__) logger.info(f"[rjb] DeepSeek实际发送的prompt前200字符: {prompt[:200] if len(prompt) > 200 else prompt}") response = None last_exc: Optional[Exception] = None for attempt in range(timeout_retries + 1): try: response = await client.chat.completions.create( model=model, messages=_chat_messages(prompt, system_prompt), temperature=temperature, max_tokens=max_tokens, timeout=request_timeout, **extra_kwargs ) break except Exception as e: last_exc = e if not _is_retryable_llm_error(e) or attempt >= timeout_retries: raise logger.warning( "DeepSeek 调用超时,重试 attempt=%s/%s timeout=%ss", attempt + 1, timeout_retries + 1, request_timeout, ) if response is None and last_exc is not None: raise last_exc content = response.choices[0].message.content if content is None: raise Exception("DeepSeek API返回的内容为空,请检查API配置和模型名称") return content except Exception as e: raise Exception(f"DeepSeek API调用失败: {str(e)}") async def call_llm( self, prompt: str, provider: str = "openai", model: Optional[str] = None, temperature: float = 0.7, max_tokens: Optional[int] = None, system_prompt: Optional[str] = None, **kwargs ) -> str: """ 通用LLM调用接口 Args: prompt: 提示词 provider: 提供商,支持openai、deepseek model: 模型名称 temperature: 温度参数 max_tokens: 最大token数 **kwargs: 其他参数 Returns: LLM返回的文本 """ if provider == "openai": # 默认模型 if not model: model = "gpt-3.5-turbo" return await self.call_openai( prompt=prompt, model=model, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt, **kwargs ) elif provider == "deepseek": # 默认模型 if not model: model = "deepseek-v4-flash" return await self.call_deepseek( prompt=prompt, model=model, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt, **kwargs ) else: raise ValueError(f"不支持的LLM提供商: {provider},目前支持: openai, deepseek") async def call_openai_with_tools( self, prompt: str, tools: List[Dict[str, Any]], model: str = "gpt-3.5-turbo", temperature: float = 0.7, max_tokens: Optional[int] = None, api_key: Optional[str] = None, base_url: Optional[str] = None, max_iterations: int = 5, execution_logger = None, tool_choice: Optional[str] = None, on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None, request_timeout: Optional[float] = None, extra_body: Optional[Dict[str, Any]] = None, system_prompt: Optional[str] = None, ) -> str: """ 调用OpenAI API,支持工具调用 Args: prompt: 提示词 tools: 工具定义列表(OpenAI Function格式) model: 模型名称 temperature: 温度参数 max_tokens: 最大token数 api_key: API密钥 base_url: API地址 max_iterations: 最大工具调用迭代次数 Returns: LLM返回的最终文本 """ # 获取客户端 if api_key is not None or base_url is not None: final_api_key = api_key if api_key else settings.OPENAI_API_KEY final_base_url = base_url if base_url else settings.OPENAI_BASE_URL if not final_api_key: raise ValueError("OpenAI API Key未配置") client = AsyncOpenAI(api_key=final_api_key, base_url=final_base_url) else: if not self.openai_client: if settings.OPENAI_API_KEY: self.openai_client = AsyncOpenAI( api_key=settings.OPENAI_API_KEY, base_url=settings.OPENAI_BASE_URL ) else: raise ValueError("OpenAI API Key未配置") client = self.openai_client messages = _chat_messages(prompt, system_prompt) request_timeout = _resolve_request_timeout( {"request_timeout": request_timeout} if request_timeout is not None else {} ) timeout_retries = 2 try: for iteration in range(max_iterations): # 准备工具参数(只在第一次调用时传递tools) create_kwargs = { "model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens } if extra_body: create_kwargs["extra_body"] = extra_body if iteration == 0: # 转换工具格式为OpenAI格式 openai_tools = [] for tool in tools: if isinstance(tool, dict): if tool.get("type") == "function": openai_tools.append(tool) elif "function" in tool: # Has function but missing type openai_tools.append({"type": "function", "function": tool["function"]}) else: # 假设是function格式,包装一下 openai_tools.append({ "type": "function", "function": tool }) create_kwargs["tools"] = openai_tools # auto:一般对话;required:至少一次 function call(节点 data.tool_choice / 环境变量 LLM_TOOL_CHOICE) _tc = (tool_choice or os.environ.get("LLM_TOOL_CHOICE") or "auto").strip().lower() create_kwargs["tool_choice"] = "required" if _tc == "required" else "auto" # 调用LLM last_exc: Optional[Exception] = None response = None for attempt in range(timeout_retries + 1): try: response = await client.chat.completions.create( timeout=request_timeout, **create_kwargs ) break except Exception as e: last_exc = e if not _is_retryable_llm_error(e) or attempt >= timeout_retries: raise logger.warning( "LLM 请求超时,准备重试 attempt=%s/%s timeout=%ss", attempt + 1, timeout_retries + 1, request_timeout, ) if response is None and last_exc is not None: raise last_exc message = response.choices[0].message tool_calls_dicts: List[Dict[str, Any]] = [] if message.tool_calls: for tc in message.tool_calls: tool_calls_dicts.append({ "id": tc.id, "type": tc.type, "function": { "name": tc.function.name, "arguments": tc.function.arguments, }, }) else: dsml = _parse_dsml_tool_invocations(message.content or "") if dsml: logger.info("检测到 DeepSeek DSML 嵌入工具调用 %s 个", len(dsml)) for i, inv in enumerate(dsml): tool_calls_dicts.append({ "id": f"dsml-{iteration}-{i}", "type": "function", "function": { "name": inv["name"], "arguments": json.dumps(inv["arguments"], ensure_ascii=False), }, }) messages.append(_assistant_message_for_tool_history(message, tool_calls_dicts)) if not tool_calls_dicts: final_content = message.content or "" if final_content: logger.info("LLM返回最终回复,无工具调用") return final_content continue logger.info(f"检测到 {len(tool_calls_dicts)} 个工具调用") if execution_logger: execution_logger.info( f"LLM请求调用 {len(tool_calls_dicts)} 个工具", data={"tool_calls_count": len(tool_calls_dicts), "iteration": iteration + 1}, ) for tool_call in tool_calls_dicts: fn = tool_call.get("function") or {} tool_name = fn.get("name") or "" tool_call_id = tool_call.get("id") or "unknown" try: tool_args = json.loads(fn.get("arguments") or "{}") except Exception: tool_args = {} logger.info(f"执行工具: {tool_name}, 参数: {tool_args}") tool_start_time = time.time() if execution_logger: execution_logger.info( f"调用工具: {tool_name}", data={ "tool_name": tool_name, "tool_call_id": tool_call_id, "tool_args": tool_args, "status": "requested", }, ) try: tool_result = await self._execute_tool(tool_name, tool_args) tool_duration = int((time.time() - tool_start_time) * 1000) if execution_logger: result_preview = tool_result if len(result_preview) > 500: result_preview = result_preview[:500] + "..." execution_logger.info( f"工具 {tool_name} 执行成功", data={ "tool_name": tool_name, "tool_call_id": tool_call_id, "tool_args": tool_args, "tool_result": result_preview, "tool_result_length": len(tool_result), "status": "success", "duration": tool_duration, }, duration=tool_duration, ) except Exception as tool_error: tool_duration = int((time.time() - tool_start_time) * 1000) if execution_logger: execution_logger.error( f"工具 {tool_name} 执行失败: {str(tool_error)}", data={ "tool_name": tool_name, "tool_call_id": tool_call_id, "tool_args": tool_args, "error": str(tool_error), "status": "failed", "duration": tool_duration, }, duration=tool_duration, ) tool_result = json.dumps({"error": str(tool_error)}, ensure_ascii=False) if on_tool_executed: await on_tool_executed(tool_name) messages.append( {"role": "tool", "tool_call_id": tool_call_id, "content": tool_result} ) # 达到最大迭代次数 logger.warning(f"达到最大工具调用迭代次数 ({max_iterations})") last_message = messages[-1] if messages else {} return last_message.get("content", "达到最大工具调用次数") except Exception as e: logger.error(f"工具调用过程中出错: {str(e)}") raise Exception(f"OpenAI工具调用失败: {str(e)}") async def call_deepseek_with_tools( self, prompt: str, tools: List[Dict[str, Any]], model: str = "deepseek-v4-flash", temperature: float = 0.7, max_tokens: Optional[int] = None, api_key: Optional[str] = None, base_url: Optional[str] = None, max_iterations: int = 5, execution_logger = None, tool_choice: Optional[str] = None, on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None, request_timeout: Optional[float] = None, extra_body: Optional[Dict[str, Any]] = None, system_prompt: Optional[str] = None, ) -> str: """ 调用DeepSeek API,支持工具调用(DeepSeek兼容OpenAI API格式) """ # DeepSeek使用相同的实现 return await self.call_openai_with_tools( prompt=prompt, tools=tools, model=model, temperature=temperature, max_tokens=max_tokens, api_key=api_key or settings.DEEPSEEK_API_KEY, base_url=base_url or settings.DEEPSEEK_BASE_URL, max_iterations=max_iterations, execution_logger=execution_logger, tool_choice=tool_choice, on_tool_executed=on_tool_executed, request_timeout=request_timeout, extra_body=extra_body, system_prompt=system_prompt, ) async def call_llm_with_tools( self, prompt: str, tools: List[Dict[str, Any]], provider: str = "openai", model: Optional[str] = None, temperature: float = 0.7, max_tokens: Optional[int] = None, execution_logger = None, tool_choice: Optional[str] = None, on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None, request_timeout: Optional[float] = None, system_prompt: Optional[str] = None, **kwargs ) -> str: """ 通用LLM调用接口(支持工具) Args: prompt: 提示词 tools: 工具定义列表 provider: 提供商,支持openai、deepseek model: 模型名称 temperature: 温度参数 max_tokens: 最大token数 **kwargs: 其他参数 Returns: LLM返回的最终文本 """ if provider == "openai": if not model: model = "gpt-3.5-turbo" return await self.call_openai_with_tools( prompt=prompt, tools=tools, model=model, temperature=temperature, max_tokens=max_tokens, execution_logger=execution_logger, tool_choice=tool_choice, on_tool_executed=on_tool_executed, request_timeout=request_timeout, system_prompt=system_prompt, **kwargs ) elif provider == "deepseek": if not model: model = "deepseek-v4-flash" return await self.call_deepseek_with_tools( prompt=prompt, tools=tools, model=model, temperature=temperature, max_tokens=max_tokens, execution_logger=execution_logger, tool_choice=tool_choice, on_tool_executed=on_tool_executed, request_timeout=request_timeout, system_prompt=system_prompt, **kwargs ) else: raise ValueError(f"不支持的LLM提供商: {provider},目前支持: openai, deepseek") async def _execute_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> str: """ 执行工具 Args: tool_name: 工具名称 tool_args: 工具参数 Returns: 工具执行结果(JSON字符串) """ # 从注册表获取工具函数 tool_func = tool_registry.get_tool_function(tool_name) if not tool_func: error_msg = f"工具 {tool_name} 未找到" logger.error(error_msg) return json.dumps({"error": error_msg}, ensure_ascii=False) try: logger.info(f"执行工具 {tool_name},参数: {tool_args}") # 执行工具(支持异步函数) if asyncio.iscoroutinefunction(tool_func): result = await tool_func(**tool_args) else: # 同步函数在事件循环中执行 result = tool_func(**tool_args) # 将结果转换为字符串 if isinstance(result, (dict, list)): result_str = json.dumps(result, ensure_ascii=False) else: result_str = str(result) logger.info(f"工具 {tool_name} 执行成功,结果长度: {len(result_str)}") return result_str except Exception as e: error_msg = f"工具 {tool_name} 执行失败: {str(e)}" logger.error(error_msg, exc_info=True) return json.dumps({"error": error_msg}, ensure_ascii=False) # 全局LLM服务实例 llm_service = LLMService()