fix: 修复子代理环境隔离、统计时间戳问题,提取子代理配置

This commit is contained in:
ViperEkura 2026-03-28 13:27:09 +08:00
parent 57e998f896
commit 24e8497230
7 changed files with 92 additions and 21 deletions

View File

@ -36,6 +36,13 @@ frontend_port: 4000
# Max agentic loop iterations (tool call rounds) # Max agentic loop iterations (tool call rounds)
max_iterations: 15 max_iterations: 15
# Sub-agent settings (multi_agent tool)
sub_agent:
max_iterations: 3 # Max tool-call rounds per sub-agent
max_tokens: 4096 # Max tokens per LLM call inside a sub-agent
max_agents: 5 # Max number of concurrent sub-agents per request
max_concurrency: 3 # ThreadPoolExecutor max workers
# Available models # Available models
# Each model must have its own id, name, api_url, api_key # Each model must have its own id, name, api_url, api_key
models: models:
@ -117,6 +124,7 @@ backend/
│ ├── data.py # 计算器、文本、JSON 处理 │ ├── data.py # 计算器、文本、JSON 处理
│ ├── weather.py # 天气查询(模拟) │ ├── weather.py # 天气查询(模拟)
│ ├── file_ops.py # 文件操作6 个工具project_id 自动注入) │ ├── file_ops.py # 文件操作6 个工具project_id 自动注入)
│ ├── agent.py # 多智能体(子 Agent 并发执行,工具权限隔离)
│ └── code.py # Python 代码执行(沙箱) │ └── code.py # Python 代码执行(沙箱)
└── utils/ # 辅助函数 └── utils/ # 辅助函数
├── helpers.py # 通用函数ok/err/build_messages 等) ├── helpers.py # 通用函数ok/err/build_messages 等)
@ -207,6 +215,7 @@ frontend/
| **代码执行** | execute_python | 沙箱环境执行 Python | | **代码执行** | execute_python | 沙箱环境执行 Python |
| **文件操作** | file_read, file_write, file_delete, file_list, file_exists, file_mkdir | project_id 自动注入 | | **文件操作** | file_read, file_write, file_delete, file_list, file_exists, file_mkdir | project_id 自动注入 |
| **天气** | get_weather | 天气查询(模拟) | | **天气** | get_weather | 天气查询(模拟) |
| **智能体** | multi_agent | 派生子 Agent 并发执行(禁止递归,工具权限与主 Agent 一致) |
## 文档 ## 文档

View File

@ -39,3 +39,10 @@ TOOL_MAX_WORKERS = _cfg.get("tool_max_workers", 4)
# Max character length for a single tool result content (truncated if exceeded) # Max character length for a single tool result content (truncated if exceeded)
TOOL_RESULT_MAX_LENGTH = _cfg.get("tool_result_max_length", 4096) TOOL_RESULT_MAX_LENGTH = _cfg.get("tool_result_max_length", 4096)
# Sub-agent settings (multi_agent tool)
_sa = _cfg.get("sub_agent", {})
SUB_AGENT_MAX_ITERATIONS = _sa.get("max_iterations", 3)
SUB_AGENT_MAX_TOKENS = _sa.get("max_tokens", 4096)
SUB_AGENT_MAX_AGENTS = _sa.get("max_agents", 5)
SUB_AGENT_MAX_CONCURRENCY = _sa.get("max_concurrency", 3)

View File

