跳到主要内容

编排主循环:main agent 的一次任务是怎么跑完的

30 秒导读: MiroFlow 处理一个问题的核心,是一个「中央控制循环」——它把任务包装成一段 prompt,然后在 while 循环里反复做同一件事:问 LLM、从回答里解析出工具调用、按顺序执行、把结果拼回对话历史,再问下一轮。循环退出后,再单独跑一次「总结」把散落的中间结果收敛成最终答案。本章只讲这条主线的骨架和它的退出/容错语义;工具怎么执行见 03,sub agent 怎么展开见 02

本章的主轴是 src/core/orchestrator.py 里的 Orchestrator.run_main_agent(orchestrator.py:689-1131)。外层由 src/core/pipeline.pyexecute_task_pipeline(pipeline.py:23-160)负责搭台子和收尾清理。


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

一句话定义: 「编排主循环」是 agent 的大脑主线——它是一段反复运行的控制流,负责协调「LLM 在想什么」和「工具真的去做了什么」这两件事,直到任务跑完。

它解决什么问题: 大模型自己只会「说话」,不会真的去搜网页、跑代码、读文件。要让它完成一个真实任务(比如「查清楚某个问题并写份报告」),就得有人在中间当调度员:

  1. 把模型说的「我要调用某工具」翻译成真实调用;
  2. 把工具跑出来的结果再喂回给模型;
  3. 判断「够了没有」——该继续问,还是该收尾出答案。

这个调度员就是 Orchestrator,而 run_main_agent 就是它跑一次完整任务的入口。

一个心智模型(类比): 把它想成一个「一问一答的接力」。

  • 你(main agent)问模型:「下一步做什么?」
  • 模型答:「去搜这三个关键词。」(这就是一次 turn)
  • 你真的去搜了,把搜索结果贴回来,再问:「现在呢?」
  • ……如此往复,直到模型说「不用再查了,我可以回答了」,或者你手里的「回合预算」用光。

用起来什么样: 上层代码不直接碰循环,而是调 pipeline:

# 示意,非源码:一次任务的最外层调用
final_answer, boxed_answer, log_path = await execute_task_pipeline(
cfg=cfg, # Hydra 配置(回合上限、LLM、工具等都在这)
task_id="task_001",
task_description="查清楚 X 并给出所有可能答案",
task_file_name=None, # 可选:附带的文件
main_agent_tool_manager=..., # 已初始化的工具池
sub_agent_tool_managers={...}, # 各 sub agent 的工具池
output_formatter=...,
log_path=...,
)

pipeline 内部再去建 LLM client、建 Orchestrator、调 run_main_agent,最后无论成败都做清理。下面先看这个「搭台」的外层,再钻进主循环。


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

2.1 两层结构:pipeline 搭台,orchestrator 唱戏

execute_task_pipeline(pipeline.py:23-160)是一次性的——建资源、跑一次任务、清理资源。真正的循环在 run_main_agent 里。两者的分工:

符号职责
外层(搭台/清理)execute_task_pipeline(pipeline.py:23)TaskTracer、建 main/sub 的 LLMClient、建 Orchestrator,用 try/finally 保证 client 一定被关掉
内层(控制循环)Orchestrator.run_main_agent(orchestrator.py:689)组 prompt → 跑主循环 → 收尾出最终答案
每一轮的原子操作_handle_llm_call_with_logging(orchestrator.py:127)「问一次 LLM + 解析 + 存档」的统一封装
收尾容错_handle_summary_with_context_limit_retry(orchestrator.py:269)生成 final summary,顶不住上下文超限时逐步砍历史重试

2.2 主循环一张图

这张图从上到下是时间顺序;中间的方框是 while 循环体,每转一圈叫一个 turn

run_main_agent(task_description, ...)

┌────────────────┼─────────────────────────────┐
│ 组装阶段(循环之前,只做一次) │
│ ① 处理输入 + 注入 task_guidence (710-747) │
│ ② 可选:生成 hint 提示 (750-777) │
│ ③ 拉工具定义(含 sub agent) (783-785) │
│ ④ 组 system prompt (793-802) │
└────────────────┬─────────────────────────────┘
│ message_history = [第一条 user 消息]

