deerflow2的Ultra 模式 的subagent 调度系统逻辑
Ultra 模式的核心是 subagent 调度系统,整个调用链分为六个环节:
1. 中间件层:并发控制
build_middlewares() 在 subagent_enabled=True 时加入 SubagentLimitMiddleware(默认 max_concurrent=3): 1
该中间件在 after_model 钩子中检查 LLM 输出的 task 工具调用数量,超过限制的调用直接从 AIMessage.tool_calls 中截断(同步更新 additional_kwargs["tool_calls"] 保持一致性): 2
2. 系统提示:编排策略注入
apply_prompt_template() 在 subagent_enabled=True 时将 <subagent_system> 章节注入系统提示,告知 LLM:
- 最多同时发起 N 个
task调用(硬限制)
让我查看SubagentLimitMiddleware的完整实现。
硬限制完全体现在 SubagentLimitMiddleware._truncate_task_calls() 中,是代码层面的强制截断,不依赖 LLM 是否遵守 prompt 指令。
核心逻辑在这里: 1
执行步骤:
- 找出 LLM 输出的最后一条
AIMessage中所有name == "task"的工具调用索引 - 如果数量 ≤
max_concurrent,直接返回None(不修改) - 超出部分的索引放入
indices_to_drop,过滤掉它们 - 用
clone_ai_message_with_tool_calls()替换原AIMessage,保持消息 ID 不变(触发 LangGraph 的 state 替换语义)
这个截断发生在 after_model 钩子,即 LLM 生成响应之后、工具实际执行之前: 2
max_concurrent 的值被 clamp 到 [2, 4],默认值 MAX_CONCURRENT_SUBAGENTS=3,即使用户在 config 中配置了超出范围的值也会被强制修正: 3
系统提示中的 <subagent_system> 章节(“⛔ HARD CONCURRENCY LIMIT”)是软提示,目的是让 LLM 主动分批规划,避免超额调用被静默丢弃。而 SubagentLimitMiddleware 才是真正的硬保障——即使 LLM 无视 prompt,超出的 task 调用也会在到达执行层之前被截断。
Citations
File: backend/packages/harness/deerflow/agents/middlewares/subagent_limit_middleware.py (L16-22)
MIN_SUBAGENT_LIMIT = 2
MAX_SUBAGENT_LIMIT = 4
def _clamp_subagent_limit(value: int) -> int:
"""Clamp subagent limit to valid range [2, 4]."""
return max(MIN_SUBAGENT_LIMIT, min(MAX_SUBAGENT_LIMIT, value))
File: backend/packages/harness/deerflow/agents/middlewares/subagent_limit_middleware.py (L54-68)
# Count task tool calls
task_indices = [i for i, tc in enumerate(tool_calls) if tc.get("name") == "task"]
if len(task_indices) <= self.max_concurrent:
return None
# Build set of indices to drop (excess task calls beyond the limit)
indices_to_drop = set(task_indices[self.max_concurrent :])
truncated_tool_calls = [tc for i, tc in enumerate(tool_calls) if i not in indices_to_drop]
dropped_count = len(indices_to_drop)
logger.warning(f"Truncated {dropped_count} excess task tool call(s) from model response (limit: {self.max_concurrent})")
# Replace the AIMessage with truncated tool_calls (same id triggers replacement)
updated_msg = clone_ai_message_with_tool_calls(last_msg, truncated_tool_calls)
return {"messages": [updated_msg]}
File: backend/packages/harness/deerflow/agents/middlewares/subagent_limit_middleware.py (L70-76)
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
return self._truncate_task_calls(state)
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
return self._truncate_task_calls(state)
- 如何分批执行超过 N 个子任务
- 何时应该直接执行 vs 委托给子 agent 3
3. task_tool:启动子 agent
LLM 调用 task 工具时,task_tool() 执行以下步骤:
① 从父 agent 的 runtime 提取上下文(sandbox_state、thread_id、parent_model、trace_id、user_id、user_role 等),确保子 agent 继承父 agent 的身份和沙箱状态: 4
② 获取子 agent 工具,强制 subagent_enabled=False 防止递归嵌套,继承父 agent 的 tool_groups 限制: 5
③ 创建 SubagentExecutor 并启动后台执行,以 tool_call_id 作为 task_id 便于追踪: 6
4. SubagentExecutor.execute_async():双线程池架构
execute_async()
├── 创建 SubagentResult(status=PENDING) 写入 _background_tasks 字典
├── copy_context() 捕获父 agent 的 contextvars(用户上下文等)
└── _scheduler_pool.submit(run_task) ← 3 workers
└── run_task()
└── _submit_to_isolated_loop_in_context()
└── 持久化独立 asyncio 事件循环(daemon 线程)
└── _aexecute(task, result_holder)
├── _build_initial_state()
├── _create_agent() ← checkpointer=False
└── agent.astream() ← 流式执行
子 agent 运行在持久化的独立 asyncio 事件循环中(避免嵌套事件循环问题),超时由 future.result(timeout=config.timeout_seconds) 强制执行(默认 1800s): 7
5. task_tool 轮询:SSE 事件推送
task_tool 在后台任务运行期间每 5 秒轮询一次,通过 get_stream_writer() 向前端推送 SSE 事件:
| 事件类型 | 触发时机 |
|---|---|
task_started |
任务启动时立即发送 |
task_running |
子 agent 产生新 AI 消息时(每条消息一个事件) |
task_completed |
子 agent 成功完成 |
task_failed |
子 agent 执行失败 |
task_cancelled |
用户取消 |
task_timed_out |
超时 |
6. 前端:子任务卡片渲染
hooks.ts 的 onCustomEvent 处理 task_running 事件,调用 updateSubtask() 更新子任务状态: 9
SubtaskCard 组件根据 task.status 展示进度(in_progress 显示最新工具调用,completed 显示结果,failed 显示错误): 10
完整调用链
Citations
File: backend/packages/harness/deerflow/agents/lead_agent/agent.py (L358-362)
# Add SubagentLimitMiddleware to truncate excess parallel task calls
subagent_enabled = cfg.get("subagent_enabled", False)
if subagent_enabled:
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
File: backend/tests/test_subagent_limit_middleware.py (L93-105)
def test_task_calls_exceeding_limit_truncated(self):
mw = SubagentLimitMiddleware(max_concurrent=2)
msg = AIMessage(
content="",
tool_calls=[_task_call("t1"), _task_call("t2"), _task_call("t3"), _task_call("t4")],
)
result = mw._truncate_task_calls({"messages": [msg]})
assert result is not None
updated_msg = result["messages"][0]
task_calls = [tc for tc in updated_msg.tool_calls if tc["name"] == "task"]
assert len(task_calls) == 2
assert task_calls[0]["id"] == "t1"
assert task_calls[1]["id"] == "t2"
File: backend/packages/harness/deerflow/agents/lead_agent/prompt.py (L236-256)
return f"""<subagent_system>
**🚀 SUBAGENT MODE ACTIVE - DECOMPOSE, DELEGATE, SYNTHESIZE**
You are running with subagent capabilities enabled. Your role is to be a **task orchestrator**:
1. **DECOMPOSE**: Break complex tasks into parallel sub-tasks
2. **DELEGATE**: Launch multiple subagents simultaneously using parallel `task` calls
3. **SYNTHESIZE**: Collect and integrate results into a coherent answer
**CORE PRINCIPLE: Complex tasks should be decomposed and distributed across multiple subagents for parallel execution.**
**⛔ HARD CONCURRENCY LIMIT: MAXIMUM {n} `task` CALLS PER RESPONSE. THIS IS NOT OPTIONAL.**
- Each response, you may include **at most {n}** `task` tool calls. Any excess calls are **silently discarded** by the system — you will lose that work.
- **Before launching subagents, you MUST count your sub-tasks in your thinking:**
- If count ≤ {n}: Launch all in this response.
- If count > {n}: **Pick the {n} most important/foundational sub-tasks for this turn.** Save the rest for the next turn.
- **Multi-batch execution** (for >{n} sub-tasks):
- Turn 1: Launch sub-tasks 1-{n} in parallel → wait for results
- Turn 2: Launch next batch in parallel → wait for results
- ... continue until all sub-tasks are complete
- Final turn: Synthesize ALL results into a coherent answer
- **Example thinking pattern**: "I identified 6 sub-tasks. Since the limit is {n} per turn, I will launch the first {n} now, and the rest in the next turn."
File: backend/packages/harness/deerflow/tools/builtins/task_tool.py (L260-289)
if runtime is not None:
sandbox_state = runtime.state.get("sandbox")
thread_data = runtime.state.get("thread_data")
thread_id = runtime.context.get("thread_id") if runtime.context else None
if thread_id is None:
thread_id = runtime.config.get("configurable", {}).get("thread_id")
# Try to get parent model from configurable
metadata = runtime.config.get("metadata", {})
parent_model = metadata.get("model_name")
# Get or generate trace_id for distributed tracing
trace_id = metadata.get("trace_id") or str(uuid.uuid4())[:8]
# Get user_id for tracing (uses standard resolution order)
user_id = resolve_runtime_user_id(runtime)
# Propagate the authenticated runtime context so delegated tool calls are
# evaluated by GuardrailMiddleware with the same identity/attribution as
# the lead agent. Sourced from the server-side context written by
# inject_authenticated_user_context (and run_id by the run worker); stays
# None when absent (e.g. internal-auth runs) so guardrail behavior is
# unchanged. Without this, role-aware policy silently mis-attributes any
# tool call delegated to a subagent (user_role=None).
parent_context = runtime.context if runtime is not None else None
parent_context = parent_context if isinstance(parent_context, dict) else {}
user_role = parent_context.get("user_role")
oauth_provider = parent_context.get("oauth_provider")
oauth_id = parent_context.get("oauth_id")
run_id = parent_context.get("run_id")
File: backend/packages/harness/deerflow/tools/builtins/task_tool.py (L309-317)
# Subagents should not have subagent tools enabled (prevent recursive nesting)
available_tools_kwargs = {
"model_name": effective_model,
"groups": parent_tool_groups,
"subagent_enabled": False,
}
if resolved_app_config is not None:
available_tools_kwargs["app_config"] = resolved_app_config
tools = get_available_tools(**available_tools_kwargs)
File: backend/packages/harness/deerflow/tools/builtins/task_tool.py (L336-353)
executor = SubagentExecutor(**executor_kwargs)
# Start background execution (always async to prevent blocking)
# Use tool_call_id as task_id for better traceability
task_id = executor.execute_async(prompt, task_id=tool_call_id)
# Poll for task completion in backend (removes need for LLM to poll)
poll_count = 0
last_status = None
last_message_count = 0 # Track how many AI messages we've already sent
# Polling timeout: execution timeout + 60s buffer, checked every 5s
max_poll_count = (config.timeout_seconds + 60) // 5
logger.info(f"[trace={trace_id}] Started background task {task_id} (subagent={subagent_type}, timeout={config.timeout_seconds}s, polling_limit={max_poll_count} polls)")
writer = get_stream_writer()
# Send Task Started message'
writer({"type": "task_started", "task_id": task_id, "description": description})
File: backend/packages/harness/deerflow/tools/builtins/task_tool.py (L355-421)
try:
while True:
result = get_background_task_result(task_id)
if result is None:
logger.error(f"[trace={trace_id}] Task {task_id} not found in background tasks")
writer({"type": "task_failed", "task_id": task_id, "error": "Task disappeared from background tasks"})
cleanup_background_task(task_id)
return f"Error: Task {task_id} disappeared from background tasks"
# Log status changes for debugging
if result.status != last_status:
logger.info(f"[trace={trace_id}] Task {task_id} status: {result.status.value}")
last_status = result.status
# Check for new AI messages and send task_running events
ai_messages = result.ai_messages or []
current_message_count = len(ai_messages)
if current_message_count > last_message_count:
# Send task_running event for each new message
for i in range(last_message_count, current_message_count):
message = ai_messages[i]
writer(
{
"type": "task_running",
"task_id": task_id,
"message": message,
"message_index": i + 1, # 1-based index for display
"total_messages": current_message_count,
}
)
logger.info(f"[trace={trace_id}] Task {task_id} sent message #{i + 1}/{current_message_count}")
last_message_count = current_message_count
# Check if task completed, failed, or timed out
usage = _summarize_usage(getattr(result, "token_usage_records", None))
if result.status == SubagentStatus.COMPLETED:
_cache_subagent_usage(tool_call_id, usage, enabled=cache_token_usage)
_report_subagent_usage(runtime, result)
writer({"type": "task_completed", "task_id": task_id, "result": result.result, "usage": usage})
logger.info(f"[trace={trace_id}] Task {task_id} completed after {poll_count} polls")
cleanup_background_task(task_id)
return f"Task Succeeded. Result: {result.result}"
elif result.status == SubagentStatus.FAILED:
_cache_subagent_usage(tool_call_id, usage, enabled=cache_token_usage)
_report_subagent_usage(runtime, result)
writer({"type": "task_failed", "task_id": task_id, "error": result.error, "usage": usage})
logger.error(f"[trace={trace_id}] Task {task_id} failed: {result.error}")
cleanup_background_task(task_id)
return f"Task failed. Error: {result.error}"
elif result.status == SubagentStatus.CANCELLED:
_cache_subagent_usage(tool_call_id, usage, enabled=cache_token_usage)
_report_subagent_usage(runtime, result)
writer({"type": "task_cancelled", "task_id": task_id, "error": result.error, "usage": usage})
logger.info(f"[trace={trace_id}] Task {task_id} cancelled: {result.error}")
cleanup_background_task(task_id)
return "Task cancelled by user."
elif result.status == SubagentStatus.TIMED_OUT:
_cache_subagent_usage(tool_call_id, usage, enabled=cache_token_usage)
_report_subagent_usage(runtime, result)
writer({"type": "task_timed_out", "task_id": task_id, "error": result.error, "usage": usage})
logger.warning(f"[trace={trace_id}] Task {task_id} timed out: {result.error}")
cleanup_background_task(task_id)
return f"Task timed out. Error: {result.error}"
# Still running, wait before next poll
await asyncio.sleep(5)
File: backend/packages/harness/deerflow/subagents/executor.py (L827-888)
def execute_async(self, task: str, task_id: str | None = None) -> str:
"""Start a task execution in the background.
Args:
task: The task description for the subagent.
task_id: Optional task ID to use. If not provided, a random UUID will be generated.
Returns:
Task ID that can be used to check status later.
"""
# Use provided task_id or generate a new one
if task_id is None:
task_id = str(uuid.uuid4())[:8]
# Create initial pending result
result = SubagentResult(
task_id=task_id,
trace_id=self.trace_id,
status=SubagentStatus.PENDING,
)
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} starting async execution, task_id={task_id}, timeout={self.config.timeout_seconds}s")
with _background_tasks_lock:
_background_tasks[task_id] = result
parent_context = copy_context()
# Submit to scheduler pool
def run_task():
with _background_tasks_lock:
_background_tasks[task_id].status = SubagentStatus.RUNNING
_background_tasks[task_id].started_at = datetime.now()
result_holder = _background_tasks[task_id]
try:
# Submit execution directly to the persistent isolated loop so the
# background path does not create a temporary loop via execute().
execution_future = _submit_to_isolated_loop_in_context(
parent_context,
lambda: self._aexecute(task, result_holder),
)
try:
# Wait for execution with timeout
execution_future.result(timeout=self.config.timeout_seconds)
except FuturesTimeoutError:
logger.error(f"[trace={self.trace_id}] Subagent {self.config.name} execution timed out after {self.config.timeout_seconds}s")
# Signal cooperative cancellation and cancel the future
result_holder.cancel_event.set()
result_holder.try_set_terminal(
SubagentStatus.TIMED_OUT,
error=f"Execution timed out after {self.config.timeout_seconds} seconds",
)
execution_future.cancel()
except Exception as e:
logger.exception(f"[trace={self.trace_id}] Subagent {self.config.name} async execution failed")
with _background_tasks_lock:
task_result = _background_tasks[task_id]
task_result.try_set_terminal(SubagentStatus.FAILED, error=str(e))
_scheduler_pool.submit(run_task)
return task_id
File: frontend/src/core/threads/hooks.ts (L808-822)
onCustomEvent(event: unknown) {
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "task_running"
) {
const e = event as {
type: "task_running";
task_id: string;
message: AIMessage;
};
updateSubtask({ id: e.task_id, latestMessage: e.message });
return;
}
File: frontend/src/components/workspace/messages/subtask-card.tsx (L138-175)
{task.status === "in_progress" &&
task.latestMessage &&
hasToolCalls(task.latestMessage) && (
<ChainOfThoughtStep
label={t.subtasks.in_progress}
icon={<Loader2Icon className="size-4 animate-spin" />}
>
{explainLastToolCall(task.latestMessage, t)}
</ChainOfThoughtStep>
)}
{task.status === "completed" && (
<>
<ChainOfThoughtStep
label={t.subtasks.completed}
icon={<CheckCircleIcon className="size-4" />}
></ChainOfThoughtStep>
<ChainOfThoughtStep
label={
task.result ? (
<MarkdownContent
content={task.result}
isLoading={false}
rehypePlugins={rehypePlugins}
/>
) : null
}
></ChainOfThoughtStep>
</>
)}
{task.status === "failed" && (
<ChainOfThoughtStep
label={<div className="text-red-500">{task.error}</div>}
icon={<XCircleIcon className="size-4 text-red-500" />}
></ChainOfThoughtStep>
)}
</ChainOfThoughtContent>
</div>
</ChainOfThought>
更多推荐


所有评论(0)