跳到主要内容

主循环:两阶段执行与 Grounding Agent

30 秒导读: OpenSpace 把"跑完一个任务"拆成两层。外层 OpenSpace.execute()调度: 决定是否用技能引导、失败如何回退、结束后如何录制与分析。内层 GroundingAgent.process()执行:一个"想 → 调工具 → 看结果 → 再想"的迭代循环,直到模型自己吐出 <COMPLETE>。 本章只讲这条主线;技能怎么被选中、怎么进化、工具怎么被检索,分别在 第2章第3章第4章


1. 这是什么(先建立直觉)

OpenSpace 是一个自进化技能引擎(全景见 index.md)。当你给它一个任务——比如 "把这个 CSV 转成折线图并存到 workspace"——它需要一条清晰的主线把任务跑完

这条主线要回答三个问题,正好是本章的三块内容:

问题谁负责在哪
一个任务怎么被跑完?GroundingAgent.process 的迭代循环agents/grounding_agent.py:133
技能怎么介入这条循环?execute() 的两阶段调度 + 技能上下文注入tool_layer.py:434grounding_agent.py:95
失败了怎么回退?清空 workspace,以完整预算无技能重跑tool_layer.py:481-521

一句类比:execute() 像项目经理——先派"熟练工"(技能)上,搞砸了就换"通用工"(纯工具)重来, 最后写复盘报告;GroundingAgent 像干活的工人——反复"动手、看效果、再动手",自己判断活儿干完没有。

本节不碰代码细节。记住一句话就够:外层调度、内层执行,分工明确。


2. 顶层全景:execute() 一条主线

OpenSpace.execute()(tool_layer.py:303-631)是唯一对外的任务入口。它的骨架是一个大 try / except / finally:try 里跑任务、finally 里无论成败都做录制落盘与事后分析。

怎么读下面这张图: 从上往下是时间顺序;中间的菱形是"技能命中了吗"这个岔路;两条竖线是两个阶段。

execute(task) [tool_layer.py:303]

┌───────────────┴────────────────┐
│ 准备阶段 │
│ · 排队等上一个任务结束 (337) │
│ · 生成 task_id、起录制 (379) │
│ · 解析 workspace 目录 (389) │
│ · 结算迭代预算 max_iter (418) │
└───────────────┬────────────────┘

has_skills? ◇ ← _select_and_inject_skills (431) [详见第2章]
┌───────┴────────┐
是 │ │ 否
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Phase 1 技能引导 │ │ 纯工具执行 │
│ process() (456) │ │ process() (521) │
└────────┬─────────┘ └──────────────────┘
success?
┌──────┴───────┐
是 │ │ 否
▼ ▼
采纳结果 ┌──────────────────────┐
(463) │ 清空 workspace 新产物 │ (481)
│ Phase 2 无技能回退 │ (508)
│ 完整预算 process() │
└──────────────────────┘

┌───────────────┴────────────────┐ finally (574)
│ · 录制落盘 save_execution_outcome│
│ · _maybe_analyze_execution (601) │ ← 事后分析/进化 [详见第3章]
│ · _maybe_evolve_quality (616) │
│ · 组装 final_result、置 _running=0│
└───────────────┬────────────────┘

return final_result (631)

部件一句话职责:

部件干什么位置
OpenSpaceConfig一个 dataclass,收全部可调参数(模型、预算、录制、进化开关)tool_layer.py:24
OpenSpace.initialize一次性搭好 LLM 客户端、接地层、录制、技能引擎tool_layer.py:102
OpenSpace.execute单任务主线:两阶段调度 + finally 录制/分析tool_layer.py:303
GroundingAgent.process真正的迭代执行循环grounding_agent.py:133

下面三节顺着这条主线往下钻。


3. 配置与初始化:主线之前的两步

3.1 OpenSpaceConfig —— 一张参数总表

所有可调项集中在一个 @dataclass 里(tool_layer.py:24)。它不是本章重点,但有几个字段直接 影响主线行为,先点出来:

字段默认值对主线的影响
grounding_max_iterations20迭代循环的预算上限(见 §4.4)
enable_recordingTrue决定 finally 是否落盘、事后分析是否启用
execution_analysis_timeout300.0_maybe_analyze_execution 的总时限(§5)
workspace_dirNone任务产物落在哪;为空则用录制目录或临时目录

__post_init__(tool_layer.py:71)只做一件事:llm_model 为空就直接报错——模型是硬依赖