╔═══════════ while turn_count < max_turns (810) ═══════════╗
║ ║
║ 问 LLM → _handle_llm_call_with_logging (816-828) ║
║ │ 返回 (回答文本, should_break, tool_calls) ║
║ ▼ ║
║ 没回答/超限/失败? ── 是 ─► task_failed=True, break ║
║ │否 (834-851) ║
║ ▼ ║
║ 有回答但 should_break? ── 是 ─► break (831-833) ║
║ │否 ║
║ ▼ ║
║ 没有工具调用? ── 是 ─► break(当作最终答案)(853-860) ║
║ │否 ║
║ ▼ ║
║ 按序执行 tool_calls[0][:max] (876-949) ║
║ · server 名以 "agent-" 开头 → run_sub_agent ║
║ · 否则 → tool_manager.execute_tool_call ║
║ · 每个结果配 call_id 收集 ║
║ ▼ ║
║ 有格式错的调用? 追加一条 "re-think" 提示 (950-969) ║
║ ▼ ║
║ update_message_history 回填结果 (972-974) ║
║ └──────────────► 回到循环顶 ║
╚══════════════════════════════════════════════════════════╝
│ (循环退出)

⑤ final summary(带容错重试) (997-1007)
⑥ 可选:GAIA / browsecomp-zh 抽取答案 (1020-1074)
⑦ 格式化返回 (final_summary, boxed) (1108-1131)

怎么读这张图: 组装阶段只跑一次;然后进 while 循环,每一轮先问 LLM、再判断三种「该不该退出」、不退出就执行工具并回填、回到顶部。退出后走收尾三步。


3. 搭台:execute_task_pipeline 怎么准备与清理

这节讲「循环之外」的那层——它把一次任务需要的资源建起来,并保证不管中途出什么错,资源都会被清干净。

3.1 建资源

execute_task_pipeline 顺序做三件事(pipeline.py:59-112):

  1. TaskTracer(pipeline.py:59):全程贯穿的日志/存档对象,后面 orchestrator 每一步都往它写。
  2. 建 LLM client:main agent 用 cfg.main_agent.llm 建一个(pipeline.py:79);如果配了 sub agents,再用第一个 sub agent 的 llm 配置建一个 sub 专用 client(pipeline.py:92)。缺 LLM 配置直接 raise ValueError
  3. Orchestrator(pipeline.py:104),把工具池、两个 client、formatter、task_logcfg 全都注入。

然后把状态置为 running,调 run_main_agent(pipeline.py:115-119)。

3.2 try / finally 的清理契约

关键在 try/except/finally 的结构(pipeline.py:75-160):

  • try:正常跑,成功后把 final_boxed_answer 存进 task_log、状态设 completed
  • except Exception(pipeline.py:124):任何异常都被兜住,final_answer 被替换成一段带 traceback 的错误报告,状态设 interrupted——不会把异常继续往上抛,而是变成一个「答案」返回。
  • finally(pipeline.py:139):无论成败,关闭 main/sub 两个 LLM client(注意用 sub != main 避免关两次同一个),记录结束时间,写最终日志,return

一个易忽略的细节: return 语句在 finally 里(pipeline.py:160)。这意味着即便 try 块内已经算出了答案,最终返回值也永远由 finally 里的这一句给出——这是 Python 里「finally 中的 return 覆盖一切」的用法,保证了返回三元组 (final_answer, final_boxed_answer, log_path) 一定成立。


4. 组装阶段:进循环之前把 prompt 拼好

从这里开始进入 run_main_agent 本体(orchestrator.py:689)。循环之前有四步纯准备工作,每步都在往「第一条 user 消息」或「system prompt」上叠东西。

4.1 处理输入 + 注入 task_guidence(710-747)

先用 process_input(orchestrator.py:706,实现见 src/utils/io_utils.py:process_input)把任务描述和可选文件名整理成 initial_user_content