@ -4,12 +4,25 @@ Provides:
- multi_agent: Spawn sub-agents with independent LLM conversation loops - multi_agent: Spawn sub-agents with independent LLM conversation loops
""" """
import json import json
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from backend.tools.factory import tool from backend.tools.factory import tool
from backend.tools.core import registry from backend.tools.core import registry
from backend.tools.executor import ToolExecutor from backend.tools.executor import ToolExecutor
from backend.config import (
DEFAULT_MODEL,
SUB_AGENT_MAX_ITERATIONS,
SUB_AGENT_MAX_TOKENS,
SUB_AGENT_MAX_AGENTS,
SUB_AGENT_MAX_CONCURRENCY,
)
logger = logging.getLogger(__name__)
# Sub-agents are forbidden from using multi_agent to prevent infinite recursion
BLOCKED_TOOLS = {"multi_agent"}
def _to_executor_calls(tool_calls: list, id_prefix: str = "tc") -> list: def _to_executor_calls(tool_calls: list, id_prefix: str = "tc") -> list:
@ -68,13 +81,16 @@ def _run_sub_agent(
"error": "LLM client not available", "error": "LLM client not available",
} }
# Build tool list filter to requested tools or use all # Build tool list filter to requested tools, then remove blocked
all_tools = registry.list_all() all_tools = registry.list_all()
if tool_names: if tool_names:
allowed = set(tool_names) allowed = set(tool_names)
tools = [t for t in all_tools if t["function"]["name"] in allowed] tools = [t for t in all_tools if t["function"]["name"] in allowed]
else: else:
tools = all_tools tools = list(all_tools)
# Remove blocked tools to prevent recursion
tools = [t for t in tools if t["function"]["name"] not in BLOCKED_TOOLS]
executor = ToolExecutor(registry=registry) executor = ToolExecutor(registry=registry)
context = {"model": model} context = {"model": model}
@ -128,7 +144,17 @@ def _run_sub_agent(
}) })
tc_list = message["tool_calls"] tc_list = message["tool_calls"]
executor_calls = _to_executor_calls(tc_list) executor_calls = _to_executor_calls(tc_list)
tool_results = executor.process_tool_calls(executor_calls, context) # Execute tools inside app_context file ops and other DB-
# dependent tools require an active Flask context and session.
with app.app_context():
if len(executor_calls) > 1:
tool_results = executor.process_tool_calls_parallel(
executor_calls, context
)
else:
tool_results = executor.process_tool_calls(
executor_calls, context
)
messages.extend(tool_results) messages.extend(tool_results)
else: else:
# Final text response # Final text response
@ -159,7 +185,7 @@ def _run_sub_agent(
"Spawn multiple sub-agents to work on tasks concurrently. " "Spawn multiple sub-agents to work on tasks concurrently. "
"Each agent runs its own independent conversation with the LLM and can use tools. " "Each agent runs its own independent conversation with the LLM and can use tools. "
"Useful for parallel research, multi-file analysis, or dividing complex tasks into sub-tasks. " "Useful for parallel research, multi-file analysis, or dividing complex tasks into sub-tasks. "
"Each agent is limited to 3 iterations and 4096 tokens to control cost." "Resource limits (iterations, tokens, concurrency) are configured in config.yml -> sub_agent."
), ),
parameters={ parameters={
"type": "object", "type": "object",
@ -221,19 +247,18 @@ def multi_agent(arguments: dict) -> dict:
tasks = arguments["tasks"] tasks = arguments["tasks"]
if len(tasks) > 5: if len(tasks) > SUB_AGENT_MAX_AGENTS:
return {"success": False, "error": "Maximum 5 concurrent agents allowed"} return {"success": False, "error": f"Maximum {SUB_AGENT_MAX_AGENTS} concurrent agents allowed"}
# Get current conversation context for model/project info # Get current conversation context for model/project info
app = current_app._get_current_object() app = current_app._get_current_object()
# Use injected model/project_id from executor context, fall back to defaults # Use injected model/project_id from executor context, fall back to defaults
from backend.config import DEFAULT_MODEL
model = arguments.get("_model") or DEFAULT_MODEL model = arguments.get("_model") or DEFAULT_MODEL
project_id = arguments.get("_project_id") project_id = arguments.get("_project_id")
# Execute agents concurrently (max 3 at a time) # Execute agents concurrently
concurrency = min(len(tasks), 3) concurrency = min(len(tasks), SUB_AGENT_MAX_CONCURRENCY)
results = [None] * len(tasks) results = [None] * len(tasks)
with ThreadPoolExecutor(max_workers=concurrency) as pool: with ThreadPoolExecutor(max_workers=concurrency) as pool:
@ -244,9 +269,10 @@ def multi_agent(arguments: dict) -> dict:
task["instruction"], task["instruction"],
task.get("tools"), task.get("tools"),
model, model,
4096, SUB_AGENT_MAX_TOKENS,
project_id, project_id,
app, app,
SUB_AGENT_MAX_ITERATIONS,
): i ): i
for i, task in enumerate(tasks) for i, task in enumerate(tasks)
} }

View File

@ -1,6 +1,6 @@
"""Common helper functions""" """Common helper functions"""
import json import json
from datetime import date, datetime from datetime import date, datetime, timezone
from typing import Any from typing import Any
from flask import jsonify from flask import jsonify
from backend import db from backend import db
@ -97,7 +97,7 @@ def message_to_dict(msg: Message) -> dict:
def record_token_usage(user_id, model, prompt_tokens, completion_tokens): def record_token_usage(user_id, model, prompt_tokens, completion_tokens):
"""Record token usage""" """Record token usage"""
today = date.today() today = datetime.now(timezone.utc).date()
usage = TokenUsage.query.filter_by( usage = TokenUsage.query.filter_by(
user_id=user_id, date=today, model=model user_id=user_id, date=today, model=model
).first() ).first()

View File

@ -66,6 +66,7 @@ backend/
│ ├── data.py # 计算器、文本、JSON │ ├── data.py # 计算器、文本、JSON
│ ├── weather.py # 天气查询 │ ├── weather.py # 天气查询
│ ├── file_ops.py # 文件操作project_id 自动注入) │ ├── file_ops.py # 文件操作project_id 自动注入)
│ ├── agent.py # 多智能体(子 Agent 并发执行,工具权限隔离)
│ └── code.py # 代码执行 │ └── code.py # 代码执行
├── utils/ # 辅助函数 ├── utils/ # 辅助函数
@ -1020,6 +1021,13 @@ frontend_port: 4000
# 智能体循环最大迭代次数(工具调用轮次上限,默认 5 # 智能体循环最大迭代次数(工具调用轮次上限,默认 5
max_iterations: 15 max_iterations: 15
# 子代理资源配置multi_agent 工具)
sub_agent:
max_iterations: 3 # 每个子代理的最大工具调用轮数
max_tokens: 4096 # 每次调用的最大 token 数
max_agents: 5 # 每次请求最多派生的子代理数
max_concurrency: 3 # 并发线程数
# 可用模型列表(每个模型必须指定 api_url 和 api_key # 可用模型列表(每个模型必须指定 api_url 和 api_key
# 支持任何 OpenAI 兼容 APIDeepSeek、GLM、OpenAI、Moonshot、Qwen 等) # 支持任何 OpenAI 兼容 APIDeepSeek、GLM、OpenAI、Moonshot、Qwen 等)
models: models:

View File

@ -249,14 +249,25 @@ file_read({"path": "src/main.py", "project_id": "xxx"})
| 工具名称 | 描述 | 参数 | | 工具名称 | 描述 | 参数 |
|---------|------|------| |---------|------|------|
| `multi_agent` | 派生子 Agent 并发执行任务(最多 5 个) | `tasks`: 任务数组name, instruction, tools<br>`_model`: 模型名称(自动注入)<br>`_project_id`: 项目 ID自动注入 | | `multi_agent` | 派生子 Agent 并发执行任务 | `tasks`: 任务数组name, instruction, tools<br>`_model`: 模型名称(自动注入)<br>`_project_id`: 项目 ID自动注入 |
**`multi_agent` 工作原理:** **`multi_agent` 工作原理:**
1. 接收任务数组,每个任务指定 name、instruction 和可选的 tools 列表 1. 接收任务数组,每个任务指定 name、instruction 和可选的 tools 列表
2. 为每个子 Agent 创建独立线程,各自拥有 LLM 对话循环(最多 3 轮迭代4096 tokens 2. 子 Agent **禁止使用 `multi_agent` 工具**`BLOCKED_TOOLS`),防止无限递归
3. 通过 Service Locator 获取 `llm_client` 实例 3. 子 Agent 工具权限与主 Agent 一致(除 multi_agent 外的所有已注册工具),支持并行工具执行
4. 子 Agent 在 `app.app_context()` 中运行,可独立调用所有注册工具 4. 为每个子 Agent 创建独立线程,各自拥有 LLM 对话循环
5. 返回 `{success, results: [{task_name, success, response/error}], total}` 5. 子 Agent 在 `app.app_context()` 中运行 LLM 调用和工具执行,确保数据库等依赖正常工作
6. 通过 Service Locator 获取 `llm_client` 实例
7. 返回 `{success, results: [{task_name, success, response/error}], total}`
**资源配置**`config.yml` → `sub_agent`
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `max_iterations` | 3 | 每个子代理的最大工具调用轮数 |
| `max_tokens` | 4096 | 每次调用的最大 token 数 |
| `max_agents` | 5 | 每次请求最多派生的子代理数 |
| `max_concurrency` | 3 | ThreadPoolExecutor 并发线程数 |
--- ---

View File

@ -132,8 +132,15 @@ const sortedDaily = computed(() => {
const chartData = computed(() => { const chartData = computed(() => {
if (period.value === 'daily' && stats.value?.hourly) { if (period.value === 'daily' && stats.value?.hourly) {
const hourly = stats.value.hourly const hourly = stats.value.hourly
// Backend returns UTC hours convert to local timezone for display.
const offset = -new Date().getTimezoneOffset() / 60 // e.g. +8 for UTC+8
const localHourly = {}
for (const [utcH, val] of Object.entries(hourly)) {
const localH = ((parseInt(utcH) + offset) % 24 + 24) % 24
localHourly[localH] = val
}
let minH = 24, maxH = -1 let minH = 24, maxH = -1
for (const h of Object.keys(hourly)) { for (const h of Object.keys(localHourly)) {
const hour = parseInt(h) const hour = parseInt(h)
if (hour < minH) minH = hour if (hour < minH) minH = hour
if (hour > maxH) maxH = hour if (hour > maxH) maxH = hour
@ -145,16 +152,19 @@ const chartData = computed(() => {
const h = start + i const h = start + i
return { return {
label: `${h}:00`, label: `${h}:00`,
value: hourly[String(h)]?.total || 0, value: localHourly[String(h)]?.total || 0,
} }
}) })
} }
const data = sortedDaily.value const data = sortedDaily.value
return Object.entries(data).map(([date, val]) => { return Object.entries(data).map(([date, val]) => {
const d = new Date(date) // date is "YYYY-MM-DD" from backend parse directly to avoid
// new Date() timezone shift (parsed as UTC midnight then
// getMonth/getDate applies local offset, potentially off by one day).
const [year, month, day] = date.split('-')
return { return {
label: `${d.getMonth() + 1}/${d.getDate()}`, label: `${parseInt(month)}/${parseInt(day)}`,
value: val.total, value: val.total,
prompt: val.prompt || 0, prompt: val.prompt || 0,
completion: val.completion || 0, completion: val.completion || 0,