Files
EastonJiangandCursor 11f38624cd fix+perf: 绘图中文字体修复、进度自动跟踪、未验证配置刷爆日志 (#102)
* fix: 加固 Docker matplotlib 中文字体配置

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: 提前读取路径

* perf: 进度时间线与代码执行区自动跟踪最新消息

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: 未验证模型配置直接启动任务时无限重试刷爆日志

- 前端保存配置时始终推送到后端,不再依赖验证通过
- 后端 workflow 入口预校验四个 Agent 的 model/apiKey
- _validate_config 改抛 LLMConfigError 避免被 JSON 重试捕获
- coordinator/modeler 的 JSON 解析重试加上限 (MAX_JSON_RETRIES=3)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:31:33 +08:00

206 lines
7.6 KiB
Python

"""LLM 交互模块,封装大语言模型的调用、重试和消息发送。"""
from typing import Any
from app.utils.common_utils import transform_link, split_footnotes
from app.utils.log_util import logger
import time
from app.schemas.response import (
CoderMessage,
WriterMessage,
ModelerMessage,
SystemMessage,
CoordinatorMessage,
)
from app.services.redis_manager import redis_manager
from app.schemas.enums import AgentType
from app.config.setting import ApiType
from app.core.llm.types import StandardResponse
from app.core.llm.providers.base import BaseProvider
from app.core.llm.providers.openai_chat import OpenAIChatProvider
from app.core.llm.providers.openai_responses import OpenAIResponsesProvider
from app.core.llm.providers.anthropic import AnthropicProvider
class LLMConfigError(RuntimeError):
"""LLM 配置缺失时抛出,与 JSON 解析的 ValueError 区分开,避免被重试循环误捕获。"""
class LLM:
"""大语言模型封装类,提供对话调用、重试和工具调用验证功能。"""
def __init__(
self,
api_type: ApiType | None = None,
api_key: str | None = None,
model: str | None = None,
base_url: str | None = None,
task_id: str = "",
max_tokens: int | None = None,
):
self.api_type = api_type
self.api_key = api_key
self.model = model
self.base_url = base_url
self.chat_count = 0
self.max_tokens = max_tokens
self.task_id = task_id
self.provider = self._create_provider(api_type)
def _create_provider(self, api_type: ApiType | None) -> BaseProvider:
"""根据 api_type 创建对应的 Provider。"""
match api_type:
case ApiType.OPENAI_RESPONSES:
return OpenAIResponsesProvider()
case ApiType.ANTHROPIC:
return AnthropicProvider()
case _:
# 默认使用 OpenAI Chat Completions(兼容未配置 api_type 的情况)
return OpenAIChatProvider()
def _validate_config(self, agent_name: str) -> None:
"""验证 LLM 配置是否完整。"""
if not self.model or not str(self.model).strip():
raise LLMConfigError(f"{agent_name} 未配置模型 ID,请设置对应的 *_MODEL")
if not self.api_key or not str(self.api_key).strip():
raise LLMConfigError(f"{agent_name} 未配置 API Key,请设置对应的 *_API_KEY")
async def chat(
self,
history: list | None = None,
tools: list | None = None,
tool_choice: str | None = None,
max_retries: int | None = None,
retry_delay: float = 1.0,
top_p: float | None = None,
agent_name: str = "SystemAgent",
sub_title: str | None = None,
) -> StandardResponse:
self._validate_config(agent_name)
# 验证和修复工具调用完整性(仅对 OpenAI 格式的历史有效)
if history:
history = self._validate_and_fix_tool_calls(history)
messages = history or []
attempt = 0
while True:
try:
response = await self.provider.call(
messages=messages,
model=self.model, # type: ignore[arg-type]
api_key=self.api_key, # type: ignore[arg-type]
base_url=self.base_url,
tools=tools,
tool_choice=tool_choice,
max_tokens=self.max_tokens,
top_p=top_p,
)
logger.info(f"API返回: content={response.content!r}, tool_calls={len(response.tool_calls)}")
self.chat_count += 1
await self.send_message(response, agent_name, sub_title)
return response
except Exception as e:
attempt += 1
logger.error(f"第{attempt}次重试: {str(e)}")
if max_retries is not None and attempt >= max_retries:
raise
time.sleep(retry_delay * min(attempt, 10))
def _validate_and_fix_tool_calls(self, history: list) -> list:
"""验证并修复工具调用完整性。"""
if not history:
return history
fixed_history = []
i = 0
while i < len(history):
msg = history[i]
if isinstance(msg, dict) and "tool_calls" in msg and msg["tool_calls"]:
valid_tool_calls = []
for tool_call in msg["tool_calls"]:
tool_call_id = tool_call.get("id")
if tool_call_id:
found_response = False
for j in range(i + 1, len(history)):
if (
history[j].get("role") == "tool"
and history[j].get("tool_call_id") == tool_call_id
):
found_response = True
break
if found_response:
valid_tool_calls.append(tool_call)
if valid_tool_calls:
fixed_msg = msg.copy()
fixed_msg["tool_calls"] = valid_tool_calls
fixed_history.append(fixed_msg)
else:
cleaned_msg = {k: v for k, v in msg.items() if k != "tool_calls"}
if cleaned_msg.get("content"):
fixed_history.append(cleaned_msg)
elif isinstance(msg, dict) and msg.get("role") == "tool":
tool_call_id = msg.get("tool_call_id")
found_call = False
for j in range(len(fixed_history)):
if fixed_history[j].get("tool_calls") and any(
tc.get("id") == tool_call_id
for tc in fixed_history[j]["tool_calls"]
):
found_call = True
break
if found_call:
fixed_history.append(msg)
else:
fixed_history.append(msg)
i += 1
return fixed_history
async def send_message(
self,
response: StandardResponse,
agent_name: str,
sub_title: str | None = None,
):
"""将 LLM 响应通过 Redis 发送给前端。"""
content = response.content
if content is None:
return
agent_msg: Any = None
match agent_name:
case AgentType.CODER:
agent_msg = CoderMessage(content=content)
case AgentType.WRITER:
content, _ = split_footnotes(content)
content = transform_link(self.task_id, content)
agent_msg = WriterMessage(content=content, sub_title=sub_title)
case AgentType.MODELER:
agent_msg = ModelerMessage(content=content)
case AgentType.SYSTEM:
agent_msg = SystemMessage(content=content)
case AgentType.COORDINATOR:
agent_msg = CoordinatorMessage(content=content)
case _:
raise ValueError(f"不支持的agent类型: {agent_name}")
await redis_manager.publish_message(self.task_id, agent_msg)
async def simple_chat(model: LLM, history: list) -> str:
"""使用 LLM 进行简单的单轮对话。"""
response = await model.provider.call(
messages=history,
model=model.model, # type: ignore[arg-type]
api_key=model.api_key, # type: ignore[arg-type]
base_url=model.base_url,
)
return response.content or ""