3.2 initialize() —— 把零件装好

initialize()(tool_layer.py:102)在第一次跑任务前调用一次(或用 async with 自动触发), 按顺序搭起五样东西。这里只需知道它们装配的先后与依赖,细节留给对应章节:

  1. LLM 客户端(:109)——推理的大脑。
  2. 接地层 GroundingClient(:187)并 initialize_all_providers()——工具的统一后端(第4章)。
  3. 录制 RecordingManager(:195)——如果开了录制,还会注入给接地层和 LLM 客户端。
  4. GroundingAgent(:223)——本章主角,把上面几样都注入进去。
  5. 技能引擎——SkillRegistry 发现技能(:239)、ExecutionAnalyzer + SkillEvolver 装好事后分析与进化(:264:279),前提是 skills.enabled 且开了录制。

一个值得记的设计:技能引擎是"可选增强"if self._grounding_config.skills.enabled(:238) 和 if self.config.enable_recording and self._skill_registry(:246)两道门决定它们是否存在。 装不上也不致命——except 里只 logger.warning(:291),主线照跑。


4. 内层:GroundingAgent.process 怎么把任务跑完

先讲内层再讲外层调度,因为外层的两个阶段本质都是"调用 process() 一次"。理解了 process, 两阶段就只是"调用它的方式不同"。

4.1 process 的一趟流程

process(context)(grounding_agent.py:133)接收一个 context 字典(里面有 instructionworkspace_dirmax_iterations 等),返回一个结果字典。它开跑前先做三件准备:

process(context) [grounding_agent.py:133]

├─ ① 检查 workspace 已有产物 _check_workspace_artifacts (148/651)
│ → 任务可能已完成的线索
├─ ② 取本次可用工具 _get_available_tools (154/501)
│ → 触发工具 RAG 搜索 [第4章]
├─ ③ 组装初始消息 construct_messages (205/428)
│ → system 提示 + 技能上下文 + 用户指令

└─ while current_iteration < max_iterations: [223]
complete() 调一次模型 → 可能带工具调用
收工具结果、查 <COMPLETE>、不完则加"继续"提示

_build_final_result → status: success / incomplete [392/793]

4.2 迭代循环:一次"想—做—看"的心跳

循环体(grounding_agent.py:223-389)是整个执行的心脏。每一圈做这些事:

  • 调用模型一次:self._llm_client.complete(messages, tools=..., tool_result_callback=_va_callback)(:266)。模型要么调工具、要么给文字回复。execute_tools=True 时工具由 LLM 客户端当场执行并把结果拼回消息(第4章讲执行细节)。
  • 收本轮工具结果:累加进 all_tool_results(:280)。
  • 判完工:is_complete = GroundingAgentPrompts.TASK_COMPLETE in assistant_content(:344)。这个 token 就是字符串 "<COMPLETE>"(prompts/grounding_agent_prompts.py:6)。命中即 break(:349)。
  • 没完工就加一句"继续"提示:见 §4.3。

循环还内建两个防呆护栏:

护栏触发行为位置
连续空回复模型连续 5 次既不调工具也无内容直接退出,疑似 API/限流/上下文过长:304 MAX_CONSECUTIVE_EMPTY
上下文膨胀第 2 轮起截超大单条消息;第 5 轮起截历史cap_message_content / truncate_messages:240:245

一个省 token 的巧思:技能上下文只在第一轮需要。到第 2 轮时,把注入的技能 system 消息从 messages 里剔掉(:230-236)——因为计划和工具结果已经在上下文里了,不必反复携带整段 SKILL.md。

4.3 迭代反馈:一句"检查一下,干完了就报 COMPLETE"

模型某轮没吐 <COMPLETE> 时,循环会先删掉上一轮的旧提示(避免堆积),再追加一条引导消息 (grounding_agent.py:369-376):它以 [INTERNAL ORCHESTRATION NOTE] 前缀开头 (message_utils.py:13ITERATION_GUIDANCE_PREFIX),内容是"第 N 轮完成,干完了就输出 <COMPLETE>, 没干完就继续"。

一个容易忽略的细节:这条引导用的是 role: "user" 而不是 system。源码注释点破了原因—— "MiniMax rejects system messages injected mid-conversation"(:367),所以运行期引导伪装成一条 内部用户备注。

