跳到主要内容

编排主循环:主 Agent、子 Agent 与工具调用的核心节奏

30 秒导读: MiroThinker 这个名字里的价值,几乎全在这一章。深度研究不是"问一次答一次",而是"让一个 Agent 连着做几百次工具交互——搜一下、读一下、想一下、再搜一下——直到把问题啃透"。这个"连着做"的引擎,就是 Orchestrator 里的一个 while 循环。本章拆开它:一轮 turn 里发生什么、一次工具调用怎么端到端跑完、主循环怎么在遇到 agent- 工具时递归进子循环,再把子循环的结构化报告回填给主 Agent。

本章聚焦编排的核心节奏。它的上游(配置怎么装配出这些组件)见 01-config-and-assembly;它依赖的容错细节(回滚判定、去重判定、修复模型输出)见 03-robustness-rollback;它收尾时的上下文压缩与答案抽取04-context-and-answer;被它调用的**工具本体(MCP 子进程)**见 05-tools-mcp。本章只讲循环本身怎么转。


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

一句话定义: Orchestrator(编排器)是一个"反复让 LLM 出招、执行它要的工具、把结果喂回去"的循环调度器。

它解决什么问题。 单次问答的 LLM 很快就撞墙:它不能上网、不能读文件、记不住几十步之前搜到的东西。深度研究恰恰需要长时间、多步骤地和外部世界交互。Orchestrator 的活儿,就是把"一次对话"拉长成"几百次工具交互"的马拉松,并在这个过程中处理失败、去重、上下文膨胀。

"interactive scaling(交互式扩展)"是它的灵魂。 别的系统靠"把模型调大"或"把 prompt 写长"来提升能力;MiroThinker 靠增加交互次数——同一个模型,允许它多搜几十次、多读几十篇,能力就上去了。这一章讲的循环,就是"交互次数"这个旋钮的底座。

用起来什么样。 上层只调一个入口函数,把任务描述丢进去,拿回三样东西:

# 示意,非源码 —— pipeline 层怎么用 Orchestrator
orchestrator = Orchestrator(main_agent_tool_manager, sub_agent_tool_managers, llm_client, ...)
final_summary, final_boxed_answer, failure_experience = await orchestrator.run_main_agent(
task_description="2024 年图灵奖得主的博士导师是谁?",
task_id="demo",
)
# 内部可能已经跑了几十上百轮 LLM 调用 + 工具执行,你只看到最终结果

真实入口在 core/pipeline.py:35execute_task_pipeline:它建好 TaskLogllm_clientOrchestrator,然后调 run_main_agent(core/pipeline.py:102-123),最后拿到 (final_summary, final_boxed_answer, failure_experience_summary)

一句话直觉:Orchestrator 想成一台"打怪循环"——每回合(turn)LLM 说"我要用这个工具",引擎替它按下按钮、把结果递回去,直到 LLM 说"我知道答案了"或回合用尽。


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

先看三个角色怎么协作。怎么读这张图: 竖线是"一轮 turn"内的时间流向;右侧的虚线框是"当工具是子 Agent 时"才会展开的递归分支。

execute_task_pipeline (pipeline.py)
│ 建 llm_client / Orchestrator

run_main_agent ─────────── 主循环 (orchestrator.py:812) ────────────┐
│ │
│ ┌──────────────── 每轮 turn 做的事 ────────────────┐ │
│ │ ① handle_llm_call → LLM 出招(文本 + tool_calls)│ │
│ │ ② 逐个 tool_call: │ │
│ │ server_name.startswith('agent-') ? │ │
│ │ ├─ 是 → run_sub_agent(递归) ┄┄┄┄┄┄┄┄┄┄┄┄┄┼┄┄┐ │
│ │ └─ 否 → tool_manager.execute_tool_call │ ┊ │
│ │ ③ update_message_history(把结果拼回历史) │ ┊ │
│ │ ④ ensure_summary_context(超长就压缩/收尾) │ ┊ │
│ └──────────────────────────────────────────────────┘ ┊ │
│ ↑ turn_count < max_turns 就再来一轮 ┊ │
▼ ┊ │
generate_and_finalize_answer(收尾,见 04 章) ┊ │
┊ │
┌────────── run_sub_agent 子循环 (orchestrator.py:327) ◄──┘ │
│ 独立 message_history / 独立 max_turns │
│ 同样跑 ①②③④,但②里没有 agent-(子 Agent 不再套娃) │
│ 循环结束 → generate_agent_summarize_prompt 产结构化报告 │
│ → 剥掉 <think> → return 给主循环当作这次工具的 result ──────┘
└──────────────────────────────────────────────────────────────