接着拼上一大段 task_guidence(orchestrator.py:710-726)。这段是 MiroFlow 的核心 prompt 工程之一,写死在代码里,它的立意很鲜明:

  • 不要急着给单一结论,而是把所有「可能的候选答案」都列出来,连同证据、推理、不确定性一起呈现;
  • 假设用户不是故意设陷阱,按最常见、最直白的理解处理;
  • 就算题目本身有矛盾或错误,也不要自作主张去「纠正」,而是把每种解读都透明列出。

如果开了中文语境(self.chinese_context,orchestrator.py:729),再追加一段「中文任务处理指导」(orchestrator.py:730-743),要求用中文关键词搜索、用中文思考和输出。

这段 guidence 直接拼进第一条 user 消息的文本(orchestrator.py:745-747),而不是 system prompt。留意它后面还会被复用于 final summary(见 §6),所以被单独存成变量传给收尾函数。

4.2 可选:hint 生成(750-777)

cfg.main_agent.input_process.hint_generation 为真(orchestrator.py:750),就先用一个独立的小 LLM 调用 extract_hints(orchestrator.py:753,见 src/utils/summary_utils.py:extract_hints)预分析题目里「容易被误解的坑」,把结果作为「预备笔记」再拼到 user 消息后面(orchestrator.py:768-769)。

容错设计: 整段包在 try/except 里(orchestrator.py:770-777),hint 生成失败不致命——记一条 failed 日志,hint_notes 置空,任务照常往下走。这体现了本项目「辅助环节可失败,主线不能断」的容错哲学(详见 05)。

拼完后,message_history 被初始化成只含这一条 user 消息(orchestrator.py:780)。

4.3 拉工具定义(783-785)

main_agent_tool_manager 拿到所有 MCP 工具定义(orchestrator.py:783),再把每个 sub agent 当成一个工具追加进去——expose_sub_agents_as_tools(orchestrator.py:785,见 src/utils/tool_utils.py:78)。这是「分层 sub agent」的关键接缝:main agent 眼里,一个 sub agent 和一个普通工具长得一样,只是名字以 agent- 开头。展开细节见 02

4.4 组 system prompt(793-802)

cfg.main_agent.prompt_class 动态加载 prompt 类(_load_agent_prompt_class,orchestrator.py:63-77),再调它的 generate_system_prompt_with_mcp_tools(orchestrator.py:797-802),把工具定义和中文标志塞进去,生成最终 system prompt。至此,循环所需的三件东西——system prompt、message_history、tool_definitions——全部就位。


5. 主循环:一轮到底做了什么(810-974)

这是全章的心脏。循环头是 while turn_count < max_turns(orchestrator.py:810);max_turns 来自配置,若配成负数则用 sys.maxsize 视作「无上限」(orchestrator.py:805-807)。每转一圈,turn_count += 1

一轮内部按四个阶段走。

5.1 阶段一:问一次 LLM(816-828)

_handle_llm_call_with_logging(orchestrator.py:127-267),它是「问 LLM」的统一封装,返回一个三元组:

(assistant_response_text, should_break, tool_calls)

这个封装内部做了不少事(orchestrator.py:127-267):

  • 可选给消息打 ID:若开了 add_message_id,给每条 user 消息前缀一个随机 [msg_xxxx](orchestrator.py:148-165),避免跨对话缓存命中。
  • 调 LLM 前后各存一次档:把 system_prompt + message_history 写进 task_logsave()(orchestrator.py:168-178199-213)。
  • 真正调用:current_llm_client.create_message(...)(orchestrator.py:181)。
  • 解析回答:process_llm_response 得到 (文本, should_break),extract_tool_calls_info 从文本里抽出工具调用(orchestrator.py:193-218)。这两步是 LLM 抽象层的活,MiroFlow 是从文本里解析工具调用而非依赖原生 function-calling,详见 04

这一层把所有异常都收敛成「三元组信号」返回,而不是抛出去(orchestrator.py:241-267):

