Files
aiagent/backend/app/services/llm_service.py
renjianbo 7aba0f9bc5 fix: 修复 Agent 流式对话无响应和工具 schema 兼容性问题
- 在 `run_stream()` LLM 调用前 yield `think` 事件,前端即时显示"思考中..."
- 修复 tool schema 规范化逻辑:`{"function":{...}}` 格式缺少 `type` 字段导致 LLM API 拒绝
- 启动时从数据库加载自定义工具(`load_tools_from_db`),解决重启后工具丢失
- 前端 SSE 添加 60s 超时保护,任何事件类型均触发 `receivedFirstEvent`
- 流式失败自动降级到非流式 POST
- 添加 `scripts/seed_coding_agent.py` 和 `scripts/test_coding_agent.py`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 00:38:41 +08:00

1037 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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」
<DSMLparameter 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 并列):
<DSMLinvoke_content name="file_path">
123.md
<DSMLinvoke_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">vDeepSeek 另一种块)
- 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-flashdeepseek-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()