一文带你聊聊multi-agent,怎么fork多个子agent解决复杂问题
Multi-agent,多么迷人的概念。再也不怕我的agent分身乏术。我的agent能多重影分身出多个子agent,而且个个拥有一样多的查克拉(上下文)。
前言
我让 Agent 做过一个任务:把仓库里所有还在用旧鉴权方式的调用点找出来。
答案是大概一百字。代价是读完三十个文件。
读到第二十三个文件的时候,上下文里已经堆了十几万字符的源码------其中二十九个和结论没有任何关系,被读进来只是为了被排除掉。而这些内容不会自己消失:从那以后每一轮请求,都得带着它们重发一遍。
压缩能救一部分。可压缩这件事本身有两面:压得太狠,比如直接上摘要,那个"第 17 个文件里的写法其实是例外"的细节就没了;压得太轻,回收的空间又不够撑完剩下的七个文件。
所以还有第三条路:把任务切开,让另一个人去读。
子 Agent 出去读三十个文件,烧掉十几万字符,回来只说两句话。父 Agent 的上下文里从头到尾只有那两句话。
这一篇讲的就是这条路怎么走,以及它的代价落在哪。
子 Agent 解决的是哪一类问题
先说清楚它不解决什么,免得把一个工具用成万能药。
适合派出去的,是那种"答案短、过程长"的任务。 搜索、审查、调研都属于这一类:读二十个文件,输出一段结论;跑一圈 grep,回答"这个函数被谁调用过"。
不适合的是需要连续决策的活。 每一步都依赖上一步结果的调试、改造、重构------这种任务拆开之后,上下文要在几个 Agent 之间来回传,传的过程本身又是一笔开销,而且每次传递都在丢信息。
还有一种情况更常见,也更容易被忽略:一个 Agent 顺手就能干完的事。
拆出去不是免费的。子 Agent 自己也要起一轮循环、也要读文件、也要烧 token,它的那部分开销一分不少,只是不记在父头上。
更麻烦的是排查成本。三个 Agent 的上下文互相交织,出了错你得先判断是谁错了------协调多个 Agent 本身就是一笔开销,每多一个 Agent,潜在交互路径是翻倍涨的。
所以我会把这句话当成一条原则用:能用一个 Agent 解决的,不拆。
只有在父 Agent 的上下文会被"过程"撑爆,而"结论"又很轻的时候,拆才划算。
拆出去的那部分,不进父的上下文
机制本身简单到有点朴素:每个子 Agent 有自己的 messages 数组。
父 Agent 的全部对话历史一个字都不给它,它拿到的只有一条用户消息,就是那句任务描述。它自己读文件、自己调工具,所有工具结果进的是它自己的数组。
跑完之后,父拿到的是它的最后一段文本------当成一次普通的工具结果,注入回父的上下文。
把上面那个任务缩到一屏能看完:6 个文件,每个几百字符。两条路线各跑一遍,一条是父自己读完 6 个文件,另一条是派 3 个子 Agent 各读 2 个。
路线 A 里父每调一次 read_file,工具结果就落进自己的数组,下一步的上下文比上一步高一截------读完第一个文件是 236 字符,读完第三个到 1,476,六个全读完是 3,505 字符,中间没有任何一步是可以省的。
路线 B 里父的上下文只有两次变化:发出去那一刻的 236 字符,和收到三段结果之后的 660 字符。中间那 8,747 字符的读文件过程,全发生在三个子 Agent 各自的数组里,父这边一个字都没经过。
| 指标 | 路线 A(自己读) | 路线 B(派出去) |
|---|---|---|
| 父上下文最终 | 3,505 字符 | 660 字符 |
| 父的 input 合计 | 13,969 字符 | 896 字符 |
| 子 Agent 的 input | 0 | 8,747 字符 |
| 全部 input 合计 | 13,969 字符 | 9,643 字符 |
父上下文小了 81%。这个数字才是拆子 Agent 的目的。
不过这里得说清楚账单的口径。上面这张表没算缓存,每一步都把当前上下文整个重发了一遍。算上缓存的话,两条路线都会便宜很多,而且路线 A 重复发送的那些前缀命中率更高------总账上两条路线会靠得很近,甚至反转。
所以拆的价值不该拿总账去论证。它的收益是父上下文的绝对大小:660 字符对 3,505 字符,而这个差值不是省下来的 token,是父在接下来的每一轮里都要重发的那部分。
一个 Agent 跑到第 40 轮的时候,那 3,505 字符已经在上下文里躺过 39 次了。
它必须能说出话来
子 Agent 跑完的日志里,会有这么一行:
ini
[Agent-1] 最后一步 toolChoice=none,只剩文本 → 48 字符
这一行是整个机制里最容易被漏掉的一环。
子 Agent 的产出必须是文本 ,不能是"又一个工具调用"。如果它跑到最后一步手上还挂着工具,有两种糟糕的结局:要么它继续调,父在那边干等,一直等到超时;要么它刚读完文件、还没来得及下结论就被掐断------父收到的是一段没有结论的中间状态,还得自己重跑一遍。
做法是在最后一步把工具摘掉:
arduino
toolChoice: isLastStep ? 'none' : 'auto'
再在最后一条 user 消息里补一句"你已经收集了足够的信息,请直接输出文字总结,不要再调用任何工具"。
这一句把子 Agent 的最后一轮从"继续干活"改成了"交作业"。 父拿到的东西因此永远是一段文本------这个保证不是靠提示子 Agent"记得总结一下",是靠 toolChoice: 'none' 在 API 层面堵死的。
超时走的是同一个思路。子 Agent 被掐断的时候,它已经读到的内容不会跟着一起丢,而是包成一段 [部分结果] 一起回去,够不够用由父自己判断------父拿到的应该是信息,不是状态。 一条被掐断的支线里,前半段读到的代码往往已经足够回答问题了,把它当成失败丢掉才是最贵的选择。
顺带一提,子 Agent 的循环里还有一句不太显眼的提示:
当你需要同时获取多个独立信息时(比如读多个文件、搜多个关键词),
尽可能在一次回复中并行调用多个工具,不要一个个串行调。
这话对父 Agent 只是"快一点",对子 Agent 是刚需------子 Agent 的延迟就是父的等待时间,它慢一步,父就多停一步。
派出去的时候,说多少
任务描述怎么写,是拆子 Agent 时最容易做错的一件事。
两种极端都试过。轻量委派 是只给任务不给上下文------"找出仓库里还在用旧鉴权的地方",怎么写、往哪找,子 Agent 自己决定。重量委派是连上下文一起给------"这三个文件里,第 17 到 24 行的调用方式可能是旧的,你确认一下"。
选哪种,看父知不知道答案的一半。
父自己也没头绪的时候,轻量委派更好,给了方向反而会把它带偏。父已经定位到范围的时候,重量委派能省掉子 Agent 好几步探索。
但有一类东西永远不该塞进去:父自己那一长串探索过程。 你 grep 了八次才定位到这三个文件------这八次的过程对子 Agent 没有任何价值,有价值的是"这三个文件"。把过程也塞进去,拆的意义就被抵消了一半:你花力气清理出去的东西,又从任务描述里溜回来了。
结果怎么回来
这一环各家做法差别很大,是观察不同 Agent 实现的一个好窗口。
| Claude Code | openClaw | opencode | |
|---|---|---|---|
| 机制 | 同步:结果当成工具结果直接注入父上下文 | 异步:结果进通知队列,父空闲了再取 | 分阶段编排,按阶段详略 |
| 超长结果 | 超过阈值直接截断 | 队列里排队 | 按阶段裁剪 |
| 父会阻塞吗 | 会,要等子 Agent 跑完 | 不会 | 不会 |
| 额外开销 | 无,复用同一条工具管线 | 防抖 + 指数退避重试 | 编排层 |
openClaw 那个队列还加了一秒防抖,防止子 Agent 频繁返回结果反复打断父 Agent。这个细节值得看一眼:它承认了"父被打断"是有成本的------所以宁可晚一秒,也不要连着打断三次。
我走的是 Claude Code 那条同步路。理由不是它更好,是它更省事:父的一次工具调用本来就是同步的,子 Agent 的产出一段文本,天然就是一次工具结果。这意味着结果回到父这里时,截断、加锁、Hook 这些已经写好的东西自动生效------不需要为"子 Agent 的结果"再新增一条通道。
代价写得明明白白:父在等。 子 Agent 跑四十秒,父就停四十秒。所以并行派发不是优化项而是必需项------3 个子 Agent 串行是一百二十秒,并行还是四十秒。
同时跑的几个,怎么不互相踩
子 Agent 之间共享什么、隔离什么,是这一整套东西里最需要提前想清楚的部分。行业里大致有四档做法。
| 做法 | 隔离掉了什么 | 代价 |
|---|---|---|
| 同进程 + 各自独立的变量 | 上下文 | 全局状态、文件系统仍然共享 |
| AsyncLocalStorage | 请求级上下文,不用手传 | 隔离不彻底,谁改全局变量谁连累别人 |
| git worktree | 文件系统也隔离 | 每个子 Agent 一份工作目录,占磁盘,改完要合并 |
| 队列串行化 | 全部,天然不冲突 | 慢,并发收益清零 |
Claude Code 是混着用的:上下文走第一档,需要做破坏性操作的子 Agent 走 worktree,子 Agent 的权限请求写到 pending 目录,父轮询到之后替它问用户。
opencode 走了另一条极端:把文件操作强行串行化,同一时间只允许一个 Agent 动文件。这样它根本不需要做隔离------省掉了所有并发控制,代价是文件操作变慢。
我这套东西的隔离边界落在第一档:每个子 Agent 一个独立的 messages 数组,同一个进程、同一个文件系统、同一份工具注册表。worktree 和队列那两档,不在这套方案里。
这里还有一处边界容易被忽略------子 Agent 拿到的工具,是不带锁的那一份。
ini
const tools = ctx.registry.toAISDKFormatUnlocked(EXCLUDED_TOOLS)
带锁的 toAISDKFormat 里包了三样东西:bash 风险分类、前置/后置 Hook、以及按 isConcurrencySafe 分的读写锁。Unlocked 那份只保留了两样------结果截断和角色权限检查。
也就是说,Hook 和读写锁的作用域是"父 Agent 自己的循环",没有延伸到子 Agent 内部。 这么选是为了不让子 Agent 连着读几个文件时互相排队,代价写在明面上:两个子 Agent 同时改同一个文件,那把锁不在场。
要往 worktree 那一档走,得先把这条边界收回来------文件系统隔离和写入串行是同一件事的两面,锁如果只留在父的循环里,光换个工作目录并不能解决问题。
完整代码
下面这个 demo 把同一个任务跑两遍,记一遍父上下文的涨幅和两边的 input 账单。它想让你看见的是------拆出去的那部分上下文是真实存在过的,只是没有留在父的记忆里。
javascript
// spawn-agent:用独立上下文换一个干净的父 Agent
// 运行:node spawn-agent.js
//
// 同一个任务跑两遍:父 Agent 自己读完 6 个文件,和派 3 个子 Agent 各读 2 个。
// 两边都记一遍父上下文的涨幅,以及各自给模型发了多少 input。
// 账单口径是不算缓存:每一步都要把当前上下文整个重发一遍。
const tok = chars => Math.ceil(chars / 3.5)
const fmt = n => n.toLocaleString('en-US')
const width = s => [...s].reduce((a, c) => a + (c.charCodeAt(0) > 255 ? 2 : 1), 0)
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)))
// 父和子的每一轮请求都要带上的系统提示词(数字取自 prompt-pipe 那一篇的场景 1)
const SYSTEM_CHARS = 217
// 子 Agent 额外带的一段说明:直接出结论、能并行就并行
const CHILD_SUFFIX_CHARS = 118
const PARENT_TASK = '把这 6 个文件里遗留的调试输出找出来'
// ── 6 个待检查的文件 ────────────────────────────────────
const FILES = {
'src/auth/login.ts': `import { sign } from '../lib/jwt'
export async function login(req, res) {
const { user, pass } = req.body
console.log('login payload', req.body)
if (!user || !pass) return res.status(400).json({ error: 'missing' })
const account = await db.findUser(user)
if (!account) return res.status(401).json({ error: 'no such user' })
const ok = await verify(pass, account.hash)
if (!ok) {
console.log('bad password for', user)
return res.status(401).json({ error: 'bad password' })
}
const token = sign({ sub: account.id }, process.env.JWT_SECRET)
res.cookie('token', token, { httpOnly: true })
return res.json({ ok: true })
}`,
'src/auth/session.ts': `const SESSIONS = new Map()
const SESSION_TTL = 1000 * 60 * 60 * 8
export function createSession(userId) {
const id = crypto.randomUUID()
SESSIONS.set(id, { userId, createdAt: Date.now() })
return id
}
export function getSession(id) {
const s = SESSIONS.get(id)
if (!s) return null
if (Date.now() - s.createdAt > SESSION_TTL) {
SESSIONS.delete(id)
return null
}
return s
}
export function dropSession(id) {
SESSIONS.delete(id)
}`,
'src/tools/registry.ts': `const tools = new Map()
export function register(tool) {
if (tools.has(tool.name)) {
console.warn('duplicated tool', tool.name)
return
}
tools.set(tool.name, { ...tool, calls: 0 })
}
export function get(name) {
const t = tools.get(name)
if (!t) throw new Error('unknown tool: ' + name)
t.calls++
return t
}
export function list() {
return [...tools.values()].map(t => ({ name: t.name, calls: t.calls }))
}`,
'src/tools/file-tools.ts': `import { readFile, writeFile } from 'node:fs/promises'
export const readTool = {
name: 'read_file',
description: '读取文件内容',
async execute({ path }) {
const text = await readFile(path, 'utf8')
console.log('read', path, text.length)
return text
},
}
export const writeTool = {
name: 'write_file',
description: '写入文件',
async execute({ path, content }) {
await writeFile(path, content, 'utf8')
return 'ok'
},
}`,
'src/loop/agent-loop.ts': `export async function agentLoop(model, registry, messages) {
let step = 0
while (step < MAX_STEPS) {
step++
const result = await model.chat(messages, registry.list())
messages.push(result.message)
if (!result.toolCalls?.length) return messages
for (const call of result.toolCalls) {
const tool = registry.get(call.name)
const output = await tool.execute(call.input)
console.log('tool done', call.name, output.length)
messages.push({ role: 'tool', content: String(output) })
}
}
return messages
}`,
'src/loop/retry.ts': `const MAX_RETRY = 3
export async function withRetry(fn, isRetryable) {
let lastErr
for (let i = 0; i < MAX_RETRY; i++) {
try {
return await fn()
} catch (err) {
lastErr = err
if (!isRetryable(err)) throw err
await sleep(2 ** i * 500)
}
}
throw lastErr
}`,
}
const ALL_FILES = Object.keys(FILES)
const wrap = path => `--- ${path} ---\n${FILES[path]}`
// 找出调试输出在第几行;正则和行号都从文件内容里现算,不写死
function locate(paths) {
const hits = []
for (const path of paths) {
FILES[path].split('\n').forEach((line, i) => {
if (line.includes('console.log(')) hits.push({ path, line: i + 1 })
})
}
return hits
}
const summarize = paths => paths.map(p => {
const lines = locate([p]).map(h => h.line)
return lines.length ? `${p} 第 ${lines.join('、')} 行` : `${p} 无`
}).join(';')
// ── 路线 A:父 Agent 自己读 ─────────────────────────────
function runAlone() {
console.log('路线 A:父 Agent 自己读全部 6 个文件\n')
let ctx = SYSTEM_CHARS + PARENT_TASK.length
let billed = 0
let n = 0
const step = (label, chars) => {
billed += chars
console.log(` Step ${++n} ${pad(label, 32)}父上下文 ${fmt(chars)} 字符`)
}
step('发起任务', ctx)
for (const path of ALL_FILES) {
step(`read_file(${path})`, ctx)
ctx += wrap(path).length + 40 // 工具结果的包装开销
}
step('得出答案', ctx)
const answer = `${ALL_FILES.length} 个文件里还有 ${locate(ALL_FILES).length} 处 console.log`
ctx += answer.length
console.log(`\n 父上下文最终:${fmt(ctx)} 字符 / 约 ${fmt(tok(ctx))} tokens`)
console.log(` 发给模型的 input 合计:${fmt(billed)} 字符 / 约 ${fmt(tok(billed))} tokens`)
return { ctx, billed }
}
// ── 路线 B:派子 Agent ──────────────────────────────────
function spawnAgent({ label, files, hang = false }) {
const task = `检查这几个文件里是否还有遗留的调试输出,逐个给出文件名和行号:${files.join('、')}`
console.log(` ${label} 启动:${task.slice(0, 34)}...`)
let own = SYSTEM_CHARS + CHILD_SUFFIX_CHARS + task.length
let billed = 0
let n = 0
for (const path of files) {
console.log(` ${label} Step ${++n} 调用 read_file(${path}) 自己上下文 ${fmt(own)} 字符`)
billed += own
own += wrap(path).length + 40
}
if (hang) {
billed += own
const partial = `已看完 ${files[0]},第 7 行有一处;${files[1]} 还没读完。`
console.log(` ${label} 超时 ✗ 返回部分结果 ${partial.length} 字符`)
return { label, result: `[部分结果] ${partial}`, own, billed }
}
// 最后一步把工具摘掉:这一轮只能出文本,出不了工具调用
billed += own
const result = summarize(files)
own += result.length
console.log(` ${label} 最后一步 toolChoice=none,只剩文本 → ${result.length} 字符`)
console.log(` ${label} 完成 √ 自己上下文峰值 ${fmt(own)} 字符`)
return { label, result, own, billed }
}
function runSpawned() {
const groups = []
for (let i = 0; i < ALL_FILES.length; i += 2) groups.push(ALL_FILES.slice(i, i + 2))
console.log(`路线 B:父 Agent 派 ${groups.length} 个子 Agent\n`)
let ctx = SYSTEM_CHARS + PARENT_TASK.length
let billed = ctx
console.log(` [父] Step 1 spawn_agent(${groups.length} 个任务) 父上下文 ${fmt(ctx)} 字符`)
const kids = groups.map((g, i) => spawnAgent({ label: `[Agent-${i + 1}]`, files: g }))
const packed = kids.map((k, i) => `## 子 Agent ${i + 1}: ${groups[i].join('、')}\n\n${k.result}`)
.join('\n\n---\n\n')
ctx += packed.length + 90
billed += ctx
console.log(`\n [父] Step 2 收到 ${kids.length} 段结果,父上下文 ${fmt(ctx)} 字符`)
const childBilled = kids.reduce((a, k) => a + k.billed, 0)
console.log(`\n 父上下文最终:${fmt(ctx)} 字符 / 约 ${fmt(tok(ctx))} tokens`)
console.log(` 父的 input 合计:${fmt(billed)} 字符 / 约 ${fmt(tok(billed))} tokens`)
console.log(` 子 Agent 的 input 合计:${fmt(childBilled)} 字符 / 约 ${fmt(tok(childBilled))} tokens`)
return { ctx, billed, childBilled }
}
// ── 收尾:三个边界 ──────────────────────────────────────
function edgeCases() {
console.log('\n── 边界一:并发上限 ──')
const MAX_CONCURRENT = 3
let active = 0
const tasks = ['检查 auth 模块', '检查 tools 模块', '检查 loop 模块', '顺便看看 README']
for (const t of tasks) {
if (active >= MAX_CONCURRENT) {
console.log(` 派发「${t}」→ [spawn] 拒绝: 已达到最大并行子Agent数 ${MAX_CONCURRENT}`)
continue
}
active++
console.log(` 派发「${t}」→ 完成`)
}
console.log('\n── 边界二:子 Agent 超时 ──')
const timed = spawnAgent({ label: '[Agent-1]', files: ['src/auth/login.ts', 'src/auth/session.ts'], hang: true })
console.log(` 父收到的工具结果:${timed.result.slice(0, 34)}... (共 ${timed.result.length} 字符)`)
console.log(' 超时不当失败处理:已经读到的部分照样回去,父不用重跑这一路')
console.log('\n── 边界三:子 Agent 的手里没有 spawn_agent ──')
console.log(' 嵌套深度天然就是一层,不用在运行时判断"我现在在第几层"')
}
function main() {
console.log(`=== ${PARENT_TASK} ===\n`)
const a = runAlone()
console.log('\n' + '─'.repeat(56) + '\n')
const b = runSpawned()
console.log('\n── 两条路线的对照 ──')
console.log(` ${pad("指标", 22)}${pad('路线 A(自己读)', 20)}路线 B(派出去)`)
const row = (name, x, y) => console.log(` ${pad(name, 22)}${pad(x, 20)}${y}`)
row('父上下文最终', fmt(a.ctx) + ' 字符', fmt(b.ctx) + ' 字符')
row('父的 input 合计', fmt(a.billed) + ' 字符', fmt(b.billed) + ' 字符')
row('子 Agent 的 input', '0', fmt(b.childBilled) + ' 字符')
row('全部 input 合计', fmt(a.billed) + ' 字符', fmt(b.billed + b.childBilled) + ' 字符')
console.log(`\n 父上下文 ${fmt(a.ctx)} → ${fmt(b.ctx)}(小了 ${(100 - b.ctx / a.ctx * 100).toFixed(0)}%)`)
console.log(' 这个差值不是省下来的 token,它是父在接下来的每一轮里都要重发的那部分')
edgeCases()
}
main()
跑出来是这样:
scss
=== 把这 6 个文件里遗留的调试输出找出来 ===
路线 A:父 Agent 自己读全部 6 个文件
Step 1 发起任务 父上下文 236 字符
Step 2 read_file(src/auth/login.ts) 父上下文 236 字符
Step 3 read_file(src/auth/session.ts) 父上下文 952 字符
Step 4 read_file(src/tools/registry.ts)父上下文 1,476 字符
Step 5 read_file(src/tools/file-tools.ts)父上下文 1,978 字符
Step 6 read_file(src/loop/agent-loop.ts)父上下文 2,493 字符
Step 7 read_file(src/loop/retry.ts) 父上下文 3,117 字符
Step 8 得出答案 父上下文 3,481 字符
父上下文最终:3,505 字符 / 约 1,002 tokens
发给模型的 input 合计:13,969 字符 / 约 3,992 tokens
────────────────────────────────────────────────────────
路线 B:父 Agent 派 3 个子 Agent
[父] Step 1 spawn_agent(3 个任务) 父上下文 236 字符
[Agent-1] 启动:检查这几个文件里是否还有遗留的调试输出,逐个给出文件名和行号:src...
[Agent-1] Step 1 调用 read_file(src/auth/login.ts) 自己上下文 403 字符
[Agent-1] Step 2 调用 read_file(src/auth/session.ts) 自己上下文 1,119 字符
[Agent-1] 最后一步 toolChoice=none,只剩文本 → 48 字符
[Agent-1] 完成 √ 自己上下文峰值 1,691 字符
[Agent-2] 启动:检查这几个文件里是否还有遗留的调试输出,逐个给出文件名和行号:src...
[Agent-2] Step 1 调用 read_file(src/tools/registry.ts) 自己上下文 411 字符
[Agent-2] Step 2 调用 read_file(src/tools/file-tools.ts) 自己上下文 913 字符
[Agent-2] 最后一步 toolChoice=none,只剩文本 → 53 字符
[Agent-2] 完成 √ 自己上下文峰值 1,481 字符
[Agent-3] 启动:检查这几个文件里是否还有遗留的调试输出,逐个给出文件名和行号:src...
[Agent-3] Step 1 调用 read_file(src/loop/agent-loop.ts) 自己上下文 406 字符
[Agent-3] Step 2 调用 read_file(src/loop/retry.ts) 自己上下文 1,030 字符
[Agent-3] 最后一步 toolChoice=none,只剩文本 → 49 字符
[Agent-3] 完成 √ 自己上下文峰值 1,443 字符
[父] Step 2 收到 3 段结果,父上下文 660 字符
父上下文最终:660 字符 / 约 189 tokens
父的 input 合计:896 字符 / 约 256 tokens
子 Agent 的 input 合计:8,747 字符 / 约 2,500 tokens
── 两条路线的对照 ──
指标 路线 A(自己读) 路线 B(派出去)
父上下文最终 3,505 字符 660 字符
父的 input 合计 13,969 字符 896 字符
子 Agent 的 input 0 8,747 字符
全部 input 合计 13,969 字符 9,643 字符
父上下文 3,505 → 660(小了 81%)
这个差值不是省下来的 token,它是父在接下来的每一轮里都要重发的那部分
── 边界一:并发上限 ──
派发「检查 auth 模块」→ 完成
派发「检查 tools 模块」→ 完成
派发「检查 loop 模块」→ 完成
派发「顺便看看 README」→ [spawn] 拒绝: 已达到最大并行子Agent数 3
── 边界二:子 Agent 超时 ──
[Agent-1] 启动:检查这几个文件里是否还有遗留的调试输出,逐个给出文件名和行号:src...
[Agent-1] Step 1 调用 read_file(src/auth/login.ts) 自己上下文 403 字符
[Agent-1] Step 2 调用 read_file(src/auth/session.ts) 自己上下文 1,119 字符
[Agent-1] 超时 ✗ 返回部分结果 56 字符
父收到的工具结果:[部分结果] 已看完 src/auth/login.ts,第 7 行... (共 63 字符)
超时不当失败处理:已经读到的部分照样回去,父不用重跑这一路
── 边界三:子 Agent 的手里没有 spawn_agent ──
嵌套深度天然就是一层,不用在运行时判断"我现在在第几层"
总结
拆子 Agent 这件事,拆到最后牵出来的是三个概念:上下文归谁、隔离到什么粒度、以及两个 Agent 之间该传递什么。
子 Agent 换的是一份干净的上下文,不是更少的 token。 不算缓存时总账可能更低,算上缓存两条路线会拉平------但"父只有 660 字符"这件事不受缓存影响,而这个差值父在接下来的每一轮里都要重发。所以衡量该不该拆的指标是父上下文的绝对大小,从来不是总消耗。
上下文隔离和执行隔离是两回事。 一个独立的 messages 数组只隔离了记忆:同一个进程、同一个文件系统、同一份工具注册表都还在共享。行业里的四档做法------同进程变量、AsyncLocalStorage、git worktree、队列串行化------每一档隔离掉的东西和它的代价是一一对应的,选哪一档取决于你能接受哪种脏。
Agent 之间传递的必须是数据,不是状态。 子 Agent 的最后一步要把工具摘掉,让它在 API 参数层面只能出文本;被超时掐断时也不能算失败,已经读到的部分照样包成 [部分结果] 回去。这两件事指向同一条约束------一个还在干活的子 Agent,对父来说等于没有结果。
委派的信息量是守恒的。 给目标、给范围都可以,唯独不要给"我是怎么找到这里的"。父自己那八次 grep 的过程对子 Agent 没有价值,把它塞进任务描述,等于把你刚清理出去的上下文又装回来了。
可延伸的方向
- 把子 Agent 的工具绑定换成带锁的那一份,让它的写入重新排队;再决定要不要往前走一步到 worktree 那一档。
- 试一次异步回传:让父在等子 Agent 的时候继续干别的,看等待时间能换成多少实际产出。
- 给回传的结果加一道截断阈值------现在几个子 Agent 各回两千字,父的上下文就涨两千字乘几,这道口子还开着。
一句话收尾:拆子 Agent 的本质不是并行,是让"过程"和"结论"分开记账------过程在子 Agent 那里报销,父的账本上只记结论。 下次再问"要不要拆",先看那个答案有多短,再看父会不会被过程撑爆;两样都不成立,就还是一个 Agent 干到底。