写在前面:系列是为了帮助大家更好的去理解Agent Harness基础设施,并不是想重复造轮子,真实开发建议选择一个成熟的SDK或Harness框架,才是最合适的选择~
1. 最小 harness 有了,缺的是「会派活」
前五篇我手写了一套最小 harness:能 loop、有工具、有审批、有会话记忆。它够干活,但只会一件事——自己干。你丢给它一个大任务,它一个人从头扛到尾,上下文越滚越乱,中途想换个思路只能推倒重来。缺的那格叫「会派活」。
单 agent 是执行者,子代理让 harness 会分工。子代理不是「更多 agent」,是两件事——把任务拆出去 + 把上下文隔离。 本文给一条从 spawn 到 fork 的极简实现路径。
2. 先看现成的:Claude Code 的 subagents 三件事
动手之前,先看 Claude Code 怎么做。官方文档「Create custom subagents」把子代理讲成三个设计点。
description 是「招工启事」。 子代理定义在 .claude/agents/ 下的 Markdown 文件里,frontmatter 必填两样:name 和 description。description 不写进子代理自己的上下文,它写给父 agent 看——父 agent 靠它决定「这个任务要不要派出去、派给谁」。官方文档原话:写详细的 description,因为 Claude 靠它决定何时派活。你在 description 里写「用于代码评审」「优先主动使用」,父 agent 就知道什么时候该找它。这跟我们《动手开发你的第一个 agent :给它装手和眼睛》那篇的工具 description 作用一模一样——决定「何时」被选。
隔离上下文,防污染。 子代理有自己的 context window、自己的 system prompt、自己的工具权限。父会话只收到子代理的最终结果,看不到它的中间思考、它翻过的每一页文件。官方文档说得直白:subagents 把重的文件读取和搜索挡在主会话外面,保护父上下文。想连文件改动都隔开,还有 isolation: "worktree"——给子代理一个临时 git worktree,它在里面改东西,主工作区不受影响。
prompt 须自包含。 子代理不继承父会话的历史(fork 是例外,后面讲)。它从零开始,只看到自己的 system prompt 和父 agent 写给它的那一份任务描述。所以任务描述必须自带全部背景——「读文件 package.json 并总结它的版本」这种话,子代理接得住;「把刚才那个文件读一下」这种话,子代理直接懵,因为它不知道「刚才」是谁。
这三件事里,后两件正好是这篇要实现的:隔离上下文、prompt 自包含。description 那件,我的实现里用 spawn_subagent 工具的 description 字段承载——它就是给主模型看的「招工启事」。
dsh 那边,我在《DeepSeek Harness 开源了,[一切皆插件],把他拆开看一看》拆过它的 SubagentProvider 接口(packages/subagent/subagent/src/types.ts:285),注册了五类实现:
- spawn:新建子 agent,
inheritsParentContext = false,孩子从零开始,看不到父对话; - fork:继承父的已完成轮次前缀(
inheritsParentContext = true),从父的上下文续着干; - acp:进程外的 ACP 子 agent,无父上下文;
- claude-code / codex:把一轮委派丢给 Claude Code 或 Codex 的进程当后端。
最后两个最开脑洞——主 harness 可以把脏活外包给别的 harness。这个我第五节对照时再提。先记着:五类实现里我最关心的是前两个,spawn 和 fork,它们是「子 agent 从哪来」这个问题的两个最基本答案。
3. Demo 先行:给 harness 加 spawn / fork
代码在 examples/first-agent/step5-subagent/index.js,零依赖,node step5-subagent/index.js 直接跑。先看两个系统提示词——它们把「上下文隔离」写进了提示词:
export const PARENT_SYSTEM =
'你是一个主 agent,会派活:把完全独立的子任务交给 spawn 子代理(从零开始、无父会话记忆);' +
'把需要在已有记忆基础上继续的子任务交给 fork 子代理(继承父会话前缀记忆)。' +
'拿到子代理回传结果后,用一句话总结。'
export const SUBAGENT_SYSTEM =
'你是一个子代理,只处理父 agent 交给你的这一个子任务。' +
'你没有父会话的记忆,任务描述是自包含的——你看到的会话消息就是你的全部世界。' +
'读到工具结果后,用一句话总结。'
SUBAGENT_SYSTEM 那句「你看到的会话消息就是你的全部世界」不是装饰——即便模型真有父会话的信息,提示词也明确要求它别依赖。这是把「隔离」从机制层面写进约束。
派活的两个原语,长在 SessionLog 上。一个是 spawn——从零新开子会话;一个是 fork——按 boundary 切子会话,继承前缀:
// spawn:从零新开一个子会话。与父会话零共享——这就是「上下文隔离」的实现。
// 子代理的会话是全新 SessionLog,父会话的事件、boundary 一概不继承。
spawn() {
return new SessionLog()
}
// fork:从指定 boundary(默认最后一次 turn/start)切子会话,继承前缀记忆。
// 切法:复制 events[0..cut],cut 是 boundary 的 seq。boundary 之后的
// 「父会话第二条」这类消息不继承——子代理只看到切点之前的记忆。
fork(seq) {
const cut = seq ?? this.boundaries.at(-1)
if (cut === undefined) return this.spawn() // 还没有 boundary → 等价于 spawn
const child = new SessionLog()
child.events = this.events.slice(0, cut + 1).map((e) => Object.freeze({ ...e }))
child.boundaries = this.boundaries.filter((b) => b <= cut)
return child
}
spawn 一行——new SessionLog(),空会话,父会话的事件一概不复制。fork 三行——从最后一次 boundary 处切开,复制 events[0..cut] 进子会话。两个原语加起来不到十行,语义正好相反:一个「什么都不给」,一个「给到切点为止」。
关键的一步是让主模型能调用这两个原语。做法和《动手开发你的第一个 agent :给它装手和眼睛》给 harness 加工具一模一样:把 spawn 和 fork 各自包装成一个工具,注册进主会话的 ToolRegistry。这是本文的核心主张——子代理不是一个新对象,它就是「工具 + 内部多一个 loop」:
export function makeSubagentTools({ mainLog, childLLM }) {
// 子代理的模型默认用基类 MockLLM(只干普通工具的活,不主动派活)。
// 想让子代理也能递归派活?把子工具注册表也传给子会话即可——子代理即工具,可组合。
const llm = childLLM ?? new MockLLM()
const spawn_subagent = {
name: 'spawn_subagent',
description: '从零新开一个子会话(子代理没有任何父会话记忆),独立完成一个子任务并回传结果。适合完全独立、可拆出去的任务。',
parameters: {
type: 'object',
properties: {
prompt: { type: 'string', description: '给子代理的自包含任务描述。子代理没有父会话记忆,prompt 必须把任务背景说全。' },
},
required: ['prompt'],
},
async execute(args) {
// spawn:全新 SessionLog。父会话此刻的所有事件一概不复制——
// 这就是「上下文隔离」的实现:子代理从一张白纸开始。
return await runSubagent({ llm, prompt: args.prompt, inherited: null, name: 'spawn_subagent' })
// 真实工程:spawn/fork 都一样,这里一次只派一个子代理、同步 await 等它跑完,
// 主 loop 完全串行。生产做法是任务队列/并发池:把一批子任务交给调度器,多个子代理
// 并发扇出(对照 Claude Code 的 subagent 扇出),全部返回后统一收结果再汇总。
},
}
const fork_subagent = {
name: 'fork_subagent',
description: '从父会话最后一次 turn/start boundary 切一个子会话,子代理继承到该 boundary 的前缀记忆,基于已有上下文继续完成子任务并回传结果。',
parameters: {
type: 'object',
properties: {
prompt: { type: 'string', description: '给子代理的子任务描述。子代理能看到继承来的前缀记忆。' },
},
required: ['prompt'],
},
async execute(args) {
// fork:继承前缀记忆的子会话。此刻主会话日志里最后一个 boundary 就是当前
// 这个「派活 turn」的起点——从那里切,子代理看不到派活本身的过程,
// 只看到 boundary 之前的记忆(父会话更早的历史)。
const inherited = mainLog.fork()
return await runSubagent({ llm, prompt: args.prompt, inherited, name: 'fork_subagent' })
},
}
return [spawn_subagent, fork_subagent]
}
两个工具长得几乎一样,唯一的分歧在 execute 里:spawn 传 inherited: null(从零开),fork 传 mainLog.fork()(继承前缀)。剩下都交给同一个 runSubagent——它就是「子代理即工具」的实现:
// runSubagent:真正跑子代理的公共逻辑。spawn 与 fork 都走这里,只差 inherited 是不是 null。
async function runSubagent({ llm, prompt, inherited, name }) {
// 子会话:spawn 传 null → 全新;fork 传已继承前缀的日志。
const child = inherited ?? new SessionLog()
// 真实工程:子会话此刻只活在内存里——main() 退出即失,无法重放。
// 生产做法是让子会话也走 step4 的 append-only 落盘(persist/resume):
// 子会话事件独立写盘(如 dsh 里子 agent 的会话目录),主进程崩溃后按日志重放续跑。
const childRegistry = new ToolRegistry()
childRegistry.register(read_file) // 子代理手里有普通工具(读文件),没有派活工具
const childPipeline = new ToolPipeline(childRegistry)
const childLoop = new ReactLoop(llm, childRegistry, childPipeline, child, SUBAGENT_SYSTEM)
// 真实工程:这里的子代理不是真隔离——childLoop 只是主进程内 new 的 ReactLoop 实例,
// 与主 loop 共享进程堆,无独立 CPU/内存/权限边界,子代理能读到的东西和主 agent 一样多。
// 生产做法是把子代理放进独立子进程(Node worker_threads / child_process.fork,自带资源与
// 权限沙箱),或接外部 harness 当后端:dsh 的 SubagentProvider 就是 spawn/fork/acp/claude-code/
// codex 五类「换子 Agent 从哪来」,能嵌套别的 agent,实现进程级隔离;子代理的会话文件
// 独立落盘,实现跨会话持久隔离。
const mode = inherited ? 'fork·继承父前缀记忆' : 'spawn·全新会话'
console.log(`\n┌─ ${name} 开始(${mode})`)
const finalText = await childLoop.turn(prompt) // 子 loop:一个完整 turn
// 真实工程:这里对子代理没有 token/时长/步数上限,子 loop 跑多深全看它自己——
// step1 的 maxTurns 停机思想只保护了单 loop 本身,没延伸到子代理。
// 生产做法是给每个子代理设独立预算:maxSteps / token 预算 / 超时回收,
// 超出即终止并标记结果状态,防止子代理失控拖垮整次任务。
// 打印子会话派生消息,直观展示「spawn 只有自己的任务 / fork 还有继承来的记忆」
const derived = child.deriveMessages()
console.log(` 子会话派生消息(模型看到 ${derived.length} 条):`)
for (const m of derived) {
if (m.role === 'toolResult') console.log(` [toolResult:${m.toolName}] ${String(m.content).slice(0, 40)}`)
else if (m.toolCall) console.log(` [assistant/toolCall] ${m.toolCall.name}`)
else console.log(` [${m.role}] ${String(m.content).slice(0, 40)}`)
}
console.log(`└─ ${name} 结束,子会话共 ${child.events.length} 条事件`)
// 回传给主会话的 toolResult:只给结论。父会话不需要、也不该读到子会话的全部过程。
return `【${name}】${finalText}(子会话 ${child.events.length} 条事件)`
// 真实工程:这里回传的是整串文本,主会话只能拿到一段话,没有结构化字段,
// 失败/超时也没有单独状态位(ToolPipeline 的 ok:false 只停在工具层,到不了这里)。
// 生产做法是按 schema 回传结构化结果(如 { status: 'ok'|'error'|'timeout', summary, artifacts }),
// 主 agent 按字段消费,对失败/超时走独立分支。
}
runSubagent 做的事,一句话:拿子会话建一个子 ReactLoop,跑一个完整 turn,把最终文本打包成字符串 return 出去。这个 return 值会走上一条既有的路——《动手开发你的第一个 agent 02》的 ToolPipeline 会把工具返回值转成 toolResult,写进主会话日志,主模型下一轮读得到。子代理机制没有造任何新管道。
画出来,主会话和子会话的关系是这样:
虚线框是「上下文隔离」的边界:子会话有自己的 SessionLog、自己的系统提示词,父会话看不到框里面的东西,只收到从框里飘出来的一句结论。
跑一遍。下面输出逐字取自 step5-subagent/PRACTICE.md(本机 Windows 11 / Node v22.12.0 / mock 模型,无 API key):
$ node step5-subagent/index.js
== step5-subagent:子代理机制(spawn / fork)==
主题:让单 agent 会派活 —— 拆任务出去 + 把上下文隔离
[user] 把『读文件 package.json』这个子任务派给 spawn 子代理
┌─ spawn_subagent 开始(spawn·全新会话)
[user] 读文件 package.json
[assistant] 工具 read_file 返回了:{
"name": "first-agent",
"version": "0.1.0",
"private"…
子会话派生消息(模型看到 4 条):
[user] 读文件 package.json
[assistant/toolCall] read_file
[toolResult:read_file] {
"name": "first-agent",
"version":
[assistant] 工具 read_file 返回了:{
"name": "first-agen
└─ spawn_subagent 结束,子会话共 5 条事件
[assistant] 主 agent 总结:子代理 spawn_subagent 完成任务,回传 → 【spawn_subagent】工具 read_file 返回了:{
"name": "first-agent",
…
[user] 我已经掌握 package.json 了,把『读文件 README.md』交给 fork 子代理(继承我的记忆)
┌─ fork_subagent 开始(fork·继承父前缀记忆)
[user] 读文件 README.md
[assistant] 工具 read_file 返回了:# first-agent —— 动手开发你的第一个 agent(系列配套工程)
系列文章《动手开发你的第一个 age…
子会话派生消息(模型看到 8 条):
[user] 把『读文件 package.json』这个子任务派给 spawn 子代理
[assistant/toolCall] spawn_subagent
[toolResult:spawn_subagent] 【spawn_subagent】工具 read_file 返回了:{
"na
[assistant] 主 agent 总结:子代理 spawn_subagent 完成任务,回传 →
[user] 读文件 README.md
[assistant/toolCall] read_file
[toolResult:read_file] # first-agent —— 动手开发你的第一个 agent(系列配套工程)
[assistant] 工具 read_file 返回了:# first-agent —— 动手开发你的
└─ fork_subagent 结束,子会话共 11 条事件
[assistant] 主 agent 总结:子代理 fork_subagent 完成任务,回传 → 【fork_subagent】工具 read_file 返回了:# first-agent —— 动手开发你的第一个 a…
两段演示,一个看 spawn 一个看 fork。第一段:主 agent 把「读 package.json」派给 spawn 子代理,子会话派生消息 4 条——user(子任务)、toolCall(read_file)、toolResult、assistant(总结),全是本次子任务的内容,父会话的历史一条都没进来。
第二段是 fork:主 agent 说「我已经掌握 package.json 了」,把「读 README.md」交给 fork 子代理。子会话派生消息变成 8 条——前 4 条是父会话第一轮的记忆(派 spawn、子代理回传、主总结),后 4 条才是自己的子任务。fork 把切点之前的记忆带来了。
主会话日志什么样?这是最该看的一张——父会话是干净的:
—— 主会话日志(append-only,共 10 条事件)——
#0 [boundary] turn/start
#1 [user] 把『读文件 package.json』这个子任务派给 spawn 子代理
#2 [assistant/toolCall] spawn_subagent({"prompt":"读文件 package.json"})
#3 [toolResult:spawn_subagent] 【spawn_subagent】工具 read_file 返回了:{
"name": "first-agent",
…
#4 [assistant] 主 agent 总结:子代理 spawn_subagent 完成任务,回传 → 【spawn_subagent】工具 read_file 返回了:{
"name": "first-agent",
…
#5 [boundary] turn/start
#6 [user] 我已经掌握 package.json 了,把『读文件 README.md』交给 fork 子代理(继承我的记忆)
#7 [assistant/toolCall] fork_subagent({"prompt":"读文件 README.md"})
#8 [toolResult:fork_subagent] 【fork_subagent】工具 read_file 返回了:# first-agent —— 动手开发你的第一个 a…
#9 [assistant] 主 agent 总结:子代理 fork_subagent 完成任务,回传 → 【fork_subagent】工具 read_file 返回了:# first-agent —— 动手开发你的第一个 a…
10 条事件,两个 toolResult(#3、#8)是子代理的结论摘要,两个主模型总结(#4、#9)。read_file 的原始结果只活在子会话里,父会话一行都没有——这就是「父会话不被污染」在日志层面的直接证据。
配套的 node step5-subagent/test.js 用三个断言锁死这三个行为:spawn 空会话(父 2 条 / 子 0 条)、fork 继承前缀且不污染父会话、子代理产出作为 toolResult 回传主会话(并反向断言主会话里没有 read_file 的 toolResult、没有子会话内部的 user 消息)。本机三个断言全过:
$ node step5-subagent/test.js
== step5-subagent 测试 ==
✓ ① spawn 子会话不继承父会话任何消息(父 2 条 / 子 0 条)
✓ ② fork 子会话继承前缀记忆(子含父前 2 条),且后续不污染父会话
[user] 把『读文件 package.json』这个子任务派给 spawn 子代理
…
✓ ③ 子代理产出作为 toolResult 回传主会话,主会话日志记录了它(无 read_file、无子会话内部 user)
全部断言通过 ✔
最后补一句:演示能跑,靠的是一个「会派活」的 mock 模型。真实模型天生会决定调哪个工具;mock 没有这个能力,所以我在基类 MockLLM 上加了三条确定性规则——用户消息里用『』包住子任务、点名 spawn 或 fork,就输出对应的派活 toolCall;收到 toolResult 就总结。核心就两条规则,其余交给基类。这是 mock 的确定性替代,设计上保持「能回退」:命中不了派活规则就走基类逻辑。
上面那几行「真实工程:…」注释就是五个简化点,我逐个说清。隔离:demo 的 spawn/fork 只是主进程内 new 一个 loop,共享进程堆、无资源与权限边界;真实工程放独立子进程(worker_threads / child_process.fork),或把委派丢给外部 harness 当后端——dsh 的 SubagentProvider 五类实现(spawn/fork/acp/claude-code/codex)能嵌套别的 agent,子会话独立落盘做跨会话持久隔离。持久化:demo 子会话只在内存、主进程退出即失;真实工程走 append-only 落盘(persist/resume),崩溃重放续跑。并发:demo 一次派一个、同步等待;真实工程上任务队列/并发池,多子代理并发扇出(对照 Claude Code 的 subagent 扇出),统一收结果。预算:demo 子代理无上限;真实工程设 maxSteps / token 预算 / 超时回收,超出即终止标记。结果:demo 整串文本回传;真实工程按 schema 回传结构化结果(如 { status, summary, artifacts }),主 agent 按字段分支处理。
4. 两个核心设计:上下文隔离 + 结果回传
把上面的实现抽象出来,子代理机制就两件事:上下文隔离和结果回传。
4.1 上下文隔离:子代理自己的会话
第一件事,子代理必须有自己的会话。spawn 开一个全新 SessionLog,fork 复制前缀——两种切法,本质相同:子代理活在一条自己的事件流里,主会话活在另一条。
为什么必须隔离?回到《动手开发你的第一个 agent :让它有记忆》那个总论点——会话是数据库,模型每次请求都要能从历史重建。如果子代理和主会话共用一条历史,主会话就会被子代理的中间过程污染:它读文件读了一半、试了个思路又放弃,这些「草稿」全进主会话日志,上下文越滚越大,模型看到的东西越来越杂。
隔离之后,子会话的思考过程不进入父会话日志。上面那张主会话日志表就是证据:10 条事件,干净利落,子代理读文件读到什么,父会话完全不知道。
隔离有一个直接后果,也是 Claude Code 官方反复强调的:子代理对主会话无记忆,prompt 必须自包含。 spawn 的子代理从一张白纸开始,它不知道你俩之前聊过什么。所以任务描述要自带背景——「把『读文件 package.json』这个子任务派给 spawn 子代理」这句话,落到子会话里变成一条干净的「读文件 package.json」。fork 子代理继承了前缀,知道「package.json 已经读过了」,但它对「切点之后」同样一无所知。
这个「无记忆」不是缺陷,是特性。它让子任务真正可独立验证:你给子代理的任务描述,就是它的全部输入;它回传的结论,就是它的全部输出。中间发生了什么,你可以选择不看。
4.2 结果回传:子代理即工具
第二件事,子代理的产出怎么回来。我的答案是:走工具结果的管道。
spawn_subagent 和 fork_subagent 就是两个普通工具,注册进主会话的 ToolRegistry,被《动手开发你的第一个 agent 02》的 ToolPipeline 接管。主模型输出一个 toolCall,流水线调 execute,execute 内部跑一个子 loop,子 loop 的最终文本被 execute return 出来,流水线把它转成 toolResult 写回主会话日志。主模型下一轮 step 读到这个 toolResult,总结一句。整条链路和「读文件」没有任何区别——除了 execute 内部多跑了一个 loop。
这就是核心主张的第二半:subagent 即工具。 子代理没有另起炉灶,它复用你早就建好的工具流水线。这带来两个直接好处。
- 不需要新机制。 ToolPipeline、toolResult 回写、审批——子代理走的就是主工具的路。你给工具加的每一道闸,子代理天然继承。
- 可组合。 既然子代理是工具,那子代理的工具也可以注册子代理——递归派活。我的实现里子代理手里只给了 read_file(没给派活工具),但
makeSubagentTools的注释写明白了:把子工具注册表也传给子会话,它就能继续往下派。子代理即工具,所以它能套娃。
这个「子代理是工具」的判断,跟我《动手开发你的第一个 agent 02》里那句话是一个意思:工具是插件,不是特例。 子代理更是如此——它不是 harness 里的一种特殊角色,它就是一个 execute 内部嵌套了另一个 loop 的工具。
5. 对照 dsh:你的 spawn/fork 是 SubagentProvider 的最小投影
照本系列的惯例,写完对照一遍。你的 spawn/fork,是 dsh SubagentProvider 五类实现里前两个的最小投影。
| 你写的(step5) | dsh 对应 | 位置 |
|---|---|---|
SessionLog.spawn() 空会话 | spawn 实现,inheritsParentContext = false | subagent-spawn-in-process/src/index.ts:41 |
SessionLog.fork(seq) 继承前缀 | fork 实现,inheritsParentContext = true | subagent-fork-in-process/src/index.ts:48 |
spawn_subagent / fork_subagent 工具 | 「子 agent 从哪来」做成接缝 | SubagentProvider types.ts:285 |
runSubagent 内部再跑一个 ReactLoop | 子 agent 也是独立 loop(ReactLoopAgent) | 我的 dsh 拆解文详述 |
| 子代理产出当 toolResult 回传 | 父会话只收最终答案 | 我的 dsh 拆解文详述 |
dsh 比我多做了什么,三件事。
第一,接口化。 我的 spawn/fork 是 SessionLog 上的两个方法,dsh 把它们抽象成一个 SubagentProvider 接口(types.ts:285)——「什么是一次委派」被定义成接口,实现随便换。我的是方法,它的是接缝。
第二,二元选择显式化。 我在《gent Harness 架构到底需要些什么》那篇讲过,多 Agent 最关键的一个旋钮是「子 Agent 继承不继承父上下文」。dsh 用一个 inheritsParentContext 布尔值把它显式化——spawn 传 false,fork 传 true。我的实现里这个选择藏在 mainLog.fork() 和 null 的区别里,语义一样,但没起名。起名有价值:它逼你把「从哪来」当成一个明确的维度去思考。
第三,后端可以是别的 harness。 五类实现里,acp 是进程外的 ACP 子 agent,claude-code / codex 更是把一轮委派丢给 Claude Code 或 Codex 的进程——父会话只收最终答案。这意味着 harness 之间不是互斥的,是可以互相嵌套的:你的主 harness 可以把脏活外包给别的 harness。我的最小实现只在一个进程里 spawn/fork,这是「换后端」接缝的最内层。真要接 Claude Code 当子代理,你的 spawn 原语外面包一层进程调用就行——接缝留好了,外面的事是协议的问题,不是架构的问题。
6. 结论:子代理 = 把任务拆出去 + 把上下文隔离
收束。这篇给你的不是一堆新对象,是两个原语和一个判断。
两个原语:spawn 从零开子会话,fork 继承前缀切子会话。加起来不到十行。
一个判断:子代理不是「更多 agent」,是「把任务拆出去 + 把上下文隔离」。拆出去,主会话不被大任务撑爆;隔离,子代理的草稿进不了主会话的真相。子代理即工具,结果回传走的是你《动手开发你的第一个 agent 02》就建好的工具流水线——没造任何新管道。
&spm=1001.2101.3001.5002&articleId=163865045&d=1&t=3&u=6a2c09c2de9040fab7401f42b3557df5)
303

被折叠的 条评论
为什么被折叠?



