多重影分身!恨不得把一个agent掰成两个? 还真能干!

一文带你聊聊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 的全部对话历史一个字都不给它,它拿到的只有一条用户消息,就是那句任务描述。它自己读文件、自己调工具,所有工具结果进的是它自己的数组。

跑完之后,父拿到的是它的最后一段文本------当成一次普通的工具结果,注入回父的上下文。

sequenceDiagram participant P as 父 Agent participant R as ToolRegistry participant C as 子 Agent P->>R: spawn_agent(task) R->>C: 新建 messages = [task] Note over C: 自己的循环:读文件、grep<br/>工具结果只进自己的数组 C->>C: 最后一步 toolChoice=none,只能出文本 C-->>R: 一段结论 R-->>P: 当成一次工具结果注回去

把上面那个任务缩到一屏能看完: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 干到底。

相关推荐
武子康2 小时前
CLAUDE.md 引用 AGENTS.md 后,两边真的读到同一套规则吗?
人工智能·llm·agent
染指11102 小时前
119.Agent-LangChain核心组件-Runtime运行时
人工智能·langchain·agent
李溪白3 小时前
篇四:记忆 —— 让 Agent 记住之前说过什么
agent
武子康3 小时前
TP、PP、DP、EP 如何组合:八张卡,怎样淘汰不合适的配置
人工智能·llm·agent
半桶水专家3 小时前
初识 LLM 应用开发
llm
爱丶不疚4 小时前
Skill的形态有哪些?什么样的Skill 叫做好Skill?
人工智能·agent
粥里有勺糖4 小时前
视野修炼第133期 | Native 回春了?
前端·github·agent
李溪白4 小时前
篇三:从手写循环到 LangGraph —— 把 Agent 循环交给框架
agent
4SAPI4 小时前
大模型接口管理平台推荐:多模型时代的API Gateway架构与选型分析
人工智能·agent