诚实标注: 仓库里还有一套更"重"的迭代反馈机制 _build_iteration_feedback(:696)——它调 GroundingAgentPrompts.iteration_feedback 生成带上轮摘要的 system 消息。但当前是关闭的: process 里调用它的那段被注释掉了(:378-387),配套的 _remove_previous_guidance(:719) 同样未走主路径。真正生效的是上面那条轻量 user 引导。

4.4 迭代预算从哪来

max_iterations 有两处协调,防止调用方把预算调得过低而"饿死"智能体:

  • 外层结算(tool_layer.py:418-425):取"调用方传入值"和"配置值"中的较大者;调用方没传就用配置值。
  • 内层读取(grounding_agent.py:197):context.get("max_iterations", self._max_iterations)

所以外层每次调 process 前都会把结算好的预算塞进 context["max_iterations"](见 §5 两个阶段)。

4.5 结果如何定性:success vs incomplete

循环结束后,_build_final_result(grounding_agent.py:793)给任务定性,只有两种正常结局:

  • success:_check_task_completion 在消息里找到了 <COMPLETE>(:817:913)。响应文本直接取模型最后一条 assistant 消息、把 <COMPLETE> 抠掉(:840)——不额外再调一次 LLM 做总结
  • incomplete:跑满 max_iterations 还没见 <COMPLETE>,附一条 warning 提示可能需要更多步数(:851-857)。

诚实标注: _generate_final_summary(grounding_agent.py:732)是一个"跑完后再让 LLM 写一份 跨轮总结"的方法,但同样已停用:_build_final_result 里调它的那段被注释(:843-850),注释 明说"LLM already generates a summary before outputting <COMPLETE>",所以直接复用模型自己的 收尾发言,省一次调用。这是个刻意的成本优化。

4.6 技能如何"接进"这条循环

技能不改动循环骨架,只改动喂进循环的东西。三个挂载点:

  1. 注入系统提示——set_skill_context(context, skill_ids)(grounding_agent.py:95)把外层选好的技能正文(整段 SKILL.md,由 SkillRegistry.build_context_injection 拼成)存进 self._skill_context。它在 construct_messages 里(:479-484)作为一条 system 消息进入初始 messages
  2. 清除——clear_skill_context()(:115)在回退前把它抹掉,让 Phase 2 干干净净地从零跑。
  3. 扩工具作用域——_get_available_tools 里,只要 has_skill_context 为真,就强行把 Shell 后端加进作用域(:524-528),因为技能常引用 shell_agentread_file 这类文件 I/O 工具。

技能怎么被选中retrieve_skill 工具(:548-562)如何在迭代中途按需再拉技能,属于 第2章;这里只需知道技能通过"注入 system 提示 + 扩工具作用域"两条通道 接进 process

4.7 _get_available_tools:循环开跑前的一次工具检索

process 一开始就调 _get_available_tools(instruction)(grounding_agent.py:154/501)。它做两件事:

  • backend_scope 里的后端名转成 BackendType,交给 grounding_client.get_tools_with_auto_search(...)(:532)——这一步会触发工具 RAG:非 MCP 工具全带上,MCP 工具超过上限时按相关性过滤。检索机制细节在 第4章
  • 若接地层不可用或搜索抛错,except 回退到 _load_all_tools 把所有工具全量返回(:544)——宁可多带工具也不让任务因检索失败而跑不动

选出的工具存进 self._last_tools(:155),供 finally 里的事后分析复用(§5)。

4.8 _check_workspace_artifacts:任务可能已经做完了?

_check_workspace_artifacts(grounding_agent.py:651)在开跑前扫一眼 workspace 目录:列出已有文件、 标出最近 600 秒内改动过的、并用正则从指令里抠出被提到的文件名去和现有文件比对(:682-688)。 命中就把 matching_files 塞进上下文,construct_messages 会据此给模型一条"这些文件可能已是产物"的 提示(:454)。目的是避免重复劳动——如果目标文件已经在,模型也许一步就能确认完工。


5. 外层:两阶段调度(技能优先 → 工具回退)

理解了 process 是"调一次跑一趟",外层就清晰了:execute() 最多调它两次

5.1 岔路口:有没有命中技能

进入执行前先问一句(tool_layer.py:427-432):

has_skills = False
if self._skill_registry:
has_skills = await self._select_and_inject_skills(task) # 第2章

_select_and_inject_skills(:689)负责选技能并 set_skill_context 注入;命中就返回 True命中与否,决定走 Phase 1 还是直接纯工具执行。