情况返回值含义
正常且有文本(文本, should_break, tool_calls_info)继续处理
无有效回答(None, True, None)让上层退出
asyncio.TimeoutError(None, True, None)超时,退出
ContextLimitError(None, True, "context_limit")特殊标记:上下文超限,上层据此走「跳到 summary」分支
其它异常(None, True, None)失败,退出

注意 "context_limit" 这个字符串被塞在本该放 tool_calls 的第三个位置——它是一个带外信号,上层靠 tool_calls == "context_limit" 来识别(orchestrator.py:836)。

5.2 阶段二:判断三种退出(831-860)

拿到三元组后,主循环按顺序判断该不该跳出。这里有三个退出闸门:

① 有文本 且 should_break? ── break (831-833) 「LLM 主动示意结束」
② 没有文本(text 为空)? (834-851)
├─ tool_calls == "context_limit" → 记 warning
└─ 否则 → 记 "LLM call failed"
两者都: task_failed=True, break 「调用失败/超限」
③ 没解析出任何工具调用? ── break (853-860) 「没工具调用 = 当作最终答案」

闸门③的判据值得细看(orchestrator.py:853-857):tool_calls is Nonelen(tool_calls) < 2tool_calls[0]tool_calls[1] 都为空。这里 tool_calls 是个二元结构——tool_calls[0] 是解析成功的调用列表,tool_calls[1]格式错误的调用列表。三者都空,才算「模型这轮什么都没要调,那它一定是在直接回答了」,于是退出循环去收尾。

5.3 阶段三:按序执行工具(862-949)

只要有工具调用,就进这一段。先算「本轮最多执行几个」——max_tool_calls = cfg.main_agent.max_tool_calls_per_turn(orchestrator.py:867);若模型一口气要调超过这个数,只取前 N 个并记 warning,同时置 tool_calls_exceeded=True(orchestrator.py:868-874)。

然后顺序遍历 tool_calls[0][:max_tool_calls](orchestrator.py:876,注意是串行 for 循环,不是并发)。对每个调用,分两条路(orchestrator.py:884-900):

call.server_name 以 "agent-" 开头?
├─ 是 → await self.run_sub_agent(server_name, arguments, ...) 把它当 sub agent 展开
└─ 否 → await main_agent_tool_manager.execute_tool_call(...) 普通 MCP 工具
  • sub agent 分支(orchestrator.py:884-892):这是 §4.3 那个「sub agent 伪装成工具」的兑现处。run_sub_agent 的内部循环见 02;本章只需知道它对 main 循环而言就是「一次会返回一段文本结果的调用」。
  • 普通工具分支(orchestrator.py:894-900):交给 ToolManager,执行/容错/纠错的细节见 03

每次调用都计时,成功就把结果、耗时记进 tool_calls_data;失败被就地 try/except 兜住(orchestrator.py:916-941),error 信息(对空错误消息还专门处理了 TimeoutError)也照样组成一条 tool_result 塞回去——单个工具报错不会中断本轮,更不会中断整个循环。每条结果都配上它的 call_id 收进 all_tool_results_content_with_id(orchestrator.py:948),这样回填时能一一对应。

5.4 阶段四:纠错提示 + 回填历史(950-974)

如果这轮里有格式错误的工具调用(tool_calls[1] 非空,orchestrator.py:950),就额外造一条 server_name="re-think" 的伪工具结果,内容是「你的工具调用格式不对,请仔细检查后重试」(orchestrator.py:951-969),配一个特殊 id "FAILED" 也塞进结果列表。这是给模型的自纠错反馈——把「你写错了」当成一次工具返回喂回去。

最后一步:update_message_history(orchestrator.py:972-974)。它由 LLM client 负责(不同 provider 的消息格式不同),把这一轮所有工具结果按 id 回填成新的对话历史,并把 tool_calls_exceeded 一并告知(以便在历史里提示「有调用被截断」)。回填完,回到循环顶,进入下一 turn。

至此一轮闭环: 问 LLM → 判退出 → 串行执行工具(含 sub agent)→ 回填 → 再问。这就是 MiroFlow「一次任务」的心跳。


