跳到主要内容

分层 sub-agent:把 sub agent 当成一个工具来调

30 秒导读: MiroFlow 最有辨识度的设计,是把一个"下属 agent"伪装成 main agent 眼里的一个普通工具。main agent 想派活时,就像调用搜索工具一样"调用"这个 sub agent;框架识别到这是个 agent,转而去跑一个和 main 几乎同构、但完全隔离的子循环,跑完把子循环生成的那段文字答案,当作"工具执行结果"塞回 main 的对话里。

本章只讲**「agent 即工具」的递归编排主/子隔离**。main agent 一次任务的循环细节见 01-orchestration-loop;工具本身怎么执行、怎么容错见 03-mcp-tools-manager;LLM 层怎么从文本里解析出工具调用见 04-llm-provider-abstraction


1. 这是什么(零基础也能懂)

一句话定义: 让 main agent 能"雇"一个下属 agent 干活,而雇佣的方式,就是把下属当成一个工具来点名调用

解决什么问题: deep research 类任务里,main agent 要负责"想清楚、拆任务、拼答案",但它不该亲自去一页页翻网页、跑代码、读文件——那会把主对话塞满一大堆杂乱的中间信息(半页 HTML、报错栈、二进制片段),既烧 token 又干扰主线思考。

MiroFlow 的做法: 把"脏活累活"交给一个下属 worker agent,让它在自己的对话里折腾几十轮,最后只回一段干净的总结报告给 main。main 的对话里只留下"我派了个子任务 → 收到一份报告",清清爽爽。

一句话直觉/类比: 像经理给外包团队派活。经理(main)不关心外包(sub)内部开了多少次会、翻了多少资料;经理只递一句需求("查清楚 X"),外包关起门来自己干,最后交回一份报告。对经理来说,"外包"和"打印机"没本质区别——都是一个你调用、它给结果的工具。

用起来什么样: main agent 在它的回复里,像调任何 MCP 工具一样写出一个工具调用,只是 server_name 指向一个 agent:

<use_mcp_tool>
<server_name>agent-worker</server_name>
<tool_name>execute_subtask</tool_name>
<arguments>
{"subtask": "查清楚 2019 年图灵奖得主的博士导师是谁,给出来源"}
</arguments>
</use_mcp_tool>

框架看到 server_nameagent- 开头,就不走普通工具执行,而是启动一个完整的子 agent 循环去办这件事。


2. 顶层全景(它大概怎么转)

怎么读这张图: 从上往下是控制流。关键在最右:main 的工具列表里混进了一个"名字叫 agent-worker 的工具",一旦它被调用,控制权就下沉到一个独立的子循环,子循环跑完把一段文字冒泡回 main 当工具结果。

┌───────────────────────────────────────────┐
│ main agent 主循环 │
│ (message_history / usage / turns 都是它自己的) │
└───────────────────────────────────────────┘
│ 每轮:LLM 想调工具

┌──────────────────────────────────┐
│ 遍历本轮工具调用 call │
│ server_name 以 "agent-" 开头? │
└──────────────────────────────────┘
│ 否 │ 是
▼ ▼
┌──────────────────┐ ┌──────────────────────────────────┐
│ 普通 MCP 工具执行 │ │ run_sub_agent(server_name, ...) │
│ (见 03 章) │ │ ┌────────────────────────────┐ │
└──────────────────┘ │ │ ① reset_usage_stats │ │
│ │ │ ② 独立 message_history │ │
│ │ │ ③ 独立 system_prompt(worker)│ │
│ │ │ ④ 独立 max_turns 的子循环 │ │
│ │ │ ⑤ summary 重试 → 一段文字 │ │
│ │ └────────────────────────────┘ │
│ └──────────────────────────────────┘
│ │ 返回 final_answer_text
▼ ▼
┌──────────────────────────────────────────┐
│ 都包成 {server_name, tool_name, result} │
│ → 作为"工具结果"写回 main 的对话 │
└──────────────────────────────────────────┘

部件一句话职责:

部件干什么在哪个文件
expose_sub_agents_as_tools把配置里的每个 sub agent 转成一条工具定义,追加进 main 的工具列表src/utils/tool_utils.py:78
expose_agent_as_tool具体产出"这个 sub agent 长什么样的工具"(名字、execute_subtask、schema)config/agent_prompts/sub_worker.py:229
main loop 的分支判断识别 server_name.startswith("agent-"),改走 run_sub_agentsrc/core/orchestrator.py:884
run_sub_agent一个和 main 几乎同构的独立子循环,跑完返回一段文字答案src/core/orchestrator.py:381
_list_tools缓存各 sub agent 各自的工具定义(懒加载一次)src/core/orchestrator.py:35
TaskTracer 的 sub-agent session给每次 sub agent 运行开一个带编号的隔离 session,单独记账src/logging/task_tracer.py:66

