feat: Agent 监控与编排、仪表盘/配置页及文档更新

- agent_runtime: orchestrator、core/memory/schemas 调整
- agent_monitoring API、service、agent_llm_log 模型与 database 注册
- 前端 AgentDashboard、AgentConfig、Agents/MainLayout/路由与 AgentChat
- 文档:(红头)项目核心文档汇总、自主AI Agent改造完成情况、AI agent改造计划

Made-with: Cursor
This commit is contained in:
renjianbo
2026-05-01 19:32:59 +08:00
parent 09467568ec
commit 036f533881
21 changed files with 2601 additions and 411 deletions

View File

@@ -5,6 +5,7 @@ Agent Runtime — 自主 AI Agent 核心运行时。
- 工具调用(复用已有 ToolRegistry)
- 分层记忆(工作记忆 + 长期记忆)
- 多模型(OpenAI / DeepSeek)
- 多 Agent 编排(路由/顺序/辩论)
- 可嵌入工作流节点或独立运行
"""
from app.agent_runtime.core import AgentRuntime
@@ -14,10 +15,17 @@ from app.agent_runtime.schemas import (
AgentLLMConfig,
AgentToolConfig,
AgentMemoryConfig,
AgentStep,
)
from app.agent_runtime.context import AgentContext
from app.agent_runtime.memory import AgentMemory
from app.agent_runtime.tool_manager import AgentToolManager
from app.agent_runtime.orchestrator import (
AgentOrchestrator,
OrchestratorAgentConfig,
OrchestratorResult,
OrchestratorStep,
)
__all__ = [
"AgentRuntime",
@@ -29,4 +37,9 @@ __all__ = [
"AgentContext",
"AgentMemory",
"AgentToolManager",
"AgentStep",
"AgentOrchestrator",
"OrchestratorAgentConfig",
"OrchestratorResult",
"OrchestratorStep",
]

View File

@@ -12,11 +12,13 @@ from __future__ import annotations
import json
import logging
from typing import Any, Callable, Dict, List, Optional
import time
from typing import Any, Callable, Dict, List, Optional, Protocol, TypedDict
from app.agent_runtime.schemas import (
AgentConfig,
AgentResult,
AgentStep,
)
from app.agent_runtime.context import AgentContext
from app.agent_runtime.memory import AgentMemory
@@ -24,6 +26,24 @@ from app.agent_runtime.tool_manager import AgentToolManager
logger = logging.getLogger(__name__)
class LLMCallMetrics(TypedDict, total=False):
"""一次 LLM 调用的度量数据"""
agent_id: Optional[str]
session_id: str
user_id: Optional[str]
model: str
provider: Optional[str]
prompt_tokens: int
completion_tokens: int
total_tokens: int
latency_ms: int
iteration_number: int
step_type: str # think / final
tool_name: Optional[str]
status: str # success / error
error_message: Optional[str]
# 可重试的 API 异常
_RETRYABLE_ERRORS = (
"timed out",
@@ -55,6 +75,7 @@ class AgentRuntime:
tool_manager: Optional[AgentToolManager] = None,
execution_logger: Optional[Any] = None,
on_tool_executed: Optional[Callable[[str], Any]] = None,
on_llm_call: Optional[Callable[[Dict[str, Any]], Any]] = None,
):
self.config = config or AgentConfig()
self.context = context or AgentContext(
@@ -72,6 +93,7 @@ class AgentRuntime:
)
self.execution_logger = execution_logger
self.on_tool_executed = on_tool_executed
self.on_llm_call = on_llm_call
self._memory_context_loaded = False
async def run(self, user_input: str) -> AgentResult:
@@ -96,6 +118,20 @@ class AgentRuntime:
llm = _LLMClient(self.config.llm)
tool_schemas = self.tool_manager.get_tool_schemas()
has_tools = self.tool_manager.has_tools()
steps: List[AgentStep] = []
# 构建 LLM 调用回调(包装 on_llm_call,补充上下文)
llm_callback_ctx = {"step_type": "think", "tool_name": None}
def _llm_callback(metrics: Dict[str, Any]):
if self.on_llm_call:
metrics.update({
"session_id": self.context.session_id,
"user_id": self.config.user_id,
"step_type": llm_callback_ctx["step_type"],
"tool_name": llm_callback_ctx["tool_name"],
})
self.on_llm_call(metrics)
while self.context.iteration < max_iter:
self.context.iteration += 1
@@ -110,11 +146,17 @@ class AgentRuntime:
tools=tool_schemas if has_tools and self.context.iteration == 1 else
(tool_schemas if has_tools else None),
iteration=self.context.iteration,
on_completion=_llm_callback,
)
except Exception as e:
err_str = str(e)
logger.error("LLM 调用失败 (iteration=%s): %s", self.context.iteration, err_str)
if self.context.iteration < max_iter and self._is_retryable(err_str):
steps.append(AgentStep(
iteration=self.context.iteration,
type="tool_result",
content=f"LLM 调用失败(可重试): {err_str}",
))
continue
return AgentResult(
success=False,
@@ -127,29 +169,55 @@ class AgentRuntime:
# 解析工具调用
tool_calls = self._extract_tool_calls(response)
content = self._extract_content(response)
reasoning = getattr(response, "reasoning_content", None) or (
response.get("reasoning_content") if isinstance(response, dict) else None
)
if not tool_calls:
# LLM 直接返回文本 → 结束
self.context.add_assistant_message(content)
final_text = content or "(模型未返回有效内容)"
steps.append(AgentStep(
iteration=self.context.iteration,
type="final",
content=final_text,
reasoning=reasoning,
))
# 保存记忆
await self.memory.save_context(user_input, final_text)
await self.memory.save_context(user_input, final_text, self.context.messages)
return AgentResult(
success=True,
content=final_text,
iterations_used=self.context.iteration,
tool_calls_made=self.context.tool_calls_made,
steps=steps,
)
# 有工具调用 → 先记录 assistant 消息(含 tool_calls + reasoning_content)
reasoning = getattr(response, "reasoning_content", None) or (
response.get("reasoning_content") if isinstance(response, dict) else None
)
# 有工具调用 → 先记录 assistant 消息(含 tool_calls)
self.context.add_assistant_message(content or "", tool_calls, reasoning)
# 记录思考步骤(含工具调用意图)
tc_names = [tc["function"]["name"] for tc in tool_calls]
tc_args_list = []
for tc in tool_calls:
try:
tc_args_list.append(json.loads(tc["function"].get("arguments", "{}")))
except (json.JSONDecodeError, TypeError):
tc_args_list.append({})
steps.append(AgentStep(
iteration=self.context.iteration,
type="think",
content=content or f"调用工具: {', '.join(tc_names)}",
reasoning=reasoning,
tool_name=tc_names[0] if len(tc_names) == 1 else None,
tool_input=tc_args_list[0] if len(tc_args_list) == 1 else None,
))
if self.execution_logger:
self.execution_logger.info(
f"Agent 调用 {len(tool_calls)} 个工具",
data={"tool_calls": [tc["function"]["name"] for tc in tool_calls],
data={"tool_calls": tc_names,
"iteration": self.context.iteration},
)
@@ -167,6 +235,15 @@ class AgentRuntime:
logger.info("Agent 执行工具 [%s]: %s", tname, targs)
result = await self.tool_manager.execute(tname, targs)
steps.append(AgentStep(
iteration=self.context.iteration,
type="tool_result",
content=f"工具 {tname} 返回结果",
tool_name=tname,
tool_input=targs,
tool_result=result[:500] + "..." if len(result) > 500 else result,
))
self.context.add_tool_result(tcid, tname, result)
self.context.tool_calls_made += 1
@@ -191,13 +268,20 @@ class AgentRuntime:
break
logger.warning("Agent 达到最大迭代次数 (%s)", max_iter)
await self.memory.save_context(user_input, last_content or "(已达最大迭代次数)")
await self.memory.save_context(user_input, last_content or "(已达最大迭代次数)", self.context.messages)
if last_content:
steps.append(AgentStep(
iteration=self.context.iteration,
type="final",
content=last_content,
))
return AgentResult(
success=True,
content=last_content or "已达最大迭代次数,但模型未返回最终回答。",
truncated=True,
iterations_used=self.context.iteration,
tool_calls_made=self.context.tool_calls_made,
steps=steps,
)
async def _inject_memory_context(self) -> None:
@@ -285,6 +369,7 @@ class _LLMClient:
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
iteration: int = 1,
on_completion: Optional[Callable[[Dict[str, Any]], Any]] = None,
) -> Any:
"""
调用 LLM。
@@ -326,5 +411,44 @@ class _LLMClient:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
response = await client.chat.completions.create(**kwargs)
return response.choices[0].message
start_time = time.perf_counter()
try:
response = await client.chat.completions.create(**kwargs)
latency_ms = int((time.perf_counter() - start_time) * 1000)
message = response.choices[0].message
# 提取 token 用量
usage = getattr(response, "usage", None)
prompt_tokens = usage.prompt_tokens if usage else 0
completion_tokens = usage.completion_tokens if usage else 0
total_tokens = usage.total_tokens if usage else 0
# 调用完成回调
if on_completion:
on_completion({
"model": self._config.model,
"provider": self._config.provider,
"prompt_tokens": prompt_tokens or 0,
"completion_tokens": completion_tokens or 0,
"total_tokens": total_tokens or 0,
"latency_ms": latency_ms,
"iteration_number": iteration,
"status": "success",
})
return message
except Exception as e:
latency_ms = int((time.perf_counter() - start_time) * 1000)
if on_completion:
on_completion({
"model": self._config.model,
"provider": self._config.provider,
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"latency_ms": latency_ms,
"iteration_number": iteration,
"status": "error",
"error_message": str(e),
})
raise