各部件一句话职责:

部件干什么在哪
execute_task_pipeline建组件、起循环、兜异常、存日志core/pipeline.py:35
Orchestrator.run_main_agent主 Agent 的核心 while 循环core/orchestrator.py:736
Orchestrator.run_sub_agent子 Agent 的递归子循环core/orchestrator.py:327
answer_generator.handle_llm_call一次 LLM 调用 + 解析出 tool_callscore/answer_generator.py:80
tool_executor修参数、执行、判是否该回滚core/tool_executor.py(细节见 03 章)
_list_tools缓存子 Agent 的工具定义(懒加载一次)core/orchestrator.py:56

主线走一遍(高层): 任务进来 → 组装 system prompt + 工具定义 → 进 while 循环 → 每轮让 LLM 出招、执行它要的工具、把结果拼回历史、检查是否超长 → 回合用尽或 LLM 主动收手 → 交给收尾逻辑抽出最终答案。


3. 核心原理之一:run_main_agent 的 while 骨架

3.1 它要解决的小问题

LLM 一次只能"说一句话"。要让它做几百步,就得有人在外面反复叫它,并且要能防住两类坏情况:一是模型陷进"无效轮次"(格式错、重复搜、拒答)把回合数白白烧光,二是循环本身失控转不停。MiroThinker 用三个计数器来分别管这两件事。

3.2 三个计数器为什么并存

主循环的条件是 while turn_count < max_turns and total_attempts < max_attempts(core/orchestrator.py:812)。这里牵涉三个量:

计数器含义关键行为
turn_count"有效轮次"——真正推进了任务的轮每轮 +1;但回滚时会 -=1,把这一轮"退掉"
total_attempts"总尝试次数"——不管有效无效,进循环就 +1只增不减,是硬性天花板
EXTRA_ATTEMPTS_BUFFER常量 200,max_attempts = max_turns + 200给回滚留出的"缓冲预算"

为什么不能只用一个计数器? 因为回滚(rollback)会把 turn_count 减回去——如果只看 turn_count,一个反复出错、反复被退回的模型会永远退不出循环(退一格、再进一格、又退一格)。于是引入只增不减的 total_attempts 作为兜底:哪怕每一轮都被回滚,total_attempts 也在稳步逼近 max_attempts,循环终会退出。

EXTRA_ATTEMPTS_BUFFER = 200 是"允许浪费多少次"的预算。 常量定义在 core/orchestrator.py:53。它的语义是:在 max_turns 个有效轮之外,再额外容忍 200 次"无效尝试"(格式错、拒答、重复、工具报错触发的回滚)。超过这个总量就强制收手,防止一个坏模型把预算榨干。

进循环后每轮先做两件事(core/orchestrator.py:813-814):

# 示意,非源码 —— 每轮开头的计数逻辑
turn_count += 1
total_attempts += 1
if consecutive_rollbacks >= self.MAX_CONSECUTIVE_ROLLBACKS: # 连续回滚太多 → 直接跳出
break

这里还有第四个"局部"计数器 consecutive_rollbacks(连续回滚数,默认上限 5,见 core/orchestrator.py:50):它专管"连着栽跟头"的场景——一旦连续回滚达到上限就直接 break,而它会在成功执行一轮后清零(core/orchestrator.py:1102)。它和回滚判定的细节属于 03 章,这里只需知道它是循环退出的第三种途径。

3.3 一轮 turn 的四步

每一轮有效循环,骨架永远是这四步(对应主循环 core/orchestrator.py:826-1135):