3. 核心原理(逐个机制,由浅入深)

3.1 机制一:sub agent 怎么"变成"一个工具

它要解决的小问题: main agent 的 LLM 只认识"工具"这一种可调用的东西。想让它能派活给下属,就得把下属伪装成一条工具定义,混进 main 的工具清单里。

思路: 一个 sub agent 对外只暴露一个工具 execute_subtask,它只收一个参数 subtask(一段自然语言子任务描述)。对 main 的 LLM 来说,这和"搜索工具收一个 query"没有任何区别。

约定(硬性): sub agent 的名字必须以 agent- 开头。这个前缀不是命名习惯,而是后面 main loop 用来做分支判断的唯一信号。配置阶段就强制校验:

if not sub_agent.startswith("agent-"):
raise ValueError(
f"Sub-agent name must start with 'agent-': {sub_agent}. ..."
)

—— expose_sub_agents_as_tools,src/utils/tool_utils.py:82-85。名字不合规直接抛错,不给你"忘了加前缀导致悄悄不生效"的机会。

真实实现: 工具定义本身由 sub agent 的 prompt 类产出。SubAgentWorkerPrompt.expose_agent_as_tool 返回一个 dict,name 就是那个 agent-xxx,tools 里只有一个 execute_subtask:

tool_definition = dict(
name=subagent_name, # 必须以 'agent-' 开头
tools=[dict(
name="execute_subtask",
description="This tool is an agent that performs various subtasks ...",
schema={"type": "object",
"properties": {"subtask": {"title": "Subtask", "type": "string"}},
"required": ["subtask"], ...},
)],
)

—— sub_worker.py:229-247。注意基类 BaseAgentPrompt.expose_agent_as_tool(base_agent_prompt.py:69-107)对 main agent 返回 {}(main 不需要把自己暴露成工具),只有 is_main_agent=False 的子类才真正产出定义;基类里那段 tool_definition = dict(...) 是永不执行的文档样例,注释明说 Name MUST starts with 'agent-'

装配时机: main agent 在拿工具定义时,先取真正的 MCP 工具,再把 sub agents 追加进去——两者在 main 的 LLM 眼里平起平坐:

tool_definitions = await self.main_agent_tool_manager.get_all_tool_definitions()
if self.cfg.sub_agents is not None and self.cfg.sub_agents:
tool_definitions += expose_sub_agents_as_tools(self.cfg.sub_agents)

—— run_main_agent,orchestrator.py:783-785

3.2 机制二:main loop 里的一次"分流"

它要解决的小问题: main 的 LLM 说"我要调 agent-workerexecute_subtask"之后,框架得判断这到底是普通工具、还是一个 agent,并走不同的执行路径。

思路: 判断依据就是 3.1 里那个 agent- 前缀。命中就调 run_sub_agent,否则走普通 MCP 工具执行。无论走哪条,结果最终都包成同样形状的 {server_name, tool_name, result},回填进 main 的对话——所以对 main 的循环逻辑而言,"调了个 sub agent"和"调了个工具"后续处理完全一致。

真实实现:

if server_name.startswith("agent-"):
sub_agent_result = await self.run_sub_agent(
server_name, str(arguments), keep_tool_result
)
tool_result = {
"server_name": server_name,
"tool_name": tool_name,
"result": sub_agent_result, # sub 循环回传的那段文字
}
else:
tool_result = await self.main_agent_tool_manager.execute_tool_call(...)

—— run_main_agent,orchestrator.py:884-900arguments 里那句 {"subtask": "..."} 被整体 str(...) 后作为子任务描述递下去。

一个关键含义: sub agent 的整个几十轮运行,在 main 眼里只占一次工具调用的位置。main 的循环轮次(turn)、token 账、对话历史,完全不被 sub 内部的折腾污染——这正是下一节要讲的隔离。

3.3 机制三:run_sub_agent —— 一个隔离的同构子循环

它要解决的小问题: 既然 sub agent 要独立干活、独立记账、独立受限,它就需要一套自己的运行时状态,而不能和 main 共用。

思路: run_sub_agent(orchestrator.py:381-687)几乎是 main loop 的翻版——同样是"LLM ↔ 工具"的多轮循环,同样用 _handle_llm_call_with_logging、同样在末尾用 _handle_summary_with_context_limit_retry 生成总结(这两个是主/子共用的方法,agent_type 参数区分身份)。但它把所有会"串味"的状态都另起一份

