2026-01-19 00:09:36 +08:00
|
|
|
|
"""
|
|
|
|
|
|
LLM服务 - 处理各种LLM提供商的调用
|
|
|
|
|
|
"""
|
2026-04-09 21:58:53 +08:00
|
|
|
|
from typing import Dict, Any, Optional, List, Callable, Awaitable
|
2026-01-19 00:09:36 +08:00
|
|
|
|
import json
|
2026-04-08 11:44:24 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import re
|
2026-01-23 09:49:45 +08:00
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
2026-03-06 22:31:41 +08:00
|
|
|
|
import time
|
2026-01-19 00:09:36 +08:00
|
|
|
|
from openai import AsyncOpenAI
|
|
|
|
|
|
from app.core.config import settings
|
2026-01-23 09:49:45 +08:00
|
|
|
|
from app.services.tool_registry import tool_registry
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
# 允许“无参调用”的内置工具;模型仅给出 invoke name 时也应执行,避免把 DSML 协议文本当最终回复返回。
|
|
|
|
|
|
_DSML_NO_ARG_TOOLS = {
|
|
|
|
|
|
"system_info",
|
|
|
|
|
|
"datetime",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-02 00:38:41 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
2026-05-01 11:31:48 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-08 11:44:24 +08:00
|
|
|
|
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 … </invoke_arg(旧)
|
|
|
|
|
|
- parameter name="k" type="…">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:
|
2026-04-13 20:17:18 +08:00
|
|
|
|
if tname in _DSML_NO_ARG_TOOLS:
|
|
|
|
|
|
args = {}
|
|
|
|
|
|
else:
|
|
|
|
|
|
continue
|
2026-04-08 11:44:24 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
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,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt: Optional[str] = None,
|
2026-01-19 00:09:36 +08:00
|
|
|
|
**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返回的文本
|
|
|
|
|
|
"""
|
2026-04-13 20:17:18 +08:00
|
|
|
|
extra_kwargs = dict(kwargs or {})
|
|
|
|
|
|
extra_kwargs.pop("request_timeout", None)
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
# 如果提供了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
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
request_timeout = _resolve_request_timeout(kwargs)
|
|
|
|
|
|
timeout_retries = 2
|
2026-01-19 00:09:36 +08:00
|
|
|
|
try:
|
2026-04-13 20:17:18 +08:00
|
|
|
|
response = None
|
|
|
|
|
|
last_exc: Optional[Exception] = None
|
|
|
|
|
|
for attempt in range(timeout_retries + 1):
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await client.chat.completions.create(
|
|
|
|
|
|
model=model,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
messages=_chat_messages(prompt, system_prompt),
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-30 00:57:13 +08:00
|
|
|
|
model: str = "deepseek-v4-flash",
|
2026-01-19 00:09:36 +08:00
|
|
|
|
temperature: float = 0.7,
|
|
|
|
|
|
max_tokens: Optional[int] = None,
|
|
|
|
|
|
api_key: Optional[str] = None,
|
|
|
|
|
|
base_url: Optional[str] = None,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt: Optional[str] = None,
|
2026-01-19 00:09:36 +08:00
|
|
|
|
**kwargs
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
调用DeepSeek API
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
prompt: 提示词
|
2026-04-30 00:57:13 +08:00
|
|
|
|
model: 模型名称,默认 deepseek-v4-flash(deepseek-chat / deepseek-reasoner 将于 2026/07/24 弃用)
|
2026-01-19 00:09:36 +08:00
|
|
|
|
temperature: 温度参数,默认0.7
|
|
|
|
|
|
max_tokens: 最大token数
|
|
|
|
|
|
api_key: API密钥(可选,如果不提供则使用默认配置)
|
|
|
|
|
|
base_url: API地址(可选,如果不提供则使用默认配置)
|
|
|
|
|
|
**kwargs: 其他参数
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
LLM返回的文本
|
|
|
|
|
|
"""
|
2026-04-13 20:17:18 +08:00
|
|
|
|
extra_kwargs = dict(kwargs or {})
|
|
|
|
|
|
extra_kwargs.pop("request_timeout", None)
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
# 如果提供了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
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
request_timeout = _resolve_request_timeout(kwargs)
|
|
|
|
|
|
timeout_retries = 2
|
2026-01-19 00:09:36 +08:00
|
|
|
|
try:
|
2026-01-20 09:40:16 +08:00
|
|
|
|
# 记录实际发送给LLM的prompt
|
|
|
|
|
|
import logging
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
logger.info(f"[rjb] DeepSeek实际发送的prompt前200字符: {prompt[:200] if len(prompt) > 200 else prompt}")
|
2026-04-13 20:17:18 +08:00
|
|
|
|
response = None
|
|
|
|
|
|
last_exc: Optional[Exception] = None
|
|
|
|
|
|
for attempt in range(timeout_retries + 1):
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await client.chat.completions.create(
|
|
|
|
|
|
model=model,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
messages=_chat_messages(prompt, system_prompt),
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt: Optional[str] = None,
|
2026-01-19 00:09:36 +08:00
|
|
|
|
**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,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt=system_prompt,
|
2026-01-19 00:09:36 +08:00
|
|
|
|
**kwargs
|
|
|
|
|
|
)
|
|
|
|
|
|
elif provider == "deepseek":
|
|
|
|
|
|
# 默认模型
|
|
|
|
|
|
if not model:
|
2026-04-30 00:57:13 +08:00
|
|
|
|
model = "deepseek-v4-flash"
|
2026-01-19 00:09:36 +08:00
|
|
|
|
return await self.call_deepseek(
|
|
|
|
|
|
prompt=prompt,
|
|
|
|
|
|
model=model,
|
|
|
|
|
|
temperature=temperature,
|
|
|
|
|
|
max_tokens=max_tokens,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt=system_prompt,
|
2026-01-19 00:09:36 +08:00
|
|
|
|
**kwargs
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(f"不支持的LLM提供商: {provider},目前支持: openai, deepseek")
|
2026-01-23 09:49:45 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-06 22:31:41 +08:00
|
|
|
|
max_iterations: int = 5,
|
2026-04-08 11:44:24 +08:00
|
|
|
|
execution_logger = None,
|
|
|
|
|
|
tool_choice: Optional[str] = None,
|
2026-04-09 21:58:53 +08:00
|
|
|
|
on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None,
|
2026-04-13 20:17:18 +08:00
|
|
|
|
request_timeout: Optional[float] = None,
|
2026-05-01 11:31:48 +08:00
|
|
|
|
extra_body: Optional[Dict[str, Any]] = None,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt: Optional[str] = None,
|
2026-01-23 09:49:45 +08:00
|
|
|
|
) -> 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
|
|
|
|
|
|
|
2026-05-02 00:38:41 +08:00
|
|
|
|
messages = _chat_messages(prompt, system_prompt)
|
2026-01-23 09:49:45 +08:00
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
request_timeout = _resolve_request_timeout(
|
|
|
|
|
|
{"request_timeout": request_timeout} if request_timeout is not None else {}
|
|
|
|
|
|
)
|
|
|
|
|
|
timeout_retries = 2
|
2026-01-23 09:49:45 +08:00
|
|
|
|
try:
|
|
|
|
|
|
for iteration in range(max_iterations):
|
|
|
|
|
|
# 准备工具参数(只在第一次调用时传递tools)
|
|
|
|
|
|
create_kwargs = {
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"messages": messages,
|
|
|
|
|
|
"temperature": temperature,
|
|
|
|
|
|
"max_tokens": max_tokens
|
|
|
|
|
|
}
|
2026-05-01 11:31:48 +08:00
|
|
|
|
if extra_body:
|
|
|
|
|
|
create_kwargs["extra_body"] = extra_body
|
2026-01-23 09:49:45 +08:00
|
|
|
|
|
|
|
|
|
|
if iteration == 0:
|
|
|
|
|
|
# 转换工具格式为OpenAI格式
|
|
|
|
|
|
openai_tools = []
|
|
|
|
|
|
for tool in tools:
|
|
|
|
|
|
if isinstance(tool, dict):
|
2026-05-02 00:38:41 +08:00
|
|
|
|
if tool.get("type") == "function":
|
2026-01-23 09:49:45 +08:00
|
|
|
|
openai_tools.append(tool)
|
|
|
|
|
|
elif "function" in tool:
|
2026-05-02 00:38:41 +08:00
|
|
|
|
# Has function but missing type
|
|
|
|
|
|
openai_tools.append({"type": "function", "function": tool["function"]})
|
2026-01-23 09:49:45 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 假设是function格式,包装一下
|
|
|
|
|
|
openai_tools.append({
|
|
|
|
|
|
"type": "function",
|
|
|
|
|
|
"function": tool
|
|
|
|
|
|
})
|
|
|
|
|
|
create_kwargs["tools"] = openai_tools
|
2026-04-08 11:44:24 +08:00
|
|
|
|
# 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"
|
2026-01-23 09:49:45 +08:00
|
|
|
|
|
|
|
|
|
|
# 调用LLM
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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
|
2026-01-23 09:49:45 +08:00
|
|
|
|
|
|
|
|
|
|
message = response.choices[0].message
|
|
|
|
|
|
|
2026-04-08 11:44:24 +08:00
|
|
|
|
tool_calls_dicts: List[Dict[str, Any]] = []
|
|
|
|
|
|
if message.tool_calls:
|
|
|
|
|
|
for tc in message.tool_calls:
|
|
|
|
|
|
tool_calls_dicts.append({
|
2026-01-23 09:49:45 +08:00
|
|
|
|
"id": tc.id,
|
|
|
|
|
|
"type": tc.type,
|
|
|
|
|
|
"function": {
|
|
|
|
|
|
"name": tc.function.name,
|
2026-04-08 11:44:24 +08:00
|
|
|
|
"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),
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-01 11:31:48 +08:00
|
|
|
|
messages.append(_assistant_message_for_tool_history(message, tool_calls_dicts))
|
2026-04-08 11:44:24 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
2026-03-06 22:31:41 +08:00
|
|
|
|
if execution_logger:
|
|
|
|
|
|
execution_logger.info(
|
2026-04-08 11:44:24 +08:00
|
|
|
|
f"调用工具: {tool_name}",
|
2026-03-06 22:31:41 +08:00
|
|
|
|
data={
|
2026-04-08 11:44:24 +08:00
|
|
|
|
"tool_name": tool_name,
|
|
|
|
|
|
"tool_call_id": tool_call_id,
|
|
|
|
|
|
"tool_args": tool_args,
|
|
|
|
|
|
"status": "requested",
|
|
|
|
|
|
},
|
2026-03-06 22:31:41 +08:00
|
|
|
|
)
|
2026-04-08 11:44:24 +08:00
|
|
|
|
try:
|
|
|
|
|
|
tool_result = await self._execute_tool(tool_name, tool_args)
|
|
|
|
|
|
tool_duration = int((time.time() - tool_start_time) * 1000)
|
2026-03-06 22:31:41 +08:00
|
|
|
|
if execution_logger:
|
2026-04-08 11:44:24 +08:00
|
|
|
|
result_preview = tool_result
|
|
|
|
|
|
if len(result_preview) > 500:
|
|
|
|
|
|
result_preview = result_preview[:500] + "..."
|
2026-03-06 22:31:41 +08:00
|
|
|
|
execution_logger.info(
|
2026-04-08 11:44:24 +08:00
|
|
|
|
f"工具 {tool_name} 执行成功",
|
2026-03-06 22:31:41 +08:00
|
|
|
|
data={
|
|
|
|
|
|
"tool_name": tool_name,
|
|
|
|
|
|
"tool_call_id": tool_call_id,
|
|
|
|
|
|
"tool_args": tool_args,
|
2026-04-08 11:44:24 +08:00
|
|
|
|
"tool_result": result_preview,
|
|
|
|
|
|
"tool_result_length": len(tool_result),
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"duration": tool_duration,
|
|
|
|
|
|
},
|
|
|
|
|
|
duration=tool_duration,
|
2026-03-06 22:31:41 +08:00
|
|
|
|
)
|
2026-04-08 11:44:24 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-09 21:58:53 +08:00
|
|
|
|
if on_tool_executed:
|
|
|
|
|
|
await on_tool_executed(tool_name)
|
|
|
|
|
|
|
2026-04-08 11:44:24 +08:00
|
|
|
|
messages.append(
|
|
|
|
|
|
{"role": "tool", "tool_call_id": tool_call_id, "content": tool_result}
|
|
|
|
|
|
)
|
2026-01-23 09:49:45 +08:00
|
|
|
|
|
|
|
|
|
|
# 达到最大迭代次数
|
|
|
|
|
|
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]],
|
2026-04-30 00:57:13 +08:00
|
|
|
|
model: str = "deepseek-v4-flash",
|
2026-01-23 09:49:45 +08:00
|
|
|
|
temperature: float = 0.7,
|
|
|
|
|
|
max_tokens: Optional[int] = None,
|
|
|
|
|
|
api_key: Optional[str] = None,
|
|
|
|
|
|
base_url: Optional[str] = None,
|
2026-03-06 22:31:41 +08:00
|
|
|
|
max_iterations: int = 5,
|
2026-04-08 11:44:24 +08:00
|
|
|
|
execution_logger = None,
|
|
|
|
|
|
tool_choice: Optional[str] = None,
|
2026-04-09 21:58:53 +08:00
|
|
|
|
on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None,
|
2026-04-13 20:17:18 +08:00
|
|
|
|
request_timeout: Optional[float] = None,
|
2026-05-01 11:31:48 +08:00
|
|
|
|
extra_body: Optional[Dict[str, Any]] = None,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt: Optional[str] = None,
|
2026-01-23 09:49:45 +08:00
|
|
|
|
) -> 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,
|
2026-03-06 22:31:41 +08:00
|
|
|
|
max_iterations=max_iterations,
|
2026-04-08 11:44:24 +08:00
|
|
|
|
execution_logger=execution_logger,
|
|
|
|
|
|
tool_choice=tool_choice,
|
2026-04-09 21:58:53 +08:00
|
|
|
|
on_tool_executed=on_tool_executed,
|
2026-04-13 20:17:18 +08:00
|
|
|
|
request_timeout=request_timeout,
|
2026-05-01 11:31:48 +08:00
|
|
|
|
extra_body=extra_body,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt=system_prompt,
|
2026-01-23 09:49:45 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-06 22:31:41 +08:00
|
|
|
|
execution_logger = None,
|
2026-04-08 11:44:24 +08:00
|
|
|
|
tool_choice: Optional[str] = None,
|
2026-04-09 21:58:53 +08:00
|
|
|
|
on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None,
|
2026-04-13 20:17:18 +08:00
|
|
|
|
request_timeout: Optional[float] = None,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt: Optional[str] = None,
|
2026-01-23 09:49:45 +08:00
|
|
|
|
**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,
|
2026-03-06 22:31:41 +08:00
|
|
|
|
execution_logger=execution_logger,
|
2026-04-08 11:44:24 +08:00
|
|
|
|
tool_choice=tool_choice,
|
2026-04-09 21:58:53 +08:00
|
|
|
|
on_tool_executed=on_tool_executed,
|
2026-04-13 20:17:18 +08:00
|
|
|
|
request_timeout=request_timeout,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt=system_prompt,
|
2026-01-23 09:49:45 +08:00
|
|
|
|
**kwargs
|
|
|
|
|
|
)
|
|
|
|
|
|
elif provider == "deepseek":
|
|
|
|
|
|
if not model:
|
2026-04-30 00:57:13 +08:00
|
|
|
|
model = "deepseek-v4-flash"
|
2026-01-23 09:49:45 +08:00
|
|
|
|
return await self.call_deepseek_with_tools(
|
|
|
|
|
|
prompt=prompt,
|
|
|
|
|
|
tools=tools,
|
|
|
|
|
|
model=model,
|
|
|
|
|
|
temperature=temperature,
|
|
|
|
|
|
max_tokens=max_tokens,
|
2026-03-06 22:31:41 +08:00
|
|
|
|
execution_logger=execution_logger,
|
2026-04-08 11:44:24 +08:00
|
|
|
|
tool_choice=tool_choice,
|
2026-04-09 21:58:53 +08:00
|
|
|
|
on_tool_executed=on_tool_executed,
|
2026-04-13 20:17:18 +08:00
|
|
|
|
request_timeout=request_timeout,
|
2026-05-02 00:38:41 +08:00
|
|
|
|
system_prompt=system_prompt,
|
2026-01-23 09:49:45 +08:00
|
|
|
|
**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)
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 全局LLM服务实例
|
|
|
|
|
|
llm_service = LLMService()
|