步骤做什么源码锚点
handle_llm_call叫 LLM 出招,拿回 assistant_response_text + tool_callsorchestrator.py:832answer_generator.py:80
② 解析 + 逐个执行没有 tool_calls 就走收尾/回滚;有就 for call in tool_calls 挨个跑orchestrator.py:872910
update_message_history把所有工具结果按 call_id 拼回对话历史orchestrator.py:1108
ensure_summary_context检查历史是否超长,超了就压缩、必要时提前收尾orchestrator.py:1124

第①步的产物结构很关键:handle_llm_call 返回 (assistant_response_text, should_break, tool_calls, message_history)(core/answer_generator.py:88)。其中 tool_calls 是一个 list,每项是形如 {"server_name", "tool_name", "arguments", "id"} 的 dict——后面逐个执行时就靠这四个字段分流(见 §4)。

第②步有个"岔路口":如果 LLM 这轮没要任何工具(if not tool_calls,core/orchestrator.py:872),说明它要么想直接作答、要么犯了格式错/拒答。此时交给 _handle_response_format_issues 判断:是"正常结束"还是"该回滚重试"。这属于 03 章的容错逻辑,这里只标出分叉点。

第④步 ensure_summary_context 若返回 pass_length_check=False,主循环会turn_count 直接设成 max_turns(core/orchestrator.py:1129)然后 break——这是一个巧妙的"强制收尾"信号:用"假装回合用尽"来统一走出循环、进入收尾流程。压缩的具体机制见 04 章

循环退出后,run_main_agentanswer_generator.generate_and_finalize_answer(core/orchestrator.py:1170)产出 (final_summary, final_boxed_answer, failure_experience_summary) 三元组返回。收尾内部怎么抽 \boxed{}、怎么攒失败经验,同样留给 04 章。


4. 核心原理之二:一次工具调用端到端

4.1 它要解决的小问题

LLM 说"我要调 search_and_browse,参数是 {...}"——这只是一段文本里解析出来的意图。引擎要:先把参数里常见的手滑修掉,再根据"这是普通工具还是子 Agent"分流去执行,执行时还要把过程事件推给前端流(stream)。

4.2 分流的关键:server_name 前缀

主循环里逐个处理 tool_call 时,第一件事是修参数,然后看 server_name 决定走哪条路(core/orchestrator.py:916-923):

# 示意,非源码 —— 一次 tool_call 的分流骨架
arguments = self.tool_executor.fix_tool_call_arguments(tool_name, arguments) # 先修手滑的参数名

if server_name.startswith("agent-") and self.cfg.agent.sub_agents:
# 这是一个子 Agent:递归进子循环
sub_agent_result = await self.run_sub_agent(server_name, arguments["subtask"])
tool_result = {"server_name": server_name, "tool_name": tool_name, "result": sub_agent_result}
else:
# 普通 MCP 工具:交给 tool_manager 直接执行
tool_result = await self.main_agent_tool_manager.execute_tool_call(
server_name=server_name, tool_name=tool_name, arguments=arguments)

两条路的对照:

分支触发条件执行者结果形态
子 Agentserver_name.startswith("agent-")(orchestrator.py:923)run_sub_agent(递归)子 Agent 的结构化报告(一段文本)
普通工具其它所有 server_namemain_agent_tool_manager.execute_tool_call(orchestrator.py:997)MCP 工具返回的 {"result"/"error"}

为什么用前缀 agent- 而不是别的开关? 因为在主 Agent 眼里,子 Agent 和工具长得一模一样——它们都是被 expose_sub_agents_as_tools(config/settings.py:383)伪装成"工具定义"暴露给主 Agent 的。比如 agent-browsing 就被包装成一个名叫 search_and_browse、只吃一个 subtask 字符串参数的"工具"(config/settings.py:399-418)。主 Agent 调它时并不知道背后是另一个完整的 Agent 循环;只有 Orchestratorserver_nameagent- 前缀在运行时把它"揭穿",转去跑子循环。这就是层级式 Agent 架构的落地方式:上层用统一的"工具调用"接口,下层偷偷藏了一整台子引擎。

