← 返回文章SOURCE NOTE · MULTI-AGENT
Lesson 8: 多 Agent 子系统
多 Agent 不是一个模块,是三层各管一段:定义层回答「有哪些 agent」,调度层回答「这次派谁」,执行层回答「怎么跑起来」。
2026.08.2512 MIN · ·
学完本课,你能回答:一个子 agent 是由谁、按什么顺序造出来的?它的系统提示词、模型、工具集分别来自哪里?subprocess 和 in_process 两种执行方式差在哪?结果怎么回到主 agent?——以及:我想加一种自己的 agent 类型 / 自己的执行后端,该在哪一行下刀?
一句话概括
多 agent 不是一个模块,是三层各管一段:coordinator/ 只回答「有哪些 agent 类型、它们长什么样」(定义层),tools/agent_tool.py 回答「这次派谁、带什么参数」(调度层),swarm/ 回答「用什么方式让它真的跑起来」(执行层)。三层之间只靠一个 dataclass——TeammateSpawnConfig——传话。
全景:一次 spawn 的数据流
主 agent 决定派活之后,数据流依次穿过四层:调度层 tools/agent_tool.py 查定义、组装 TeammateSpawnConfig、拿执行器;定义层 coordinator/agent_definitions.py 按内置 → 用户 → 插件合并出 AgentDefinition;执行层 swarm/ 挑后端(in_process / tmux / subprocess)真正把任务跑起来;回收层 tasks/manager.py 用 task_id 管进程,task_output 读输出、SUBAGENT_STOP hook 发通知。
下面的交互块可以逐层点开看职责与陷阱,并切换执行后端观察 TeammateSpawnConfig 的字段消耗:
LAYER 01 · 调度层这次派谁、带什么参数
tools/agent_tool.py
这次派谁、带什么参数
AgentTool.execute() 三步:① get_agent_definition(subagent_type) 查定义,查不到降级为裸 agent;② 组装 TeammateSpawnConfig——AgentDefinition 二十多个字段里只有 model / system_prompt / permissions 三个被取走;③ registry.get_executor("subprocess") 拿执行器并 spawn。
这一层的陷阱mode 参数不选后端。第 66 行硬编码 subprocess,mode 只是塞进 task_type 当标签——传 in_process_teammate 并不会走 in-process,因为 asyncio 内部 ID 用 task_* 工具查不到。刻意的取舍,不是 bug。
TeammateSpawnConfig(name, team, prompt, cwd, model, command, system_prompt, permissions, task_type)TEAMMATE spawn config 字段视角当前后端:subprocess · 亮起 = 被消费
- model
- system_prompt
- system_prompt_mode
- command
- task_type
- plan_mode_required
- name
- team
- parent_session_id
- color
- prompt
第一层:定义层 —— agent 是什么
源码:src/openharness/coordinator/agent_definitions.py(975 行,其中约 400 行是内置提示词原文)。
核心是一个 Pydantic 模型 AgentDefinition(agent_definitions.py:60)。字段可以分成五组:
- 身份 —
name / description:必填。description 是给主 agent 模型看的「什么时候该派它」
- 提示词与工具 —
system_prompt / tools / disallowed_tools:tools=None 与 tools=["*"] 等价,都表示全开
- 模型与预算 —
model / effort / max_turns:model=None 表示继承父进程
- 能力接入 —
skills / mcp_servers / required_mcp_servers / hooks:把第 5、6、7 课的子系统按 agent 粒度接进来
- 生命周期 —
background / initial_prompt / memory / isolation / permission_mode:isolation="worktree" 给它独立 git 工作树
三级合并,后写者赢
get_all_agent_definitions()(agent_definitions.py:905)按 内置 → 用户 → 插件 的顺序往同一个 dict 里塞,key 是 name。所以:
- 内置
_BUILTIN_AGENTS(:510)—— general-purpose、Explore、Plan、verification、statusline-setup、claude-code-guide、worker
- 用户
~/.openharness/agents/*.md —— 同名覆盖内置
- 插件 —— 同名覆盖用户
这跟 Lesson 6 见过的 Skills 发现规则是同一个套路,但注意差别:Skills 有 bundled / user / project / plugin 四个来源、含项目级 .openharness/skills/;agent 定义只有三个来源,没有项目级目录。想给某个仓库配专属 agent,目前只能走插件。
一个用户 agent 长这样
放到 ~/.openharness/agents/db-reviewer.md,重启即生效——零行 Python:
~/.openharness/agents/db-reviewer.mdyaml---
name: db-reviewer
description: 审查数据库迁移脚本。当改动涉及 migrations/ 或 schema 时使用。
tools: Read, Grep, Glob, Bash
model: sonnet
effort: high
max_turns: 20
---
你是数据库迁移审查专家。只读地检查迁移脚本,重点关注:
可逆性、对存量数据的影响、锁表时长、索引缺失。
不要修改任何文件,把发现按严重度排序汇报。
解析规则见 _parse_agent_frontmatter()(:633):YAML frontmatter 用 yaml.safe_load,解析失败会静默降级成朴素的 key: value 逐行切分——所以嵌套结构(hooks、mcpServers)写错缩进不会报错,只会悄悄丢掉。--- 之后的正文整体成为 system_prompt。
第二层:调度层 —— 这次派谁
源码:src/openharness/tools/agent_tool.py,全文只有 141 行,值得整篇读完。它做四件事:
- 校验
mode ∈ {local_agent, remote_agent, in_process_teammate}
- 按
subagent_type 查定义,查不到就是 None,不报错(降级为无系统提示词的裸 agent)
- 组装
TeammateSpawnConfig,调 executor.spawn(config)
- 注册
SUBAGENT_STOP hook 的完成监听器(回到 Lesson 5 的生命周期事件)
第 3 步是全课最该盯住的十一行(agent_tool.py:68-79):
tools/agent_tool.py:68-79pythonconfig = TeammateSpawnConfig(
name=agent_name,
team=team,
prompt=arguments.prompt,
cwd=str(context.cwd),
parent_session_id="main",
model=arguments.model or (agent_def.model if agent_def else None),
command=arguments.command,
system_prompt=agent_def.system_prompt if agent_def else None,
permissions=agent_def.permissions if agent_def else [],
task_type=arguments.mode,
)
请数一下:AgentDefinition 有二十多个字段,这里只有三个来自它(model / system_prompt / permissions)。tools、disallowed_tools、permission_mode、max_turns、effort、skills、hooks、isolation 全部止步于此。
第三层:执行层 —— 怎么跑起来
源码:src/openharness/swarm/(约 4900 行,本课只取两个后端 + 注册表)。
后端注册表与检测优先级
swarm/registry.py:128 的 detect_backend(),三级优先:
- in_process —— 仅当之前 spawn 失败过、
mark_in_process_fallback() 被调用过
- tmux —— 在 tmux 会话里且后端已注册(当前未实现,见 :379 的
_register_defaults,tmux 注册被留白了)
- subprocess —— 永远可用的兜底
后端是 Protocol(swarm/types.py:358 的 TeammateExecutor),只要求几个成员:type、is_available()、spawn()、send_message()、shutdown()。这就是你加自定义后端的接缝。
两个后端的实质差别
subprocess
- 真开一个新进程:
python -m openharness --task-worker <flags> - 配置靠命令行参数传递(
build_inherited_cli_flags) - 加上继承的环境变量(
build_inherited_env_vars) - 通信:stdin 一行 JSON
- 崩溃隔离好;启动慢;跨平台稳
- 子进程被强制设
CLAUDE_CODE_COORDINATOR_MODE=0,避免递归进入协调者模式
in_process
- 同进程内一个
asyncio.Task,自己跑一遍 agent loop - 配置直接读 dataclass,不经过 CLI
- 通信:文件邮箱
~/.openharness/teams/<team>/agents/<id>/inbox/ - 有
TeammateAbortController 支持协作式取消 - 启动快、能共享内存对象;一崩全崩
- 只在
supports_swarm_mailbox 的平台注册
注意两者吃的配置字段并不相同:subprocess 用 model / system_prompt / system_prompt_mode / plan_mode_required / command / task_type;in_process 用 name / team / parent_session_id / color / plan_mode_required / prompt——它连 system_prompt 都不读。同一个 dataclass,两个后端各取所需,没取的那些就是死字段。
subprocess 是怎么把配置变成命令行的
swarm/spawn_utils.py:109 的 build_inherited_cli_flags(),逐条映射:
permission_mode="bypassPermissions" → --dangerously-skip-permissions:plan_mode_required=True 时整段跳过——plan 优先于 bypass
permission_mode="acceptEdits" → --permission-mode acceptEdits:同上受 plan 压制
model → --model X:值为 "inherit" 时不生成,改走环境变量
system_prompt → --system-prompt 或 --append-system-prompt:由 system_prompt_mode == "append" 决定
plugin_dirs → 每个目录一条 --plugin-dir:保证子进程看得见 inline 插件
子 agent 的系统提示词不是主 agent 装配好递过去的,是子进程自己重跑一遍 build_runtime_system_prompt(),只不过多吃了一个 --system-prompt 覆盖。它自己发现自己的 CLAUDE.md、自己列自己的技能。这解释了为什么 omit_claude_md 需要是 AgentDefinition 上的一个字段。
第四层:结果怎么回来
SubprocessBackend.spawn() 不自己管进程,它调 BackgroundTaskManager.create_agent_task()(tasks/manager.py:49),拿回一个 task_id。之后主 agent 有三条路知道结果:
- 主动轮询:
task_output / task_list 工具 → read_task_output(task_id, max_bytes=12000),注意有截断上限
- 被动通知:
register_completion_listener(),agent_tool.py:125 用它触发 SUBAGENT_STOP hook
- 结构化回传:coordinator_mode.py 的
format_task_notification() / parse_task_notification(),把结果包成 <task-notification> XML 信封(含 status / summary / result / usage),XML 特殊字符走 escape()