5.2 Phase 1 —— 技能引导执行

命中技能时(tool_layer.py:434-513):

  1. 快照 workspace:记下开跑前目录里已有哪些文件 pre_skill_files(:442-452)。这份快照是后面"只清理本阶段新产物"的依据。
  2. 记住注入的技能 ID injected_skill_ids(:455)——因为下一步就要清除技能上下文,得先抓下来。
  3. 跑一趟 self._grounding_agent.process(execution_context_p1)(:456),预算是结算好的 max_iterations
  4. 无论成败,立即 clear_skill_context()(:461)。
  5. 看结果:skill_status == "success" 就采纳,给结果盖上 active_skills(:463-469);否则落入回退。

5.3 失败回退:先清空 workspace,再无技能重跑

Phase 1 没成功时(tool_layer.py:470-513),做两件事,顺序很关键:

① 清理本阶段产物(:481-503):遍历当前 workspace,把不在 pre_skill_files 快照里的文件/目录 删掉(shutil.rmtree / unlink)。这样残次品不会污染回退。

② 无技能、完整预算重跑(:505-513):新建 execution_context_p2,max_iterations 仍是完整预算, 再调一次 process

源码注释点破了这个设计的关键(:471-474):

Fallback gets the full budget because we clean the workspace below — it starts completely from scratch with no skill context and no leftover artifacts.

用白话说:回退不是"接着 Phase 1 剩下的步数继续",而是把现场恢复到起点、以完整预算重来一次。 因为技能上下文已清除(§4.6)、产物已清空,Phase 2 是一次干净的"通用工"执行。

5.4 无技能:标准纯工具执行

一开始就没命中技能时(tool_layer.py:514-521),没有两阶段之说——直接一趟纯工具 process。 这也是回退分支在语义上要对齐的目标状态。

三条路径对比:

路径技能上下文workspace预算触发条件
Phase 1 技能引导注入保留完整 max_iterations命中技能
Phase 2 回退已清除已清空新产物完整 max_iterationsPhase 1 未 success
纯工具执行保留完整 max_iterations未命中技能

6. finally:录制落盘与事后分析

不管 try 里成功、失败、还是被取消,finally(tool_layer.py:574-627)都会跑。它是主线的收尾仪式, 干四件事:

  1. 录制落盘(:576-594):先 save_execution_outcome(status, iterations, execution_time) 把结局写进 metadata.json,再 stop() 关闭录制。两步都是 best-effort,包在 try/except 里,绝不因录制失败而阻断返回
  2. 事后分析 + 进化(:596-616):只在非取消时跑。_maybe_analyze_execution(task_id, recording_dir, result)execution_analysis_timeout(默认 300s)时限;超时就打 analysis_timed_out 标记(:606-613)。这一步会让 ExecutionAnalyzer 复盘本次执行、SkillEvolver 据此产出 FIX / DERIVED / CAPTURED 技能——完整机制是 第3章
  3. 周期性质量进化(:616):_maybe_evolve_quality 按全局执行计数触发工具质量与技能指标检查(同属第3章)。
  4. 组装返回值、释放运行锁(:618-627):把 task_idexecution_timeskills_usedevolved_skills 合进 final_result;_running = False_task_done.set() 放行下一个排队任务。

关于 _maybe_analyze_execution(tool_layer.py:787)对主线的意义,记两点即可:

  • 它把 self._grounding_agent._last_tools(§4.7 存下的工具)喂给分析器(:805),让复盘能复现/验证错误。
  • 分析产出的进化记录塞进 self._last_evolved_skills(:840-850),最终随 final_result 返回给调用方——"跑完顺带自我进化"这条闭环的落点就在这里

一个健壮性约定:_maybe_analyze_execution 整体包在 try/except 里(:852-854),注释写明 "Analysis failure must never break the main execution flow"——分析/进化再重要,也不能拖垮把任务跑完这件正事。


7. 支撑件:base.pymessage_utils.py

7.1 BaseAgent —— 智能体基类