4.3 fix_tool_call_arguments 先修参数

两条路都在分流之前先跑 fix_tool_call_arguments(core/orchestrator.py:917491)。这是对"模型手滑"的容错——比如把参数名写错、格式不对。它属于 03 章"对模型输出的容错修复",这里只标出它在时序上的位置:修参数在最前,早于去重检查、早于真正执行

4.4 stream 事件旁路

执行前后穿插着一串 self.stream.tool_call(...) / start_llm / end_llm 调用(如 core/orchestrator.py:9941038)。它们把"正在调哪个工具、结果是什么"实时推给前端展示队列,不影响循环的控制流——是一条"旁路"。所以读循环逻辑时可以先把所有 self.stream.* 忽略,它们只负责"让用户看见过程",不改变"下一步做什么"。

执行成功后,结果经 format_tool_result_for_user 格式化,连同 call_id 攒进 all_tool_results_content_with_id(core/orchestrator.py:1090);一轮里所有工具都跑完后,再由第③步 update_message_history 一次性按 call_id 拼回历史。


5. 核心原理之三:run_sub_agent 子循环

5.1 它要解决的小问题

深度研究要"分而治之":主 Agent 负责统筹,把"上网搜某个具体事实"这种脏活外包给一个专门的子 Agent。子 Agent 得有自己独立的一段对话(不能污染主 Agent 的历史),跑完还要把成果压成一段干净的报告递回主 Agent。

5.2 子循环和主循环的异同

run_sub_agent(core/orchestrator.py:327)的骨架和主循环几乎是同一个模板:同样是 while turn_count < max_turns and total_attempts < max_attempts(core/orchestrator.py:390),同样跑"handle_llm_call → 逐个执行 → update_message_history → ensure_summary_context"四步。差异在这几处:

方面主循环 run_main_agent子循环 run_sub_agent
message_history整个任务的完整历史独立新建,只含这个子任务(orchestrator.py:358)
max_turnscfg.agent.main_agent.max_turnscfg.agent.sub_agents[name].max_turns(orchestrator.py:381)
工具分流可能遇到 agent-(会递归)不会再有 agent-——子 Agent 直接 execute_tool_call(orchestrator.py:530),不套娃
收尾generate_and_finalize_answer(抽 boxed)generate_agent_summarize_prompt结构化报告
返回值(summary, boxed, failure_exp) 三元组一段字符串,回填给主循环当工具结果

独立历史是关键设计。 子 Agent 在 message_history = [{"role": "user", "content": task_description}](core/orchestrator.py:358)上从零开一段对话。它搜了 20 次网页产生的一大堆中间结果,不会灌进主 Agent 的上下文——主 Agent 只会收到最后那段压缩过的报告。这是控制上下文膨胀的核心手段之一。

5.3 结尾:产出结构化报告并回填

子循环退出后,不是直接把最后一句话返回,而是再叫一次 LLM 专门写总结(core/orchestrator.py:674-700):

# 示意,非源码 —— 子 Agent 收尾产报告
summary_prompt = generate_agent_summarize_prompt(task_description, agent_type=sub_agent_name)
if message_history[-1]["role"] == "user":
message_history.pop()
message_history.append({"role": "user", "content": summary_prompt})
# 再调一次 LLM,让它把这段子对话总结成一份结构化报告
final_answer_text, _, _, message_history = await self.answer_generator.handle_llm_call(...)

这份 final_answer_text 就是回填给主循环的东西:在 §4.2 里,run_sub_agent(...) 的返回值被塞进 tool_result["result"](core/orchestrator.py:960-964),从主 Agent 视角看,这和"调了个普通工具、拿到一段结果"毫无区别。层级架构的接缝在这里被完全抹平。

5.4 <think> 剥离

回填之前,报告文本要先剥掉推理痕迹(core/orchestrator.py:727-728):

# 示意,非源码 —— 去掉思维链,只留结论
final_answer_text = final_answer_text.split("<think>")[-1].strip()
final_answer_text = final_answer_text.split("</think>")[-1].strip()

