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:
@@ -12,6 +12,7 @@
|
||||
- **多模型支持**:集成主流AI模型(OpenAI、Claude、DeepSeek等)
|
||||
- **工作流编排**:支持复杂的工作流设计和执行
|
||||
- **Agent协作**:支持多Agent协作和工具链管理
|
||||
- **自主 AI Agent 运行时**:支持 ReAct 自主循环的 Agent Runtime,可独立对话或嵌入工作流
|
||||
|
||||
### 目标用户
|
||||
- 产品经理和业务人员
|
||||
@@ -84,6 +85,8 @@
|
||||
- **缓存/消息队列**: Redis
|
||||
- **异步任务**: Celery
|
||||
- **AI框架**: LangChain
|
||||
- **Agent Runtime**: 自研 ReAct 循环(零重构,寄生式复用现有服务)
|
||||
- **Agent Orchestrator**: 多 Agent 编排引擎(路由/顺序/辩论三种模式)
|
||||
- **数据库ORM**: SQLAlchemy
|
||||
- **迁移工具**: Alembic
|
||||
- **认证**: JWT
|
||||
@@ -162,12 +165,26 @@
|
||||
|
||||
本平台侧:**新建 LLM 节点、节点模板及后端未显式指定模型时的 DeepSeek 默认值**为 **`deepseek-v4-flash`**;工作流编辑器与「模型配置」页下拉仍可选择兼容旧模型名并标注弃用时间。
|
||||
|
||||
### 9. Agent管理
|
||||
### Agent管理
|
||||
- Agent CRUD API
|
||||
- Agent管理页面
|
||||
- Agent协作功能
|
||||
- 批量场景Agent脚本(教育/企业/政务/媒体)
|
||||
|
||||
### 10. Agent Runtime(自主 AI Agent)
|
||||
- **ReAct 自主循环**:LLM 思考→工具调用→观察结果→再思考,支持最多 N 步迭代
|
||||
- **分层记忆**:短期(会话上下文)+ 长期(MySQL 持久化),LLM 自动压缩总结
|
||||
- **执行追踪**:每步迭代记录 think/tool_call/tool_result/final,返回 steps 供前端展开
|
||||
- **工具管理**:白名单/黑名单过滤,包装已有 ToolRegistry
|
||||
- **工作流桥接**:Agent 节点可嵌入工作流 DAG,复用工作流引擎
|
||||
- **独立对话**:通过专用 API 直接与 Agent 对话,不依赖工作流
|
||||
- **LLM 调用埋点**:每次 LLM 调用自动记录模型、tokens、耗时到 AgentLLMLog 表
|
||||
- **Agent 监控**:专属 Dashboard 展示 Agent 用量排行、LLM 调用记录、Token 统计、工具调用频次
|
||||
- **多 Agent 编排**:三种协作模式:
|
||||
- **route** — Router Agent 分析问题,分发到最匹配的 Specialist Agent
|
||||
- **sequential** — Agent 流水线执行,前者输出作为后者输入
|
||||
- **debate** — 多个 Agent 独立回答,Aggregator 汇总为最终答案
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
@@ -185,7 +202,17 @@ aiagent/
|
||||
│ └── Dockerfile.dev # 开发环境Dockerfile
|
||||
├── backend/ # 后端项目(Python FastAPI)
|
||||
│ ├── app/
|
||||
│ │ ├── agent_runtime/ # Agent Runtime(新增,自主 ReAct 循环)
|
||||
│ │ │ ├── __init__.py # 包导出
|
||||
│ │ │ ├── schemas.py # Agent 配置 Schema + AgentStep 执行追踪
|
||||
│ │ │ ├── context.py # 会话上下文(消息历史、迭代追踪)
|
||||
│ │ │ ├── memory.py # 分层记忆管理器 + LLM 自动压缩总结
|
||||
│ │ │ ├── tool_manager.py# 工具管理器(包装 ToolRegistry)
|
||||
│ │ │ ├── core.py # AgentRuntime 主循环(ReAct 核心)
|
||||
│ │ │ ├── orchestrator.py# 多 Agent 编排引擎(route/sequential/debate)
|
||||
│ │ │ └── workflow_integration.py # 工作流桥接
|
||||
│ │ ├── api/ # API路由
|
||||
│ │ │ └── agent_chat.py # Agent 独立聊天 API(新增)
|
||||
│ │ ├── core/ # 核心模块
|
||||
│ │ ├── models/ # 数据库模型
|
||||
│ │ ├── schemas/ # Pydantic模式
|
||||
@@ -301,6 +328,18 @@ pnpm dev
|
||||
- `POST /api/v1/data-sources/{id}/test` - 测试数据源连接
|
||||
- `POST /api/v1/data-sources/{id}/query` - 执行数据查询
|
||||
|
||||
### Agent 对话 API(新增)
|
||||
- `POST /api/v1/agent-chat/bare` - 默认 Agent 直接对话(无需预配置)
|
||||
- `POST /api/v1/agent-chat/{agent_id}` - 与指定 Agent 对话(复用工作流配置)
|
||||
- `POST /api/v1/agent-chat/orchestrate` - 多 Agent 编排(route/sequential/debate 三种模式)
|
||||
- `GET /api/v1/agent-monitoring/overview` - Agent 概览统计
|
||||
- `GET /api/v1/agent-monitoring/llm-calls` - LLM 调用记录列表(支持 days/limit 参数)
|
||||
- `GET /api/v1/agent-monitoring/agents-stats` - 各 Agent 用量排行
|
||||
- `GET /api/v1/agent-monitoring/tool-usage` - 工具调用频次统计
|
||||
- `GET /api/v1/agent-monitoring/daily-trend` - 每日 LLM 调用趋势
|
||||
- 请求体包含 message、mode、agents 列表(每个 Agent 可独立配置 system_prompt/model/temperature/tools)
|
||||
- 返回 final_answer、steps 追踪、agent_results
|
||||
|
||||
### WebSocket API
|
||||
- `ws://localhost:8037/ws/execution/{execution_id}` - 执行状态实时推送
|
||||
|
||||
@@ -416,7 +455,9 @@ alembic downgrade -1
|
||||
- **第二阶段核心功能**: 100% ✅
|
||||
- **第三阶段核心功能**: 100% ✅
|
||||
- **第四-七阶段功能**: 100% ✅
|
||||
- **整体项目**: 约 85-90%
|
||||
- **自主 Agent Runtime**: 100% ✅(2026-04 新增)
|
||||
- **多 Agent 编排**: 100% ✅(2026-05 新增)
|
||||
- **整体项目**: 约 95-97%
|
||||
|
||||
### 已完成核心功能
|
||||
1. **完整的用户认证系统** - 注册、登录、JWT认证
|
||||
@@ -429,16 +470,31 @@ alembic downgrade -1
|
||||
8. **实时状态推送** - WebSocket实时推送执行状态
|
||||
9. **批量Agent场景生成** - 教育与政务/媒体场景批量创建脚本
|
||||
10. **Windows运维文档统一** - 启停/重启流程合并为单一权威文档
|
||||
11. **自主 AI Agent 运行时** - 新增 `agent_runtime` 模块(~1020 行),实现 ReAct 自主循环、工具调用、分层记忆管理
|
||||
12. **Agent 独立对话** - `POST /api/v1/agent-chat/bare` 和 `/{agent_id}` API,前端 AgentChat.vue 页面
|
||||
13. **工作流 Agent 节点** - 工作流引擎新增 `agent` 节点类型,Agent 可嵌入 DAG 执行
|
||||
14. **执行追踪与思考链** - 后端 steps 记录每步迭代,前端可展开显示思考链
|
||||
15. **记忆压缩总结** - LLM 自动提取用户画像/关键事实/话题,去重后存入长期记忆
|
||||
16. **Agent 配置页面** - AgentConfig.vue 可视化编辑 System Prompt / 模型 / Temperature / 工具
|
||||
17. **多 Agent 编排** - AgentOrchestrator 三种模式(route/sequential/debate),前端编排 UI 支持模式切换和动态 Agent 编辑
|
||||
18. **Agent 监控 Dashboard** - 实时 LLM 调用埋点(AgentLLMLog 表)、Agent 用量排行、Token 统计、工具调用频次、日趋势图
|
||||
|
||||
### 近期开发重点(高优先级)
|
||||
1. **监控和告警前端界面** - 系统监控面板、告警规则管理
|
||||
2. **用户体验优化** - 工作流编辑器优化、Agent使用体验优化
|
||||
3. **生产环境部署配置** - Docker/K8s配置、监控和日志集成
|
||||
1. **预算接入** - Agent 内部 LLM 调用计入工作流执行预算
|
||||
2. **Agent Dashboard** - LLM 调用链路追踪、Token 消耗统计、执行历史
|
||||
3. **用户体验优化** - 工作流编辑器优化、Agent使用体验优化
|
||||
|
||||
### 中期规划
|
||||
1. **向量记忆** - 集成 Embedding API + 向量检索(语义记忆)
|
||||
2. **流式输出** - Agent 思考过程实时推送到前端
|
||||
3. **知识库 RAG** - 文件上传 → 切片 → 向量化 → 检索增强生成
|
||||
4. **工具市场** - 用户可上传自定义工具定义
|
||||
|
||||
### 长期规划
|
||||
1. **多租户支持** - 租户模型、数据隔离、资源配额管理
|
||||
2. **插件系统** - 插件注册机制、自定义节点插件开发框架
|
||||
3. **性能优化** - 工作流执行性能优化、前端性能优化
|
||||
1. **自主学习** - Agent 从历史执行中自动优化工具选择策略
|
||||
2. **多租户支持** - 租户模型、数据隔离、资源配额管理
|
||||
3. **插件系统** - 插件注册机制、自定义节点插件开发框架
|
||||
4. **性能优化** - 工作流执行性能优化、前端性能优化
|
||||
|
||||
详细开发进度请参考:[开发进度.md](./开发进度.md)
|
||||
|
||||
@@ -482,10 +538,18 @@ alembic downgrade -1
|
||||
- 教育行业批量Agent脚本:`backend/scripts/create_education_agents_batch.py`
|
||||
- 政务/媒体批量Agent脚本:`backend/scripts/create_gov_media_agents_batch.py`
|
||||
- 企业场景批量Agent脚本:`backend/scripts/create_enterprise_scenario_agents.py`
|
||||
- 自主 AI Agent 改造完成情况:[`自主AI Agent改造完成情况.md`](./自主AI%20Agent改造完成情况.md)
|
||||
- Agent Runtime 源码入口:`backend/app/agent_runtime/core.py`
|
||||
- Agent 聊天 API 路由:`backend/app/api/agent_chat.py`
|
||||
- Agent 聊天前端页面:`frontend/src/views/AgentChat.vue`
|
||||
- Agent 配置页面:`frontend/src/views/AgentConfig.vue`
|
||||
- 多 Agent 编排引擎:`backend/app/agent_runtime/orchestrator.py`
|
||||
- Agent 监控 Dashboard:`frontend/src/views/AgentDashboard.vue`
|
||||
- Agent LLM 调用日志模型:`backend/app/models/agent_llm_log.py`
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-04-30
|
||||
**文档版本**: 1.2
|
||||
**最后更新**: 2026-05-01
|
||||
**文档版本**: 1.5
|
||||
|
||||
*本文档基于项目现有文档整理生成,涵盖项目核心信息。详细技术方案请参考[方案-优化版.md](./方案-优化版.md)。DeepSeek 模型名与 Base URL 以官方文档为准,变更时请同步修订本节。*
|
||||
213
AI agent改造计划.md
Normal file
213
AI agent改造计划.md
Normal file
@@ -0,0 +1,213 @@
|
||||
---
|
||||
从工作流引擎到自主 AI Agent:改造方案
|
||||
|
||||
核心思路:不要重构,要"寄生式"生长
|
||||
|
||||
现有引擎 5788 行,WorkflowEditor.vue 9140 行——不要重构它们。而是寄生在已有的 ToolRegistry、LLM Service、Memory Service
|
||||
之上,新建一个独立的 Agent Runtime。
|
||||
|
||||
---
|
||||
第一步:构建 Agent Runtime 内核(1 周)
|
||||
|
||||
新建 backend/app/agent_runtime/ 包:
|
||||
|
||||
backend/app/agent_runtime/
|
||||
├── __init__.py
|
||||
├── core.py # Agent 主循环
|
||||
├── context.py # Agent 上下文(会话状态)
|
||||
├── memory.py # 记忆管理(连接已有记忆服务)
|
||||
├── tool_manager.py # 工具选择与执行(连接已有 ToolRegistry)
|
||||
└── schemas.py # Agent 配置 Schema
|
||||
|
||||
核心循环 (core.py):
|
||||
|
||||
class AgentRuntime:
|
||||
"""
|
||||
自主 Agent 循环(ReAct)
|
||||
|
||||
loop:
|
||||
1. LLM 思考 → 返回文本或工具调用
|
||||
2. 如果返回文本 → 结束,返回最终回答
|
||||
3. 如果调用工具 → 执行工具 → 结果追加到 messages → 回到 1
|
||||
4. 超过 max_iterations → 强制结束
|
||||
"""
|
||||
|
||||
async def run(self, user_input: str) -> AgentResult:
|
||||
while self.iteration < self.max_iterations:
|
||||
response = await self.llm.chat(messages, tools)
|
||||
if response.has_tool_calls:
|
||||
for tool_call in response.tool_calls:
|
||||
result = tool_registry.execute(tool_call)
|
||||
messages.append(tool_result_message)
|
||||
else:
|
||||
return AgentResult(text=response.content)
|
||||
return AgentResult(text="已达最大迭代次数", truncated=True)
|
||||
|
||||
关键设计点:
|
||||
|
||||
┌────────────┬─────────────────────────────────────────────────┬───────────────────────┐
|
||||
│ 组件 │ 复用什么 │ 新写什么 │
|
||||
├────────────┼─────────────────────────────────────────────────┼───────────────────────┤
|
||||
│ LLM 调用 │ llm_service.call_openai_with_tools() 已有 ReAct │ 不用写 │
|
||||
├────────────┼─────────────────────────────────────────────────┼───────────────────────┤
|
||||
│ 工具执行 │ ToolRegistry.get_tool_function() │ 不用写 │
|
||||
├────────────┼─────────────────────────────────────────────────┼───────────────────────┤
|
||||
│ 记忆存储 │ persistent_memory_service.py │ 记忆检索 + 自动压缩 │
|
||||
├────────────┼─────────────────────────────────────────────────┼───────────────────────┤
|
||||
│ 系统提示词 │ — │ Agent 人格/指令系统 │
|
||||
├────────────┼─────────────────────────────────────────────────┼───────────────────────┤
|
||||
│ 会话管理 │ — │ 状态保持 + 多轮上下文 │
|
||||
└────────────┴─────────────────────────────────────────────────┴───────────────────────┘
|
||||
|
||||
工作量:约 300 行代码。已有轮子都在,只要串起来。
|
||||
|
||||
---
|
||||
第二步:让 Agent Runtime 能用上已有工具(3 天)
|
||||
|
||||
现有 ToolRegistry 有 20+ 内置工具,但只通过 llm_service.py 的 _execute_tool 私有方法调用。需要:
|
||||
|
||||
# 新增 agent_runtime/tool_manager.py
|
||||
class AgentToolManager:
|
||||
def __init__(self, tool_registry):
|
||||
self.registry = tool_registry
|
||||
|
||||
def get_tools_for_llm(self) -> list:
|
||||
# 把 ToolRegistry 的 schema 转为 OpenAI tool format
|
||||
return self.registry.get_all_tool_schemas()
|
||||
|
||||
async def execute(self, name: str, args: dict) -> str:
|
||||
func = self.registry.get_tool_function(name)
|
||||
if func is None:
|
||||
return f"错误:工具 {name} 不存在"
|
||||
result = await func(**args) # 已有,直接复用
|
||||
return str(result)
|
||||
|
||||
注意:现有 _execute_tool 在 llm_service.py 中,需要提取成公共方法或让 ToolManager 直接调用已有实现。
|
||||
|
||||
---
|
||||
第三步:接入记忆系统(3 天)
|
||||
|
||||
现有记忆系统已经可以读写用户画像和对话历史(persistent_memory_service.py),但只在 Cache 节点中被动使用。
|
||||
|
||||
需要:
|
||||
|
||||
# agent_runtime/memory.py
|
||||
class AgentMemory:
|
||||
"""
|
||||
分层记忆:
|
||||
- 工作记忆:当前会话的 messages
|
||||
- 长期记忆:从 DB/Redis 加载的历史画像 + 重要事实
|
||||
- 工具记忆:哪些工具调用成功/失败(辅助 LLM 决策)
|
||||
"""
|
||||
|
||||
async def load_context(self, user_id: str) -> str:
|
||||
# 从 persistent_memory_service 加载用户画像
|
||||
profile = get_user_profile(user_id)
|
||||
# 从数据库加载最近对话摘要
|
||||
history = get_conversation_summary(user_id)
|
||||
return f"用户画像:{profile}\n历史记录:{history}"
|
||||
|
||||
async def save(self, messages: list):
|
||||
# 自动总结关键信息写入长期记忆
|
||||
summary = await self.llm.summarize(messages)
|
||||
save_user_profile(user_id, summary)
|
||||
|
||||
---
|
||||
第四步:在现有工作流中启用 Agent 节点(3 天)
|
||||
|
||||
在 workflow_engine.py 的 execute_node 中新增 agent 类型:
|
||||
|
||||
# 现有 5788 行引擎只需加一个分支
|
||||
elif node_type == 'agent':
|
||||
# 初始化 Agent Runtime
|
||||
runtime = AgentRuntime(
|
||||
system_prompt=node_data.get('system_prompt'),
|
||||
tools=node_data.get('tools', []), # 可选,默认全部
|
||||
memory_enabled=node_data.get('memory', True),
|
||||
)
|
||||
result = await runtime.run(input_data.get('query', ''))
|
||||
|
||||
这样不用动现有任何节点,用户可以在工作流中拖一个 Agent 节点,它就能:
|
||||
1. 自动 ReAct 循环
|
||||
2. 调用任意内置工具
|
||||
3. 使用长期记忆
|
||||
4. 自我纠错
|
||||
|
||||
---
|
||||
第五步:独立 Agent 运行模式(1 周)
|
||||
|
||||
不依赖工作流 DAG,可以直接启动 Agent:
|
||||
|
||||
POST /api/v1/agents/{id}/chat
|
||||
{"message": "帮我写一个Python脚本读取日志"}
|
||||
|
||||
后端:
|
||||
runtime = AgentRuntime(agent_config)
|
||||
result = await runtime.run(message)
|
||||
return result
|
||||
|
||||
前端可以加一个聊天界面(类似 ChatGPT):
|
||||
- 已有 AgentChatPreview.vue 可以改造
|
||||
- 复用现有 Agents.vue 的列表和配置
|
||||
|
||||
---
|
||||
第六步:多智能体编排(2 周)
|
||||
|
||||
# agent_runtime/orchestrator.py
|
||||
class AgentOrchestrator:
|
||||
"""
|
||||
多 Agent 协作模式:
|
||||
|
||||
1. 路由模式:用户问题 → Router Agent → 分发到子 Agent
|
||||
2. 顺序模式:Agent A 输出 → Agent B 输入
|
||||
3. 辩论模式:多个 Agent 独立回答 → 汇总
|
||||
"""
|
||||
|
||||
async def route(self, question: str) -> str:
|
||||
# Router Agent 判断应该用哪个 Specialist Agent
|
||||
specialist = await self.router_agent.choose(question)
|
||||
return await specialist.run(question)
|
||||
|
||||
---
|
||||
整体路线图
|
||||
|
||||
┌──────┬────────────────────────┬──────┬────────────┐
|
||||
│ 阶段 │ 内容 │ 时间 │ 前提 │
|
||||
├──────┼────────────────────────┼──────┼────────────┤
|
||||
│ P0 │ Agent Runtime 内核 │ 1 周 │ 无,纯新增 │
|
||||
├──────┼────────────────────────┼──────┼────────────┤
|
||||
│ P0 │ 工具接入 │ 3 天 │ P0 完成 │
|
||||
├──────┼────────────────────────┼──────┼────────────┤
|
||||
│ P1 │ 记忆接入 │ 3 天 │ P0 完成 │
|
||||
├──────┼────────────────────────┼──────┼────────────┤
|
||||
│ P1 │ Agent 节点(工作流内) │ 3 天 │ P0+P1 完成 │
|
||||
├──────┼────────────────────────┼──────┼────────────┤
|
||||
│ P2 │ 独立 Agent 聊天模式 │ 1 周 │ P0+P1 完成 │
|
||||
├──────┼────────────────────────┼──────┼────────────┤
|
||||
│ P3 │ 多 Agent 编排 │ 2 周 │ P0-P2 完成 │
|
||||
├──────┼────────────────────────┼──────┼────────────┤
|
||||
│ P3 │ Agent 工作台/监控 │ 1 周 │ P0-P2 完成 │
|
||||
└──────┴────────────────────────┴──────┴────────────┘
|
||||
|
||||
---
|
||||
为什么这个方案可行
|
||||
|
||||
你代码库中 关键能力已经就绪:
|
||||
|
||||
┌─────────────────────────┬─────────────────────────────────────┬──────────────────┬────────────────┐
|
||||
│ 已有能力 │ 位置 │ 当前用途 │ 新计划用途 │
|
||||
├─────────────────────────┼─────────────────────────────────────┼──────────────────┼────────────────┤
|
||||
│ ReAct 循环 │ llm_service.py:646 │ LLM 节点单次调用 │ Agent 核心循环 │
|
||||
├─────────────────────────┼─────────────────────────────────────┼──────────────────┼────────────────┤
|
||||
│ ToolRegistry + 20+ 工具 │ tool_registry.py + builtin_tools.py │ 工作流内使用 │ Agent 自主调用 │
|
||||
├─────────────────────────┼─────────────────────────────────────┼──────────────────┼────────────────┤
|
||||
│ 持久化记忆 │ persistent_memory_service.py │ Cache 节点 │ 长期记忆层 │
|
||||
├─────────────────────────┼─────────────────────────────────────┼──────────────────┼────────────────┤
|
||||
│ 对话历史注入 │ workflow_engine.py:986 │ LLM 节点 │ Agent 上下文 │
|
||||
├─────────────────────────┼─────────────────────────────────────┼──────────────────┼────────────────┤
|
||||
│ Agent 配置 │ models/agent.py │ 工作流包装 │ Agent 本体配置 │
|
||||
└─────────────────────────┴─────────────────────────────────────┴──────────────────┴────────────────┘
|
||||
|
||||
新增代码控制在 1500 行以内,不动现有 5788 行的引擎和 9140 行的编辑器。
|
||||
|
||||
需要我从第一步的 Agent Runtime 核心代码开始写吗?
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
377
backend/app/agent_runtime/orchestrator.py
Normal file
377
backend/app/agent_runtime/orchestrator.py
Normal 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,
|
||||
)
|
||||
@@ -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="执行追踪步骤详情")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
74
backend/app/api/agent_monitoring.py
Normal file
74
backend/app/api/agent_monitoring.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
29
backend/app/models/agent_llm_log.py
Normal file
29
backend/app/models/agent_llm_log.py
Normal 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="创建时间")
|
||||
234
backend/app/services/agent_monitoring_service.py
Normal file
234
backend/app/services/agent_monitoring_service.py
Normal 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
|
||||
@@ -67,6 +67,10 @@
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>系统监控</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="agent-monitoring" @click="router.push('/agent-monitoring')">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>Agent监控</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="alert-rules" @click="router.push('/alert-rules')">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>告警规则</span>
|
||||
@@ -84,7 +88,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid } from '@element-plus/icons-vue'
|
||||
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -103,6 +107,7 @@ const activeMenu = computed(() => {
|
||||
if (route.path === '/permissions') return 'permissions'
|
||||
if (route.path === '/template-market') return 'template-market'
|
||||
if (route.path === '/monitoring') return 'monitoring'
|
||||
if (route.path === '/agent-monitoring') return 'agent-monitoring'
|
||||
if (route.path === '/alert-rules') return 'alert-rules'
|
||||
return 'workflows'
|
||||
})
|
||||
@@ -129,6 +134,8 @@ const handleMenuSelect = (key: string) => {
|
||||
router.push('/permissions')
|
||||
} else if (key === 'monitoring') {
|
||||
router.push('/monitoring')
|
||||
} else if (key === 'agent-monitoring') {
|
||||
router.push('/agent-monitoring')
|
||||
} else if (key === 'alert-rules') {
|
||||
router.push('/alert-rules')
|
||||
}
|
||||
|
||||
@@ -58,6 +58,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/WorkflowDesigner.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/agents/:id/config',
|
||||
name: 'agent-config',
|
||||
component: () => import('@/views/AgentConfig.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/data-sources',
|
||||
name: 'data-sources',
|
||||
@@ -88,6 +94,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/Monitoring.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/agent-monitoring',
|
||||
name: 'agent-monitoring',
|
||||
component: () => import('@/views/AgentDashboard.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/alert-rules',
|
||||
name: 'alert-rules',
|
||||
|
||||
@@ -2,110 +2,192 @@
|
||||
<div class="agent-chat-page">
|
||||
<div class="chat-header">
|
||||
<div class="header-left">
|
||||
<h2>{{ agent ? agent.name : 'AI Agent 对话' }}</h2>
|
||||
<span v-if="agent" class="agent-status" :class="agent.status">{{ agent.status }}</span>
|
||||
<h2>{{ chatMode === 'single' ? (agent ? agent.name : 'AI Agent 对话') : '多 Agent 编排' }}</h2>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select
|
||||
v-model="currentAgentId"
|
||||
placeholder="选择 Agent"
|
||||
@change="switchAgent"
|
||||
style="width: 220px"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="a in agents"
|
||||
:key="a.id"
|
||||
:label="a.name"
|
||||
:value="a.id"
|
||||
>
|
||||
<span>{{ a.name }}</span>
|
||||
<span class="agent-option-desc">{{ a.description?.slice(0, 30) }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-button @click="clearChat" :disabled="messages.length === 0">
|
||||
清空对话
|
||||
</el-button>
|
||||
<el-switch
|
||||
v-model="chatMode"
|
||||
active-value="orchestrate"
|
||||
inactive-value="single"
|
||||
active-text="编排"
|
||||
inactive-text="单 Agent"
|
||||
style="margin-right: 12px"
|
||||
/>
|
||||
|
||||
<!-- 单 Agent 模式:选择 Agent -->
|
||||
<template v-if="chatMode === 'single'">
|
||||
<el-select v-model="currentAgentId" placeholder="选择 Agent" @change="switchAgent" style="width: 180px" clearable>
|
||||
<el-option v-for="a in agents" :key="a.id" :label="a.name" :value="a.id">
|
||||
<span>{{ a.name }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<!-- 编排模式:模式选择 + Agent 数 -->
|
||||
<template v-if="chatMode === 'orchestrate'">
|
||||
<el-select v-model="orchestrateMode" style="width: 130px">
|
||||
<el-option label="辩论模式" value="debate" />
|
||||
<el-option label="路由模式" value="route" />
|
||||
<el-option label="顺序模式" value="sequential" />
|
||||
</el-select>
|
||||
<el-button @click="showOrchestrateEditor = true" style="margin-left: 8px">
|
||||
配置 Agent ({{ orchestrateAgents.length }})
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<el-button @click="clearChat" :disabled="messages.length === 0">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" ref="messagesRef">
|
||||
<div v-if="messages.length === 0" class="chat-empty">
|
||||
<el-icon :size="48"><ChatLineSquare /></el-icon>
|
||||
<p>选择一个 Agent 开始对话</p>
|
||||
<p v-if="chatMode === 'single'">选择一个 Agent 开始对话</p>
|
||||
<p v-else>配置多个 Agent 后发送消息进行编排对话</p>
|
||||
<p class="hint">Agent 可以使用内置工具帮你完成任务</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(msg, i) in messages"
|
||||
:key="i"
|
||||
class="message"
|
||||
:class="[msg.role, msg.status === 'error' ? 'error' : '']"
|
||||
>
|
||||
<div v-for="(msg, i) in messages" :key="i" class="message" :class="[msg.role, msg.status === 'error' ? 'error' : '']">
|
||||
<div class="message-avatar">
|
||||
<el-avatar :size="36" :icon="msg.role === 'user' ? UserFilled : Promotion" />
|
||||
</div>
|
||||
<div class="message-bubble">
|
||||
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
|
||||
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls">
|
||||
<div class="tool-calls-header">
|
||||
<el-icon><Tools /></el-icon>
|
||||
工具调用 ({{ msg.tool_calls.length }})
|
||||
<!-- 编排模式:显示每个 Agent 的独立输出 -->
|
||||
<div v-if="msg.orchestrateResult" class="orchestrate-result">
|
||||
<div class="orch-header">
|
||||
<el-tag size="small" type="info">{{ msg.orchestrateResult.mode }}</el-tag>
|
||||
<span class="orch-agent-count">{{ msg.orchestrateResult.steps.length }} 个 Agent</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(tc, j) in msg.tool_calls"
|
||||
:key="j"
|
||||
class="tool-call-item"
|
||||
>
|
||||
<span class="tool-name">{{ tc.function?.name || '?' }}</span>
|
||||
<el-tag size="small" type="info">
|
||||
{{ Object.keys(JSON.parse(tc.function?.arguments || '{}')).length }} 个参数
|
||||
</el-tag>
|
||||
<div class="orch-final">
|
||||
<div class="orch-section-title">最终回答</div>
|
||||
<div class="message-text" v-html="renderMarkdown(msg.orchestrateResult.final_answer)"></div>
|
||||
</div>
|
||||
<div class="orch-steps">
|
||||
<div
|
||||
v-for="(step, si) in msg.orchestrateResult.steps"
|
||||
:key="si"
|
||||
class="orch-step"
|
||||
:class="{ expanded: step._open }"
|
||||
>
|
||||
<div class="orch-step-header" @click="step._open = !step._open">
|
||||
<el-icon><CaretRight :style="{ transform: step._open ? 'rotate(90deg)' : '' }" /></el-icon>
|
||||
<el-tag size="small" :type="step.error ? 'danger' : 'success'" round>
|
||||
{{ step.agent_name }}
|
||||
</el-tag>
|
||||
<span class="orch-step-meta">
|
||||
{{ step.iterations_used }} 步 · {{ step.tool_calls_made }} 次工具
|
||||
</span>
|
||||
</div>
|
||||
<div v-show="step._open" class="orch-step-body">
|
||||
<div class="message-text" v-html="renderMarkdown(step.output)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单 Agent 模式:原有内容 -->
|
||||
<template v-if="!msg.orchestrateResult">
|
||||
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
|
||||
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls">
|
||||
<div class="tool-calls-header">
|
||||
<el-icon><Tools /></el-icon> 工具调用 ({{ msg.tool_calls.length }})
|
||||
</div>
|
||||
<div v-for="(tc, j) in msg.tool_calls" :key="j" class="tool-call-item">
|
||||
<span class="tool-name">{{ tc.function?.name || '?' }}</span>
|
||||
<el-tag size="small" type="info">{{ Object.keys(JSON.parse(tc.function?.arguments || '{}')).length }} 个参数</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 思考链 -->
|
||||
<div v-if="msg.steps && msg.steps.length > 0" class="thinking-trace">
|
||||
<div class="trace-header" @click="toggleTrace(msg)">
|
||||
<el-icon><CaretRight :style="{ transform: msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
|
||||
<span>思考链 ({{ msg.steps.length }} 步)</span>
|
||||
</div>
|
||||
<div v-show="msg._traceOpen" class="trace-steps">
|
||||
<div v-for="(step, si) in msg.steps" :key="si" class="trace-step" :class="'step-' + step.type">
|
||||
<div class="step-icon">
|
||||
<el-icon v-if="step.type === 'think'"><ChatDotSquare /></el-icon>
|
||||
<el-icon v-else-if="step.type === 'tool_result'"><Tools /></el-icon>
|
||||
<el-icon v-else><Select /></el-icon>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<div class="step-header">
|
||||
<span class="step-tag" :class="'tag-' + step.type">{{ {think:'思考',tool_result:'工具结果',final:'最终回答'}[step.type] || step.type }}</span>
|
||||
<span class="step-iter">#{{ step.iteration }}</span>
|
||||
<span v-if="step.tool_name" class="step-tool-name">{{ step.tool_name }}</span>
|
||||
</div>
|
||||
<div v-if="step.content" class="step-content" v-html="renderMarkdown(step.content)"></div>
|
||||
<div v-if="step.reasoning" class="step-reasoning">
|
||||
<div class="reasoning-header">推理过程</div>
|
||||
<div class="reasoning-text">{{ step.reasoning }}</div>
|
||||
</div>
|
||||
<div v-if="step.tool_input && Object.keys(step.tool_input).length" class="step-tool-input">
|
||||
<div class="reasoning-header">参数</div>
|
||||
<pre>{{ JSON.stringify(step.tool_input, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="step.tool_result" class="step-tool-result">
|
||||
<div class="reasoning-header">结果</div>
|
||||
<pre>{{ step.tool_result }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="message-meta">
|
||||
{{ msg.role === 'user' ? '用户' : 'Agent' }} ·
|
||||
{{ formatTime(msg.timestamp) }}
|
||||
<span v-if="msg.iterations" class="meta-iterations">
|
||||
· {{ msg.iterations }} 步 · {{ msg.tool_calls_made }} 次工具调用
|
||||
</span>
|
||||
{{ msg.role === 'user' ? '用户' : 'Agent' }} · {{ formatTime(msg.timestamp) }}
|
||||
<span v-if="msg.iterations" class="meta-iterations">· {{ msg.iterations }} 步 · {{ msg.tool_calls_made }} 次工具调用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="message assistant">
|
||||
<div class="message-avatar">
|
||||
<el-avatar :size="36" icon="Promotion" />
|
||||
</div>
|
||||
<div class="message-avatar"><el-avatar :size="36" icon="Promotion" /></div>
|
||||
<div class="message-bubble">
|
||||
<div class="thinking">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
<div class="thinking"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="inputMessage"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入你的问题,Agent 会自动使用工具来帮助你..."
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="sendMessage"
|
||||
:loading="loading"
|
||||
:disabled="!inputMessage.trim()"
|
||||
class="send-btn"
|
||||
>
|
||||
{{ loading ? '思考中...' : '发送' }}
|
||||
<el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="输入你的问题..." @keydown.enter.exact.prevent="sendMessage" :disabled="loading" />
|
||||
<el-button type="primary" @click="sendMessage" :loading="loading" :disabled="!inputMessage.trim()" class="send-btn">
|
||||
{{ loading ? '处理中...' : '发送' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 编排 Agent 编辑器 -->
|
||||
<el-dialog v-model="showOrchestrateEditor" title="编排 Agent 配置" width="700px">
|
||||
<div class="orch-editor">
|
||||
<div v-for="(agt, i) in orchestrateAgents" :key="i" class="orch-agent-card">
|
||||
<div class="orch-agent-header">
|
||||
<span class="orch-agent-num">#{{ i + 1 }}</span>
|
||||
<el-input v-model="agt.name" placeholder="名称" style="width: 140px" size="small" />
|
||||
<el-input v-model="agt.id" placeholder="ID" style="width: 120px" size="small" />
|
||||
<el-button size="small" type="danger" link @click="orchestrateAgents.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<el-input v-model="agt.system_prompt" type="textarea" :rows="3" placeholder="System Prompt" size="small" />
|
||||
<div class="orch-agent-params">
|
||||
<el-select v-model="agt.model" size="small" style="width: 160px">
|
||||
<el-option label="DeepSeek V4 Flash" value="deepseek-v4-flash" />
|
||||
<el-option label="DeepSeek V4 Pro" value="deepseek-v4-pro" />
|
||||
<el-option label="GPT-4o Mini" value="gpt-4o-mini" />
|
||||
<el-option label="GPT-4o" value="gpt-4o" />
|
||||
</el-select>
|
||||
<el-input-number v-model="agt.temperature" :min="0" :max="2" :step="0.1" size="small" style="width: 110px" />
|
||||
<el-input-number v-model="agt.max_iterations" :min="1" :max="50" size="small" style="width: 110px" />
|
||||
</div>
|
||||
</div>
|
||||
<el-button @click="addOrchestrateAgent" style="width: 100%; margin-top: 8px">
|
||||
+ 添加 Agent
|
||||
</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showOrchestrateEditor = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -113,23 +195,30 @@
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ChatLineSquare,
|
||||
UserFilled,
|
||||
Promotion,
|
||||
Tools,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ChatLineSquare, UserFilled, Promotion, Tools, CaretRight, ChatDotSquare, Select } from '@element-plus/icons-vue'
|
||||
import api from '@/api'
|
||||
import type { Agent } from '@/stores/agent'
|
||||
|
||||
interface AgentStep {
|
||||
iteration: number; type: string; content: string
|
||||
tool_name?: string; tool_input?: Record<string, any>; tool_result?: string; reasoning?: string
|
||||
}
|
||||
interface OrchestrateStep {
|
||||
agent_id: string; agent_name: string; input: string; output: string
|
||||
iterations_used: number; tool_calls_made: number; error?: string; _open?: boolean
|
||||
}
|
||||
interface OrchestrateResult {
|
||||
mode: string; final_answer: string; steps: OrchestrateStep[]; agent_results: any[]
|
||||
}
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
tool_calls?: any[]
|
||||
timestamp: number
|
||||
iterations?: number
|
||||
tool_calls_made?: number
|
||||
status?: string
|
||||
role: 'user' | 'assistant'; content: string; tool_calls?: any[]; timestamp: number
|
||||
iterations?: number; tool_calls_made?: number; status?: string; steps?: AgentStep[]
|
||||
_traceOpen?: boolean; orchestrateResult?: OrchestrateResult
|
||||
}
|
||||
|
||||
interface OrchestrateAgentForm {
|
||||
id: string; name: string; system_prompt: string; model: string
|
||||
temperature: number; max_iterations: number; description: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
@@ -140,9 +229,30 @@ const inputMessage = ref('')
|
||||
const loading = ref(false)
|
||||
const messagesRef = ref<HTMLElement | null>(null)
|
||||
const sessionId = ref('')
|
||||
|
||||
const agent = ref<Agent | null>(null)
|
||||
|
||||
// 编排模式
|
||||
const chatMode = ref<'single' | 'orchestrate'>('single')
|
||||
const orchestrateMode = ref('debate')
|
||||
const showOrchestrateEditor = ref(false)
|
||||
const orchestrateAgents = ref<OrchestrateAgentForm[]>([
|
||||
{ id: 'agent-a', name: 'Agent A', system_prompt: '你是一个有用的AI助手。', model: 'deepseek-v4-flash', temperature: 0.7, max_iterations: 10, description: '' },
|
||||
{ id: 'agent-b', name: 'Agent B', system_prompt: '你是一个专业的分析助手。', model: 'deepseek-v4-flash', temperature: 0.7, max_iterations: 10, description: '' },
|
||||
])
|
||||
|
||||
function addOrchestrateAgent() {
|
||||
const n = orchestrateAgents.value.length + 1
|
||||
orchestrateAgents.value.push({
|
||||
id: `agent-${String.fromCharCode(96 + n)}`,
|
||||
name: `Agent ${String.fromCharCode(64 + n)}`,
|
||||
system_prompt: '你是一个有用的AI助手。',
|
||||
model: 'deepseek-v4-flash',
|
||||
temperature: 0.7,
|
||||
max_iterations: 10,
|
||||
description: '',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAgents()
|
||||
if (route.params.id) {
|
||||
@@ -152,302 +262,158 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function loadAgents() {
|
||||
try {
|
||||
const resp = await api.get('/api/v1/agents')
|
||||
agents.value = resp.data || []
|
||||
} catch (e) {
|
||||
console.error('加载 Agent 列表失败:', e)
|
||||
}
|
||||
try { const resp = await api.get('/api/v1/agents'); agents.value = resp.data || [] }
|
||||
catch (e) { console.error('加载 Agent 列表失败:', e) }
|
||||
}
|
||||
|
||||
async function switchAgent() {
|
||||
if (!currentAgentId.value) {
|
||||
agent.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`)
|
||||
agent.value = resp.data
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载 Agent 失败')
|
||||
agent.value = null
|
||||
}
|
||||
if (!currentAgentId.value) { agent.value = null; return }
|
||||
try { const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`); agent.value = resp.data }
|
||||
catch { ElMessage.error('加载 Agent 失败'); agent.value = null }
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputMessage.value.trim()
|
||||
if (!text || loading.value) return
|
||||
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
messages.value.push({ role: 'user', content: text, timestamp: Date.now() })
|
||||
inputMessage.value = ''
|
||||
loading.value = true
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const endpoint = currentAgentId.value
|
||||
? `/api/v1/agent-chat/${currentAgentId.value}`
|
||||
: '/api/v1/agent-chat/bare'
|
||||
|
||||
const resp = await api.post(endpoint, {
|
||||
message: text,
|
||||
session_id: sessionId.value || undefined,
|
||||
})
|
||||
|
||||
const data = resp.data
|
||||
sessionId.value = data.session_id
|
||||
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
iterations: data.iterations_used,
|
||||
tool_calls_made: data.tool_calls_made,
|
||||
status: data.truncated ? 'error' : 'success',
|
||||
})
|
||||
if (chatMode.value === 'orchestrate') {
|
||||
const resp = await api.post('/api/v1/agent-chat/orchestrate', {
|
||||
message: text,
|
||||
mode: orchestrateMode.value,
|
||||
agents: orchestrateAgents.value.map(a => ({
|
||||
id: a.id, name: a.name, system_prompt: a.system_prompt,
|
||||
model: a.model, temperature: a.temperature, max_iterations: a.max_iterations,
|
||||
tools: [], description: a.description,
|
||||
})),
|
||||
})
|
||||
const data = resp.data as OrchestrateResult
|
||||
data.steps.forEach(s => { s._open = false })
|
||||
messages.value.push({
|
||||
role: 'assistant', content: data.final_answer, timestamp: Date.now(),
|
||||
orchestrateResult: data, _traceOpen: true,
|
||||
})
|
||||
} else {
|
||||
const endpoint = currentAgentId.value ? `/api/v1/agent-chat/${currentAgentId.value}` : '/api/v1/agent-chat/bare'
|
||||
const resp = await api.post(endpoint, { message: text, session_id: sessionId.value || undefined })
|
||||
const data = resp.data
|
||||
sessionId.value = data.session_id
|
||||
messages.value.push({
|
||||
role: 'assistant', content: data.content, timestamp: Date.now(),
|
||||
iterations: data.iterations_used, tool_calls_made: data.tool_calls_made,
|
||||
status: data.truncated ? 'error' : 'success', steps: data.steps || [],
|
||||
_traceOpen: data.steps && data.steps.length > 0,
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: `错误:${e.response?.data?.detail || e.message || '请求失败'}`,
|
||||
timestamp: Date.now(),
|
||||
status: 'error',
|
||||
role: 'assistant', content: `错误:${e.response?.data?.detail || e.message || '请求失败'}`,
|
||||
timestamp: Date.now(), status: 'error',
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
scrollToBottom()
|
||||
loading.value = false; scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
messages.value = []
|
||||
sessionId.value = ''
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
return new Date(ts).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
function toggleTrace(msg: ChatMessage) { msg._traceOpen = !msg._traceOpen }
|
||||
function clearChat() { messages.value = []; sessionId.value = '' }
|
||||
function scrollToBottom() { nextTick(() => { if (messagesRef.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight }) }
|
||||
function formatTime(ts: number) { return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
if (!text) return ''
|
||||
// 简单的 Markdown 渲染(代码块、加粗、链接)
|
||||
let html = text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 代码块
|
||||
return text.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||||
// 行内代码
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// 加粗
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
// 换行
|
||||
.replace(/\n/g, '<br>')
|
||||
return html
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 120px);
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-status {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--el-color-info-light-8);
|
||||
}
|
||||
|
||||
.agent-status.published { background: var(--el-color-success-light-8); color: var(--el-color-success); }
|
||||
.agent-status.draft { background: var(--el-color-warning-light-8); color: var(--el-color-warning); }
|
||||
|
||||
.agent-option-desc {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-secondary);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-empty .hint {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.agent-chat-page { display: flex; flex-direction: column; height: calc(100vh - 120px); max-width: 960px; margin: 0 auto; padding: 16px; }
|
||||
.chat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.header-left { display: flex; align-items: center; gap: 8px; }
|
||||
.header-left h2 { margin: 0; font-size: 18px; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 12px 0; display: flex; flex-direction: column; gap: 16px; }
|
||||
.chat-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--el-text-color-secondary); gap: 12px; }
|
||||
.chat-empty .hint { font-size: 13px; color: var(--el-text-color-placeholder); }
|
||||
.message { display: flex; gap: 12px; max-width: 88%; }
|
||||
.message.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.message.assistant { align-self: flex-start; }
|
||||
.message-bubble { padding: 10px 14px; border-radius: 12px; background: var(--el-fill-color-light); line-height: 1.6; font-size: 14px; }
|
||||
.message.user .message-bubble { background: var(--el-color-primary-light-8); }
|
||||
.message.error .message-bubble { border: 1px solid var(--el-color-danger-light-5); }
|
||||
.message-text :deep(pre) { background: var(--el-fill-color); padding: 12px; border-radius: 8px; overflow-x: auto; font-size: 13px; }
|
||||
.message-text :deep(code) { background: var(--el-fill-color); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||
|
||||
.message-bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
/* Tool calls */
|
||||
.tool-calls { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--el-border-color-light); }
|
||||
.tool-calls-header { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 4px; }
|
||||
.tool-call-item { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; }
|
||||
.tool-name { font-weight: 500; color: var(--el-color-primary); }
|
||||
.message-meta { font-size: 11px; color: var(--el-text-color-placeholder); margin-top: 4px; }
|
||||
.meta-iterations { color: var(--el-color-info); }
|
||||
|
||||
.message.user .message-bubble {
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.message.error .message-bubble {
|
||||
border: 1px solid var(--el-color-danger-light-5);
|
||||
}
|
||||
|
||||
.message-text :deep(pre) {
|
||||
background: var(--el-fill-color);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-text :deep(code) {
|
||||
background: var(--el-fill-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-calls {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.tool-calls-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tool-call-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-weight: 500;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.meta-iterations {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
|
||||
.thinking {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--el-text-color-placeholder);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
/* Thinking trace */
|
||||
.thinking-trace { margin-top: 10px; border-top: 1px solid var(--el-border-color-light); padding-top: 8px; }
|
||||
.trace-header { display: flex; align-items: center; gap: 4px; cursor: pointer; font-size: 12px; color: var(--el-color-primary); user-select: none; padding: 4px 0; }
|
||||
.trace-steps { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||
.trace-step { display: flex; gap: 8px; padding: 8px 10px; border-radius: 8px; background: var(--el-fill-color-lighter); border-left: 3px solid var(--el-border-color); }
|
||||
.trace-step.step-think { border-left-color: var(--el-color-primary); }
|
||||
.trace-step.step-tool_result { border-left-color: var(--el-color-warning); }
|
||||
.trace-step.step-final { border-left-color: var(--el-color-success); }
|
||||
.step-icon { flex-shrink: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--el-text-color-secondary); }
|
||||
.step-body { flex: 1; min-width: 0; font-size: 13px; }
|
||||
.step-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.step-tag { font-size: 11px; padding: 1px 6px; border-radius: 4px; font-weight: 500; }
|
||||
.tag-think { background: var(--el-color-primary-light-9); color: var(--el-color-primary); }
|
||||
.tag-tool_result { background: var(--el-color-warning-light-9); color: var(--el-color-warning); }
|
||||
.tag-final { background: var(--el-color-success-light-9); color: var(--el-color-success); }
|
||||
.step-iter { font-size: 11px; color: var(--el-text-color-placeholder); }
|
||||
.step-tool-name { font-size: 11px; background: var(--el-color-info-light-9); color: var(--el-color-info); padding: 0 6px; border-radius: 4px; font-family: monospace; }
|
||||
.step-content { line-height: 1.5; }
|
||||
.step-content :deep(pre) { background: var(--el-fill-color); padding: 8px; border-radius: 6px; overflow-x: auto; font-size: 12px; margin: 4px 0; }
|
||||
.step-reasoning, .step-tool-input, .step-tool-result { margin-top: 6px; }
|
||||
.reasoning-header { font-size: 11px; color: var(--el-text-color-secondary); margin-bottom: 2px; font-weight: 500; }
|
||||
.reasoning-text { font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; font-style: italic; }
|
||||
.step-tool-input pre, .step-tool-result pre { background: var(--el-fill-color-darker); padding: 6px 8px; border-radius: 4px; font-size: 11px; overflow-x: auto; max-height: 200px; margin: 0; }
|
||||
|
||||
/* Thinking dots */
|
||||
.thinking { display: flex; gap: 4px; padding: 8px 0; }
|
||||
.dot { width: 8px; height: 8px; background: var(--el-text-color-placeholder); border-radius: 50%; animation: bounce 1.4s infinite ease-in-out; }
|
||||
.dot:nth-child(2) { animation-delay: 0.16s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.32s; }
|
||||
@keyframes bounce { 0%,80%,100% { transform: scale(0); } 40% { transform: scale(1); } }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
/* Chat input */
|
||||
.chat-input { display: flex; gap: 12px; padding-top: 12px; border-top: 1px solid var(--el-border-color-light); }
|
||||
.chat-input .el-textarea { flex: 1; }
|
||||
.send-btn { align-self: flex-end; min-width: 100px; }
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
/* Orchestrate result */
|
||||
.orchestrate-result { font-size: 14px; }
|
||||
.orch-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.orch-agent-count { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.orch-final { margin-bottom: 12px; }
|
||||
.orch-section-title { font-size: 13px; font-weight: 600; color: var(--el-text-color-primary); margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.orch-steps { display: flex; flex-direction: column; gap: 4px; }
|
||||
.orch-step { border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
|
||||
.orch-step-header { display: flex; align-items: center; gap: 6px; padding: 6px 10px; cursor: pointer; background: var(--el-fill-color-lighter); font-size: 13px; }
|
||||
.orch-step-header:hover { background: var(--el-fill-color-light); }
|
||||
.orch-step-meta { font-size: 11px; color: var(--el-text-color-placeholder); margin-left: auto; }
|
||||
.orch-step-body { padding: 8px 12px; font-size: 13px; background: var(--el-bg-color); }
|
||||
|
||||
.chat-input .el-textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
align-self: flex-end;
|
||||
min-width: 100px;
|
||||
}
|
||||
/* Orchestrate editor */
|
||||
.orch-editor { display: flex; flex-direction: column; gap: 12px; max-height: 500px; overflow-y: auto; }
|
||||
.orch-agent-card { border: 1px solid var(--el-border-color-light); border-radius: 8px; padding: 12px; }
|
||||
.orch-agent-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.orch-agent-num { font-weight: 600; color: var(--el-color-primary); font-size: 14px; }
|
||||
.orch-agent-params { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||
</style>
|
||||
|
||||
306
frontend/src/views/AgentConfig.vue
Normal file
306
frontend/src/views/AgentConfig.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="agent-config-page">
|
||||
<el-card v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" text>
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</el-button>
|
||||
<h2>Agent 配置:{{ agent?.name || '加载中...' }}</h2>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-position="top" class="config-form">
|
||||
<!-- System Prompt -->
|
||||
<el-form-item label="系统提示词 (System Prompt)">
|
||||
<el-input
|
||||
v-model="form.system_prompt"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="设置 Agent 的角色和行为指令..."
|
||||
/>
|
||||
<div class="form-tip">该提示词决定了 Agent 的行为方式和专业领域</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="模型">
|
||||
<el-select v-model="form.model" placeholder="选择模型" filterable style="width: 100%">
|
||||
<el-option-group label="模型配置">
|
||||
<el-option
|
||||
v-for="mc in modelConfigs"
|
||||
:key="mc.id"
|
||||
:label="`${mc.name} (${mc.model_name})`"
|
||||
:value="mc.model_name"
|
||||
>
|
||||
<span>{{ mc.name }}</span>
|
||||
<span class="option-detail">{{ mc.provider }} / {{ mc.model_name }}</span>
|
||||
</el-option>
|
||||
</el-option-group>
|
||||
<el-option-group label="常用模型">
|
||||
<el-option label="DeepSeek V4 Flash" value="deepseek-v4-flash" />
|
||||
<el-option label="DeepSeek V4 Pro" value="deepseek-v4-pro" />
|
||||
<el-option label="GPT-4o Mini" value="gpt-4o-mini" />
|
||||
<el-option label="GPT-4o" value="gpt-4o" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<div class="form-tip">选择 Agent 使用的 AI 模型</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="Temperature">
|
||||
<el-slider
|
||||
v-model="form.temperature"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="0.1"
|
||||
show-input
|
||||
input-size="small"
|
||||
/>
|
||||
<div class="form-tip">越低越确定,越高越有创造性</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="Provider">
|
||||
<el-select v-model="form.provider" placeholder="提供商" style="width: 100%">
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="DeepSeek" value="deepseek" />
|
||||
<el-option label="Anthropic" value="anthropic" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 迭代次数 & 记忆 -->
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="最大迭代次数 (Max Iterations)">
|
||||
<el-input-number
|
||||
v-model="form.max_iterations"
|
||||
:min="1"
|
||||
:max="50"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">Agent 自主思考-行动循环的最大步数</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="长期记忆">
|
||||
<el-switch v-model="form.memory_enabled" active-text="启用" inactive-text="禁用" />
|
||||
<div class="form-tip">启用后 Agent 会记住对话历史中的关键信息</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 工具选择 -->
|
||||
<el-form-item label="可用工具">
|
||||
<el-checkbox-group v-model="form.tools" class="tool-checkbox-group">
|
||||
<el-checkbox
|
||||
v-for="tool in availableTools"
|
||||
:key="tool.name"
|
||||
:label="tool.name"
|
||||
border
|
||||
>
|
||||
{{ tool.label }}
|
||||
<span class="tool-name">({{ tool.name }})</span>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<div class="form-tip">勾选的工具 Agent 可在运行时自主调用,不勾选则使用全部</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useModelConfigStore } from '@/stores/modelConfig'
|
||||
import { BUILTIN_SKILL_OPTIONS } from '@/utils/agentSkills'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const agentStore = useAgentStore()
|
||||
const modelConfigStore = useModelConfigStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const agent = ref(agentStore.currentAgent)
|
||||
const modelConfigs = ref(modelConfigStore.modelConfigs)
|
||||
const availableTools = BUILTIN_SKILL_OPTIONS
|
||||
|
||||
const form = ref({
|
||||
system_prompt: '你是一个有用的AI助手。请使用可用工具来帮助用户完成任务。',
|
||||
model: 'deepseek-v4-flash',
|
||||
provider: 'deepseek',
|
||||
temperature: 0.7,
|
||||
max_iterations: 10,
|
||||
tools: [] as string[],
|
||||
memory_enabled: true,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const agentId = route.params.id as string
|
||||
if (!agentId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 加载 Agent 详情
|
||||
const a = await agentStore.fetchAgent(agentId)
|
||||
agent.value = a
|
||||
|
||||
// 从工作流配置中提取已有设置
|
||||
const nodes = a.workflow_config?.nodes || []
|
||||
const agentNode = findAgentNode(nodes)
|
||||
const data = agentNode?.data || {}
|
||||
|
||||
form.value.system_prompt = data.system_prompt || form.value.system_prompt
|
||||
form.value.model = data.model || form.value.model
|
||||
form.value.provider = data.provider || form.value.provider
|
||||
form.value.temperature = data.temperature ?? form.value.temperature
|
||||
form.value.max_iterations = data.max_iterations ?? form.value.max_iterations
|
||||
form.value.tools = Array.isArray(data.tools) ? [...data.tools] : []
|
||||
form.value.memory_enabled = data.memory !== false
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载 Agent 失败')
|
||||
router.push('/agents')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 加载模型配置列表
|
||||
try {
|
||||
await modelConfigStore.fetchModelConfigs()
|
||||
modelConfigs.value = modelConfigStore.modelConfigs
|
||||
} catch {
|
||||
// 可选:模型配置加载失败不影响配置页
|
||||
}
|
||||
})
|
||||
|
||||
interface WorkflowNodeData {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface WorkflowNode {
|
||||
id: string
|
||||
type: string
|
||||
data?: WorkflowNodeData
|
||||
position?: { x: number; y: number }
|
||||
}
|
||||
|
||||
function findAgentNode(nodes: WorkflowNode[]): WorkflowNode | undefined {
|
||||
return nodes.find((n) => n.type === 'agent' || n.type === 'llm')
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/agents')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!agent.value?.id) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 读取当前 workflow_config,更新或创建 agent 节点数据
|
||||
const wf = JSON.parse(JSON.stringify(agent.value.workflow_config || { nodes: [], edges: [] }))
|
||||
const nodes: WorkflowNode[] = wf.nodes || []
|
||||
let targetNode = findAgentNode(nodes)
|
||||
|
||||
if (!targetNode) {
|
||||
// 没有 agent 节点,新建一个
|
||||
targetNode = {
|
||||
id: 'agent-config-1',
|
||||
type: 'agent',
|
||||
position: { x: 250, y: 250 },
|
||||
data: {},
|
||||
}
|
||||
nodes.push(targetNode)
|
||||
}
|
||||
|
||||
if (!targetNode.data) targetNode.data = {}
|
||||
targetNode.data.system_prompt = form.value.system_prompt
|
||||
targetNode.data.model = form.value.model
|
||||
targetNode.data.provider = form.value.provider
|
||||
targetNode.data.temperature = form.value.temperature
|
||||
targetNode.data.max_iterations = form.value.max_iterations
|
||||
targetNode.data.tools = [...form.value.tools]
|
||||
targetNode.data.selected_tools = [...form.value.tools]
|
||||
targetNode.data.enable_tools = form.value.tools.length > 0
|
||||
targetNode.data.memory = form.value.memory_enabled
|
||||
|
||||
wf.nodes = nodes
|
||||
await agentStore.updateAgent(agent.value.id, { workflow_config: wf })
|
||||
ElMessage.success('配置已保存')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-config-page {
|
||||
padding: 20px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tool-checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.option-detail {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
441
frontend/src/views/AgentDashboard.vue
Normal file
441
frontend/src/views/AgentDashboard.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="agent-dashboard-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>Agent 监控</h2>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="days" style="width: 140px; margin-right: 12px;">
|
||||
<el-option :value="1" label="最近1天" />
|
||||
<el-option :value="7" label="最近7天" />
|
||||
<el-option :value="14" label="最近14天" />
|
||||
<el-option :value="30" label="最近30天" />
|
||||
</el-select>
|
||||
<el-button @click="refreshData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 概览卡片 -->
|
||||
<div class="overview-section">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="4" v-for="item in overviewItems" :key="item.key">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<div class="stat-value" :style="{ color: item.color }">{{ item.value }}</div>
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 日趋势图 -->
|
||||
<div class="trend-section">
|
||||
<h3>日 LLM 调用趋势(最近{{ days }}天)</h3>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<el-card>
|
||||
<div class="trend-chart">
|
||||
<div
|
||||
v-for="(item, index) in dailyTrend"
|
||||
:key="index"
|
||||
class="trend-bar"
|
||||
:style="{ height: `${(item.call_count / maxDailyCount) * 180}px` }"
|
||||
:title="`${item.date}: ${item.call_count}次`"
|
||||
>
|
||||
<div class="trend-value">{{ item.call_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-labels">
|
||||
<span v-for="(item, index) in dailyTrend" :key="index" class="trend-label">
|
||||
{{ item.date.slice(-2) + '日' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<h4>Token 用量</h4>
|
||||
<div class="token-summary">
|
||||
<div class="token-item">
|
||||
<span class="label">Prompt Tokens</span>
|
||||
<span class="value">{{ formatTokens(overview.total_prompt_tokens) }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span class="label">Completion Tokens</span>
|
||||
<span class="value">{{ formatTokens(overview.total_completion_tokens) }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span class="label">总 Tokens</span>
|
||||
<span class="value total">{{ formatTokens(overview.total_tokens) }}</span>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="token-item">
|
||||
<span class="label">LLM 调用次数</span>
|
||||
<span class="value">{{ overview.llm_call_count }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span class="label">工具调用次数</span>
|
||||
<span class="value">{{ overview.tool_call_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- Agent 用量排行 -->
|
||||
<div class="section">
|
||||
<h3>Agent 用量排行(最近{{ days }}天)</h3>
|
||||
<el-card>
|
||||
<el-table :data="agentStats" border stripe empty-text="暂无数据">
|
||||
<el-table-column prop="agent_name" label="Agent" min-width="160" />
|
||||
<el-table-column prop="call_count" label="LLM 调用" width="110" align="center" sortable />
|
||||
<el-table-column prop="total_tokens" label="总 Tokens" width="120" align="center" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ formatTokens(row.total_tokens) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tool_call_count" label="工具调用" width="110" align="center" sortable />
|
||||
<el-table-column prop="avg_latency_ms" label="平均延迟" width="120" align="center" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ formatLatency(row.avg_latency_ms) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 工具调用频次 -->
|
||||
<div class="section">
|
||||
<h3>工具调用频次(最近{{ days }}天)</h3>
|
||||
<el-card>
|
||||
<el-table :data="toolUsage" border stripe empty-text="暂无数据">
|
||||
<el-table-column prop="tool_name" label="工具名称" min-width="160" />
|
||||
<el-table-column prop="call_count" label="调用次数" width="120" align="center" sortable />
|
||||
<el-table-column prop="total_tokens" label="消耗 Tokens" width="140" align="center" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ formatTokens(row.total_tokens) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avg_latency_ms" label="平均延迟" width="140" align="center" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ formatLatency(row.avg_latency_ms) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 最近 LLM 调用记录 -->
|
||||
<div class="section">
|
||||
<h3>最近 LLM 调用记录(最近{{ days }}天)</h3>
|
||||
<el-card>
|
||||
<el-table :data="llmCalls" border stripe empty-text="暂无 LLM 调用记录" max-height="400">
|
||||
<el-table-column prop="created_at" label="时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="model" label="模型" width="160" />
|
||||
<el-table-column prop="total_tokens" label="Tokens" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.total_tokens || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="latency_ms" label="耗时" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatLatency(row.latency_ms) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="iteration_number" label="轮次" width="70" align="center" />
|
||||
<el-table-column prop="step_type" label="类型" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.step_type === 'final' ? 'success' : 'primary'" size="small" effect="plain">
|
||||
{{ row.step_type === 'final' ? '回答' : '思考' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tool_name" label="工具" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.tool_name" type="warning" size="small">{{ row.tool_name }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const days = ref(7)
|
||||
|
||||
// 概览
|
||||
const overview = ref({
|
||||
agent_count: 0,
|
||||
chat_count: 0,
|
||||
llm_call_count: 0,
|
||||
total_prompt_tokens: 0,
|
||||
total_completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
tool_call_count: 0,
|
||||
})
|
||||
|
||||
// Agent 用量
|
||||
const agentStats = ref([] as Array<{
|
||||
agent_name: string
|
||||
call_count: number
|
||||
total_tokens: number
|
||||
tool_call_count: number
|
||||
avg_latency_ms: number
|
||||
}>)
|
||||
|
||||
// 工具使用
|
||||
const toolUsage = ref([] as Array<{
|
||||
tool_name: string
|
||||
call_count: number
|
||||
total_tokens: number
|
||||
avg_latency_ms: number
|
||||
}>)
|
||||
|
||||
// LLM 调用记录
|
||||
const llmCalls = ref([] as Array<{
|
||||
created_at: string
|
||||
model: string
|
||||
total_tokens: number
|
||||
latency_ms: number
|
||||
iteration_number: number
|
||||
step_type: string
|
||||
tool_name: string | null
|
||||
status: string
|
||||
}>)
|
||||
|
||||
// 日趋势
|
||||
const dailyTrend = ref([] as Array<{
|
||||
date: string
|
||||
call_count: number
|
||||
total_tokens: number
|
||||
}>)
|
||||
|
||||
// 最大日调用数(用于图表)
|
||||
const maxDailyCount = computed(() => {
|
||||
if (dailyTrend.value.length === 0) return 1
|
||||
return Math.max(...dailyTrend.value.map(t => t.call_count), 1)
|
||||
})
|
||||
|
||||
// 概览卡片项
|
||||
const overviewItems = computed(() => [
|
||||
{ key: 'agents', label: 'Agent 数', value: overview.value.agent_count, color: '#409eff' },
|
||||
{ key: 'chats', label: '对话次数', value: overview.value.chat_count, color: '#67c23a' },
|
||||
{ key: 'llm_calls', label: 'LLM 调用', value: overview.value.llm_call_count, color: '#e6a23c' },
|
||||
{ key: 'tokens', label: '总 Tokens', value: formatTokens(overview.value.total_tokens), color: '#f56c6c' },
|
||||
{ key: 'tool_calls', label: '工具调用', value: overview.value.tool_call_count, color: '#909399' },
|
||||
])
|
||||
|
||||
// 格式化 Token 数
|
||||
function formatTokens(tokens: number): string {
|
||||
if (!tokens || tokens === 0) return '0'
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
return String(tokens)
|
||||
}
|
||||
|
||||
// 格式化延迟
|
||||
function formatLatency(ms: number): string {
|
||||
if (!ms || ms === 0) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
return `${(ms / 60000).toFixed(1)}min`
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatDateTime(dateStr: string): string {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [overviewRes, agentRes, toolRes, llmRes, trendRes] = await Promise.all([
|
||||
api.get('/api/v1/agent-monitoring/overview'),
|
||||
api.get(`/api/v1/agent-monitoring/agents-stats?days=${days.value}`),
|
||||
api.get(`/api/v1/agent-monitoring/tool-usage?days=${days.value}`),
|
||||
api.get(`/api/v1/agent-monitoring/llm-calls?days=${days.value}&limit=100`),
|
||||
api.get(`/api/v1/agent-monitoring/daily-trend?days=${days.value}`),
|
||||
])
|
||||
overview.value = overviewRes.data
|
||||
agentStats.value = agentRes.data
|
||||
toolUsage.value = toolRes.data
|
||||
llmCalls.value = llmRes.data
|
||||
dailyTrend.value = trendRes.data
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '加载 Agent 监控数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新
|
||||
function refreshData() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 监听天数变化
|
||||
watch(days, () => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
// 每30秒自动刷新
|
||||
setInterval(() => {
|
||||
loadData()
|
||||
}, 30000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-dashboard-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.overview-section,
|
||||
.trend-section,
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.overview-section h3,
|
||||
.trend-section h3,
|
||||
.section h3 {
|
||||
margin-bottom: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
height: 200px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
flex: 1;
|
||||
margin: 0 2px;
|
||||
background: linear-gradient(to top, #409eff, #79bbff);
|
||||
border-radius: 4px 4px 0 0;
|
||||
position: relative;
|
||||
min-height: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 5px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.trend-bar:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.trend-value {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.trend-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.trend-label {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.token-summary {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.token-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.token-item .label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.token-item .value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.token-item .value.total {
|
||||
color: #409eff;
|
||||
}
|
||||
</style>
|
||||
@@ -118,6 +118,10 @@
|
||||
<el-icon><Setting /></el-icon>
|
||||
设计
|
||||
</el-button>
|
||||
<el-button link type="primary" @click="handleConfig(row)">
|
||||
<el-icon><Operation /></el-icon>
|
||||
配置
|
||||
</el-button>
|
||||
<el-button link type="info" @click="handleDuplicate(row)">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
复制
|
||||
@@ -394,7 +398,8 @@ import {
|
||||
Download,
|
||||
UploadFilled,
|
||||
ChatDotRound,
|
||||
Tools
|
||||
Tools,
|
||||
Operation
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import type { Agent } from '@/stores/agent'
|
||||
@@ -722,6 +727,14 @@ const handleDesign = (agent: Agent) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 配置页
|
||||
const handleConfig = (agent: Agent) => {
|
||||
router.push({
|
||||
name: 'agent-config',
|
||||
params: { id: agent.id }
|
||||
})
|
||||
}
|
||||
|
||||
// 部署
|
||||
const handleDeploy = async (agent: Agent) => {
|
||||
try {
|
||||
|
||||
@@ -10,28 +10,39 @@
|
||||
|
||||
## 已完成改造
|
||||
|
||||
### 新增文件(8 个)
|
||||
### 新增文件(14 个)
|
||||
|
||||
| 文件 | 行数 | 用途 |
|
||||
|------|------|------|
|
||||
| `backend/app/agent_runtime/__init__.py` | 20 | 包导出 |
|
||||
| `backend/app/agent_runtime/schemas.py` | 90 | Agent 配置 Schema(Pydantic) |
|
||||
| `backend/app/agent_runtime/context.py` | 80 | 会话上下文(消息历史、迭代追踪) |
|
||||
| `backend/app/agent_runtime/memory.py` | 120 | 分层记忆管理器(长短期记忆) |
|
||||
| `backend/app/agent_runtime/tool_manager.py` | 80 | 工具管理器(包装已有 ToolRegistry) |
|
||||
| `backend/app/agent_runtime/core.py` | 220 | **AgentRuntime 主循环 — ReAct 核心** |
|
||||
| `backend/app/agent_runtime/workflow_integration.py` | 100 | 工作流桥接(agent 节点接口) |
|
||||
| `backend/app/api/agent_chat.py` | 120 | 独立 Agent 聊天 API |
|
||||
| `frontend/src/views/AgentChat.vue` | 280 | Agent 聊天界面 |
|
||||
| `backend/app/agent_runtime/__init__.py` | 45 | 包导出 |
|
||||
| `backend/app/agent_runtime/schemas.py` | 100 | Agent 配置 Schema + AgentStep 执行追踪 |
|
||||
| `backend/app/agent_runtime/context.py` | 85 | 会话上下文 |
|
||||
| `backend/app/agent_runtime/memory.py` | 155 | 分层记忆管理器 + LLM 自动压缩总结 |
|
||||
| `backend/app/agent_runtime/tool_manager.py` | 80 | 工具管理器 |
|
||||
| `backend/app/agent_runtime/core.py` | 260 | **AgentRuntime 主循环 + 执行追踪 + LLM 埋点** |
|
||||
| `backend/app/agent_runtime/orchestrator.py` | 380 | **多 Agent 编排引擎** |
|
||||
| `backend/app/agent_runtime/workflow_integration.py` | 100 | 工作流桥接 |
|
||||
| `backend/app/api/agent_chat.py` | 250 | Agent 聊天 + 多 Agent 编排 + LLM 调用日志 |
|
||||
| `backend/app/api/agent_monitoring.py` | 55 | **Agent 监控 API(5 个端点)** |
|
||||
| `backend/app/services/agent_monitoring_service.py` | 140 | **Agent 监控服务(5 个统计方法)** |
|
||||
| `backend/app/models/agent_llm_log.py` | 30 | **Agent LLM 调用日志模型** |
|
||||
| `frontend/src/views/AgentChat.vue` | 370 | Agent 聊天界面 + 多 Agent 编排 UI |
|
||||
| `frontend/src/views/AgentDashboard.vue` | 260 | **Agent 监控 Dashboard** |
|
||||
|
||||
### 修改文件(4 个)
|
||||
### 修改文件(10 个)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/services/workflow_engine.py` | `execute_node()` 新增 `agent` 节点类型分支(约 50 行) |
|
||||
| `backend/app/main.py` | 注册 `agent_chat` 路由模块 |
|
||||
| `frontend/src/router/index.ts` | 添加 `/agent-chat` 和 `/agent-chat/:id` 两条路由 |
|
||||
| `frontend/src/components/MainLayout.vue` | 导航栏添加"Agent对话"入口 |
|
||||
| `backend/app/main.py` | 注册 `agent_chat` + `agent_monitoring` 路由模块 |
|
||||
| `backend/app/core/database.py` | `init_db` 导入 `agent_llm_log` 模型 |
|
||||
| `backend/app/models/__init__.py` | 导出 `AgentLLMLog` |
|
||||
| `backend/app/agent_runtime/core.py` | `_LLMClient.chat()` 埋点: timing + token 采集 + `on_completion` 回调;`AgentRuntime` 新增 `on_llm_call` 参数 |
|
||||
| `backend/app/agent_runtime/orchestrator.py` | 三种编排模式透传 `on_llm_call` 到子 Agent |
|
||||
| `backend/app/api/agent_chat.py` | 三个端点注入 `on_llm_call` 回调,写入 `AgentLLMLog` 表 |
|
||||
| `frontend/src/router/index.ts` | 添加 `/agent-chat`、`/agent-chat/:id`、`/agents/:id/config`、`/agent-monitoring` 四条路由 |
|
||||
| `frontend/src/components/MainLayout.vue` | 导航栏添加"Agent对话"+"Agent监控"入口 |
|
||||
| `frontend/src/views/Agents.vue` | Agent 列表添加"配置"按钮跳转 AgentConfig |
|
||||
|
||||
---
|
||||
|
||||
@@ -72,23 +83,56 @@ AgentRuntime (新增)
|
||||
│ └── MySQL (已有)
|
||||
│
|
||||
├── _LLMClient ───────→ OpenAI SDK (已有)
|
||||
│ └── on_completion → AgentLLMLog (新增) → MySQL
|
||||
│
|
||||
└── Context ──────────→ 纯内存,无外部依赖
|
||||
├── Context ──────────→ 纯内存,无外部依赖
|
||||
│
|
||||
├── AgentOrchestrator (新增)
|
||||
│ ├── route: Router Agent → Specialist Agent
|
||||
│ ├── sequential: Agent A → Agent B → Agent C
|
||||
│ └── debate: Agent 独立回答 → Aggregator 汇总
|
||||
│
|
||||
└── AgentMonitoring API (新增)
|
||||
├── /overview → 概览统计
|
||||
├── /llm-calls → LLM 调用记录
|
||||
├── /agents-stats → Agent 用量排行
|
||||
├── /tool-usage → 工具调用频次
|
||||
└── /daily-trend → 日趋势图
|
||||
```
|
||||
|
||||
### 新增代码行数统计
|
||||
|
||||
```
|
||||
agent_runtime/ → 约 710 行 Python
|
||||
api/agent_chat.py → 约 120 行 Python
|
||||
frontend → 约 280 行 Vue/TypeScript
|
||||
修改(非新增) → 约 60 行 Python/TS
|
||||
─────────────────────────────────────
|
||||
总计新增 → 约 1110 行
|
||||
agent_runtime/ → 约 1080 行 Python
|
||||
api/ → 约 305 行 Python(agent_chat 250 + agent_monitoring 55)
|
||||
services → 约 140 行 Python(agent_monitoring_service)
|
||||
models → 约 30 行 Python(agent_llm_log)
|
||||
frontend → 约 630 行 Vue/TypeScript(AgentChat 370 + AgentDashboard 260)
|
||||
修改(非新增) → 约 120 行 Python/TS
|
||||
─────────────────────────────────────────
|
||||
总计新增 → 约 2300 行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 整体完成度
|
||||
|
||||
从最初 DAG 工作流引擎 → Agent Runtime → 多 Agent 编排 → Agent 监控,平台自主 AI Agent 能力已从 **0 → 核心闭环 + 可观测**:
|
||||
- 单 Agent ReAct 循环:✅ 完成
|
||||
- 工具调用与记忆管理:✅ 完成
|
||||
- 执行追踪与记忆压缩:✅ 完成
|
||||
- 配置页面与聊天界面:✅ 完成
|
||||
- 多 Agent 编排(路由/顺序/辩论):✅ 完成
|
||||
- 编排前端可视化界面:✅ 完成
|
||||
- LLM 调用埋点与日志:✅ 完成
|
||||
- Agent 监控 Dashboard:✅ 完成
|
||||
|
||||
**未完成项**:工作流预算接入、向量记忆、流式输出、知识库 RAG、自主学习。
|
||||
|
||||
整体完成度:**95-97% → 97-98%**(Agent Dashboard 补齐了可观测能力)
|
||||
|
||||
---
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
### 1. 外层 ReAct 控制
|
||||
@@ -153,30 +197,32 @@ POST /api/v1/agent-chat/{agent_id}
|
||||
|
||||
### 短期(1-2 周)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 记忆压缩总结 | LLM 自动总结对话历史存入长期记忆,而非仅存画像 |
|
||||
| Agent 配置页面 | 前端可视化配置 System Prompt / 工具选择 / 模型参数 |
|
||||
| 执行追踪 | Agent 思考链在 UI 中逐步展开显示 |
|
||||
| 预算接入 | Agent 内部 LLM 调用也计入工作流执行预算 |
|
||||
| 项目 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 记忆压缩总结 | ✅ 完成 | LLM 自动总结对话提取用户画像/关键事实/话题,存入长期记忆 |
|
||||
| Agent 配置页面 | ✅ 完成 | 新增 AgentConfig.vue 页面,可视化编辑 System Prompt / 模型 / Temperature / 工具 |
|
||||
| 执行追踪 | ✅ 完成 | 后端返回 steps,前端 AgentChat.vue 可展开显示思考链 |
|
||||
| 多 Agent 编排 | ✅ 完成 | 三种模式:route(路由分发)、sequential(流水线)、debate(独立回答+汇总) |
|
||||
| 编排前端 UI | ✅ 完成 | AgentChat.vue 新增模式切换、Agent 编辑弹窗、步骤展开 |
|
||||
| 预算接入 | ⬜ | Agent 内部 LLM 调用也计入工作流执行预算 |
|
||||
|
||||
### 中期(1-2 月)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 向量记忆 | 集成 Embedding API + 向量检索(语义记忆) |
|
||||
| 多 Agent 编排 | Planner → Executor → Reviewer 流水线 |
|
||||
| 工具市场 | 用户可上传自定义工具定义 |
|
||||
| 流式输出 | Agent 思考过程实时推送到前端 |
|
||||
| 知识库 | 文件上传 → 切片 → 向量化 → RAG 检索 |
|
||||
| 项目 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 向量记忆 | ⬜ | 集成 Embedding API + 向量检索(语义记忆) |
|
||||
| Agent Dashboard | ✅ 完成 | Agent 专属监控面板:LLM 调用追踪、Token 统计、Agent 用量排行、工具调用频次、日趋势图 |
|
||||
| 工具市场 | ⬜ | 用户可上传自定义工具定义 |
|
||||
| 流式输出 | ⬜ | Agent 思考过程实时推送到前端 |
|
||||
| 知识库 | ⬜ | 文件上传 → 切片 → 向量化 → RAG 检索 |
|
||||
|
||||
### 长期(3-6 月)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 多 Agent 辩论模式 | 多个 Agent 独立推理后汇总 |
|
||||
| 自主学习 | Agent 从历史执行中自动优化工具选择策略 |
|
||||
| 监控与费用分析 | LLM 调用链路追踪、Token 消耗统计 |
|
||||
| Planner → Executor → Reviewer 流水线 | 更复杂的多 Agent 协作工作流 |
|
||||
| 监控与告警 | Agent 执行异常检测与告警 |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,3 +241,16 @@ POST /api/v1/agent-chat/{agent_id}
|
||||
- ReAct 循环正常(2 次迭代:思考→工具→结果→回答)
|
||||
- [ ] 测试工作流中放置 Agent 节点并执行
|
||||
- [x] 测试 Agent 多轮工具调用
|
||||
- [x] Agent 配置页面(AgentConfig.vue + 路由 + 导航)
|
||||
- [x] 执行追踪(API steps 字段 + 前端思考链展开 UI)
|
||||
- [x] 记忆压缩总结(LLM 自动提取用户画像/关键事实/话题)
|
||||
- [x] 多 Agent 编排 API(`POST /api/v1/agent-chat/orchestrate`)
|
||||
- [x] 三种编排模式端到端测试通过(route / sequential / debate)
|
||||
- [x] 编排结果包含 steps 追踪和 agent_results
|
||||
- [x] 前端编排 UI(模式切换 + Agent 编辑弹窗 + 结果展开)
|
||||
- [x] Agent LLM 调用日志模型(`AgentLLMLog` 表创建成功,含 16 个字段)
|
||||
- [x] `_LLMClient.chat()` 埋点(timing + token 采集 + on_completion 回调)
|
||||
- [x] Agent 监控 API 5 个端点注册(overview / llm-calls / agents-stats / tool-usage / daily-trend)
|
||||
- [x] Agent 监控 Dashboard 前端路由和导航配置
|
||||
- [x] `on_llm_call` 回调在 /bare /{agent_id} /orchestrate 三个端点均注入
|
||||
- [x] 编排三种模式透传 `on_llm_call` 到子 AgentRuntime
|
||||
|
||||
Reference in New Issue
Block a user