6. 退出与收尾:把散乱结果收敛成答案

循环一旦退出(无论哪种原因),就进入收尾。

6.1 退出原因归档(977-991)

先判断退出是不是因为触顶 max_turns(orchestrator.py:977):若是且此前未标失败,就补 task_failed=True 并记 max_turns_reached warning;否则记 main_loop_completed。也就是说,「跑满回合数」被视为一种软失败——任务没自然收尾,答案质量存疑,但仍会尝试出个总结。

四种退出原因汇总:

退出原因触发点task_failed?
LLM 无工具调用(自然收尾)orchestrator.py:853-860
LLM 主动 should_breakorchestrator.py:831-833
LLM 调用失败 / 无回答orchestrator.py:834-851
上下文超限(context_limit)orchestrator.py:836-842
跑满 max_turnsorchestrator.py:977-986

6.2 final summary + 上下文超限重试(993-1007 → 269-379)

不管循环里最后一条消息是什么,收尾都会再单独跑一次 LLM 调用来生成最终总结,入口是 _handle_summary_with_context_limit_retry(orchestrator.py:997,实现 orchestrator.py:269-379)。它把前面存下来的 task_guidence 也拼回总结 prompt(orchestrator.py:1006),让总结阶段和主循环遵循同一套「列全候选、保留不确定性」的要求。

这个函数是收尾的容错核心,处理「历史太长、summary 也会超限」的死局。它的策略是两层重试:

while True:
生成 summary prompt,append 到历史尾部 (296-313)
for network_retry in range(5): (315-339)
调一次 LLM
成功(有文本) 或 明确 context_limit → break
否则 → 等 60 秒重试(应对网络抖动)
有文本? → return 这段总结 (341-343)
还是不行(context_limit):
砍掉刚加的 summary prompt + 最近一对 assistant/user (351-355)
task_failed = True(丢了信息,判失败) (357)
只剩最初的 system+user 两条了? → 放弃退出 (359-363)

直觉: 「历史太长塞不下」时,就从最近的对话往回一对一对地删(删掉最新的一轮工具往返),每删一次就重试一次总结,直到能塞下为止;删到只剩最初的问题也总结不出来,才彻底放弃,返回一段 [ERROR] ... 文本(orchestrator.py:379)。删历史意味着信息丢失,所以一旦开始删就把任务标记为失败。

6.3 GAIA / browsecomp-zh 两条答案抽取分支(1020-1074)

拿到 summary 文本后,若开了 cfg.main_agent.output_process.final_answer_extraction(orchestrator.py:1022),再用一个独立 LLM 把「散文式总结」里的精确答案抽出来。按 benchmark 名分两条路:

分支判据抽取函数最终答案怎么组
browsecomp-zh"browsecomp-zh" in cfg.benchmark.name(orchestrator.py:1026)extract_browsecomp_zh_final_answer(orchestrator.py:1027)直接用抽取结果替换 final_answer_text(orchestrator.py:1049)
GAIA / 其它else(orchestrator.py:1050)extract_gaia_final_answer(orchestrator.py:1051)原总结 + 抽取答案拼接(orchestrator.py:1074)

两条路都会把抽取结果伪装成一条 assistant 消息追加进历史(orchestrator.py:1037-10461061-1071),便于日志复盘。整段同样包在 try/except(orchestrator.py:1076-1085)里——抽取失败不致命,退回用原始总结。

6.4 格式化返回(1100-1131)