为什么要剥? 有些模型会把思维链包在 <think>...</think> 里输出。子 Agent 的这段"内心独白"对主 Agent 是噪音——主 Agent 只需要结论,不需要看子 Agent 是怎么想的。split("</think>")[-1] 取最后一段,等于"丢掉思考、只留答案"。这既省了主 Agent 的上下文,也避免子 Agent 的犹豫误导主 Agent。


6. 一张图 + 一段伪代码:把控制流串起来

怎么读这张图: 实线是主循环的推进,虚线框是"当工具是子 Agent 时"临时展开的一整台子引擎,报告产出后沿右边回到主循环。

┌──────────────────────── run_main_agent 主循环 ───────────────────────────┐
│ while turn < max_turns and attempts < max_attempts: │
│ turn++; attempts++ │
│ ┌─① LLM 出招─────────────────────────────────────────┐ │
│ │ handle_llm_call → (text, should_break, tool_calls) │ │
│ └─────────────────────────────────────────────────────┘ │
│ if not tool_calls: → 收尾 / 回滚(见 03、04 章) │
│ ┌─② 逐个执行 tool_calls────────────────────────────────┐ │
│ │ for call in tool_calls: │ │
│ │ fix_tool_call_arguments(修参数) │ │
│ │ if server_name.startswith('agent-'): │ │
│ │ └────► run_sub_agent ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐ │ │
│ │ else: ┊ │ │
│ │ execute_tool_call(普通 MCP 工具) ┊ │ │
│ └───────────────────────────────────────────────────┊────┘ │
│ ③ update_message_history(结果按 call_id 拼回) ┊ │
│ ④ ensure_summary_context(超长→压缩/收尾) ┊ │
└──────────────────────────────────────────────────────────┊──────────────────┘

┌─────────── run_sub_agent 子循环(递归) ◄─────────┘
│ 独立 message_history / 独立 max_turns
│ while turn < max_turns and attempts < max_attempts:
│ ①②③④ 同上,但 ② 里不再出现 agent-
│ generate_agent_summarize_prompt → 结构化报告
│ 剥掉 <think> → return 文本 ──────► 回填成主循环里这次工具的 result
└───────────────────────────────────────────────────────────────

对应的 while 骨架伪代码(把主循环压缩成一屏):

# 示意,非源码 —— run_main_agent 的 while 骨架
turn_count = total_attempts = consecutive_rollbacks = 0
max_attempts = max_turns + EXTRA_ATTEMPTS_BUFFER # 200 缓冲

while turn_count < max_turns and total_attempts < max_attempts:
turn_count += 1; total_attempts += 1
if consecutive_rollbacks >= 5: # 连续回滚太多 → 退出
break

text, should_break, tool_calls, history = await handle_llm_call(...) # ①
if not text: # LLM 没回话 → 退一格重试
turn_count -= 1; await sleep(5); continue
if should_break: # LLM 主动收手
break

if not tool_calls: # 没要工具 → 收尾 or 回滚(03 章)
continue_or_break(...); ...

for call in tool_calls: # ② 逐个执行
args = fix_tool_call_arguments(call.tool_name, call.arguments)
if call.server_name.startswith("agent-"):
result = await run_sub_agent(call.server_name, args["subtask"]) # 递归
else:
result = await tool_manager.execute_tool_call(call.server_name, call.tool_name, args)
# (去重检查 / 回滚判定穿插其中,细节见 03 章)
results.append((call.id, format(result)))

consecutive_rollbacks = 0 # 成功一轮 → 清零
history = update_message_history(history, results) # ③
ok, history = ensure_summary_context(history, ...) # ④
if not ok:
turn_count = max_turns; break # 超长 → 假装回合用尽,强制收尾