四样东西,全是独立的:

隔离项mainsub代码锚点
usage 统计main 自己的进 sub 先 resetorchestrator.py:395 reset_usage_stats()
message_historymain 的对话sub 从子任务重开一条orchestrator.py:398-399
system promptMain*PromptSubAgentWorkerPrompt(worker 专用)orchestrator.py:421-427
max_turns / max_tool_callscfg.main_agent.*cfg.sub_agents[name].*orchestrator.py:430504

独立记账: 一进来就把 sub 的 LLM client usage 清零,这样 sub 这趟花了多少 token 能被单独统计,不和 main 的账混在一起:

# Reset sub-agent usage stats for independent tracking
self.sub_agent_llm_client.reset_usage_stats()

—— orchestrator.py:394-395。跑完再 get_usage_log() 把这一 session 的用量单独 log(orchestrator.py:673-679)。reset_usage_stats 干的就是把四个累加器清零(provider_client_base.py:383-388)。

独立对话与 system prompt: sub 从子任务描述重新开一条 message_history,并用 worker 专属的 system prompt(强调"你是执行单一、明确子任务的 agent,别越权推理"):

initial_user_content = [{"type": "text", "text": task_description}]
message_history = [{"role": "user", "content": initial_user_content}]
...
sub_agent_prompt_instance = _load_agent_prompt_class(
self.cfg.sub_agents[sub_agent_name].prompt_class) # 通常是 SubAgentWorkerPrompt
system_prompt = sub_agent_prompt_instance.generate_system_prompt_with_mcp_tools(...)

—— orchestrator.py:398-427

独立上限: sub 的循环上限从 cfg.sub_agents[name] 里取,和 main 各管各的(-1 表示不限,会被折成 sys.maxsize):

max_turns = self.cfg.sub_agents[sub_agent_name].max_turns
if max_turns < 0:
max_turns = sys.maxsize

—— orchestrator.py:430-432;单轮工具调用上限 max_tool_calls_per_turnorchestrator.py:504

跑完 → 一段文字: 子循环结束(正常收尾 / 撞轮次上限 / 撞 context 上限)后,用和 main 同一套 summary 重试逻辑,把整段对话压缩成一段结构化文字答案:

final_answer_text = await self._handle_summary_with_context_limit_retry(
system_prompt, sub_agent_prompt_instance, message_history,
tool_definitions, f"Sub agent {sub_agent_name} final summary",
task_description, task_failed, agent_type=sub_agent_name)
...
# Return final answer instead of dialogue log, so main agent can use directly
return final_answer_text

—— orchestrator.py:639-687回给 main 的是这段总结,不是几十轮的原始对话——这是"下属只交报告"的落点,也是 main 对话保持干净的关键。summary 重试本身(context 超限就逐对删旧对话再试)属于全局容错哲学,见 05-config-and-fault-tolerance

3.4 机制四:sub agent 的工具定义缓存

它要解决的小问题: 每个 sub agent 有自己的一套工具(worker 挂了搜索/读文件/跑代码等 MCP server)。这些工具定义要在运行 sub 时拿到,但不该每次派活都重新去各 MCP server 拉一遍。

思路: 用一个闭包 + 懒加载缓存:第一次真正需要时才去各 ToolManager 拉全量定义,之后直接命中缓存。

真实实现:

def _list_tools(sub_agent_tool_managers):
cache = None
async def wrapped():
nonlocal cache
if cache is None:
result = {name: await tm.get_all_tool_definitions()
for name, tm in sub_agent_tool_managers.items()} \
if sub_agent_tool_managers else {}
cache = result
return cache
return wrapped

—— orchestrator.py:35-54。Orchestrator 初始化时调一次拿到这个闭包 self._list_sub_agent_tools(orchestrator.py:101);run_sub_agentawait self._list_sub_agent_tools() 再按名字取自己那份(orchestrator.py:402-403)。单 agent 模式(没配 sub agents)时缓存就是 {},一切照常空跑。

3.5 机制五:TaskTracer 的 sub-agent session

它要解决的小问题: 一次任务里,main 可能派好几次活(甚至并列派给同一个 worker 多次)。日志得能分清"这段轨迹属于哪一次 sub 运行",而不是糊成一片。

思路: 每次进 run_sub_agent开一个带编号的 session,离开时关掉。session id 用一个自增计数器拼出来,保证每次唯一。

真实实现: 开 session 时计数器 +1、拼出 agent-worker_1 这样的 id、记一条 start log:

def start_sub_agent_session(self, sub_agent_name, subtask_description):
self.sub_agent_counter += 1
session_id = f"{sub_agent_name}_{self.sub_agent_counter}"
self.current_sub_agent_session_id = session_id
...
return session_id

—— task_tracer.py:66-82;对应的 end_sub_agent_session 记 end log 并把 current_sub_agent_session_id 清回 None(task_tracer.py:84-93)。

这个 session id 怎么被用: _handle_llm_call_with_logging 保存对话历史时,如果当前有 current_sub_agent_session_id,就把这段历史存进 sub_agent_message_history_sessions[session_id](orchestrator.py:174-177)——于是每次 sub 运行的完整对话被分门别类归档;main 的历史则单独存 main_agent_message_historyrun_sub_agent 首尾正好各调一次开/关(orchestrator.py:392681)。


4. 巧妙之处(可借鉴的技术)

  • 用命名前缀当"类型标签":不引入新的类型系统或注册表,仅靠 server_nameagent- 开头,就在一个 if 里完成"这是工具还是 agent"的分流(orchestrator.py:884)。前缀在配置阶段被强校验(tool_utils.py:82),既是约定也是护栏。

  • 主/子共用代码、只换状态:sub 循环没有另写一套,而是复用 _handle_llm_call_with_logging / _handle_summary_with_context_limit_retry,靠 agent_type 参数切换身份和用哪个 LLM client(orchestrator.py:143-145303-305)。同构 = 更少 bug、行为一致。

  • 递归编排的天然可扩展性:因为"sub agent 就是一条工具定义",理论上一个 sub agent 也能在自己的工具列表里再挂 sub agent,形成多层。当前配置(如 agent-worker)是单层 main→sub,但机制本身不限层数——(inferred) 这一点由"分流逻辑只认前缀、不认层级"推得,仓库里未见多层实际配置。

  • 只回总结、不回轨迹:sub 把几十轮对话压成一段报告再回传(orchestrator.py:687 的注释 so main agent can use directly),让 main 的上下文只增长"一次工具结果"的量级,而不是被子任务的原始噪声撑爆——这是分层最实际的收益。


5. 边界与局限

  • 只有一个入参 subtask:sub agent 对外的接口极简,一段自然语言描述就是全部输入(sub_worker.py:236-243)。复杂结构化输入需 main 自己在文字里表达清楚;run_sub_agent 开头还硬拼了一句 "Please provide the answer and detailed supporting information..."(orchestrator.py:388),不接受文件附件(orchestrator.py:397 注释 no file attachments)。

  • 回传的是"总结"而非"事实原文":sub 的输出经过一轮 LLM 总结压缩,存在丢细节/二次转述的风险。worker 的 summary prompt 因此反复强调"优先摘录原文、别改写、标注不确定性"(sub_worker.py),但这是 prompt 层的缓解,不是硬保证。

  • 失败会被标记但不会中断 main:sub 撞轮次上限或 context 上限时,task_failed 被置真,summary 里会明确写"我失败了"(sub_worker.py:186-190),但仍返回一段文字给 main;是否重试、怎么补救,由 main 的 LLM 自行决定。

  • 顺序执行:main 一轮里的多个工具调用(含多个 sub agent 调用)是 for 循环依次 await(orchestrator.py:876),不是并行 fan-out;派多个子任务是串行的。


6. 代码地图(导航索引)

主题文件路径符号名
把 sub agents 转成工具列表(前缀校验)src/utils/tool_utils.py:78expose_sub_agents_as_tools
单个 sub agent 的工具定义产出config/agent_prompts/sub_worker.py:229SubAgentWorkerPrompt.expose_agent_as_tool
基类:main 返回 {}、文档样例、agent- 约定config/agent_prompts/base_agent_prompt.py:69BaseAgentPrompt.expose_agent_as_tool
main loop 里 agent- 前缀分流src/core/orchestrator.py:884run_main_agent(分支)
独立同构子循环src/core/orchestrator.py:381run_sub_agent
sub usage 清零(独立记账)src/llm/provider_client_base.py:383reset_usage_stats
sub 工具定义懒加载缓存src/core/orchestrator.py:35_list_tools
主/子共用的 LLM 调用+日志src/core/orchestrator.py:127_handle_llm_call_with_logging
主/子共用的 summary 重试src/core/orchestrator.py:269_handle_summary_with_context_limit_retry
sub-agent session 开/关(隔离日志)src/logging/task_tracer.py:66start_sub_agent_session / end_sub_agent_session
sub agent 配置块(名字/prompt_class/上限/工具)config/agent_gaia-validation_claude37sonnet.yaml:44sub_agents.agent-worker