10 · 什么时候值得拆 Subagent
用交接成本、共享状态、重复工作和合并时间判断多 Agent 是否值得
本章任务
要回答的问题
多一个 Subagent 带来的并行收益,什么时候能覆盖交接、重复工作和合并成本?
读完你能
- 用可并行性、上下文污染和权限分层判断是否拆分
- 为委派边定义目标、上下文、权限、预算和输出格式
- 设置深度、并发、递归和失败回收防护栏
- 适合现在读
- 正在实现委派、多 Agent 协作或动态任务分解的工程师
- 先修知识
- 理解单 Agent Loop、上下文隔离和工具权限
- 实践产物
- 一张 Subagent 决策表和一份委派边 Schema
- 证据边界
- 多 Agent 收益依赖任务可并行性和额外 Token;节点数量不是成熟度
什么时候值得拆 Subagent
Section titled “什么时候值得拆 Subagent”场景:为了比较 8 家平台,调度器一次派出 8 个 Subagent。四个重复打开同一份定价页,两个使用不同版本口径,另两个返回没有来源的结论;并行结束得很快,父 Agent 却花更久去重、补引用和解决冲突。
通过标准:拆分前有单 Agent 基线;每条委派边包含目标、范围、来源要求、权限、预算和输出 Schema;调度器能检测重叠任务;父节点只接收带证据和完成状态的结果。
先算一笔交接账
Section titled “先算一笔交接账”四家在 5 件并发相关的事情上的覆盖:
| 维度 | Codex | Claude Code | OpenClaw | Hermes |
|---|---|---|---|---|
| 子 agent 抽象 | `SubAgentSource` 枚举 5 种(Review、Compact、ThreadSpawn{depth, agent_role}、MemoryConsolidation、Other) | `TaskType` 7 种(local_bash、local_agent、remote_agent、in_process_teammate、local_workflow、monitor_mcp、dream) | `SpawnSubagentParams` 13 字段(task、label、agentId、model、thinking、mode、sandbox、cleanup、attachments 等) | `delegate_task(goal, context, toolset, max_iterations)` 函数式调用 |
| 深度限制 | `ThreadSpawn.depth` 记录,超过配置限制后拒 spawn | 没显式 depth 限制(用户自己管) | `DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH` 可配,session store 持久化 spawnDepth | `MAX_DEPTH = 2`(parent 0 走 child 1 走 grandchild reject) |
| 工具传递策略 | 继承父 `exec_policy`、`plugins`、`mcp_manager`,review 还要主动 disable 一些 feature | 每个 task type 自带 spec | inherit、require sandbox 二选一,workspace 可继承可重设 | 硬禁 5 个工具:delegate、clarify、memory、send_message、execute_code |
| 并发模型 | interactive 接 receiver、sender,one-shot 等结果。上层用 `tokio::join!` 拼并行 | `isConcurrencySafe()` 接口判每个 task 能否并行 | 每个 session 有 active runs 限制,push-based 完成事件加 auto-announce 不要 polling | ThreadPoolExecutor,默认 3 并发,可配。parent 阻塞等所有完成 |
| 完成通知 | receiver 推 `EventMsg::TurnComplete` 事件,父消费 | task 状态机 pending 走 running 走 completed、failed、killed | subagent-announce push 到父,超时降级走 polling | 同步 `as_completed` 收集 future 结果 |
源码证据:权限、深度与回传
Section titled “源码证据:权限、深度与回传”Codex · 把「开子 agent」拆成 5 种类型化来源,每种走不同的权限和 prompt 路径
Section titled “Codex · 把「开子 agent」拆成 5 种类型化来源,每种走不同的权限和 prompt 路径”Codex 先问“这次为什么要派子 Agent”,再把用途写进 SubAgentSource。不同来源携带不同 prompt、权限和 telemetry;子 Agent 继承父 session 的服务,不另开一条提权通道。
如果把它们都塞进一个通用 spawn 函数,调用方就得在每次调用前手动配齐这些差异。调用点增加后,漏配权限或让配置逐渐分叉的风险也会上升。
所以 Codex 强制每次 spawn 需要带一个 SubAgentSource 枚举值,明确告诉系统这是什么用途的子 agent:
Codex codex/codex-rs/protocol/src/protocol.rs:2558-2576 SubAgentSource 5 种类型:每种走不同的 prompt / 权限路径
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "snake_case")]#[ts(rename_all = "snake_case")]pub enum SubAgentSource { Review, Compact, ThreadSpawn { parent_thread_id: ThreadId, depth: i32, #[serde(default)] agent_path: Option<AgentPath>, #[serde(default)] agent_nickname: Option<String>, #[serde(default, alias = "agent_type")] agent_role: Option<String>, }, MemoryConsolidation, Other(String),}这 5 种来源各自的语义和约束完全不同。Review 对应用户在对话里输入 /review 让 agent 审查代码的场景,子 agent 走 review 专用 system prompt 并禁用一批 feature(详见第 09 章),输出是结构化的代码审查报告。Compact 对应上下文超出 token 限制时系统自动触发的压缩场景,子 agent 接收的是父对话的全部历史,输出是「保留语义但更短的摘要」,详见第 11 章会话生命周期。
ThreadSpawn 是用户自己定义的子 agent 模板,可以通过 agent_role 和 agent_nickname 自由编排(比如「定义一个叫 testwriter 的 agent,专门负责给我的代码补测试」),并且这个变体里带了 depth 字段,让框架可以追踪「当前调用链已经多深了」并在超出配置限制时拒绝继续 spawn。
MemoryConsolidation 是周期性触发的记忆固化任务,子 agent 在后台跑,把对话历史里值得长期保留的洞察提炼成 memory 条目(详见第 16 章)。Other(String) 是兜底,为插件和实验性场景留口子,但要求显式传字符串说明用途,「无名 spawn」是被禁止的。
把这 5 种类型化的好处在哪?最直接的好处是 dispatch 层可以基于 source 做条件分支:Review 走 review prompt、Compact 走 summarize prompt、MemoryConsolidation 在后台不打扰用户、ThreadSpawn 显示给用户可监控、Other 走最严格的默认约束。如果没有这层类型化,dispatch 就只能靠 prompt 内容启发式判断,又脆弱又难维护。
第二个好处是 telemetry 一致:每条 spawn 事件都自动带 source 标签,分析时可以问「过去一周有多少 Review?平均跑多久?失败率多少?」而不是从一堆 spawn 日志里手动挖。第三个好处是权限路径明确:不同 source 对应不同的 feature 约束(比如 Review 自动禁用 web_search 因为审查代码不需要联网,而 ThreadSpawn 默认允许),dispatch 时按 source 查表配置就行。
spawn 本身走一条统一的入口 Codex::spawn(CodexSpawnArgs { ... }),但传入的参数里有大量从父 session 继承的 service:
Codex codex/codex-rs/core/src/codex_delegate.rs:74-105 子 agent 继承父 session 的 7 类 service,避免重启冷启动开销
pub(crate) async fn run_codex_thread_interactive( config: Config, auth_manager: Arc<AuthManager>, models_manager: SharedModelsManager, parent_session: Arc<Session>, parent_ctx: Arc<TurnContext>, cancel_token: CancellationToken, subagent_source: SubAgentSource, initial_history: Option<InitialHistory>,) -> Result<Codex, CodexErr> { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, installation_id: parent_session.installation_id.clone(), auth_manager, models_manager, environment_manager: Arc::clone(&parent_session.services.environment_manager), skills_manager: Arc::clone(&parent_session.services.skills_manager), plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), conversation_history: initial_history.unwrap_or(InitialHistory::New), session_source: SessionSource::SubAgent(subagent_source.clone()), thread_source: Some(ThreadSource::Subagent), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, // ... inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), // ...注意 inherited_exec_policy 这一行:这是 Codex 子 agent 设计里最关键的安全边界。子 agent 不能比父更宽松,父 session 禁掉的 binary 子 agent 也禁,没有「开子 agent 绕过 execpolicy」的逃生通道。这一条约束直接堵死了一类很恶心的攻击模式:如果父子能权限分离,恶意 prompt 就可以诱导 agent 开一个子 agent「帮我跑这个工具」绕过父的限制,等于多了一道权限提升的攻击面。
Codex 直接关闭这条路。同样的逻辑也适用于 plugins、mcp_manager、skills_manager、environment_manager、agent_control、models_manager、auth_manager 这些共享服务:它们全部是 Arc 引用从父继承,子 agent 看到的工具集就是父看到的(减去主动 disable 的几个),没有任何渠道让子 agent 拿到比父更多的能力。
approval 流程的处理也走同样的思路:子 agent 想跑危险命令时,不会自己弹 UI 让用户审批(如果允许,用户会看到一堆来自不同子 agent 的弹窗根本分不清在审批什么),而是把 approval event 推回父 session,由父 session 决定要不要弹给用户。所以从用户视角看,所有审批都来自父 agent,子 agent 是「父的一只手」,UI 一致性始终保持。
这跟第 09 章讲到的 review sub-agent 用 AskForApproval::Never 是同一种思路在更广义场景下的应用:子 agent 永远不弹 UI,所有用户交互走父。
Claude Code · 把子 agent 跟所有其他异步执行统一成一个 Task 抽象
Section titled “Claude Code · 把子 agent 跟所有其他异步执行统一成一个 Task 抽象”IDE 里的异步工作不止子 Agent。Claude Code 用 Task 统一本地命令、远程 Agent、workflow 和 teammate,再用状态机与 isConcurrencySafe 决定排队或并发。
如果给每种异步执行单独写一套生命周期管理、状态机、并发控制、UI 展示,长期维护成本极高,并且行为会逐渐不一致(一个跑 bash 的取消机制和一个跑 agent 的取消机制差异巨大)。
所以 Claude Code 把所有异步执行抽象成一个统一的 Task 概念,TaskType 枚举有 7 种变体:
Claude Code claude-code/src/Task.ts:6-30 TaskType 7 种:subagent 只是其中之一
export type TaskType = | 'local_bash' | 'local_agent' | 'remote_agent' | 'in_process_teammate' | 'local_workflow' | 'monitor_mcp' | 'dream'
export type TaskStatus = | 'pending' | 'running' | 'completed' | 'failed' | 'killed'
/** * True when a task is in a terminal state and will not transition further. * Used to guard against injecting messages into dead teammates, evicting * finished tasks from AppState, and orphan-cleanup paths. */export function isTerminalTaskStatus(status: TaskStatus): boolean { return status === 'completed' || status === 'failed' || status === 'killed'}7 种 TaskType 各自对应一种异步执行场景。local_bash 是「本地跑一个 shell 命令」(cd 到某目录、跑测试、跑 lint),跟开子 agent 用同一套生命周期管理但不涉及 LLM 调用。local_agent 是「本地跑一个子 agent」(Codex 风格的子 agent),主要用于让模型把一个大任务拆给子 agent 去专注做。
remote_agent 是「调用远程 agent service」(比如 Anthropic 部署的某个专门做某事的 agent endpoint)。in_process_teammate 是「跟同进程的另一个 teammate agent 协作」,typical 用法是同一个 IDE 里有多个 agent profile 各自负责一块(一个写代码、一个写文档、一个做 review),需要协作时互相 spawn。
local_workflow 是「跑一个预定义的 workflow」(比如「上线流程」「review 流程」这种由多步组成的标准化操作)。monitor_mcp 是「跑一个常驻的 MCP 监控任务」(比如监控 GitHub PR 状态变化)。
dream 是 Claude Code 2.1 引入的最特别的一种:「后台预热子任务」,用户主对话进行时 dream task 在后台准备可能用得到的资源(比如预先 grep 项目里可能相关的文件、预先加载相关文档),这是 7 类里单一一个不是用户主动触发的 task,存在的理由是「让 agent 看起来更快」(用户问的时候资源已经准备好了)。
TaskStatus 是一个清晰的状态机:pending(等待调度)、running(运行中)、completed(成功完成)、failed(失败)、killed(被取消)。isTerminalTaskStatus(status) 是个简单的函数判断「这个 task 是不是已经到终态了」:用来防止给已死的 teammate 注入消息、在终态时把 task 从 AppState 里 evict、清理 orphan 资源。
这种「状态机终态」的抽象在异步执行系统里很基础但很重要:没有它就会有大量 race condition。
每个 task 还自报「能不能并行」:每个工具实现 isConcurrencySafe() 接口返回 true 或 false,dispatch 层(详见第 04 章工具系统)会读这个值决定能不能让这个工具跟同一 turn 的其他工具并发跑。
比如 read 文件操作是 concurrency safe(多个并行读不会有副作用),write 文件操作不是(两个并行写同一个文件会乱),spawn 子 agent 通常 safe(每个子 agent 自带独立状态空间),跑 bash 命令通常 not safe(命令之间可能有副作用依赖)。这种「工具自报能力」让 dispatch 层不需要硬编码每个工具的并发性。
Claude Code 还提供了一组工具让模型主动管理任务列表:TaskCreateTool 创建新 task、TaskListTool 列出当前活动的 task、TaskUpdateTool 给 task 注入新信息、TaskGetTool 查 task 结果、TaskStopTool 终止某个 task。这 5 件套构成用户级 API,模型可以在 prompt 里主动决定「我要派 3 个子 agent 并行做三件事,然后等它们都完成再汇总」。
每个 task 的输出会写到 outputFile 持久化到磁盘,避免大量子 agent 输出把主进程内存撑爆,这是工程上很务实的细节。
OpenClaw · 把子 agent 做成一个较全的子系统:6 模块加 13 字段加反 polling 警告
Section titled “OpenClaw · 把子 agent 做成一个较全的子系统:6 模块加 13 字段加反 polling 警告”OpenClaw 把委派当作可持久化的运行对象:spawn、registry、depth、announce、attachments 和 lifecycle events 各有边界,完成结果通过 push event 回父流程。
这种「6 个文件协作完成一件事」的拆法说明 OpenClaw 不把子 agent 当成轻量工具,而是当成一个跟主 agent 平级的运行时实体。
最直接的体现是 SpawnSubagentParams 有 13 个配置字段:
OpenClaw openclaw/src/agents/subagent-spawn.ts:46-80 SpawnSubagentParams 13 个字段,配置粒度比 Codex、Claude Code 都细
export type SpawnSubagentParams = { task: string; label?: string; agentId?: string; model?: string; thinking?: string; runTimeoutSeconds?: number; thread?: boolean; mode?: SpawnSubagentMode; // "run" | "session" cleanup?: "delete" | "keep"; sandbox?: SpawnSubagentSandboxMode; // "inherit" | "require" expectsCompletionMessage?: boolean; attachments?: Array<{ name: string; content: string; encoding?: "utf8" | "base64"; mimeType?: string; }>; attachMountPath?: string;};这 13 个字段每个都解一类具体场景。task 是要派给子 agent 做的事情的描述。label 是给这个子 agent 的人类可读名字(让父 agent 在状态汇报里能说「review-agent 还在跑、test-writer 已完成」)。agentId 选用哪个 agent profile(OpenClaw 支持多个 agent 模板)。model 让子 agent 用跟父不同的模型(比如父用 sonnet 但子用 opus 做更复杂的推理)。
thinking 指定推理深度(low、medium、high)。runTimeoutSeconds 是子 agent 最长执行时间(超了会被强杀)。thread 决定是新开 thread 还是接到父 thread。mode 是 run 或 session 二选一(run 跑完就结束,session 保留可继续追问)。
cleanup 决定子 agent 完成后是把 session 数据 delete 还是 keep(keep 用于 debug,delete 用于减少磁盘占用)。sandbox 决定子用 inherit 父的沙箱还是 require 一个独立沙箱。expectsCompletionMessage 决定父是同步等待子完成消息还是 fire-and-forget。
attachments 是要传给子 agent 的文件附件列表(每个有 name、content、encoding、mimeType)。attachMountPath 是附件在子沙箱里挂载到哪个路径。这种「13 维度可配」让 OpenClaw 可以服务多样的 agent 编排场景,但也意味着新手上手时容易在 mode、cleanup、sandbox 这种枚举字段上踩坑,需要清晰的文档和合理的默认值。
OpenClaw 最特别的设计是 push-based 完成事件加把反 polling 的警告硬写进 spawn tool 的返回值文本里:
OpenClaw openclaw/src/agents/subagent-spawn.ts:81-84 给模型的硬警告:不要 polling,等 push event
export const SUBAGENT_SPAWN_ACCEPTED_NOTE = "Auto-announce is push-based. After spawning children, do NOT call sessions_list, sessions_history, exec sleep, or any polling tool. Wait for completion events to arrive as user messages, track expected child session keys, and only send your final answer after ALL expected completions arrive. If a child completion event arrives AFTER your final answer, reply ONLY with NO_REPLY.";这段话直接出现在 spawn 工具的返回值里。模型 spawn 完一个子 agent 后会读到这段,被明确告知三件事:「不要去 poll」(不要调用 sessions_list、sessions_history、exec sleep 这些试图主动查询子状态的工具)、「等 push event」(子 agent 完成时会以 user message 形式自动送回到父的对话)、「追踪期待的子 session key 集合」(spawn 时拿到的 session key 要记住,等所有期待的 key 都完成才能给最终答案)、「迟到的完成事件回 NO_REPLY」(如果父已经给了最终答案后某个子 agent 才完成,不要再回应,避免重复内容)。
这种「把工程纪律直接写进 tool 输出文本」的做法看似简单粗暴但很有效:模型读到 spawn 返回值时正在思考下一步该做什么,这时候直接告诉它「不要 poll」比写在 system prompt 里更显眼也更可执行。这是从工程上解决「子 agent 模式下模型疯狂 poll 浪费 token」的明确反模式,反映了 OpenClaw 团队在真实使用中观察到这个问题反复出现,最终决定用工具返回值文本作为 in-context 警告。
depth 信息持久化到 session store 而不是只放内存里:
OpenClaw openclaw/src/agents/subagent-depth.ts:1-48 spawnDepth 持久化到 session store,跨进程重启也保留
import fs from "node:fs";import JSON5 from "json5";import type { OpenClawConfig } from "../config/config.js";import { resolveStorePath } from "../config/sessions/paths.js";import { getSubagentDepth, parseAgentSessionKey } from "../sessions/session-key-utils.js";import { resolveDefaultAgentId } from "./agent-scope.js";
type SessionDepthEntry = { sessionId?: unknown; spawnDepth?: unknown; spawnedBy?: unknown;};
function normalizeSpawnDepth(value: unknown): number | undefined { if (typeof value === "number") { return Number.isInteger(value) && value >= 0 ? value : undefined; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) { return undefined; } const numeric = Number(trimmed); return Number.isInteger(numeric) && numeric >= 0 ? numeric : undefined; } return undefined;}这跟 OpenClaw 的整体定位一致:长期 agent 平台,session 跨进程能 resume,子 agent 的 depth 信息也得是持久化状态而不是进程内存里的瞬时变量。想象这个场景:父 agent spawn 了一个子 agent,进程被 IDE 重启了,重启后父 session resume 回来,子 agent 也 resume 回来。
如果 depth 只在内存里,重启后大家都从 depth=0 开始,会破坏「最多 N 层递归」的限制(因为重启后理论上可以再 spawn N 层)。持久化到 session store 确保这种 corner case 也不会破窗。
Hermes · 窄范围的子 agent 模型:一个函数、5 个禁用工具和固定 MAX_DEPTH
Section titled “Hermes · 窄范围的子 agent 模型:一个函数、5 个禁用工具和固定 MAX_DEPTH”Hermes 只暴露 delegate_task。MAX_DEPTH=2、工具白名单和 heartbeat 处理小型委派;父线程同步等待,长任务的 UI 代价需要单独测。
理由是:Hermes 自己是一个聊天 agent(服务多平台 chat),不是 agent 编排框架,所以它对子 agent 的需求只有一个:「把一个明确的子任务派给一个临时 agent 干,干完拿结果」,不需要 OpenClaw 那种复杂的生命周期管理。
但「简单」不等于「没设计」。Hermes 的关键安全约束都写死在常量里,让任何调用方都没法绕过:
Hermes hermes-agent/tools/delegate_tool.py:31-54 硬禁 5 个工具防递归加单次产生副作用加 MAX_DEPTH 写死
# Tools that children must never have access toDELEGATE_BLOCKED_TOOLS = frozenset([ "delegate_task", # no recursive delegation "clarify", # no user interaction "memory", # no writes to shared MEMORY.md "send_message", # no cross-platform side effects "execute_code", # children should reason step-by-step, not write scripts])
# Build a description fragment listing toolsets available for subagents.# Excludes toolsets where ALL tools are blocked, composite/platform toolsets# (hermes-* prefixed), and scenario toolsets._EXCLUDED_TOOLSET_NAMES = frozenset({"debugging", "safe", "delegation", "moa", "rl"})_SUBAGENT_TOOLSETS = sorted( name for name, defn in TOOLSETS.items() if name not in _EXCLUDED_TOOLSET_NAMES and not name.startswith("hermes-") and not all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", [])))_TOOLSET_LIST_STR = ", ".join(f"'{n}'" for n in _SUBAGENT_TOOLSETS)
_DEFAULT_MAX_CONCURRENT_CHILDREN = 3MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2)这 5 条限制对应源码作者描述的失败模式;本站没有 Hermes 的事故记录可独立核验。以 delegate_task 为例,允许自递归会让子 agent 继续 spawn,同一上下文和费用随层级增长,因此应当用深度和工具权限限制它。
clarify 是 Hermes 让 agent 跟用户回话澄清意图的工具,子 agent 不应该有这个能力:因为用户当前在跟父 agent 对话,子 agent 凭空往用户那里发问题会让用户根本分不清是谁在问什么,UI 上一片混乱。memory 是 Hermes 写 MEMORY.md 长期记忆的工具,子 agent 写进去会污染父 agent 的长期偏好:子 agent 是临时的,它学到的东西不应该影响父的永久状态。
send_message 是给 Telegram、Slack、Discord 等平台发消息的工具,子 agent 拿到这个就会跨平台发副本消息(一个 Telegram 消息可能被父和子都发一遍)。execute_code 是跑 Python 沙箱执行代码的工具,子 agent 不应该用:子 agent 的定位是「专注用现有工具解决一个具体问题」,再 spawn 一层 Python 沙箱让推理链变得难以追踪。
这 5 条限制还有一个共同点:它们都不是「子 agent 可能滥用所以禁」,而是「子 agent 在概念上就不应该有」。这是个值得学习的设计原则:限制不是怕模型乱来,而是从概念上理清楚「这种角色就不该有这种能力」。
MAX_DEPTH 写死成 2 的语义是:根 agent(用户在跟它对话)算 depth 0、它可以 spawn 子 agent 到 depth 1、depth 1 的子 agent 试图再 spawn 时会被直接拒绝(因为没有 delegate_task 工具)。为什么是 2 不是 3 不是 5?
Hermes 的源码选择是把深度限制在 2:一层委派覆盖它的目标任务,更深层级被视为拆分循环风险。这里没有跨产品样本支持「99%」这个比例;若要放宽深度,应记录每层带来的完成率、重复工作和 token,再决定上限。
并发模型走 ThreadPoolExecutor:
Hermes hermes-agent/tools/delegate_tool.py:56-84 ThreadPoolExecutor 限并发加父阻塞等所有完成
def _get_max_concurrent_children() -> int: """Read delegation.max_concurrent_children from config, falling back to DELEGATION_MAX_CONCURRENT_CHILDREN env var, then the default (3).
Uses the same ``_load_config()`` path that the rest of ``delegate_task`` uses, keeping config priority consistent (config.yaml > env > default). """ cfg = _load_config() val = cfg.get("max_concurrent_children") if val is not None: try: return max(1, int(val)) except (TypeError, ValueError): logger.warning( "delegation.max_concurrent_children=%r is not a valid integer; " "using default %d", val, _DEFAULT_MAX_CONCURRENT_CHILDREN, ) env_val = os.getenv("DELEGATION_MAX_CONCURRENT_CHILDREN") if env_val: try: return max(1, int(env_val)) except (TypeError, ValueError): pass return _DEFAULT_MAX_CONCURRENT_CHILDRENDEFAULT_MAX_ITERATIONS = 50_HEARTBEAT_INTERVAL = 30 # seconds between parent activity heartbeats during delegation父进程同步阻塞在 as_completed() 上等所有 children 都完成。执行过程中每 30 秒发一次 heartbeat 让上层 UI 知道父 agent 还在等(而不是卡死了)。这是典型的同步并发模型,跟 OpenClaw 的 push-based 异步模型刚好相反:同步阻塞的优点是父 agent 的逻辑很清晰(spawn 完所有子,等齐,汇总),代价是父 agent 在等待期间不能做别的事,对长时间任务来说会影响 UI 响应。
Hermes 的源码选择同步等待并配 heartbeat。它是否适合某类任务,要看子任务时长分布、UI 是否需要继续响应,以及慢子任务的超时策略;本文没有一到两分钟的运行样本。
子 Agent 需要共享的约束
Section titled “子 Agent 需要共享的约束”把四个源码快照放在一起,会反复遇到四个控制问题:递归深度、委派后的权限、完成回传,以及等待方式。它们是审查清单,不是“四家一致同意”的行业定律。
第一件是深度如何受控。Codex 通过 ThreadSpawn.depth 字段记录,OpenClaw 把 spawnDepth 持久化到 session store,Hermes 把 MAX_DEPTH = 2 写成源码常量;本文引用的 Claude Code Task 路径没有展示同样的显式上限。无界递归会扩大调用、token 和调试链路,因此实现时要用深度、总调用预算或不可递归工具集至少提供一层硬约束。
第二件是子 agent 能否继续委派。Hermes 直接把 delegate_task 放进硬禁工具列表;其他实现通过 source、feature flag、toolset 或 depth 控制不同委派路径。是否允许子级调度是产品选择,但允许时必须同时限制深度、总预算和权限继承,不能只依赖 prompt 自律。
第三件是子 agent 不应凭委派获得更宽的能力。这条防的不是「子 agent 自己作恶」,而是 prompt 注入借 spawn 绕过父级限制。本文引用的实现分别通过继承 exec policy、限制工具集或禁止递归来收紧边界;具体机制不同,验证时要检查每条 spawn 路径,而不能只看一个配置默认值。
第四件是完成结果怎样回到父级。Codex 用 receiver/sender 推 EventMsg::TurnComplete,OpenClaw 的 subagent-announce 主动通知并提示不要 poll,Hermes 用 as_completed() await future,Claude Code 用 TaskStatus 状态变迁。频繁让模型调用 sessions_list、sessions_history 会增加 token 和延迟;节省多少要用同一批任务比较 push、await 与 polling trace。
按任务形状选择委派方式
Section titled “按任务形状选择委派方式”这四个检查项并不导向同一种抽象。真正的选择是:你的任务需要多少状态、权限和调度能力,愿意承担多少维护面。
如果编排框架确实需要跨进程状态、附件和完成通知,可以先研究 OpenClaw 的子 agent 平台。引用快照的 6 模块、13 字段、push 通知和 depth 持久化覆盖了这些能力;代价是配置、存储和生命周期都要长期维护。“几十个并发”仍要由任务样本和资源预算证明。
如果用途已收敛到代码审查、上下文压缩、记忆固化等少数场景,可以先研究 Codex 的 SubAgentSource。类型化让 dispatch、telemetry 和权限路径有明确依据;代价是新增用途要改 protocol 定义,外部扩展更慢。这个约束是否合算,要看用途变化频率和插件需求。
如果 IDE 需要统一管理子 agent、bash、远程调用和 workflow,可以先研究 Claude Code 的 Task 抽象。共用生命周期、状态机、UI 和取消机制能减少入口差异;代价是 7 种 TaskType 的边界要维护,而且引用路径没有展示统一的显式 depth 上限。
如果对话 agent 只需要一次受限委派,可以先研究 Hermes 的 delegate_task 函数和 5 工具硬禁。模块表面很小,每条限制也能对应到明确风险;理解和交付时间仍取决于周边工具、测试和失败恢复。代价是缺乏 attachments、mode、cleanup 等能力,同步阻塞还会影响长任务的 UI 响应。
选择:并发收益要超过交接成本
Section titled “选择:并发收益要超过交接成本”这里不做星级评分。并行数应由任务的可分割程度和交接成本决定;本站尚未测出哪一个并发数更快。
| 任务形状 | 可先读 | 先测什么 |
|---|---|---|
| 用途固定,子 Agent 需要继承父权限 | Codex 的 SubAgentSource 与 service inheritance | 子任务交接字段、权限收窄和失败回传 |
| shell、远程调用和 Agent 都要统一管理 | Claude Code 的 TaskType、状态机和并发安全标记 | 排队、取消、共享资源冲突 |
| 要编排多模型、附件和跨进程通知 | OpenClaw 的 spawn/registry/depth/announce | push 通知、重复工作、存储和合并时长 |
| 只有一次性的受限委派 | Hermes 的 delegate_task、深度和工具禁用 | 单任务 wall time、父流程阻塞、预算耗尽 |
建议用同一任务跑 1、2、4、8 个 Agent,记录 wall time、输入/输出 token、重复读取、冲突和人工合并时间;没有数据时不要写速度结论。
从一次受限委派开始
Section titled “从一次受限委派开始”下面是从引用实现整理出的起步清单。先用函数式委派验证任务形状,再按 trace 添加权限、通知和持久化。
复刻方案
最小可行
- 从函数式起步(参考 Hermes 的 delegate_task):参数 task(任务描述)、context(必要上下文,不是父 agent 全部历史)、toolset(受限工具集)、max_iterations(独立预算)。简单清晰,参数明确
- 硬禁 5 个工具防递归、跨平台副作用:blocked_tools = [delegate, clarify, memory, send_message, execute_code]。delegate 防递归、clarify 防子 agent 反过来问父 agent、memory 防共享 memory 污染、send_message 防越界发消息、execute_code 限制可用工具
- 先设置 MAX_DEPTH(参考 Hermes 的 2 层):parent(0) 走 child(1) 走 grandchild reject。2 层是该源码的产品选择,不代表覆盖 95% 场景;需要更深时用任务样本和审计记录证明
- 并发上限可配(Hermes 源码默认 3),父用 ThreadPoolExecutor 等待 children。3 是该快照的实现参数,不是通用最佳值;按 provider quota、任务耗时、内存和限流记录调整
进阶
- 把子 agent 来源类型化(参考 Codex 的 SubAgentSource):Review、Compact、ThreadSpawn、MemoryConsolidation、Other。不同来源的子 agent 需要不同 trace、监控策略,类型化让事件流可分类查询
- 子 agent 继承父的核心 service(exec_policy、plugins、mcp、skills)不允许更宽松。防止子 agent「绕过父的安全限制做事」(很多攻击模式都靠这个)
- approval 路由回父(子 agent AskForApproval::Never),子永远不弹 UI。子 agent 默默跑,需要审批的事件 bubble 到父 agent,避免「多个子 agent 同时弹审批框」UX 灾难
- push-based 完成事件(参考 OpenClaw 的 subagent-announce):spawn 返回值告诉模型「不要 poll,等通知就行」。模型 polling 会浪费 token 调用 wait_for_subagent 工具
- depth 持久化到 session store(参考 OpenClaw 的 subagent-depth.ts):跨进程重启保留 depth 信息(恢复时知道这是第几层)。纯内存 depth 无法 resume
- 把 subagent 跟其他异步执行统一抽象(参考 Claude Code 的 Task 加 TaskStatus):7 种 TaskType 在一套抽象下管理(local_bash、local_agent、remote_agent、in_process_teammate、local_workflow、monitor_mcp、dream),UI、监控只看 Task 不区分类型
- outputFile 落盘(参考 Claude Code):大量子 agent 输出撑爆主进程内存,落盘后主进程只持有 path 即可。这是长跑场景的内存优化
一开始别做
- 别让子 agent 共享父的对话历史:上下文继承会让子被「已经在做的方向」污染(认知锚定 bias),失去独立判断。只传 task 描述加必要 context
- 别让子 agent 无界 spawn 同类子 agent:递归会扩大调用数、token 和调试链路。设置 MAX_DEPTH 与总预算,并用任务 trace 决定上限
- 别在子 agent 完成前 polling:父 agent 不停调 wait_for_subagent 工具白白烧 token。用 push-based 完成事件加硬警告(参考 OpenClaw)
- 别让子 agent 跑 send_message、写共享 memory:跨平台副作用(子 agent 突然给用户发消息,用户懵)、共享状态污染(多个子 agent 同时写 memory 数据冲突)
- 别忽视 attachments 大小:base64 会放大传输体积,进入模型后的 token 成本还取决于 provider 的多模态编码。按字节和 provider token 预算双重限额,超限时压缩或拒绝
把并发差异放回任务
Section titled “把并发差异放回任务”把 4 种放一起,抽象厚度的 spectrum 一眼可见:函数式(Hermes)走 类型化(Codex)走 任务抽象(Claude Code)走 较全平台(OpenClaw)。
委派权限与完成回传的证据
Section titled “委派权限与完成回传的证据”本章带走什么与下一步实验
Section titled “本章带走什么与下一步实验”Subagent 不是免费的并行线程。每增加一个节点,就增加一次上下文复述、权限边界、失败回收和合并成本。默认不拆,只有可并行性、上下文污染、权限分层或人工门禁提供明确收益时才拆。
下一步实验:用同一任务跑 1、2、4、8 路配置,固定模型与总预算,记录墙钟、总 Token、重复 URL、冲突结论、缺失来源、人工合并时间和最终质量。不要只比较“最快返回”;总成本和可验证结果必须一起看。
附录:练习与复盘
Section titled “附录:练习与复盘”按需展开练习和十道复盘题
- 🟢 写一个
delegate_task函数:参数goal、context、toolset、max_iterations。在子 agent 工具集里硬禁递归 delegate。 - 🟠 加深度限制:在 session 里记
spawn_depth,超过 2 直接 reject。验证:试图 spawn 三层时第三层失败,错误信息包含 depth。 - 🟠 push-based 完成:父 agent 不能 poll 子 agent。子完成时通过 user message 形式回到父 turn。验证:父 spawn 后只能等 message,不能再发
sessions_list。 - 🔴 子 agent 来源类型化:定义
SubAgentSource枚举(至少 Review、Compact、ThreadSpawn)。不同来源走不同的 system prompt 加不同的 feature 约束。验证:Review source 自动 disable web_search,ThreadSpawn source 允许 web_search。
Q1 · 概念:为什么 Codex 要把子 agent 的来源类型化成 SubAgentSource 枚举?一个 spawn(prompt) 函数不行?
类型化解决三件具体事:
1. dispatch 路径不同
5 种 source 走完全不同的代码路径。Review 要加载 review prompt + disable 一批 feature;Compact 要切到摘要专用 prompt 但允许部分工具;MemoryConsolidation 要走 memory 子系统不能用 file edit;ThreadSpawn 是用户自定义模板,feature set 看 config。
如果是单一 spawn(prompt),所有 dispatch 逻辑塞进调用方,每次新增类型都要改所有 caller。enum 让 dispatch 在中心点做。
2. telemetry / metrics 能聚合
Codex 关心「review 子 agent 平均耗时多少」「compact 失败率多少」「memory consolidation 触发频率」。这些指标按 source 聚合才有意义。如果 source 是字符串,每个调用方传不同的 string(review / Review / “code review” / “/review”),metric 一团乱。enum 强制对齐。
3. 权限 / sandbox 配置一处定义
每种 source 对应一组「合理的权限收紧」:Review 要 AskForApproval::Never;Compact 要 web_search=Disabled;MemoryConsolidation 不需要 BashTool。enum 让这些配置在一处定义(subagent_source.rs),不是散在 5 处 caller。
为什么不用 trait 多态?
Rust 里 trait 也能做。但 enum 序列化更直接(已经 Serialize + Deserialize + JsonSchema + TS),跨进程 / 跨语言传递更友好。Codex 协议要发到 TUI 前端、要日志落盘、要传给 sub-agent 自身,每一步都要序列化。enum + serde 一条龙。
反例:Claude Code 的 TaskType 是字符串 union,没有强类型,加新类型靠 TypeScript 编译器查;新增一种要改 3-4 个 switch case,编译器只能查到部分。Codex 的 Rust enum 改一处 enum,所有 match 都被编译器强制更新。
源码:codex/codex-rs/protocol/src/protocol.rs:2558-2576(SubAgentSource 定义)+ codex_delegate.rs 里大量按 source 分支的代码。
追问:「为什么 Other(String) 存在?」给插件 / 实验性场景兜底。这 5 种类型是当前源码快照的已知分类,不代表覆盖 95% 的生产用例;新类型可以先走 Other("plugin-name"),等调用量和约束稳定后再升级成正式 variant。
Q2 · 架构:Codex 让子 agent 继承父的 exec_policy / plugins / mcp_manager。这种”父子共享 service”在什么场景下会出问题?
继承默认正确的有三个理由:
- 子永远 ≤ 父的能力集:没有”派一个子 agent 绕过限制”的逃生通道。安全。
- 避免重复初始化:plugins / mcp_manager 可能需要 spawn 子进程、建立连接。共享可以少做一次初始化,但本文没有 100-500ms 的测量;需要分别记录冷启动与复用路径。
- 状态一致:父刚 disable 了某个 plugin,子也应该 disable。共享一份就不会漂移。
但有 3 个具体场景会反咬:
场景 1 · 并发修改 mcp_manager 状态
mcp_manager 内部维护一份「活跃 MCP server」列表。父在 turn 1 启用了 git server,子 agent 在 turn 1.5 也启用了 git server(计数 +1)。父在 turn 2 disable git server(计数 -1,但还 = 1,server 不退出)。子 agent 完成,没显式 disable(依赖父)。最终 git server 永远活着,进程泄露。
Codex 的解法:mcp_manager 用 Arc 共享,但「启用 / 禁用」是父独占;子只读不写。enforce 在 codex_delegate 的 dispatch 层。
场景 2 · exec_policy 在子完成后被父改了
Rust 里 Arc::clone(&parent.exec_policy) 拿到的是 immutable Arc。但如果 exec_policy 内部用 Mutex 包了一个可变 HashMap(typed),父在子运行期间改了 policy,子是否要看到新 policy?
Codex 设计:子拿到的是 Arc<ExecPolicy> 不可变快照(启动时复制)。父中途改 policy 不影响子。理由:子的 prompt 是在 spawn 时确定的,运行期 policy 改变会让子的行为不可预测;用户改 config 想立即生效是反直觉的。
Trade-off:子 agent 期间用户更新 policy 不能立即生效。但 Codex 选「行为可预测」优先于「config 实时性」。
场景 3 · skills_manager 加载顺序
skills_manager 维护「已加载 skills」列表。父在 turn 0 加载了 skill A;子 agent 在 turn 0.5 加载了 skill B;父能不能 turn 1 看到 skill B?
Codex 设计:skills 加载到共享 manager,父子都能看到。理由:skills 是「装载性能力」,不是「行为状态」。装多了没害处,下次用到就直接 ready。
工程教训:共享 service 的边界靠语义判断:「只读 + 加载性」可共享;「带状态机的能力」要复制;「跨子 agent 修改父状态」要严格 enforce。
源码:codex/codex-rs/core/src/codex_delegate.rs:74-105(继承 7 个 service 的 ARC clone)。
追问:「跨平台 agent 怎么处理?」每个进程一份 service tree,跨进程通过 IPC 序列化传递。子 agent 在另一个进程时拿到的是 snapshot,不是 Arc。这意味着「跨进程子 agent 受限更多」(不能用父的 plugins 实时状态),但安全性 / 隔离性更高。
Q3 · 概念:Hermes 把 delegate_task / clarify / memory / send_message / execute_code 5 个工具硬禁给子 agent。每条对应的真实失败案例是什么?
5 条不是教条,是历史教训。逐条对应真实坑:
delegate_task · 自递归套娃
子 agent 也是个 LLM 调用。如果子拿到 delegate_task,它可以继续生成子任务,调用数和 token 随分支增长,最终可能触发预算或 timeout。源码能证明的是 Hermes 同时设置 MAX_DEPTH = 2 和工具级硬禁;本站没有事故记录可证明「4 层、60K context」这类具体叙述。
clarify · 子 agent 不能跟用户对话
用户是跟父 agent 对话的。子 agent 弹出 clarify(“你想用哪种算法?”) 时,系统必须定义问题由谁展示、如何关联 child_id、父是否暂停。Hermes 的 blocked-tools 列表选择让子返回不确定性、由父决定;本站没有它「早期 TUI 卡死」的事故证据。
memory · 共享 MEMORY.md 被污染
MEMORY.md 是 Hermes 的长期记忆文件,用户级共享。如果子 agent 能写:
- 子 A:「用户偏好简洁回答」
- 子 B:「用户偏好详细解释」(因为 A 和 B 任务不同,各自感知不一样)
两次写完,MEMORY.md 自相矛盾。父 agent 下次回答就分裂。从此 memory 只能父写,子的「短期事实」通过 result 回传由父决定要不要 promote。
send_message · 跨平台副作用重复发
send_message 是 Hermes 的 Telegram / Slack / Email 发送工具。如果子 agent 也能发,一个「summarize and notify」任务:
- 父 agent 调子 agent 做 summary
- 子 agent 完成后心想「我要不顺便发个通知吧」(LLM 自动决策)
- 父 agent 拿到 result 后也发通知
如果父和子都能发送同一结果,用户可能收到重复通知。外部副作用由父级统一提交是一种简单边界;若允许子级发送,就需要幂等键、归属和审计记录。本站没有 Hermes 重复发送的事故记录。
execute_code · 子应该用工具,不应该再 spawn Python
execute_code 是 Hermes 的 Python sandbox。子 agent 拿到后,会倾向「写个 Python 脚本解决」。但子的任务通常是「分析这段日志」「总结这个文件」,应该 reasoning + 用 read_file / grep 工具,不是再 spawn 一层 Python 进程。一旦子 agent 也用 execute_code:
- 多一层进程,资源占用 ×2
- Python 错误不好 propagate 到父
- 子的 stdout 被子 LLM 看,但父也想看,得显式回传
工程教训:子 agent 是个「资源受限的 LLM 思考者」,不是「自主选择工具栈的 mini agent」。复用父的工具集合理,但不让子做「开新副本」类的操作。
源码:hermes-agent/tools/delegate_tool.py:31-54(DELEGATE_BLOCKED_TOOLS 定义)。
追问:「如果业务需要子 agent 写 memory 怎么办?」让子 agent 输出 proposed_memory_update: ["fact 1", "fact 2"],父 agent 看到后人工或规则判断要不要写 MEMORY.md。把决策权留给父。
Q4 · 概念:OpenClaw 的「push-based 完成」+ 在 tool 返回值里塞反 polling 警告,解决什么问题?
解决 LLM-as-orchestrator 的典型坑:模型不会等,会不停 poll。
问题场景
父 agent spawn 一个 child 之后,模型的思维模式是:「我开了任务,得查查进度」。于是开始疯狂调用 sessions_list、sessions_history、exec sleep 等。每次 poll 都是一次 LLM 调用 + tool call,烧 token,UI 还误以为父在「干活」。
GPT-4 / Claude 3 / Sonnet 都被观察到这个行为。哪怕你在 system prompt 里说「不要 poll」,模型几轮后会忘。
OpenClaw 的解法
SUBAGENT_SPAWN_ACCEPTED_NOTE 直接写在 tool 的 result 字段里:
Auto-announce is push-based. After spawning children, do NOT callsessions_list, sessions_history, exec sleep, or any polling tool.Wait for completion events to arrive as user messages, track expectedchild session keys, and only send your final answer after ALL expectedcompletions arrive. If a child completion event arrives AFTER yourfinal answer, reply ONLY with NO_REPLY.塞在 tool result 不是 system prompt,原因有 4:
- 位置紧贴行为:模型刚 spawn 完,立即读到「不要 poll」。比 system prompt 在对话顶部更显著。
- per-call 重复:每次 spawn 都塞一次。模型即使忘了 system,也会被反复提醒。
- 指向具体反例:「不要调 sessions_list / sessions_history / exec sleep」,给出具体禁忌,比抽象「不要 poll」可执行。
- NO_REPLY 协议:教模型在「子完成在最终答之后」时怎么 graceful 处理(避免重复回答 + 重新触发)。
配套机制
光警告不够,还需要:
- subagent-announce push event:子完成时主动发 user message 到父对话。父被「唤醒」,不需要主动查。
- 超时降级:push 超过 N 秒没到,自动降级为 polling 1 次(兜底)。
- session key 追踪:父 spawn 时拿到 child session key,最终答需要等所有 key 都 announce 完。
这是「prompt engineering as architecture」
OpenClaw 没改模型,没加 fine-tune,没用 RAG。就是把「行为规则」放在「行为发生的最近位置」。这是 prompt engineering 升级成系统设计的典型案例。
和 Codex 对比
Codex 子 agent 完成也是 push(EventMsg::TurnComplete),但父代码层就是 await recv(),不依赖模型「不 poll」。原因:Codex 的父也是 Rust 代码(不是 LLM 决策),不会 poll。
对比哪种更好?
Codex 模式更稳(代码 enforce),但限制了灵活性(父需要用 Rust 写)。OpenClaw 模式更灵活(模型可以做更复杂决策),但需要 prompt 引导。两种都对,看你的 orchestrator 是代码还是模型。
源码:openclaw/src/agents/subagent-spawn.ts:81-84(SUBAGENT_SPAWN_ACCEPTED_NOTE)+ subagent-announce.ts。
追问:「为什么不直接禁掉 sessions_list 工具?」因为 sessions_list 对其他场景(用户主动查会话列表)是合法的。工具不能因为子 agent 场景的滥用就全局禁。所以走 prompt enforce。
Q5 · 概念:Claude Code 的 Task 抽象统一了 7 种异步执行。把 subagent 和 bash 抽到同一个抽象层,好处和代价分别是什么?
好处 1 · UI 一致
任务列表 UI 不用为 bash / subagent / remote / teammate 写 4 套渲染。一套 TaskListUI 统一展示 status / progress / result。用户认知成本低。
好处 2 · concurrency dispatch 一处实现
isConcurrencySafe() 是接口方法,每种 task 自报。dispatcher 不用 if task_type === 'bash' then ... else if task_type === 'subagent' then ...。新加 task type 只要 implement 接口,dispatcher 不用动。开闭原则。
好处 3 · 状态机一致
pending → running → completed/failed/killed 对所有 task type 一样。日志、监控、错误处理统一。isTerminalTaskStatus() 一个函数到处用。
好处 4 · 持久化一致
每个 task 写 outputFile 到磁盘。bash 输出、subagent reply、remote result 都一样的存盘策略。崩溃恢复、跨会话查询都用一套机制。
代价 1 · 抽象失血
7 种 task type 实际差异很大:
- bash:阻塞 / 流式输出 / signal handling
- subagent:异步 / token cost / approval routing
- remote_agent:网络延迟 / auth / quota
- teammate:长期存活 / message injection
- workflow:多步骤 / state persistence
每种独有的特性,要么塞进 Task 接口(接口膨胀),要么放在 type-specific config(抽象漏出)。Claude Code 选了后者,TaskCreateTool 的 params 已经 13+ 字段,很多字段只对某种 type 有意义。
代价 2 · 加新 task type 成本不为零
虽然 dispatcher 不用改,但要 implement isConcurrencySafe() / outputFile / status 转换 / signal handling 等。dream 这种新类型加进来时,要解决一堆 corner case(用户切会话时 dream 怎么 cleanup?dream 的 result 给谁看?)。
代价 3 · 调试更难
bash 出错和 subagent 出错的现象不一样,但 log 出来都是 Task xxx failed。要往下挖到 type-specific 错误。如果是分散设计(每种 type 自己的 Logger),日志会更直观。
结论
Task 抽象适合「异步执行多样化但调度模式相似」的产品:
- Claude Code 产品上是个 agent IDE,用户视角下「跑 bash / 调 subagent / 派 teammate」都是「等结果」。抽象成 Task 帮用户。
- Hermes 是个聊天 agent,subagent 是少数派(主要工具是 read / write / bash)。统一抽象成本高、收益低。所以 Hermes 用函数式
delegate_task单独 handle。
工程教训:抽象不是无条件好。看用户场景下「这些东西是不是同质」。是就抽象;不是就散开。
源码:claude-code/src/Task.ts:6-30(TaskType + TaskStatus 定义)+ src/tools/TaskCreateTool/。
追问:「dream 这种 task 是怎么 fit 进 7 种 type 的?」dream 是 2.1 加的「后台预热」,用户没主动触发。要 fit 进 Task 抽象,dispatcher 加了 auto_triggered flag,UI 上 dream 默认不显示(除非用户开 debug 视图)。这是抽象漏出的具体例子:为了塞 dream 进来,抽象多了一个属性。
Q6 · 实战:你要给自己的 agent 加 subagent 能力,从 0 起步。最小可用 → 生产就绪要走多远?
按 函数式 MVP → 安全边界 → 并发 → 类型化 → 平台化 五个 scope 推进;每个 scope 都要通过资源、权限和结果契约的 fixture。
阶段 1 · 函数式 MVP
参考 Hermes:
def delegate_task(goal: str, context: str, toolset: list[str], max_iter: int = 50): # 1. spawn 一个新的 LLM 调用 thread # 2. system prompt 加: "你是子 agent,专注于 {goal},max {max_iter} 轮" # 3. 工具集只给 toolset 里的工具 # 4. 等子完成,返回 result return run_subagent_sync(goal, context, toolset, max_iter)先用它验证父子结果契约。父 agent 用法:
父 agent: 我要分析这 3 个日志文件父 agent: 调 delegate_task("分析 log 1", "...", ["read_file", "grep"])等结果父 agent: 调 delegate_task("分析 log 2", "...", ["read_file", "grep"])等结果阶段 2 · 安全边界(最关键)
加 4 件事,不能省:
- MAX_DEPTH = 2:parent → child → grandchild reject。简单计数,存在 spawn args 里传给子。
- blocked_tools:硬禁 5 个(recursive delegate / clarify / memory / send_message / execute_code)。即使 toolset 列了,也过滤掉。
- concurrent limit:默认 3,可配。用 ThreadPoolExecutor / asyncio.Semaphore。
- timeout:每个子 agent max_iterations + 总 timeout(比如 5 分钟)。超时强制 kill,父收到
TimeoutError。
这一步是「最小可用 → 不会爆炸」的关键。先有这些,再加复杂功能。
验收门槛:递归、禁用工具、并发上限和 timeout fixture 都能拒绝或终止子任务,并回收资源。
阶段 3 · 并发并行
前置:阶段 2 的深度、工具和 timeout 边界已经稳定。
让父能同时开多个子:
import asyncio
async def parent_turn(): tasks = [ delegate_task_async("分析 log 1", ...), delegate_task_async("分析 log 2", ...), delegate_task_async("分析 log 3", ...), ] results = await asyncio.gather(*tasks) # 父用 results 综合三个调用没有数据依赖时可以并发,wall time 通常受最慢子任务和调度开销支配;实际加速比要与串行基线一起测量。
UI 要解决:3 个子同时跑,progress bar 怎么显示?建议给每个子一个 child_id,主 UI 显示 progress per child。
验收门槛:无数据依赖的固定任务集与串行基线对比,重复调用、冲突和 child cleanup 都有记录。
阶段 4 · 类型化
前置:子 agent source 已出现可重复的行为差异,字符串配置开始难以维护。
当子 agent 类型超过 3 种(比如 review / summarize / search / analyze),切到 enum:
class SubAgentSource(Enum): Review = "review" Summarize = "summarize" Search = "search" Analyze = "analyze"
def delegate_task(source: SubAgentSource, ...): prompt = PROMPTS[source] feature_constraints = FEATURE_CONSTRAINTS[source] ...带来好处:
- dispatch 集中
- metrics 按 source 聚合
- feature 收紧由 source 决定(Review 自动 disable web_search)
验收门槛:每个 source 的 prompt、工具约束和 metrics 标签在 schema fixture 中一致,未知 source 明确拒绝。
阶段 5 · 平台化(按需)
前置:产品需要跨进程状态、通知、附件或多用户 framework 能力。
如果产品是 agent 框架(多个用户用你的 framework 写自己的 agent),参考 OpenClaw:
- subagent-registry(看到所有活跃 run)
- subagent-depth(跨进程持久化)
- subagent-announce(push 完成通知)
- subagent-attachments(传文件给子)
- subagent-lifecycle-events(spawn / progress / complete 事件流)
这些是 agent 框架的「产品力」,自用 agent 不需要。
验收门槛:kill/resume、push 通知、附件传递和 lifecycle event 可重放,权限继承不会被平台层放宽。
关键经验:
- MAX_DEPTH 先固定(防递归套娃,token 爆炸是最容易出的事故)
- blocked_tools 先固定(防共享状态污染)
- timeout + heartbeat 通过 fixture 后再扩展并发(防卡死 UI)
- 类型化 / 平台化按需求推进(用 trace 和真实调用决定)
源码路线:Hermes tools/delegate_tool.py 展示最小委派,Codex protocol.rs SubAgentSource 展示用途类型化,OpenClaw agents/subagent-*.ts 展示状态与通知。它们的契约不同,不能直接拼接;先按任务 trace 选需要的层。
追问:「子 agent 失败父怎么处理?」三种策略:(1) 重试 1 次(适合 transient error);(2) 标记 failed 让父决定要不要继续(适合业务逻辑错误);(3) abort 整个父 task(适合致命错误如 quota exhausted)。多数情况选 (2),把决策权留给父 agent。
Q7 · 架构:Codex 的 inherited_exec_policy 让子永远不能比父更宽松。这是好事吗?如果业务需要”子比父能干更多”怎么办?
默认不能更宽松 = 安全默认
子 agent 的 prompt 是父决定的。父可能被注入 / 被欺骗 / 被恶意操控。如果子能突破父的限制,攻击者可以「让父 spawn 一个更危险的子」绕过 sandbox。
经典攻击模式:用户输入「写一个脚本 delete /etc/passwd」→ 父 exec_policy 禁了 rm -rf → 但子 agent 没继承禁令 → 子能跑 → boom。
Codex 的默认收紧适合作为安全起点:默认子是父的子集;具体业务若确有需要,可由父层显式授予更宽权限并附加约束。
但「子比父能干更多」的合理需求存在
3 种典型场景:
场景 1 · 父是受限主对话,子是后台分析任务
用户开 Claude Code 在 --restricted 模式(只读分析),不允许写文件。但用户 trigger /review 想让 review subagent 分析 PR,review 需要跑 gh pr diff、调 GitHub API。父禁的 network,子需要。
怎么破?
不是让子突破父,而是用户在父层配置:「review subagent 允许 network」。父显式 grant 子额外权限,不是子自己拿。父在 grant 时知道这是给 review 用,可以加追加约束(只 allow gh 命令、白名单 GitHub 域名)。
Codex 实际就是这么做的:subagent_source = Review 时,spawn 时 explicit set web_access = AllowedFor(["github.com"]),绕过父的 web_access = Disabled。但只对 Review source 生效,其他 source 不行。
场景 2 · 父是 IDE 模式(不许 spawn 子进程),子是 build task
build 需要 spawn cargo / npm 子进程。父在 IDE 模式下 disable spawn 是为了 UX(避免污染 IDE 进程状态),子的 build 是显式用户意图。
怎么破?
父记录「这次 spawn 是 build 任务,例外允许子进程」。子的 args 里带 purpose: BuildTask,子 spawn 时 dispatch 把权限从父继承的限制里去掉这一条。
场景 3 · 父是 read-only review,子需要 fix
/review 父是 read-only。用户看到 review 后说「修一下」,要 spawn 一个 fix subagent 写文件。
怎么破?
把 fix subagent 当成新的 user request 重新评估权限,不是子从父继承。这种情况下「子」是名义上的子,权限重置。或者 fix subagent 直接走父的入口(user re-prompt),不走 subagent。
通用解法
inherited_exec_policy 默认严格 + 显式 grant 例外。grant 需要:
- 在 spawn 点(不是子运行中)声明
- 限定 source(只对特定 SubAgentSource 生效)
- 加追加约束(白名单域名 / 命令前缀 / 资源上限)
- 审计日志(任何 grant 都记录,方便事后查)
反模式
让子 agent 「在运行中请求父放宽权限」。子在 prompt 里说「我需要 rm 权限」,父的 LLM 决定要不要 grant。这是把权限决策外包给 LLM,安全敞口太大。攻击者写一段 prompt 让父觉得「这次合理」就破防了。
源码:codex/codex-rs/core/src/codex_delegate.rs:117(inherited_exec_policy = Some(Arc::clone(...)))+ Review-specific overrides 在 tasks/review.rs。
追问:「Anthropic / OpenAI 的 fine-grained permissions 提案能解决这事吗?」类似的。Anthropic 在 Computer Use 里引入 per-action permissions,每个 action 单独决定,不是「子继承父」。但工程上 still 子默认收紧 + 显式 grant;只是 grant 的颗粒度更细。
Q8 · 实战:用户报告”我的 agent spawn 10 个 subagent 之后整个父 UI 卡死了”。系统化排查。
四层排查:并发模型 → 资源 → 通信 → 模型。
第一层 · 并发模型(先检查)
看你的 subagent 是同步还是异步:
- 同步阻塞(Hermes 风格
as_completed):父进程在executor.shutdown(wait=True)卡住。10 个子都跑完才解锁。看 child 是否都完成。 - 异步 push(OpenClaw 风格):父不该卡。如果卡了,是 push event 没回来。
排查命令:
# 父进程的线程 / coroutine 状态py-spy dump --pid <parent_pid> # Pythonrust-gdb -p <parent_pid> # Rust看父是不是 stuck 在 wait() / recv() / gather()。是的话进下一层。
第二层 · 资源耗尽(随后检查)
10 个子可能炸 4 类资源:
- CPU:每个子是 LLM 调用,本机不算贵。但子内部跑 bash / build,每个子吃 1 CPU 核,10 个把 8 核机器跑爆。
- 内存:每个子有自己的对话历史 + tool output buffer。10 个 × 50K context × 4 字节/token ≈ 几百 MB。Node.js / Python 进程容易爆。
- 文件句柄:每个子开 N 个 file handle(log / temp / socket)。10 个子 × 50 句柄 ≈ 500,触 ulimit 上限。
- API rate limit:10 个子并发调 LLM API,超出 RPM 限制。后续子全 retry,连锁拖慢。
排查:
top -p <parent_pid> -H # CPU / memory per threadlsof -p <parent_pid> | wc -l # 句柄数cat /proc/<parent_pid>/limits # ulimitgrep "429" logs/ # rate limit 错误第三层 · 通信瓶颈
如果是 push-based:
- event queue 堵了:10 个子同时 push completion,主 event loop 处理不过来。检查 queue size / drain rate。
- token buffer 爆:每个子 result 几 K 字符,10 个一起回,父 LLM 调用的 input 一次 50K,超过 context window。
- UI render 慢:每次 push 触发 UI 重渲染。10 个事件 in 1 秒,前端 60fps render 跟不上。
排查:
# event queue size (OpenClaw 用 in-memory queue)curl localhost:9090/metrics | grep subagent_queue# 父 LLM 调用 token 数grep "input_tokens" logs/parent.log# UI render timechrome devtools performance tab第四层 · 模型行为(最后检查)
如果以上都没问题,但父 UI 还卡:
- 父 LLM 在 polling:哪怕 OpenClaw 警告了,父 LLM 还是开始
sessions_list。每次 poll 都是一个 LLM 调用,10 秒一次,UI 显示「父在思考」但实际在做无意义查询。 - 父 LLM 在等所有完成:父被指示「等所有子结束再回答」,但子 1 卡住,父 stuck。需要 timeout 兜底。
- 子 LLM 互相 spawn:哪怕 MAX_DEPTH = 2,子 1 可能跟父说「我需要再开子」,父 LLM 在父 turn 里递归 spawn 更多。
排查:看父 LLM 的 trace(每次调用的 messages),找 polling 模式或 spawn 模式。
常见根因 + 修复
下面按排查顺序列出候选原因。原文的 30/25/20/15/10 没有事故样本支撑,因此不保留为发生率;上线后应按 trace 和 incident 数据重新排序。
- rate limit 触发:限并发,加 exponential backoff。
- 同步阻塞被慢子拖死:切异步 / push-based + 单子 timeout。
- 资源耗尽:监控 + 限并发 + cleanup。
- 父模型疯狂 polling:参考 OpenClaw 的反 polling warning。
- UI render 慢:debounce + batch event。
预防
把这些做成 default config 不要让用户踩坑:
- max concurrent = 3
- single child timeout = 5min
- queue size limit + drop policy
- API rate limit aware retry
源码参考:Hermes 的 _DEFAULT_MAX_CONCURRENT_CHILDREN = 3 + _HEARTBEAT_INTERVAL = 30 + DEFAULT_MAX_ITERATIONS = 50。这三个 default 是踩过的坑沉淀。
追问:「如果业务需要 10 并发,又不能挂呢?」专门做并发优化(worker pool、connection pool、API tier upgrade),不是用通用 subagent 框架硬扛。subagent 是「让一个 agent 派活给一个临时 agent」,不是「批处理 10000 任务」。后者要数据 pipeline 不是 agent。
Q9 · 工程:Claude Code 把 task outputFile 写到磁盘,OpenClaw 持久化 spawnDepth 到 session store。两种持久化设计哪个更重要?
两者目的不同,但都重要
outputFile(Claude Code)
目的:避免 in-memory 撑爆 + 跨会话恢复。
场景:用户 spawn 一个 bash task 跑 find / 几分钟,输出几十万行。in-memory buffer 不能装这么多。outputFile 写盘后,父只持有一个引用(文件路径),需要时按需读片段。
进阶价值:会话恢复。用户 kill Claude Code,重新启动恢复会话。outputFile 还在磁盘,task 历史较全。
spawnDepth(OpenClaw)
目的:安全约束跨进程保持。
场景:父 spawn 子,记录 child.spawnDepth = 1。父进程 crash 重启。如果重启后 spawnDepth 丢失(in-memory only),子重新加载会以为自己是 root,可以再 spawn 孙,MAX_DEPTH 防线失效。
持久化 spawnDepth 让安全约束在崩溃 / 重启 / 跨进程下都保持。
哪个更重要?
如果只能选一个:
- 单进程 short session(CLI 工具):outputFile 更重要(内存爆炸是日常)。
- 多进程 long session(agent 平台):spawnDepth 更重要(安全约束跨重启)。
Claude Code 是前者(CLI / IDE),OpenClaw 是后者(长期 agent 平台),所以重点不同。
长跑系统的组合建议
对于跨进程、长期运行,或需要跨重启恢复的 subagent 系统,建议同时持久化:
- 大输出落盘(outputFile / stdout 重定向 / chunked storage)
- 安全约束持久化(depth / quota / rate limit state)
- 状态机持久化(task FSM 跨重启)
Hermes 哪个都没做的话:
- 同步阻塞模型,session 短,输出小:outputFile 不上急
- 单进程:spawnDepth in-memory 够用
但 Hermes 长期 agent 化时这两个都得补。
实现细节
outputFile:
- 路径:
~/.claude/tasks/<task_id>/output.log - 写入:append-only,子进程 stdout 直接 redirect
- 读取:父按需 read(不全量 load)
- 清理:task complete 后 N 天清,或用户主动 delete
spawnDepth:
- 路径:
~/.openclaw/sessions/<session_id>/depth.json - 写入:每次 spawn 时 update
- 读取:父进程启动时 load
- 清理:session end 时 archive
反模式
- 存到内存只:进程 crash 全丢,安全约束失效。
- 存到 SQL DB:subagent 是高频 spawn / complete,每个都 DB write 太慢。轻量 file-based 够用。
- 共享文件多进程并发写:会 race。每个 session 自己一个 dir,避免冲突。
追问:「outputFile 跨用户共享怎么办?」task 是 user-scoped,outputFile 在 user home dir,不共享。如果要跨用户分析 task pattern,做单独的 telemetry pipeline,不和 outputFile 混。
Q10 · 开放:按任务约束设计一个可组合的 subagent 框架,给出最小 API 与验证路径。
分层设计,按需启用:
Layer 1 · 函数式接口(需要)
interface SpawnArgs { goal: string; context?: string; toolset?: string[]; max_iterations?: number; source?: SubAgentSource;}
async function spawnSubagent(args: SpawnArgs): Promise<SubAgentResult> { // 参考 Hermes delegate_task}这是最小接口示意;生产实现还需要超时、取消、预算、trace 和错误传播,不能用这段示例的行数估算交付量。
Layer 2 · 类型化(推荐)
enum SubAgentSource { Review = 'review', Summarize = 'summarize', Search = 'search', Analyze = 'analyze', Custom = 'custom',}
const SOURCE_CONFIG: Record<SubAgentSource, SourceConfig> = { [SubAgentSource.Review]: { system_prompt: REVIEW_PROMPT, feature_constraints: { web_search: 'disabled', spawn_subagent: 'disabled' }, approval_policy: 'never', }, // ...};参考 Codex SubAgentSource。每种来源走不同配置。
Layer 3 · 安全约束(需要,不能省)
const HARD_LIMITS = { MAX_DEPTH: 2, MAX_CONCURRENT: 3, SINGLE_TIMEOUT_MS: 5 * 60 * 1000, BLOCKED_TOOLS: new Set([ 'spawn_subagent', 'clarify', 'memory_write', 'send_message', 'execute_code', ]),};
function validateSpawn(parent: Session, args: SpawnArgs) { if (parent.spawnDepth >= HARD_LIMITS.MAX_DEPTH) { throw new DepthExceeded(); } args.toolset = args.toolset?.filter(t => !HARD_LIMITS.BLOCKED_TOOLS.has(t)); // ...}参考 Hermes 5 工具硬禁 + MAX_DEPTH。跨进程或长期运行场景应保留这些约束;短会话、单进程工具可按风险取舍。
Layer 4 · 服务继承(推荐)
interface ParentServices { execPolicy: ExecPolicy; plugins: PluginManager; mcpManager: McpManager; skills: SkillsManager;}
function inheritServices(parent: ParentServices): ParentServices { return { execPolicy: cloneStrict(parent.execPolicy), // 子永远 ≤ 父 plugins: shareRef(parent.plugins), mcpManager: shareRef(parent.mcpManager), skills: shareRef(parent.skills), };}参考 Codex inherited_exec_policy。
Layer 5 · 并发模型(可选,二选一)
// 同步阻塞(Hermes 风格)async function runSync(args: SpawnArgs[]): Promise<SubAgentResult[]> { const limit = pLimit(HARD_LIMITS.MAX_CONCURRENT); return Promise.all(args.map(a => limit(() => spawnSubagent(a))));}
// 异步 push(OpenClaw 风格)async function spawnAsync(args: SpawnArgs, callback: (r: SubAgentResult) => void) { spawnSubagent(args).then(callback);}业务选一种。复杂场景两种都暴露。
Layer 6 · 持久化(生产需要)
interface SubAgentStore { saveDepth(sessionId: string, depth: number): Promise<void>; loadDepth(sessionId: string): Promise<number>; saveOutput(taskId: string, output: string): Promise<void>; loadOutput(taskId: string): Promise<string>;}
class FileSystemStore implements SubAgentStore { // outputFile 到 ~/.youragent/tasks/<task_id>/output.log // spawnDepth 到 ~/.youragent/sessions/<session_id>/depth.json}参考 Claude Code outputFile + OpenClaw spawnDepth。
Layer 7 · 完成通知(可选)
type CompletionEvent = | { kind: 'started'; taskId: string } | { kind: 'progress'; taskId: string; output: string } | { kind: 'completed'; taskId: string; result: SubAgentResult } | { kind: 'failed'; taskId: string; error: Error };
interface SubAgentBroadcaster { subscribe(callback: (e: CompletionEvent) => void): void;}参考 OpenClaw subagent-announce。push 给 UI / logger / parent agent。
Layer 8 · 抗 polling 警告(强烈推荐)
const ANTI_POLL_WARNING = `Auto-announce is push-based. After spawning children, do NOT callsessions_list, sessions_history, exec sleep, or any polling tool.Wait for completion events to arrive as user messages.`;
function spawnTool(args: SpawnArgs): ToolResult { // ... return { success: true, message: `Spawned ${args.taskId}. ${ANTI_POLL_WARNING}`, };}参考 OpenClaw SUBAGENT_SPAWN_ACCEPTED_NOTE。塞在 tool result 里。
Layer 9 · 任务抽象(可选 · agent 平台才上)
type TaskType = 'subagent' | 'bash' | 'remote_agent' | 'workflow' | ...;type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'killed';
interface Task { id: string; type: TaskType; status: TaskStatus; isConcurrencySafe(): boolean;}参考 Claude Code Task abstraction。只有当你产品有多种异步执行(不止 subagent)时才上。否则过度设计。
总 API
import { spawnSubagent } from '@your-org/subagent';
const result = await spawnSubagent({ goal: '分析这 3 个 log', toolset: ['read_file', 'grep'], source: SubAgentSource.Analyze, max_iterations: 30,});
console.log(result.summary);简单的一行调用。复杂场景按 Layer 启用。
vs 四家:
- Codex 参考 SubAgentSource + service 继承
- Claude Code 参考 outputFile + Task 抽象(可选 Layer)
- OpenClaw 参考 anti-polling warning + spawnDepth 持久化
- Hermes 参考 5 工具硬禁 + ThreadPoolExecutor
按 scope 评估:
- Layer 1-3 · 核心委派:包含 spawn contract、深度/工具边界和超时语义。验收:单元测试覆盖拒绝路径,集成 fixture 能收到 child result 或明确 failure。
- Layer 4-6 · 并发与类型化:只有在子任务确实无数据依赖、或 source 类型已经出现可维护性问题时增加。前置:先有串行基线和 child_id;验收:固定任务集对比并发收益、重复调用、资源清理和 source 约束。
- Layer 7-9 · 平台能力:仅在需要跨进程、持久通知、附件或 UI 观察时增加。前置:先定义生命周期和权限边界;验收:kill/resume、push 通知、附件传递和状态查询 fixture 均可重放。
关键决策:
- 核心 API JSON 互通(schema 跨语言共享)
- 配置 file-based(YAML / JSON 配置 source 行为)
- 拒绝 polling:在核心契约中固定 anti-polling warning
追问:「这个框架怎么测试?」三层测试:(1) 单元测试 spawn / depth / blocked_tools;(2) 集成测试 push event flow;(3) chaos 测试 spawn 100 个子 / kill 一半 / 看资源回收。前两层 CI 跑,第三层每周跑一次。