return await generate_and_finalize_answer(...) # 收尾(04 章)

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

  • 双计数器防"回滚死循环"。 turn_count(可减)管"进度",total_attempts(只增)管"预算",EXTRA_ATTEMPTS_BUFFER=200 是二者之间的缓冲带。任何会 turn_count -= 1 的容错都不会让循环转不停。core/orchestrator.py:53806812

  • 子 Agent 伪装成工具。 expose_sub_agents_as_tools 让子 Agent 以"工具定义"身份出现在主 Agent 面前,主循环靠 server_name.startswith("agent-") 在运行时揭穿并递归。上层接口统一、下层能力可无限嵌套(虽然本实现只嵌一层)。config/settings.py:383orchestrator.py:923

  • 子循环用同一模板但独立状态。 run_sub_agentrun_main_agent 共享四步骨架,却各自持有独立的 message_historymax_turns——既复用了逻辑,又隔离了上下文,防止子任务的一堆中间结果污染主线。orchestrator.py:358381

  • "假装回合用尽"做统一收尾。 上下文超长时,把 turn_count = max_turnsbreak(orchestrator.py:1129),而不是走另一条退出路径——让所有退出都汇入同一个收尾入口,代码更干净。

  • 子 Agent 结果先剥 <think> 再回填。 只把结论递给主 Agent,思维链留在子 Agent 内部,既省 token 又减噪音。orchestrator.py:727-728

  • 工具定义懒加载 + 缓存。 _list_tools 返回一个带闭包缓存的 async 函数,子 Agent 的工具定义只 fetch 一次,之后命中缓存。orchestrator.py:56-83


8. 边界与局限

  • 子 Agent 只嵌一层。 子循环里 execute_tool_call 直连工具、不再判 agent-(orchestrator.py:530),所以子 Agent 不能再派生孙 Agent。层级是"主 + 子"两层,不是任意深树。

  • arguments["subtask"] 是硬约定。 主循环递归子 Agent 时直接取 arguments["subtask"](orchestrator.py:955),依赖 expose_sub_agents_as_tools 里把子 Agent 工具的必填参数固定为 subtask(config/settings.py:412)。若模型没给 subtask 就会走进异常分支。

  • total_attempts 是硬上限,不是"任务一定完成"的保证。 预算耗尽就收尾,哪怕答案还没找到——此时靠 04 章的收尾逻辑尽力从已有历史里抽出最好的答案。

  • 回滚、去重的"判定"不在本章。 本章只画出它们在循环里的位置(修参数在最前、去重在执行前、should_rollback_result 在执行后)。具体"什么算重复""什么错误该回滚"见 03 章


9. 横向对比

同 shelf 的其它深度研究/Agent 项目,循环形态各有取舍。MiroThinker 的特点是把"交互次数"当成一等旋钮(interactive scaling),并用"子 Agent 伪装成工具 + 独立子循环"来做层级分工——这和"单一扁平循环 + 更长上下文"的路线是两种不同的扩展哲学。装配层如何用 Hydra 配置把 max_turns、子 Agent 列表这些旋钮喂进来,见 01-config-and-assembly


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

主题文件路径符号名
主循环入口(三元组返回)apps/miroflow-agent/src/core/orchestrator.pyOrchestrator.run_main_agent
子循环(递归、独立历史)apps/miroflow-agent/src/core/orchestrator.pyOrchestrator.run_sub_agent
三计数器 / 缓冲常量apps/miroflow-agent/src/core/orchestrator.pyEXTRA_ATTEMPTS_BUFFERDEFAULT_MAX_CONSECUTIVE_ROLLBACKS
工具定义懒加载缓存apps/miroflow-agent/src/core/orchestrator.py_list_tools
一次 LLM 调用 + 解析 tool_callsapps/miroflow-agent/src/core/answer_generator.pyAnswerGenerator.handle_llm_call
子 Agent 伪装成工具apps/miroflow-agent/src/config/settings.pyexpose_sub_agents_as_tools
参数修复(时序最前)apps/miroflow-agent/src/core/tool_executor.pyToolExecutor.fix_tool_call_arguments
管线入口:建组件、起循环、兜异常apps/miroflow-agent/src/core/pipeline.pyexecute_task_pipeline
组件工厂:建主/子 ToolManagerapps/miroflow-agent/src/core/pipeline.pycreate_pipeline_components