跳到主要内容

配置驱动的装配:Hydra 配置如何长出一个 Agent

30 秒导读: MiroThinker 是个"深度研究 Agent"。它最聪明的一点不在算法,而在装配方式:一个 agent 用哪些工具、跑多少轮、怎么压上下文、要不要挂子 agent,全部写在 yaml 配置里;程序启动时把配置逐条翻译成一批 MCP 工具子进程和管理它们的 ToolManager。想造一个新 agent,基本上就是写一个新 yaml。本章讲清"配置 → 对象"这条装配线;真正的运行循环留给 02


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

一句话定义: 这一层是 MiroThinker 的装配说明书 + 装配工厂——说明书是 Hydra 配置(yaml),工厂是两个 Python 函数,它们照着说明书把一个能干活的 agent 拼出来。

它解决什么问题。 一个深度研究 agent 由很多零件组成:一个大语言模型、一堆工具(搜索、抓网页、跑 Python、看图、转写音频……)、可能还有子 agent、以及一套"上下文怎么省着用"的策略。如果把这些零件在代码里写死,那么"我想让主 agent 少用两个工具、多跑 280 轮、开启上下文压缩"就得改代码。MiroThinker 的做法是:把这些选择全抽到配置里,代码只负责"读配置 → 造对象"。

一句话直觉。 把它想成乐高说明书。conf/ 目录是说明书,规定这台 agent 用哪几块积木(工具)、拼多高(max_turns)、留多少备用件(keep_tool_result)。settings.py / pipeline.py 是照着说明书动手的手。换说明书,就换出另一台 agent。

用起来什么样。 启动一个 agent 就是选一个配置组合。入口 main.py:48 用 Hydra 装饰器把 conf/config.yaml 挂上,命令行上覆盖某一层即可:

# 示意:换掉 agent 层的预设,其余(llm/benchmark)沿用默认
python main.py agent=mirothinker_1.7_keep5_max300

这一行没改任何 Python 代码,却换出了一个"最多跑 300 轮、开上下文压缩、只保留最近 5 个工具结果"的 agent。下面就拆开看这是怎么发生的。


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

主线:配置三层叠加 → 两个装配函数 → 一批工具子进程 + 每个 agent 一个 ToolManager。

怎么读下面这张图:从上到下是"配置 → 对象"的单向流水线,左边是数据(yaml),右边是被造出来的运行时对象。

conf/config.yaml (defaults: llm + agent + benchmark)
│ Hydra 把三层合并成一个 cfg

┌─────────────┴─────────────┐
│ cfg.llm 模型/温度/base_url │
│ cfg.agent 工具清单/轮数/上下文策略 │
│ cfg.benchmark 数据集/并发 │
└─────────────┬─────────────┘

create_pipeline_components(cfg) ← pipeline.py:180
│ 为 main + 每个 sub-agent 各走一遍

create_mcp_server_parameters(cfg, agent_cfg) ← settings.py:69
│ 工具名 → StdioServerParameters(子进程命令 + env)

ToolManager(configs, tool_blacklist) ← 每个 agent 一个

│ (运行时,orchestrator 里再补一步)

expose_sub_agents_as_tools(cfg.agent.sub_agents) ← settings.py:383
把子 agent 包装成主 agent 眼里的一个"工具"

部件一句话职责:

部件干什么在哪
conf/config.yaml声明三层默认(llm / agent / benchmark)apps/miroflow-agent/conf/config.yaml:2
conf/agent/*.yaml一个 agent 预设:工具清单、轮数、上下文策略、子 agentapps/miroflow-agent/conf/agent/
create_mcp_server_parameters工具名 → MCP 子进程参数;黑名单 → (server,tool) 集合settings.py:69
expose_sub_agents_as_tools把子 agent 包装成主 agent 可调用的工具定义settings.py:383
create_pipeline_components为 main + 每个 sub-agent 各建一个 ToolManagerpipeline.py:180
ToolManager连上那批 MCP 子进程、拉取/过滤工具定义libs/miroflow-tools/.../manager.py:49

一句话:这一层不"跑"任何任务,它只负责把配置文本变成一组等待被 orchestrator 调用的对象。


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

3.1 三层 Hydra 配置:defaults 叠加

它要解决的小问题: 一个 agent 的配置天然分三块——用哪个模型、装哪些工具、跑什么数据集。把它们塞进一个大文件既难读又难复用。

思路: Hydra 的 defaults 列表允许"组合式配置"。顶层 config.yaml 不写具体值,只声明"从 llm 组里取 default、从 agent 组里取 default、从 benchmark 组里取 default,再叠加自己":

真实实现见 conf/config.yaml:2-6:

# 示意,取自 config.yaml
defaults:
- llm: default # 取 conf/llm/default.yaml
- agent: default # 取 conf/agent/default.yaml
- benchmark: default # 取 conf/benchmark/default.yaml
- _self_ # 最后叠加本文件顶部定义的变量

_self_ 放在最后,意味着本文件里定义的顶层变量(project_namedebug_dir)优先级最高,能覆盖前面三层带进来的同名键。命令行上写 agent=xxx 就是把第二行的 default 换成 xxx

三层各管什么:

  • llm 层(conf/llm/default.yaml:2):provider(anthropic/openai/qwen)、model_nametemperaturemax_tokensbase_urlapi_key。换 llm=qwen-3 就换一套采样参数与 base_url(conf/llm/qwen-3.yaml:6)。
  • agent 层(conf/agent/*.yaml):本章主角——工具清单、轮数、上下文策略、子 agent。下面 3.2 细讲。
  • benchmark 层(conf/benchmark/default.yaml:1):数据集字段映射(task_id_field 等)、max_concurrentpass_at_k。属于"喂什么任务、跑多并发",与 agent 本身解耦。

agent 预设也用同样的叠加。default.yaml 外,其它 agent 预设第一行就 defaults: [default, _self_](见 conf/agent/mirothinker_1.7_keep5_max300.yaml:4-6),即"先继承 default,再用本文件覆盖差异"。所以一个预设文件通常很短,只写它和 default 不一样的地方。

3.2 用一张表看懂 agent 预设的差异

它要解决的小问题: MiroThinker 自带十几个 agent 预设(conf/agent/ 下),它们到底差在哪?

agent 层的关键字段(全部来自 conf/agent/default.yaml):

字段含义
main_agent.tools主 agent 装哪些工具(工具名列表)
main_agent.tool_blacklist屏蔽某工具服务器里的某个具体子工具,形如 [server, tool]
main_agent.max_turns主 agent 最多跑多少轮
sub_agents挂哪些子 agent(空则单 agent 模式)
keep_tool_result只在上下文里保留最近 N 个工具结果(-1 = 全保留)
context_compress_limit>0 开启上下文压缩,0 关闭
retry_with_summary收尾抽不到答案时,是否带失败总结重试(默认 True)

三个预设对比(逐格核实过源码):

字段defaultdemomirothinker_1.7_keep5_max300
main_agent.toolspython / vqa / transcribe / reasoning / readersearch_and_scrape_webpage / jina_scrape_llm_summary / pythonsearch_and_scrape_webpage / jina_scrape_llm_summary / python
max_turns2020300
sub_agentsagent-browsing(4 个工具)无(单 agent)无(单 agent)
keep_tool_result-1(全留)-15(只留最近 5 个)
context_compress_limit0(关)05(开)
retry_with_summary默认 True默认 TrueFalse

引用锚点:defaultconf/agent/default.yaml:11/23/24;max300 版的差异见 conf/agent/mirothinker_1.7_keep5_max300.yaml:16/21/22/23

读这张表能读出设计意图: default 是"带子 agent、短跑、不压上下文"的教学基线;mirothinker_1.7_keep5_max300 是"扁平单 agent、狂跑 300 轮、靠 keep5 + 压缩把上下文摁住"的实战预设——轮数从 20 拉到 300 必然撑爆上下文,所以同一个文件里必须同时把 keep_tool_result 收到 5、context_compress_limit 开到 5。这三个数字是一组配套决策,它们为什么必须一起动、具体怎么省上下文,见 04 上下文管理

注:max_turns / keep_tool_result 等只是被配置声明出来;真正消费它们的是运行层——context_compress_limitorchestrator.py:149 读取,retry_with_summaryanswer_generator.py:78 读取。本章只管"值从哪来",不管"值怎么用"。

3.3 工具名 → MCP 子进程:create_mcp_server_parameters

它要解决的小问题: 配置里写的是 tool-python 这种字符串,但真正的工具是一个独立的 MCP 服务器进程。谁把字符串变成"怎么启动那个进程"?

思路: create_mcp_server_parameters(cfg, agent_cfg)(settings.py:69)就是这张翻译表。它逐个检查 agent_cfg["tools"] 里出现了哪些工具名,命中一个就往 configs 里追加一条 StdioServerParameters——即"用哪个 Python、跑哪个模块、注入哪些环境变量"。

tool-python 为例(settings.py:139-149):

# 示意,结构同源码
if "tool-python" in agent_cfg["tools"]:
configs.append({
"name": "tool-python",
"params": StdioServerParameters(
command=sys.executable, # 当前解释器
args=["-m", "miroflow_tools.mcp_servers.python_mcp_server"], # 以模块方式启动
env={"E2B_API_KEY": E2B_API_KEY}, # 注入沙箱密钥
),
})

三个要点:

  • 每个工具是一个独立子进程,用 sys.executable -m <模块> 启动。工具名 → 模块名是硬编码在这个函数里的 if 链(tool-google-searchsearching_google_mcp_server,search_and_scrape_webpagedev_mcp_servers.search_and_scrape_webpage,task_plannerdev_mcp_servers.task_planner,tool-reader→现成的 markitdown_mcp,等等)。
  • env 逐工具注入对应 API key。 文件顶部(settings.py:25-65)从环境变量读出一堆 key(SERPER_API_KEYJINA_API_KEYE2B_API_KEY……),再在每个工具的 env 里精确塞进它需要的那几个。搜索工具拿 Serper+Jina,沙箱拿 E2B,推理工具拿 Anthropic key——各取所需,互不越界
  • task_planner 每次生成一个随机 TASK_ID(settings.py:360-372),用 UUID 注入 env,让并发任务的 todo 列表天然隔离。

黑名单是一个副产物。 函数结尾(settings.py:377-380)把 agent_cfg 里的 tool_blacklist(形如 [[server, tool], ...])拍平成一个 (server_name, tool_name) 元组集合返回:

# 示意
blacklist = set()
for item in agent_cfg.get("tool_blacklist", []):
blacklist.add((item[0], item[1])) # ("search_and_scrape_webpage", "sogou_search")
return configs, blacklist

这个集合后面交给 ToolManager;拉取工具定义时,凡 (server, tool) 命中黑名单的子工具就被跳过(manager.py:131)。所以配置里的 tool_blacklist 能做到保留一个工具服务器、只关掉它内部的某一个动作(比如留 search_and_scrape_webpage 但禁掉其中的 sogou_search)。

3.4 把子 agent 当工具:expose_sub_agents_as_tools

它要解决的小问题: MiroThinker 想让主 agent"派活给子 agent"。但主 agent 只会做一件事——调用工具。怎么让它把子 agent 当成一个普通工具来调?

思路:把子 agent 伪装成一个工具定义。 expose_sub_agents_as_tools(sub_agents_cfg)(settings.py:383)遍历配置里的子 agent,凡遇到 agent-browsing,就手工造一个名为 search_and_browse 的工具定义——带 name / description / schema,和真工具长得一模一样(settings.py:399-418):

# 示意,结构同源码
dict(
name="agent-browsing",
tools=[dict(
name="search_and_browse",
description="... performs the subtask of searching and browsing the web ...",
schema={
"type": "object",
"properties": {"subtask": {"title": "Subtask", "type": "string"}},
"required": ["subtask"],
},
)],
)

关键点: 从主 agent 的视角看,search_and_browse 只是一个"输入一段 subtask 字符串、输出结果"的工具;它完全不知道背后其实是另一个完整的 agent 循环在跑。这就是 MiroThinker"层级 agent"的接缝——把子 agent 当工具,让主 agent 用统一的"调用工具"范式来编排它。这段工具定义如何在运行时被拼进主 agent 的工具清单、以及调用后如何真正跑起子 agent,见 02 编排主循环

它在 orchestrator 里的接入位置很明确(orchestrator.py:777-784):先向主 agent 的 ToolManager 要真工具定义,若配置里 sub_agents 非空,就把 expose_sub_agents_as_tools(...) 的结果拼接到工具定义列表尾部:

# 示意
tool_definitions = await self.main_agent_tool_manager.get_all_tool_definitions()
if self.cfg.agent.sub_agents is not None:
tool_definitions += expose_sub_agents_as_tools(self.cfg.agent.sub_agents)

3.5 为 main + 每个 sub-agent 各建一个 ToolManager

它要解决的小问题: 主 agent 和每个子 agent 各有自己的一套工具(见 default.yaml:主 agent 5 个工具,agent-browsing 子 agent 是 google-search / vqa / reader / python 这另 4 个)。它们的工具子进程和黑名单都不同,不能共用一个管理器。

思路: create_pipeline_components(cfg)(pipeline.py:180)是这条装配线的总入口,它对每个 agent 各走一遍 3.3 的翻译,各建一个 ToolManager:

# 示意,结构同 pipeline.py:190-215
# 1) 主 agent
main_configs, main_blacklist = create_mcp_server_parameters(cfg, cfg.agent.main_agent)
main_agent_tool_manager = ToolManager(main_configs, tool_blacklist=main_blacklist)

# 2) 单 agent 模式:sub_agents 为空就直接返回
if not cfg.agent.sub_agents:
return main_agent_tool_manager, {}, output_formatter

# 3) 否则给每个子 agent 也建一个 ToolManager
sub_agent_tool_managers = {}
for sub_agent in cfg.agent.sub_agents:
sub_configs, sub_blacklist = create_mcp_server_parameters(cfg, cfg.agent.sub_agents[sub_agent])
sub_agent_tool_managers[sub_agent] = ToolManager(sub_configs, tool_blacklist=sub_blacklist)

三个要点:

  • ToolManager 的构造只吃两样东西:一批 MCP server 配置 + 一个黑名单集合(manager.py:49__init__(self, server_configs, tool_blacklist=None))。它本身不认识 Hydra、不认识 agent——纯粹是"给我一批子进程参数,我负责连上去、按需过滤地拉工具定义"。
  • 单 agent 模式是一条快捷分支(pipeline.py:204):cfg.agent.sub_agents 为空(如 demo / mirothinker_1.7_keep5_max300)就返回空字典 {},根本不建子 agent 管理器。所以"多 agent vs 单 agent"这个大区别,在配置层只是**sub_agents 写不写**的差异。
  • 返回三件套 (main_agent_tool_manager, sub_agent_tool_managers, output_formatter),交给 execute_task_pipeline(pipeline.py:35)进而喂给 Orchestrator。装配到此结束,接力棒交给运行层。

这个工厂在两个入口都被调用:main.py:27(单任务 demo)和 benchmarks/common_benchmark.py:153(批量跑数据集)。


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

  • 一切皆 MCP 子进程 + 配置驱动。 工具不是 import 进来的 Python 函数,而是 sys.executable -m <模块> 起的独立进程。好处是工具与主程序故障隔离、依赖隔离,且"装哪些工具"退化成一个 yaml 列表。见 settings.py:69
  • "子 agent 即工具"是一处优雅的接缝。 用一个手写的工具定义(search_and_browse)把整个子 agent 包成主 agent 眼里的普通工具,让层级编排复用"调用工具"这一条统一通路,主 agent 无需任何"我在调子 agent"的特判。见 settings.py:383
  • 配套参数写在同一个预设里。 max_turns:300keep_tool_result:5 + context_compress_limit:5 被刻意放进同一个文件——把"这三个数字必须一起调"的隐性约束,固化成一份可复用、可对比的预设。见 conf/agent/mirothinker_1.7_keep5_max300.yaml
  • 黑名单的粒度是 (server, tool) 而非整个 server。 能保留一个工具服务器却只关掉它内部一个动作,配置表达力更细。见 settings.py:378manager.py:131

5. 边界与局限(诚实说明)

  • 工具名 → 模块名的映射是硬编码 if 链(settings.py:88-375)。加一个全新类别的工具,不能只改 yaml,还要在这个函数里补一段 if ... in tools。配置只在"已注册的工具集合内"是自由的。
  • expose_sub_agents_as_tools 只认得 agent-browsing(settings.py:399if "agent-browsing" in sub_agent)。想暴露别种子 agent 为工具,同样得改这段 Python,而非纯配置。
  • API key 缺失的行为不一致。 tool-google-searchSERPER_API_KEY 会直接 raise ValueError(settings.py:92-95),而多数其它工具只是把 None 塞进 env、把失败推迟到运行时。
  • 本章只覆盖"配置 → 对象"的装配。工具如何被真正调用、子 agent 如何真正跑起来、上下文如何压缩,都属于运行层,分别见 02/04/05

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

主题文件路径符号名
三层配置入口(defaults 叠加)apps/miroflow-agent/conf/config.yamldefaults
agent 基线预设(工具/轮数/上下文)apps/miroflow-agent/conf/agent/default.yamlmain_agent / sub_agents / keep_tool_result
实战预设(max300 + keep5 + 压缩)apps/miroflow-agent/conf/agent/mirothinker_1.7_keep5_max300.yamlmax_turns / context_compress_limit / retry_with_summary
llm 层默认参数apps/miroflow-agent/conf/llm/default.yamlprovider / model_name
benchmark 层(数据/并发)apps/miroflow-agent/conf/benchmark/default.yamlfield_mapping / max_concurrent
工具名 → MCP 子进程 + 黑名单集合apps/miroflow-agent/src/config/settings.py:69create_mcp_server_parameters
子 agent → 工具定义(search_and_browse)apps/miroflow-agent/src/config/settings.py:383expose_sub_agents_as_tools
环境/配置快照(日志用)apps/miroflow-agent/src/config/settings.py:422get_env_info
为 main+每个 sub 建 ToolManagerapps/miroflow-agent/src/core/pipeline.py:180create_pipeline_components
ToolManager 构造 + 黑名单过滤libs/miroflow-tools/src/miroflow_tools/manager.py:49ToolManager.__init__ / get_all_tool_definitions
装配后接入运行层(拼子 agent 工具)apps/miroflow-agent/src/core/orchestrator.py:777run_main_agent
单任务入口apps/miroflow-agent/main.py:48main