最后把最终答案存回 task_log(orchestrator.py:1101-1105),调 format_final_summary_and_log(orchestrator.py:1109,见 src/utils/io_utils.py:144)拆出 (final_summary, final_boxed_answer),再按 benchmark 返回(orchestrator.py:1128-1131):browsecomp-zh 两个位置都返回 final_summary,其它返回 (final_summary, final_boxed_answer)。这个二元组顺着回到 execute_task_pipelinefinally,成为一次任务的最终产物。


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

  • 异常收敛成信号而非抛出。 _handle_llm_call_with_logging 把超时、超限、任意异常都统一成 (None, True, ...) 三元组(orchestrator.py:241-267),让主循环的控制流保持线性、可读——循环体里几乎没有 try/except,只看三元组做分支。
  • context_limit 走「第三个返回位」当带外信号。tool_calls == "context_limit"(orchestrator.py:836)区分「普通失败」和「上下文超限」,不必额外加异常类型或返回字段。
  • 从最近往回砍历史来救活总结。 _handle_summary_with_context_limit_retry(orchestrator.py:351-363)优先丢弃最新的一轮往返,尽量保住最初的问题——这是「宁可丢过程、也要出个答案」的务实取舍。
  • sub agent = 一个 agent- 前缀的工具。 用命名约定(orchestrator.py:884)把「分层递归」无缝接进「工具调用」的同一套执行路径,循环体无需为 sub agent 写特例。
  • 辅助环节全部可失败。 hint 生成(orchestrator.py:770)、答案抽取(orchestrator.py:1076)、单个工具执行(orchestrator.py:916)都各自被 try/except 包住,主线永不因辅助环节而中断。

8. 边界与局限(本章视角)

  • 工具执行是串行的。 主循环 for call in tool_calls[0][:max_tool_calls](orchestrator.py:876)一个接一个 await,同一轮里的多个工具不会并发——吞吐受限,但控制流简单、日志顺序清晰。
  • 跑满 max_turns 被判为失败。 即便模型仍在稳步推进,触顶就 task_failed=True(orchestrator.py:977-981),只能靠收尾总结兜个「尽力而为」的答案。
  • 删历史即判失败。 一旦为救总结而砍对话(orchestrator.py:357),任务就标失败——保证答案能出,但透明地承认「信息不完整」。
  • 答案抽取绑定 benchmark 名。 分支靠 cfg.benchmark.name 里的字符串匹配(orchestrator.py:1026),是为评测场景定制的,不是通用产品路径。

9. 横向对比 / 延伸阅读

  • 想知道 sub agent 被调用后内部怎么跑一个几乎同构的循环(run_sub_agent,orchestrator.py:381-687),看 02 分层 sub-agent
  • 想知道 execute_tool_call 背后 MCP server 池怎么起、怎么容错纠错,看 03 工具层
  • 想知道 process_llm_response / extract_tool_calls_info 怎么从文本里解析出工具调用、多 provider 怎么插件化,看 04 LLM 抽象层
  • 想知道 task_guidence、hint、summary 这些 prompt 和全局容错哲学的全貌,看 05 配置与容错
  • 顶层结构与阅读顺序见 index

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

主题文件路径符号名
一次任务的外层入口(搭台+清理)src/core/pipeline.py:23execute_task_pipeline
建工具池/formattersrc/core/pipeline.py:163create_pipeline_components
main agent 主循环本体src/core/orchestrator.py:689Orchestrator.run_main_agent
单次 LLM 调用+解析+存档src/core/orchestrator.py:127_handle_llm_call_with_logging
final summary + 超限重试src/core/orchestrator.py:269_handle_summary_with_context_limit_retry
sub agent 内部循环src/core/orchestrator.py:381Orchestrator.run_sub_agent
把 sub agent 暴露成工具src/utils/tool_utils.py:78expose_sub_agents_as_tools
输入处理src/utils/io_utils.py:14process_input
工具结果格式化(喂回 LLM)src/utils/io_utils.py:119format_tool_result_for_user
最终输出格式化src/utils/io_utils.py:144format_final_summary_and_log
hint 预分析src/utils/summary_utils.py:24extract_hints
GAIA 答案抽取src/utils/summary_utils.py:142extract_gaia_final_answer
browsecomp-zh 答案抽取src/utils/summary_utils.py:493extract_browsecomp_zh_final_answer
动态加载 prompt 类src/core/orchestrator.py:63_load_agent_prompt_class
上下文超限异常类型src/llm/providers/claude_openrouter_client.pyContextLimitError
回填对话历史(provider 相关)src/llm/provider_client_base.py:325update_message_history