View File

@@ -1,5 +1,6 @@
"""
Agent 记忆管理:包装已有 persistent_memory_service,提供会话级和长期记忆。
支持 LLM 自动压缩总结对话历史。
"""
from __future__ import annotations
@@ -14,7 +15,6 @@ from app.services.persistent_memory_service import (
save_persistent_memory,
persist_enabled,
)
from app.core.config import settings
logger = logging.getLogger(__name__)
@@ -25,7 +25,7 @@ class AgentMemory:
- 工作记忆:当前会话消息列表(由 AgentRuntime 直接管理)
- 长期记忆:从 MySQL 加载/保存的用户画像和关键事实
- 上下文压缩:对话过长时自动裁剪或总结
- 记忆压缩:LLM 自动总结对话历史,提取关键信息存入长期记忆
"""
def __init__(
@@ -43,6 +43,8 @@ class AgentMemory:
self.max_history = max_history
# 从长期记忆加载的上下文(启动时加载)
self._long_term_context: Dict[str, Any] = {}
# 记录已压缩的消息数,避免重复压缩
self._last_compressed_msg_count = 0
async def initialize(self) -> str:
"""
@@ -87,9 +89,10 @@ class AgentMemory:
return ""
async def save_context(
self, user_message: str, assistant_reply: str
self, user_message: str, assistant_reply: str,
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""将单轮对话保存到长期记忆。"""
"""将单轮对话保存到长期记忆。如有消息列表,LLM 自动压缩总结。"""
if not self.persist or not self.scope_id:
return
@@ -99,6 +102,11 @@ class AgentMemory:
ctx["last_assistant_reply"] = assistant_reply[:500]
self._long_term_context["context"] = ctx
# 如果有完整消息列表且新增了足够多的消息,运行 LLM 压缩总结
if messages and len(messages) > self._last_compressed_msg_count + 2:
await self._compress_and_summarize(messages)
self._last_compressed_msg_count = len(messages)
db: Optional[Session] = None
try:
db = SessionLocal()
@@ -112,6 +120,111 @@ class AgentMemory:
if db:
db.close()
async def _compress_and_summarize(
self, messages: List[Dict[str, Any]]
) -> None:
"""
使用 LLM 压缩总结对话历史,提取用户画像和关键事实。
只处理非 system 消息。
"""
from openai import AsyncOpenAI
from app.core.config import settings
# 提取对话消息(去掉 system 和 tool 消息)
conversation = []
for m in messages:
role = m.get("role", "")
if role == "system":
continue
if role == "tool":
# 工具结果精简后加入
content = m.get("content", "")
name = m.get("name", "tool")
conversation.append({"role": "user" if role == "tool" else role, "content": f"[工具 {name} 执行结果]\n{content[:200]}"})
else:
conversation.append({"role": role, "content": m.get("content", "")[:500]})
if len(conversation) < 2:
return
# 构建总结 prompt
summary_prompt = (
"你是一个记忆管理助手。请分析以下对话历史,提取关于用户的关键信息。\n\n"
"请返回 JSON 格式(不要 markdown 包裹),包含以下字段:\n"
"1. user_profile: 用户画像对象,包含用户的偏好、角色、关键需求等\n"
"2. key_facts: 从对话中提取的关键事实列表(字符串数组)\n"
"3. summary: 对话的简要总结(100字以内)\n"
"4. topics: 讨论过的话题列表(字符串数组)\n\n"
"如果没有足够信息,相应字段设为空对象或空数组。"
)
summary_messages = [
{"role": "system", "content": summary_prompt},
*conversation[-10:], # 只取最近 10 条消息
]
try:
api_key = settings.DEEPSEEK_API_KEY or settings.OPENAI_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or settings.OPENAI_BASE_URL or "https://api.deepseek.com"
if api_key == "your-openai-api-key":
api_key = settings.DEEPSEEK_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or "https://api.deepseek.com"
if not api_key:
logger.warning("记忆压缩:未配置 API Key,跳过")
return
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
resp = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=summary_messages,
temperature=0.3,
max_tokens=1024,
timeout=30,
)
raw = resp.choices[0].message.content or ""
# 解析 JSON
result = json.loads(raw.strip().removeprefix("```json").removesuffix("```").strip())
# 合并到长期记忆
existing_profile = self._long_term_context.get("user_profile", {})
new_profile = result.get("user_profile", {})
if isinstance(new_profile, dict) and new_profile:
# 合并画像(新信息覆盖旧信息)
existing_profile.update(new_profile)
self._long_term_context["user_profile"] = existing_profile
# 合并关键事实
existing_facts = self._long_term_context.get("key_facts", [])
new_facts = result.get("key_facts", [])
if isinstance(new_facts, list):
all_facts = list(dict.fromkeys(existing_facts + new_facts)) # 去重
self._long_term_context["key_facts"] = all_facts[-20:] # 最多保留 20 条
# 更新摘要
summary = result.get("summary", "")
if summary:
ctx = self._long_term_context.get("context", {})
ctx["compressed_summary"] = summary
self._long_term_context["context"] = ctx
# 记录话题
topics = result.get("topics", [])
if isinstance(topics, list) and topics:
existing_topics = self._long_term_context.get("topics", [])
all_topics = list(dict.fromkeys(existing_topics + topics))
self._long_term_context["topics"] = all_topics[-20:]
logger.info("记忆压缩总结完成: profile=%s facts=%d topics=%d",
"updated" if new_profile else "unchanged",
len(new_facts), len(topics))
except json.JSONDecodeError:
logger.warning("记忆压缩:LLM 返回非 JSON 格式,跳过")
except Exception as e:
logger.warning("记忆压缩失败: %s", e)
def trim_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
裁剪消息列表:保留最近的 N 条,但始终保留第一条 system 消息。
@@ -127,7 +240,7 @@ class AgentMemory:
@staticmethod
def _summarize_history(history: List[Dict[str, Any]]) -> str:
"""简单汇总历史对话(不做 LLM 压缩,仅计数)。"""
"""汇总历史对话。"""
turns = 0
for m in history:
if m.get("role") == "user":

View File

@@ -0,0 +1,377 @@
"""
Agent Orchestrator — 多 Agent 编排引擎。
支持三种协作模式:
- route: Router Agent 分析问题 → 分发到最合适的 Specialist Agent
- sequential: Agent 流水线执行,前者输出作为后者输入
- debate: 多个 Agent 独立回答 → Aggregator 汇总为最终答案
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import Any, Callable, Dict, List, Optional
from pydantic import BaseModel, Field
from app.agent_runtime import (
AgentRuntime,
AgentConfig,
AgentLLMConfig,
AgentToolConfig,
AgentResult,
)
from app.agent_runtime.core import _LLMClient
logger = logging.getLogger(__name__)
class OrchestratorAgentConfig(BaseModel):
"""编排中单个 Agent 的配置"""
id: str = Field(..., description="Agent 标识")
name: str = Field(default="Agent", description="显示名称")
system_prompt: str = Field(default="你是一个有用的AI助手。")
model: str = Field(default="deepseek-v4-flash")
provider: str = Field(default="deepseek")
temperature: float = 0.7
max_iterations: int = 10
tools: List[str] = Field(default_factory=list, description="工具白名单,空=全部")
description: str = Field(default="", description="Agent 专长描述(路由模式用)")
class OrchestratorStep(BaseModel):
"""编排中的单步执行记录"""
agent_id: str
agent_name: str
input: str = ""
output: str = ""
iterations_used: int = 0
tool_calls_made: int = 0
error: Optional[str] = None
class OrchestratorResult(BaseModel):
"""编排执行结果"""
mode: str
final_answer: str
steps: List[OrchestratorStep] = Field(default_factory=list)
agent_results: List[Dict[str, Any]] = Field(default_factory=list)
_ROUTER_SYSTEM_PROMPT = """你是一个路由调度员。你的任务是从以下 Specialist Agent 中选择一个最适合处理用户问题的 Agent。
可用的 Specialist Agent:
{agent_list}
请返回 JSON 格式(不要 markdown 包裹),包含:
1. "selected_agent": 选中的 Agent ID
2. "reason": 选择理由(一句话)
规则:
- 选择与问题最匹配的 Agent
- 如果问题涉及多个领域,选择最相关的那个
- 必须从上述列表中选择,不能编造 Agent ID"""
_AGGREGATOR_SYSTEM_PROMPT = """你是一个回答汇总员。多个 AI Agent 对同一个问题给出了不同的回答。
请分析所有回答,输出一份综合的最终答案。
- 如果各 Agent 回答一致,合并要点
- 如果有分歧,指出不同观点并给出你的判断
- 以专业、清晰的格式输出最终答案"""
class AgentOrchestrator:
"""
多 Agent 编排器。
用法:
orch = AgentOrchestrator()
result = await orch.run("route", question, [agent1, agent2, agent3])
"""
def __init__(self, default_llm_config: Optional[AgentLLMConfig] = None):
self._default_llm = default_llm_config or AgentLLMConfig(
model="deepseek-v4-flash",
temperature=0.3,
)
async def run(
self,
mode: str,
question: str,
agents: List[OrchestratorAgentConfig],
on_llm_call: Optional[Callable[[Dict[str, Any]], Any]] = None,
) -> OrchestratorResult:
"""执行多 Agent 编排。"""
mode = mode.lower()
if mode == "route":
return await self._route(question, agents, on_llm_call)
elif mode == "sequential":
return await self._sequential(question, agents, on_llm_call)
elif mode == "debate":
return await self._debate(question, agents, on_llm_call)
else:
raise ValueError(f"不支持的编排模式: {mode},可选: route, sequential, debate")
async def _route(
self, question: str, agents: List[OrchestratorAgentConfig],
on_llm_call: Optional[Callable] = None,
) -> OrchestratorResult:
"""路由模式:Router → Specialist。"""
# 构建 Agent 列表描述
agent_lines = []
for a in agents:
desc = a.description or a.name
agent_lines.append(f"- id: {a.id}, name: {a.name}, description: {desc}")
agent_list_str = "\n".join(agent_lines)
router_prompt = _ROUTER_SYSTEM_PROMPT.format(agent_list=agent_list_str)
# 创建 Router Agent
router_runtime = AgentRuntime(
AgentConfig(
name="router",
system_prompt=router_prompt,
llm=AgentLLMConfig(
model=self._default_llm.model,
temperature=0.1, # 低温度确保确定性
),
tools=AgentToolConfig(
include_tools=[], # Router 不需要工具
),
),
on_llm_call=on_llm_call,
)
router_result = await router_runtime.run(question)
if not router_result.success:
return OrchestratorResult(
mode="route",
final_answer=f"路由决策失败: {router_result.content}",
steps=[],
)
# 解析 Router 的输出
selected_agent_id = None
try:
parsed = json.loads(router_result.content.strip().removeprefix("```json").removesuffix("```").strip())
selected_agent_id = parsed.get("selected_agent", "")
except (json.JSONDecodeError, AttributeError):
# 尝试从文本中提取
for a in agents:
if a.id in router_result.content:
selected_agent_id = a.id
break
if not selected_agent_id:
# 取第一个
selected_agent_id = agents[0].id if agents else ""
# 找到对应的 Specialist Agent
specialist = next((a for a in agents if a.id == selected_agent_id), agents[0] if agents else None)
if not specialist:
return OrchestratorResult(
mode="route",
final_answer="没有可用的 Specialist Agent",
steps=[],
)
# 运行 Specialist Agent
specialist_runtime = AgentRuntime(
AgentConfig(
name=specialist.name,
system_prompt=specialist.system_prompt,
llm=AgentLLMConfig(
model=specialist.model,
provider=specialist.provider,
temperature=specialist.temperature,
max_iterations=specialist.max_iterations,
),
tools=AgentToolConfig(
include_tools=specialist.tools,
),
),
on_llm_call=on_llm_call,
)
specialist_result = await specialist_runtime.run(question)
return OrchestratorResult(
mode="route",
final_answer=specialist_result.content,
steps=[
OrchestratorStep(
agent_id="router",
agent_name="Router",
input=question,
output=f"选择: {specialist.name} ({specialist.id})",
),
OrchestratorStep(
agent_id=specialist.id,
agent_name=specialist.name,
input=question,
output=specialist_result.content[:300],
iterations_used=specialist_result.iterations_used,
tool_calls_made=specialist_result.tool_calls_made,
),
],
agent_results=[
{"agent_id": specialist.id, "agent_name": specialist.name, "output": specialist_result.content},
],
)
async def _sequential(
self, question: str, agents: List[OrchestratorAgentConfig],
on_llm_call: Optional[Callable] = None,
) -> OrchestratorResult:
"""顺序模式:Agent A 输出 → Agent B 输入。"""
if not agents:
return OrchestratorResult(mode="sequential", final_answer="无 Agent 可执行")
steps: List[OrchestratorStep] = []
current_input = question
for i, agent_cfg in enumerate(agents):
runtime = AgentRuntime(
AgentConfig(
name=agent_cfg.name,
system_prompt=agent_cfg.system_prompt,
llm=AgentLLMConfig(
model=agent_cfg.model,
provider=agent_cfg.provider,
temperature=agent_cfg.temperature,
max_iterations=agent_cfg.max_iterations,
),
tools=AgentToolConfig(
include_tools=agent_cfg.tools,
),
),
on_llm_call=on_llm_call,
)
# 第一个 Agent 接收原始问题,后续 Agent 接收前一个的输出
agent_input = current_input
if i > 0:
agent_input = (
f"这是前一个 Agent 的处理结果,请在此基础上继续处理。\n\n"
f"原始问题: {question}\n\n"
f"前序输出:\n{current_input}"
)
result = await runtime.run(agent_input)
step = OrchestratorStep(
agent_id=agent_cfg.id,
agent_name=agent_cfg.name,
input=agent_input[:200],
output=result.content[:500],
iterations_used=result.iterations_used,
tool_calls_made=result.tool_calls_made,
error=None if result.success else result.error,
)
steps.append(step)
if not result.success:
break
current_input = result.content
final_answer = steps[-1].output if steps else "无输出"
return OrchestratorResult(
mode="sequential",
final_answer=final_answer,
steps=steps,
agent_results=[
{"agent_id": s.agent_id, "agent_name": s.agent_name, "output": s.output}
for s in steps
],
)
async def _debate(
self, question: str, agents: List[OrchestratorAgentConfig],
on_llm_call: Optional[Callable] = None,
) -> OrchestratorResult:
"""辩论模式:多 Agent 独立回答 → Aggregator 汇总。"""
if not agents:
return OrchestratorResult(mode="debate", final_answer="无 Agent 可执行")
steps: List[OrchestratorStep] = []
agent_outputs: List[Dict[str, Any]] = []
# 第一阶段:所有 Agent 独立回答
for agent_cfg in agents:
runtime = AgentRuntime(
AgentConfig(
name=agent_cfg.name,
system_prompt=agent_cfg.system_prompt,
llm=AgentLLMConfig(
model=agent_cfg.model,
provider=agent_cfg.provider,
temperature=agent_cfg.temperature,
max_iterations=agent_cfg.max_iterations,
),
tools=AgentToolConfig(
include_tools=agent_cfg.tools,
),
),
on_llm_call=on_llm_call,
)
result = await runtime.run(question)
step = OrchestratorStep(
agent_id=agent_cfg.id,
agent_name=agent_cfg.name,
input=question,
output=result.content[:500],
iterations_used=result.iterations_used,
tool_calls_made=result.tool_calls_made,
error=None if result.success else result.error,
)
steps.append(step)
agent_outputs.append({
"agent_id": agent_cfg.id,
"agent_name": agent_cfg.name,
"output": result.content,
})
# 第二阶段:Aggregator 汇总所有回答
if len(agent_outputs) >= 2:
outputs_text = "\n\n---\n\n".join(
f"## {ao['agent_name']} 的回答\n{ao['output']}" for ao in agent_outputs
)
aggregator_prompt = (
f"用户问题: {question}\n\n"
f"以下是多个 AI Agent 对该问题的回答:\n\n{outputs_text}\n\n"
"请综合所有回答,输出一份完整、准确的最终答案。"
)
aggregator_runtime = AgentRuntime(
AgentConfig(
name="aggregator",
system_prompt=_AGGREGATOR_SYSTEM_PROMPT,
llm=AgentLLMConfig(
model=self._default_llm.model,
temperature=0.3,
),
tools=AgentToolConfig(include_tools=[]),
),
on_llm_call=on_llm_call,
)
final_result = await aggregator_runtime.run(aggregator_prompt)
final_answer = final_result.content
steps.append(OrchestratorStep(
agent_id="aggregator",
agent_name="Aggregator",
input="汇总各 Agent 回答",
output=final_answer[:500],
))
else:
final_answer = agent_outputs[0]["output"] if agent_outputs else "无回答"
return OrchestratorResult(
mode="debate",
final_answer=final_answer,
steps=steps,
agent_results=agent_outputs,
)

View File

@@ -54,6 +54,17 @@ class AgentMessage(BaseModel):
name: Optional[str] = None
class AgentStep(BaseModel):
"""Agent 单步执行记录(用于执行追踪)"""
iteration: int = Field(..., description="第几步")
type: str = Field(..., description="步骤类型: think / tool_call / tool_result / final")
content: str = Field(default="", description="步骤内容")
tool_name: Optional[str] = Field(default=None, description="工具名称(tool_call/tool_result 类型时)")
tool_input: Optional[Dict[str, Any]] = Field(default=None, description="工具输入参数")
tool_result: Optional[str] = Field(default=None, description="工具执行结果")
reasoning: Optional[str] = Field(default=None, description="思考过程")
class AgentResult(BaseModel):
"""Agent 执行结果"""
success: bool = True
@@ -62,3 +73,4 @@ class AgentResult(BaseModel):
iterations_used: int = 0
tool_calls_made: int = 0
error: Optional[str] = None
steps: List[AgentStep] = Field(default_factory=list, description="执行追踪步骤详情")

View File

@@ -8,20 +8,24 @@ POST /api/v1/agent-chat/bare
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.core.database import get_db
from sqlalchemy.orm import Session
from app.api.auth import get_current_user
from app.models.user import User
from app.models.agent import Agent
from app.models.agent_llm_log import AgentLLMLog
from app.agent_runtime import (
AgentRuntime,
AgentConfig,
AgentLLMConfig,
AgentToolConfig,
AgentStep,
AgentOrchestrator,
OrchestratorAgentConfig,
)
from app.core.config import settings
@@ -29,6 +33,37 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/agent-chat", tags=["agent-chat"])
def _make_llm_logger(
db: Session,
agent_id: Optional[str] = None,
user_id: Optional[str] = None,
):
"""创建 LLM 调用日志回调,写入 AgentLLMLog 表。"""
def _log(metrics: dict):
try:
log = AgentLLMLog(
agent_id=agent_id,
session_id=metrics.get("session_id"),
user_id=user_id,
model=metrics.get("model", ""),
provider=metrics.get("provider"),
prompt_tokens=metrics.get("prompt_tokens", 0),
completion_tokens=metrics.get("completion_tokens", 0),
total_tokens=metrics.get("total_tokens", 0),
latency_ms=metrics.get("latency_ms", 0),
iteration_number=metrics.get("iteration_number", 0),
step_type=metrics.get("step_type"),
tool_name=metrics.get("tool_name"),
status=metrics.get("status", "success"),
error_message=metrics.get("error_message"),
)
db.add(log)
db.commit()
except Exception as e:
logger.warning("写入 AgentLLMLog 失败: %s", e)
return _log
class ChatRequest(BaseModel):
message: str
session_id: Optional[str] = None
@@ -44,12 +79,103 @@ class ChatResponse(BaseModel):
truncated: bool
session_id: str
agent_id: Optional[str] = None
steps: List[AgentStep] = Field(default_factory=list, description="执行追踪步骤")
class OrchestrateAgentItem(BaseModel):
"""编排中单个 Agent 的定义"""
id: str
name: str = "Agent"
system_prompt: str = "你是一个有用的AI助手。"
model: str = "deepseek-v4-flash"
provider: str = "deepseek"
temperature: float = 0.7
max_iterations: int = 10
tools: List[str] = Field(default_factory=list)
description: str = ""
class OrchestrateRequest(BaseModel):
"""多 Agent 编排请求"""
message: str
mode: str = "debate"
agents: List[OrchestrateAgentItem] = Field(..., min_length=1)
model: Optional[str] = None
class OrchestrateStepItem(BaseModel):
"""编排步骤"""
agent_id: str
agent_name: str
input: str = ""
output: str = ""
iterations_used: int = 0
tool_calls_made: int = 0
error: Optional[str] = None
class OrchestrateResponse(BaseModel):
"""多 Agent 编排响应"""
mode: str
final_answer: str
steps: List[OrchestrateStepItem] = Field(default_factory=list)
agent_results: List[Dict[str, Any]] = Field(default_factory=list)
@router.post("/orchestrate", response_model=OrchestrateResponse)
async def orchestrate_agents(
req: OrchestrateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""多 Agent 编排:支持 route / sequential / debate 三种模式。"""
agents = [
OrchestratorAgentConfig(
id=a.id, name=a.name,
system_prompt=a.system_prompt,
model=req.model or a.model,
provider=a.provider,
temperature=a.temperature,
max_iterations=a.max_iterations,
tools=a.tools,
description=a.description,
)
for a in req.agents
]
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
orchestrator = AgentOrchestrator(
default_llm_config=AgentLLMConfig(
model=req.model or "deepseek-v4-flash",
temperature=0.3,
),
)
result = await orchestrator.run(req.mode, req.message, agents, on_llm_call=on_llm_call)
return OrchestrateResponse(
mode=result.mode,
final_answer=result.final_answer,
steps=[
OrchestrateStepItem(
agent_id=s.agent_id,
agent_name=s.agent_name,
input=s.input,
output=s.output,
iterations_used=s.iterations_used,
tool_calls_made=s.tool_calls_made,
error=s.error,
)
for s in result.steps
],
agent_results=result.agent_results,
)
@router.post("/bare", response_model=ChatResponse)
async def chat_bare(
req: ChatRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""无需 Agent 配置,使用默认设置直接对话。"""
config = AgentConfig(
@@ -65,7 +191,8 @@ async def chat_bare(
),
user_id=current_user.id,
)
runtime = AgentRuntime(config=config)
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
runtime = AgentRuntime(config=config, on_llm_call=on_llm_call)
result = await runtime.run(req.message)
return ChatResponse(
@@ -74,6 +201,7 @@ async def chat_bare(
tool_calls_made=result.tool_calls_made,
truncated=result.truncated,
session_id=runtime.context.session_id,
steps=result.steps,
)
@@ -113,7 +241,8 @@ async def chat_with_agent(
user_id=current_user.id,
)
runtime = AgentRuntime(config=config)
on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id)
runtime = AgentRuntime(config=config, on_llm_call=on_llm_call)
result = await runtime.run(req.message)
return ChatResponse(
@@ -123,6 +252,7 @@ async def chat_with_agent(
truncated=result.truncated,
session_id=runtime.context.session_id,
agent_id=agent_id,
steps=result.steps,
)

View File

@@ -0,0 +1,74 @@
"""
Agent 监控 API — 提供 Agent 专属统计数据
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.api.auth import get_current_user
from app.models.user import User
from app.services.agent_monitoring_service import AgentMonitoringService
router = APIRouter(
prefix="/api/v1/agent-monitoring",
tags=["agent-monitoring"],
responses={
401: {"description": "未授权"},
403: {"description": "无权访问"},
},
)
@router.get("/overview")
async def get_agent_overview(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Agent 概览统计:Agent 数、对话次数、LLM 调用次数、Token 用量、工具调用次数。"""
user_id = None if current_user.role == "admin" else current_user.id
return AgentMonitoringService.get_overview(db, user_id)
@router.get("/llm-calls")
async def get_llm_calls(
days: int = Query(7, ge=1, le=30, description="统计天数"),
limit: int = Query(50, ge=1, le=200, description="返回条数"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""最近 LLM 调用记录列表。"""
user_id = None if current_user.role == "admin" else current_user.id
return AgentMonitoringService.get_llm_calls(db, user_id, days, limit)
@router.get("/agents-stats")
async def get_agent_stats(
days: int = Query(7, ge=1, le=30, description="统计天数"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""各 Agent 用量统计(按 Agent 分组)。"""
user_id = None if current_user.role == "admin" else current_user.id
return AgentMonitoringService.get_agent_stats(db, user_id, days)
@router.get("/tool-usage")
async def get_tool_usage(
days: int = Query(7, ge=1, le=30, description="统计天数"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""工具调用频次统计。"""
user_id = None if current_user.role == "admin" else current_user.id
return AgentMonitoringService.get_tool_usage(db, user_id, days)
@router.get("/daily-trend")
async def get_daily_trend(
days: int = Query(7, ge=1, le=30, description="统计天数"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""每日 LLM 调用趋势。"""
user_id = None if current_user.role == "admin" else current_user.id
return AgentMonitoringService.get_daily_trend(db, user_id, days)

View File

@@ -46,4 +46,5 @@ def init_db():
import app.models.workflow_template
import app.models.permission
import app.models.alert_rule
import app.models.agent_llm_log
Base.metadata.create_all(bind=engine)

View File

@@ -201,7 +201,7 @@ async def startup_event():
# 不抛出异常,允许应用继续启动
# 注册路由
from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat
from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_monitoring
app.include_router(auth.router)
app.include_router(uploads.router)
@@ -224,6 +224,7 @@ app.include_router(node_test.router)
app.include_router(node_templates.router)
app.include_router(tools.router)
app.include_router(agent_chat.router)
app.include_router(agent_monitoring.router)
if __name__ == "__main__":
import uvicorn

View File

@@ -12,5 +12,6 @@ from app.models.node_template import NodeTemplate
from app.models.permission import Role, Permission, WorkflowPermission, AgentPermission
from app.models.alert_rule import AlertRule, AlertLog
from app.models.persistent_user_memory import PersistentUserMemory
from app.models.agent_llm_log import AgentLLMLog
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory"]
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog"]

View File

@@ -0,0 +1,29 @@
"""
Agent LLM 调用日志模型 — 记录每次 Agent Runtime 发起的 LLM 调用
"""
from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey, func
from sqlalchemy.dialects.mysql import CHAR
from app.core.database import Base
import uuid
class AgentLLMLog(Base):
"""Agent LLM 调用日志表"""
__tablename__ = "agent_llm_logs"
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="日志ID")
agent_id = Column(CHAR(36), ForeignKey("agents.id"), nullable=True, comment="Agent ID")
session_id = Column(String(100), nullable=True, comment="会话ID")
user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=True, comment="用户ID")
model = Column(String(100), nullable=False, comment="模型名称")
provider = Column(String(50), nullable=True, comment="提供商")
prompt_tokens = Column(Integer, default=0, comment="提示 tokens")
completion_tokens = Column(Integer, default=0, comment="生成 tokens")
total_tokens = Column(Integer, default=0, comment="总 tokens")
latency_ms = Column(Integer, default=0, comment="调用耗时(ms)")
iteration_number = Column(Integer, default=0, comment="ReAct 迭代轮次")
step_type = Column(String(20), nullable=True, comment="步骤类型: think/final")
tool_name = Column(String(100), nullable=True, comment="工具名称(如是工具调用)")
status = Column(String(20), default="success", comment="状态: success/error")
error_message = Column(Text, nullable=True, comment="错误信息")
created_at = Column(DateTime, default=func.now(), comment="创建时间")

View File

@@ -0,0 +1,234 @@
"""
Agent 监控服务 — 提供 Agent 专属统计数据
"""
from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from app.models.agent_llm_log import AgentLLMLog
from app.models.agent import Agent
from app.models.execution import Execution
import logging
logger = logging.getLogger(__name__)
class AgentMonitoringService:
"""Agent 监控服务"""
@staticmethod
def get_overview(db: Session, user_id: Optional[str] = None) -> Dict[str, Any]:
"""
获取 Agent 概览统计。
- 总对话次数(Execution 中 agent_id 不为空的记录数)
- 总 LLM 调用次数
- 总 tokens 数(近似)
- 总工具调用次数
- 活跃 Agent 数
"""
user_filter = Agent.user_id == user_id if user_id else True
agent_ids_query = db.query(Agent.id).filter(user_filter)
agent_ids = {row[0] for row in agent_ids_query.all()}
# Agent 数量
agent_count = len(agent_ids)
# 对话次数(Execution 表中 agent 执行的记录)
exec_filter = Execution.agent_id.in_(agent_ids) if agent_ids else False
chat_count = 0
if agent_ids:
chat_count = db.query(func.count(Execution.id)).filter(exec_filter).scalar() or 0
# LLM 调用统计
llm_filter = AgentLLMLog.agent_id.in_(agent_ids) if agent_ids else False
llm_count = 0
total_prompt = 0
total_completion = 0
if agent_ids:
stats = db.query(
func.count(AgentLLMLog.id),
func.coalesce(func.sum(AgentLLMLog.prompt_tokens), 0),
func.coalesce(func.sum(AgentLLMLog.completion_tokens), 0),
).filter(llm_filter).first()
llm_count = stats[0] or 0
total_prompt = stats[1] or 0
total_completion = stats[2] or 0
# 工具调用次数(从 ExecutionLog 统计 agent 相关)
tool_call_count = 0
if agent_ids:
tool_call_count = db.query(func.count(AgentLLMLog.id)).filter(
and_(
AgentLLMLog.agent_id.in_(agent_ids),
AgentLLMLog.tool_name.isnot(None),
)
).scalar() or 0
return {
"agent_count": agent_count,
"chat_count": chat_count,
"llm_call_count": llm_count,
"total_prompt_tokens": total_prompt,
"total_completion_tokens": total_completion,
"total_tokens": total_prompt + total_completion,
"tool_call_count": tool_call_count,
}
@staticmethod
def get_llm_calls(
db: Session,
user_id: Optional[str] = None,
days: int = 7,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""获取最近 LLM 调用记录。"""
end_time = datetime.utcnow()
start_time = end_time - timedelta(days=days)
filters = [AgentLLMLog.created_at >= start_time]
if user_id:
filters.append(AgentLLMLog.user_id == user_id)
records = db.query(AgentLLMLog).filter(
and_(*filters)
).order_by(AgentLLMLog.created_at.desc()).limit(limit).all()
return [
{
"id": r.id,
"agent_id": r.agent_id,
"session_id": r.session_id,
"model": r.model,
"provider": r.provider,
"prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens,
"total_tokens": r.total_tokens,
"latency_ms": r.latency_ms,
"iteration_number": r.iteration_number,
"step_type": r.step_type,
"tool_name": r.tool_name,
"status": r.status,
"error_message": r.error_message,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in records
]
@staticmethod
def get_agent_stats(
db: Session,
user_id: Optional[str] = None,
days: int = 7,
) -> List[Dict[str, Any]]:
"""获取各 Agent 的用量统计(按 Agent 分组)。"""
end_time = datetime.utcnow()
start_time = end_time - timedelta(days=days)
filters = [AgentLLMLog.created_at >= start_time]
if user_id:
filters.append(AgentLLMLog.user_id == user_id)
rows = db.query(
AgentLLMLog.agent_id,
Agent.name.label("agent_name"),
func.count(AgentLLMLog.id).label("call_count"),
func.coalesce(func.sum(AgentLLMLog.prompt_tokens), 0).label("total_prompt"),
func.coalesce(func.sum(AgentLLMLog.completion_tokens), 0).label("total_completion"),
func.coalesce(func.sum(AgentLLMLog.total_tokens), 0).label("total_tokens"),
func.coalesce(func.avg(AgentLLMLog.latency_ms), 0).label("avg_latency"),
func.count(AgentLLMLog.tool_name).label("tool_calls"),
).outerjoin(
Agent, AgentLLMLog.agent_id == Agent.id
).filter(
and_(*filters)
).group_by(AgentLLMLog.agent_id).order_by(
func.count(AgentLLMLog.id).desc()
).all()
return [
{
"agent_id": r.agent_id or "未知",
"agent_name": r.agent_name or "未知 Agent",
"call_count": r.call_count,
"total_prompt_tokens": r.total_prompt,
"total_completion_tokens": r.total_completion,
"total_tokens": r.total_tokens,
"avg_latency_ms": round(r.avg_latency, 2) if r.avg_latency else 0,
"tool_call_count": r.tool_calls or 0,
}
for r in rows
]
@staticmethod
def get_tool_usage(
db: Session,
user_id: Optional[str] = None,
days: int = 7,
) -> List[Dict[str, Any]]:
"""获取工具调用频次统计。"""
end_time = datetime.utcnow()
start_time = end_time - timedelta(days=days)
filters = [
AgentLLMLog.created_at >= start_time,
AgentLLMLog.tool_name.isnot(None),
]
if user_id:
filters.append(AgentLLMLog.user_id == user_id)
rows = db.query(
AgentLLMLog.tool_name,
func.count(AgentLLMLog.id).label("call_count"),
func.coalesce(func.sum(AgentLLMLog.total_tokens), 0).label("total_tokens"),
func.coalesce(func.avg(AgentLLMLog.latency_ms), 0).label("avg_latency"),
).filter(
and_(*filters)
).group_by(AgentLLMLog.tool_name).order_by(
func.count(AgentLLMLog.id).desc()
).all()
return [
{
"tool_name": r.tool_name or "未知",
"call_count": r.call_count,
"total_tokens": r.total_tokens,
"avg_latency_ms": round(r.avg_latency, 2) if r.avg_latency else 0,
}
for r in rows
]
@staticmethod
def get_daily_trend(
db: Session,
user_id: Optional[str] = None,
days: int = 7,
) -> List[Dict[str, Any]]:
"""获取每日 LLM 调用趋势。"""
end_time = datetime.utcnow()
start_time = end_time - timedelta(days=days)
filters = [AgentLLMLog.created_at >= start_time]
if user_id:
filters.append(AgentLLMLog.user_id == user_id)
results = []
for i in range(days):
day_start = end_time - timedelta(days=days - i)
day_end = day_start + timedelta(days=1)
day_filter = and_(
*filters,
AgentLLMLog.created_at >= day_start,
AgentLLMLog.created_at < day_end,
)
day_count = db.query(func.count(AgentLLMLog.id)).filter(day_filter).scalar() or 0
day_tokens = db.query(
func.coalesce(func.sum(AgentLLMLog.total_tokens), 0)
).filter(day_filter).scalar() or 0
results.append({
"date": day_start.strftime("%m-%d"),
"call_count": day_count,
"total_tokens": day_tokens,
})
return results