GroundingAgent 继承自 BaseAgent(agents/base.py:17),一个 ABC。基类提供共用管线:

  • 持有 name / backend_scope / llm_client / grounding_client / recording_manager / step(:36-42)。
  • 两个抽象方法子类必须实现:process(:81)和 construct_messages(:85)——正是本章 §4 的两块。
  • 通用工具:get_llm_response(:93)薄封装一次 LLM 调用;response_to_dict(:113)把可能裹着 ```json 围栏的模型输出解析成字典,带 raw_decode 容错(:129-137);increment_step(:145)推进步数。
  • AgentRegistry(:166)在实例化时自动登记类名(:148-153),支持按名取类。

7.2 message_utils.py —— 消息卫生

agents/message_utils.py 是循环用来管住上下文体量与外部输入的工具箱:

函数作用位置
cap_message_content截断单条超大消息(默认 3 万字符),保留头尾、system 与首条指令不动:16
truncate_messages历史超 token 预算时,保 system + 首条指令,只留最近若干轮,并留一条"已截断 N 条"提示:45
normalize_external_history把外部传入的会话历史规整成 {role, content},只收 user/assistant:114
build_channel_context_message把通信渠道元数据(平台/会话/附件)拼成一条 system 提示,并附"闲聊直接回、别乱调工具"的回复策略:152

两个常量也在这里:ITERATION_GUIDANCE_PREFIX(§4.3 那条引导的前缀,:13)和 MAX_SINGLE_CONTENT_CHARS(:12)。


8. 边界与局限(诚实)

  • 完工判定强依赖 <COMPLETE> token。 模型若忘了输出它,任务只会被判 incomplete(§4.5),哪怕实际已做完。护栏是迭代预算和连续空回复上限,而非语义校验。
  • 两套"更聪明"的反馈/总结机制当前停用。 _build_iteration_feedback(:696)与 _generate_final_summary(:732)都在,但主路径把它们注释掉了(§4.3、§4.5),生效的是轻量 user 引导与"直接复用最后发言"。读源码时别被这两个方法误导。
  • 回退是"整趟重来"而非"续跑"。 Phase 1 失败会清空本阶段产物、以完整预算重跑(§5.3)。若 Phase 1 的部分产物其实有用,会被一并删掉——这是"干净回退"换来的代价。
  • 单实例串行执行。 execute_running / _task_done 让并发任务排队(:337-350),等待上限 660s,超时直接 RuntimeError。它不是为高并发设计的。
  • 技能引擎整体可选。 技能未启用或初始化失败时,主线退化为纯工具执行(§3.2、§5.4),不影响"把任务跑完"。

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

主题文件路径符号名
配置总表openspace/tool_layer.py:24OpenSpaceConfig
一次性装配openspace/tool_layer.py:102OpenSpace.initialize
主线入口openspace/tool_layer.py:303OpenSpace.execute
迭代预算结算openspace/tool_layer.py:418execute(max_iterations 逻辑)
Phase 1 技能引导openspace/tool_layer.py:434execute(if has_skills)
workspace 快照/清理openspace/tool_layer.py:442,:481pre_skill_files / 回退清理块
Phase 2 无技能回退openspace/tool_layer.py:508execute(fallback process)
纯工具执行openspace/tool_layer.py:514execute(else 分支)
finally 录制/分析openspace/tool_layer.py:574execute(finally)
事后分析+进化openspace/tool_layer.py:787OpenSpace._maybe_analyze_execution
技能选择注入openspace/tool_layer.py:689OpenSpace._select_and_inject_skills
执行智能体循环openspace/agents/grounding_agent.py:133GroundingAgent.process
技能上下文注入/清除openspace/agents/grounding_agent.py:95,:115set_skill_context / clear_skill_context
初始消息组装openspace/agents/grounding_agent.py:428GroundingAgent.construct_messages
工具检索(触发 RAG)openspace/agents/grounding_agent.py:501GroundingAgent._get_available_tools
workspace 产物检查openspace/agents/grounding_agent.py:651GroundingAgent._check_workspace_artifacts
迭代反馈(停用)openspace/agents/grounding_agent.py:696GroundingAgent._build_iteration_feedback
最终总结(停用)openspace/agents/grounding_agent.py:732GroundingAgent._generate_final_summary
结果定性openspace/agents/grounding_agent.py:793GroundingAgent._build_final_result
完工 tokenopenspace/prompts/grounding_agent_prompts.py:6GroundingAgentPrompts.TASK_COMPLETE
智能体基类openspace/agents/base.py:17BaseAgent
消息卫生工具openspace/agents/message_utils.py:16,:45cap_message_content / truncate_messages

相邻章节: 总览见 index.md;技能怎么被选中注入见 第2章; FIX/DERIVED/CAPTURED 自进化见 第3章;工具统一后端与 RAG 见 第4章;MCP 服务与云端社区见 第5章