开场:读完二十个文件,它忘了要干什么
有一次我接手一个陌生的项目,想先让 mini-agent 帮我摸摸底:"看看这个仓库的每个模块都是干什么的,然后告诉我,要给订单加一个'部分退款'的功能,应该改哪几个地方。"
它很认真。list_files,然后一个模块一个模块地读:order/、payment/、refund/、notify/……每个模块读三四个文件。二十多个文件读完,上下文已经涨到了十几万 token,触发了一次自动压缩(第 28 篇)。
压缩之后,它给出了每个模块的介绍,写得还不错。然后它说:"以上是各模块的概况。请问您接下来想做什么?"
它忘了我问题的后半句。 那句"要加部分退款,应该改哪几个地方",在二十多个文件的内容和一次压缩之后,已经不在它的注意力范围里了。
我把问题的后半句又说了一遍,它才开始回答,而且回答得有点含糊,因为那些文件的细节已经在压缩里丢了。我当时就想:读这二十个文件,是一件"读得多、但最后只需要一个结论"的事情。为什么要让这些文件全部堆在主对话的上下文里?如果能派一个"分身"去读,让它只把结论带回来,主对话的上下文就能一直保持干净。
这就是 subagent(子 Agent)。这一篇我们给 mini-agent 加上分身的能力。读完本篇,你应该能回答这几个问题:
- subagent 解决的是什么问题?
task工具是怎么实现的?子 Agent 和父 Agent 共享什么、不共享什么?- 怎么让多个子 Agent 并行干活?并行有什么限制?
- 怎么防止子 Agent 再开子 Agent,无限套娃?
- 多开分身的代价是什么?什么时候不该用?
- 一个分身失败了,或者偷偷改了文件,会发生什么?
一、分身解决的问题
1.1 上下文隔离
第 14 篇讲过上下文工程的四个基本动作:写入、选择、压缩、隔离。subagent 就是"隔离"的那一个。
然后告诉我加部分退款要改哪里"] M2["task:调研各模块"] M3["报告:300 字"] M4["……继续回答后半个问题"] end subgraph C["子 Agent 的上下文(用完就扔)"] C1["任务说明"] C2["读了 20 个文件
十几万 token"] C3["写报告"] end M2 -->|"派出去"| C1 C3 -->|"只带回报告"| M3 style M fill:#e8f5e9,stroke:#2e7d32 style C fill:#fff9c4
子 Agent 有一个全新的、独立的上下文。它在自己的上下文里读文件、跑命令,可能塞进去十几万 token。但这些都留在它自己那里;它完成任务后,只把最后那份报告交回给主 Agent,作为 task 工具的结果。然后它的上下文就被丢弃了。
主 Agent 的上下文里,只多了一个工具调用和一段几百字的报告。用户问题的后半句,依然清清楚楚地在那里。
1.2 本质:用多花 token 换主上下文干净
agent/subagent.go 开头的注释把这件事说得很直白:
// task 工具开一个拥有全新上下文的子循环。子 Agent 可能读了几十个文件、跑了一堆命令,
// 但这些都留在它自己的上下文里,主 Agent 只拿到最后那份报告。
// 本质上是用"多花 token"换"主上下文干净"。它不省 token,反而更费。 这一点后面专门讲。
二、task 工具
2.1 一个工具,开一个新的 Agent
subagent 的实现出奇地简单:它就是一个工具,这个工具的 Run 方法里,新建了一个 Agent,然后调用它的 Send。
func (t *taskTool) Run(ctx context.Context, input json.RawMessage) (string, error) {
// ……解析 description 和 prompt
p := t.parent
child := New(t.model, subagentPrompt+"\n\n"+t.env, t.tools, &childUI{parent: p.UI, label: in.Description})
child.Gate = p.Gate
child.MaxSteps = 30
child.ContextBudget = p.ContextBudget
child.MaxTokens = p.MaxTokens
// ……
report, err := child.Send(ctx, in.Prompt)
p.addUsage(child.Usage) // 分身花的钱也是钱
// ……
return strings.TrimSpace(report) + stats, nil
}child.Send(ctx, in.Prompt):子 Agent 用的就是第 24 到 29 篇写的那个 Send,同一个循环、同一套工具执行、同一套上下文管理。Agent 套 Agent,不需要任何新机制。 这是前面几篇把 Agent 设计成一个独立的、可复用的结构体所带来的回报。
2.2 工具定义
Description: "启动一个子 Agent 独立完成一项任务,它有自己全新的上下文,完成后只返回一份简短的报告。" +
"适合:需要读很多文件才能回答的调研问题(比如\"找出所有调用了 X 的地方并总结用法\");" +
"几个互不相关、可以同时进行的调查(在一次回复里发起多个 task 调用,它们会并行执行)。" +
"不适合:简单的、一两次工具调用就能完成的事情——直接自己做更快更省。" +
"子 Agent 看不到当前对话,prompt 里必须写清楚背景、要做什么、要报告什么。",两个参数:
description:3 到 10 个字的任务标题,用于在界面上显示;prompt:交给子 Agent 的完整任务说明。
描述里最重要的是最后一句:"子 Agent 看不到当前对话,prompt 里必须写清楚背景、要做什么、要报告什么。"
这是用 subagent 时最容易犯的错误:主 Agent 写了一个"帮我查一下那个问题"的 prompt,可子 Agent 根本不知道"那个问题"是什么。第 01 篇的老话:模型知道的一切,都在这次请求的输入里。 对子 Agent 来说,它的全部输入就是 system prompt 加上这一句 prompt。
2.3 子 Agent 的 system prompt
const subagentPrompt = `你是一个子 Agent,主 Agent 把一项具体任务委派给了你。你看不到主 Agent 和用户之间的对话,
你知道的只有下面的任务描述。
<rules>
- 只做任务描述里要求的事,做完就停。
- 你的最终回答会原样交给主 Agent,它看不到你中间读过的文件和命令输出。
所以最终回答要自成一体:结论、关键证据(文件路径和行号)、不确定的地方。
- 报告要精炼,通常不超过 300 字。不要贴大段代码,引用位置就够了。
</rules>`这段 prompt 解决的是报告的质量。子 Agent 的报告会作为工具结果进入主 Agent 的上下文,所以:
- 要自成一体:主 Agent 看不到子 Agent 读过的东西,报告里说"如上所述",主 Agent 不知道"上"是什么;
- 要带证据:文件路径和行号。主 Agent 如果需要细节,可以自己去读那几行,而不用重新调研一遍;
- 要精炼:不超过 300 字,不贴大段代码。否则隔离就失去了意义,子 Agent 把十几万 token 的内容又原样搬了回来。
后面拼上的 t.env,是第 24 篇的环境信息(工作目录、操作系统、日期),主 Agent 和子 Agent 共用。
2.4 共享什么,不共享什么
几个值得说的:
权限策略是共享的。 child.Gate = p.Gate:子 Agent 执行命令时,过的是同一套权限检查,用户之前选过"本次会话都允许"的命令,子 Agent 也不用再问。子 Agent 不能绕过父 Agent 的权限。
工具只给一部分。 mini-agent 在 main.go 里给子 Agent 的工具是:
// 第 30 篇:子 Agent 只有只读工具和终端,用自己的 Workspace(它读过的文件不算主 Agent 读过)。
childWS := tools.NewWorkspace(wd)
a.EnableSubagents(tools.NewRegistry(
tools.ReadFile{WS: childWS},
tools.ListFiles{WS: childWS},
tools.RunBash{WS: childWS},
), childModel, agent.EnvInfo(wd))读文件、列目录、跑命令,没有 edit_file。子 Agent 是去调研的,不是去改代码的。改代码这种需要整体把握的事,留给主 Agent。
读过哪些文件,各记各的。 第 26 篇的"改之前必须读过",靠的是 Workspace 记录读取时间。子 Agent 用一个单独的 childWS,它读过的文件,不算主 Agent 读过。否则主 Agent 可能在没有亲自读过一个文件的情况下就去改它,而它对这个文件的了解只来自子 Agent 那份 300 字的报告。
2.5 没有 edit_file,不等于不能改文件
这里要老实交代一个漏洞:子 Agent 虽然没有 edit_file,但它有 run_bash。而 run_bash 能做的事情,第 27 篇已经说过了:sed -i 能改文件,rm 能删文件,echo > 能写文件。
所以"子 Agent 只调研、不改代码",靠的不是工具箱里少了一个工具,而是两件事一起:
- 子 Agent 的 system prompt 里写着"只做任务描述里要求的事",主 Agent 写的任务说明也是调研性质的;
- 共享的权限策略:
sed和rm都不在白名单里,echo >属于第 27 篇说的"变味"写法,在 default 模式下都要问你。子 Agent 想偷偷改文件,你会在确认提示里看到它,提示前面还带着↳ [任务名]。
如果你在 yolo 模式下运行,这两道防线就只剩下第一道了:一段 prompt。更严格的做法是给子 Agent 一个"只读版"的 run_bash:只允许白名单里的命令,其他的一律拒绝,不问人。这是一个不错的练习,改的地方是 main.go 里创建子 Agent 工具的那几行,给子 Agent 单独配一个更严的 Gate。
能力的边界,要用权限来画,而不是用"少给一个工具"来暗示。 这一点在第 38 篇讲安全时还会再说一次。
2.6 子 Agent 的界面
子 Agent 说的话不显示给用户,只有它的工具调用会缩进显示在主界面上:
// childUI 把子 Agent 的工具调用缩进显示在父 Agent 的界面上;子 Agent 说的话不显示,只有最终报告会回到父 Agent。
func (u *childUI) ToolCall(name string, input json.RawMessage) {
u.parent.ToolCall(" ↳ ["+u.label+"] "+name, input)
}终端里看起来是这样的:
⏺ task {"description":"调研订单模块","prompt":"……"}
⏺ ↳ [调研订单模块] list_files {"path":"order"}
⏺ ↳ [调研订单模块] read_file {"path":"order/service.go"}
⏺ ↳ [调研订单模块] read_file {"path":"order/model.go"}用户能看到分身在干什么,但不会被它的中间输出淹没。这又一次用到了第 24 篇的 UI 接口:子 Agent 的 UI 是一个包装了父 UI 的 childUI,Agent 本身完全不知道自己是不是一个"子"。
三、并行
3.1 一次回复,多个分身
第 19 篇讲过,模型可以在一次回复里调用多个工具。如果它一次发起了三个 task 调用,这三个子 Agent 完全可以同时干活:
// parallelSafe 里的工具互不影响,一次响应里调用多个时可以并发执行(第 30 篇)。
// 会改东西的工具(edit_file、run_bash)一律串行:两个并发的编辑可能互相覆盖。
var parallelSafe = map[string]bool{"read_file": true, "list_files": true, "task": true}runTools 在执行一批工具调用时,如果它们全部都在这个"可以并行"的名单里,就并发执行:
// runTools 执行一批工具调用,结果顺序与调用顺序一致。
func (a *Agent) runTools(ctx context.Context, calls []llm.ContentBlock) []llm.ContentBlock {
results := make([]llm.ContentBlock, len(calls))
concurrent := len(calls) > 1
for _, c := range calls {
concurrent = concurrent && parallelSafe[c.Name]
}
if !concurrent {
// ……依次执行
return results
}
var wg sync.WaitGroup
for i, call := range calls {
a.UI.ToolCall(call.Name, call.Input)
wg.Add(1)
go func() {
defer wg.Done()
results[i] = a.runTool(ctx, call)
a.UI.ToolResult(call.Name, results[i].Content, results[i].IsError)
}()
}
wg.Wait()
return results
}注意 results[i]:结果按调用的顺序存放,而不是按完成的顺序。第 19 篇说过,结果要通过 id 和调用一一对应,按顺序放能避免很多麻烦。
同时进行 A-->>M: 报告 C-->>M: 报告 B-->>M: 报告 Note over M: 三份报告按调用顺序
放进同一条 user 消息
3.2 什么能并行,什么不能
只有互不影响的工具才能并行:读文件、列目录、开子 Agent(子 Agent 本身只有只读工具和终端)。
会改东西的工具一律串行。两个 edit_file 同时改同一个文件,后写的会覆盖先写的;两个 run_bash 同时跑,一个在 go build、一个在 git checkout,结果谁也说不清。并行的前提是互不干扰,没法保证就别并行。
3.3 并发时的权限确认
子 Agent 跑命令时,可能需要问用户"允许吗"。三个子 Agent 同时跑,就可能同时有三个问题要问。如果三个问题同时打印到终端,用户的回答就不知道是给谁的了。
所以权限策略里有一把锁,一次只问一个问题:
askMu sync.Mutex // 多个子 Agent 并发运行时,一次只问一个问题其他子 Agent 在问题排队时会等着。
3.4 记账
子 Agent 花的 token,要记到父 Agent 的账上:
p.addUsage(child.Usage) // 分身花的钱也是钱多个子 Agent 并发运行时,会同时往父 Agent 身上记账,所以 addUsage 也加了锁(第 24 篇的 usageMu)。task 的结果最后还会附上一行统计:
(子 Agent「调研订单模块」:14 条消息,输入 52031 token,输出 1203 token)主 Agent 和用户都能看到这个分身花了多少。
3.5 并行时,run_bash 也在并行
3.2 节说"会改东西的工具一律串行"。但仔细看那个名单:task 在可并行的名单里,而每个子 Agent 内部都有 run_bash。三个子 Agent 并行,就意味着它们各自的 run_bash 也可能同时在跑。
大部分时候这没问题:三个子 Agent 都在 grep、git log、go list,都是只读的。但如果其中一个跑了 go test ./...,另一个也跑了 go test ./...,它们会抢同一份构建缓存;如果一个在 git stash,另一个在 git diff,结果就说不清了。
这是并行带来的一个真实的风险,mini-agent 没有完全解决它,只是靠前面说的两点降低了概率:子 Agent 是调研性质的,修改性的命令要过权限检查。第 34 篇讲多 Agent 并行时,会用 git worktree 给每个 Agent 一份独立的工作目录,那才是彻底的隔离。
3.6 失败和取消
几个分身里,有一个没做完怎么办?
taskTool.Run 的处理是:子 Agent 的 Send 返回错误(比如 30 步用完了),就把它已经写出的部分报告连同统计信息,作为一个出错的工具结果交回去,错误信息是"子 Agent 没有完成任务"。主 Agent 看到的是:这个分身做了一部分,没做完。它可以基于这部分结果继续,也可以换一个说法再派一次,或者自己去读。一个分身失败,不影响其他分身,也不会让主 Agent 崩掉。
取消则是一起取消:所有子 Agent 用的是主 Agent 传下来的同一个 context。你按下 Ctrl+C,正在跑的几个分身会同时停下,各自给出"用户中断了操作"的结果(第 24 篇)。
四、防止无限套娃
子 Agent 能不能再开子 Agent?如果能,一个子 Agent 觉得任务太大,又派出三个分身;每个分身又派出三个……指数级的爆炸,账单也是指数级的。
防止的办法很简单:子 Agent 的工具箱里没有 task。
// EnableSubagents 给 Agent 加上 task 工具。reg 是子 Agent 能用的工具(不应包含 task 本身,防止无限套娃)。子 Agent 的工具注册表,是 main.go 里单独创建的那一个(只有读文件、列目录、跑命令),task 工具只加到父 Agent 的注册表里。有一个测试专门检查这件事:
func TestTaskToolNotAvailableToChild(t *testing.T) {
a := New(&llmtest.Fake{}, "", tools.NewRegistry(), &recordUI{})
childTools := tools.NewRegistry()
a.EnableSubagents(childTools, nil, "")
if _, ok := childTools.Get("task"); ok {
t.Fatal("子 Agent 不应该能再开子 Agent")
}
// ……
}另外,子 Agent 的步数上限是 30,比主 Agent 的 50 小。一个调研任务转 30 圈还没完成,多半是任务说明没写清楚,或者它迷路了。
4.1 好的任务说明长什么样
主 Agent 写给子 Agent 的那段 prompt,决定了分身能不能把事做好。一个好的任务说明,通常有四部分:
- 背景:用户最终想做什么,为什么需要这次调研。比如"用户想给订单加部分退款功能,现在要先搞清楚现有的退款流程";
- 目标:这次具体要查清楚什么。比如"找出退款从发起到完成经过了哪些函数";
- 范围:去哪里找、不用管哪些。比如"重点看
refund/和payment/目录,不用看测试文件"; - 报告格式:要什么样的结果。比如"按调用顺序列出函数,每个给出文件路径和行号,最后指出哪里最可能需要修改"。
对比一下"帮我看看退款是怎么做的",你会发现,好的任务说明,其实就是第 15 篇讲的 system prompt 原则,用在了一次委派上:说清楚场景、目标、边界和格式。
这段说明是主 Agent 写的,你不能直接控制它的质量。但可以在 task 工具的描述里给出要求(现在的描述里已经有"写清楚背景、要做什么、要报告什么"),也可以在主 Agent 的 system prompt 里加一个例子。如果你发现分身经常带回答非所问的报告,先去看看主 Agent 给它写的任务说明。
五、分身的代价
5.1 更费 token
这一点值得反复强调:subagent 通常比主 Agent 自己干更费 token。
- 每个子 Agent 都要从零建立上下文:重新发一遍 system prompt、工具定义,重新读一遍可能主 Agent 已经读过的文件;
- 子 Agent 的上下文是全新的,前缀和主 Agent 不同,缓存也要重新积累(第 10 篇);
- 报告本身要额外生成一次;
- 主 Agent 写任务说明,也要花输出 token。
它换来的是主 Agent 上下文的干净。在开场那种"读得多、报得少"的场景里,这笔交易很划算:主 Agent 的上下文少了十几万 token 的文件内容,后面的每一圈都便宜了,也不容易变笨。但在一两次工具调用就能完成的小事上,开分身纯粹是浪费。
5.2 信息损失
子 Agent 的报告是一份摘要,摘要就会丢信息。主 Agent 拿到"退款逻辑在 refund/service.go 的 Process 函数里",但不知道这个函数的具体实现。如果它接下来要改这个函数,还得自己去读一遍。
这其实是好事(读一个函数比读二十个文件便宜得多),但要意识到:子 Agent 的报告是线索,不是全部。 这也是为什么子 Agent 的 prompt 要求报告里带上文件路径和行号。
5.3 让分身用便宜一点的模型
既然分身干的大多是"读文件、搜代码、写一份摘要"这类杂活,它真的需要和主 Agent 一样强的模型吗?
很多时候不需要。EnableSubagents 的第二个参数就是子 Agent 用的模型,传 nil 表示和主 Agent 用同一个;mini-agent 的 -sub-model 参数可以把它换掉:
go run ./cmd/mini-agent -model deepseek-v4-pro -sub-model deepseek-flash主 Agent 用更强的模型做规划和拍板,分身用更便宜的模型去打杂。第 06 篇的价目表里,两个模型的单价差了好几倍。第 37 篇讲成本时,会把这叫做"模型路由",并且用账本看看它到底省了多少。
代价是分身的报告质量可能会差一点。如果你发现便宜模型的报告经常漏掉关键的地方,主 Agent 就得自己再去读一遍,省下的钱又花了回去。换不换,用数字说话,别凭感觉。
5.4 什么时候用
| 适合 | 不适合 |
|---|---|
| 需要读很多文件、但最后只要一个结论的调研 | 一两次工具调用就能完成的事 |
| 几个互不相关、可以同时进行的调查 | 需要整体把握、步骤之间强依赖的任务 |
| 主 Agent 的上下文已经很宝贵的长任务 | 后续还要频繁用到中间细节的事 |
第 24 篇的基础 prompt 里有一条对应的规则:"需要大范围调研(读很多文件才能回答)时,用 task 交给子 Agent,保持自己的上下文干净;互相独立的调研可以一次发起多个 task 并行进行。"
六、动手:派出分身
实验 1:一个需要大范围调研的问题
要看模型自己决定什么时候派分身,需要接真实模型:先 export DEEPSEEK_API_KEY=sk-...。会读很多文件,留意费用。在线 Playground 跑不了 mini-agent。在配套仓库的根目录启动 mini-agent:
export DEEPSEEK_API_KEY=sk-...
go run ./cmd/mini-agent说:"列出这个仓库里每个 examples 目录分别演示了什么,每个目录用一个子 Agent 去看。"
观察:
- 它是不是在一次回复里发起了多个
task调用? - 界面上那些缩进的
↳ [xxx]行,是不是交错出现的(说明它们在并行执行)? - 每个
task的结果最后那行统计:每个分身各花了多少 token? - 全部完成之后,输入
/usage,看看累计用量;再看看当前的上下文大小,是不是远小于累计用量?这个差距,就是子 Agent 替主 Agent 隔离掉的部分。
实验 2:对比一下不用分身
/reset 之后,说:"列出这个仓库里每个 examples 目录分别演示了什么,不要用子 Agent,自己读。"
完成之后再 /usage。对比两次的累计用量(不用分身可能更省)和当前上下文大小(不用分身会大很多)。这就是"用多花 token 换主上下文干净"。
实验 3:给分身一个更严的权限
按 2.5 节的思路,在 cmd/mini-agent/main.go 里给子 Agent 单独配一个权限策略:它的 Ask 设成 nil。第 27 篇第五节说过,Ask 为 nil 时,凡是需要确认的操作都会被自动拒绝。这样,子 Agent 只能跑白名单里的命令,想改文件、想跑别的命令,一律被拒,连问都不会问你。
改的时候要注意:taskTool.Run 里有一行 child.Gate = p.Gate,它会把父 Agent 的权限策略原样传给子 Agent,你需要让它改用这个单独的策略。改完之后,派一个分身去"把 README 里的错别字改掉",看看它会收到什么样的拒绝,以及它在报告里怎么说。
实验 4:跑测试
go test ./agent -run 'TestSubagentsRunInParallelWithFreshContext|TestTaskToolNotAvailableToChild' -vTestSubagentsRunInParallelWithFreshContext 用一个按 system prompt 区分主、子 Agent 的假模型,检查了四件事:两个子 Agent 是并行执行的(同时在跑的最多有 2 个,总耗时小于两个串行的耗时);子 Agent 的上下文是全新的(只有一条任务消息);父 Agent 只拿到了报告;子 Agent 的用量记到了父 Agent 身上。
七、踩坑记录:分身的七个坑
坑 1:任务说明写得太简略。
"帮我查一下那个问题",子 Agent 不知道"那个问题"是什么,只能瞎猜。子 Agent 看不到对话,prompt 里要写清楚背景、目标、要报告什么。工具描述里要反复强调这一点。
坑 2:报告太长,把隔离的意义抵消了。
子 Agent 把读到的代码大段大段地贴进报告,十几万 token 的内容又搬回了主上下文。要求它精炼,只给结论和位置。
坑 3:给子 Agent 改代码的能力。
几个子 Agent 并行地改不同的文件,彼此不知道对方改了什么,最后拼起来对不上。调研交给分身,改代码留给主 Agent。
坑 4:子 Agent 读过的文件,被当成主 Agent 读过。
主 Agent 基于一份 300 字的报告就去改一个它没亲自读过的文件。子 Agent 用独立的 Workspace,edit_file 会强制主 Agent 自己先读。
坑 5:子 Agent 还能开子 Agent。
无限套娃,账单爆炸。子 Agent 的工具箱里不放 task,并且有测试守着。
坑 6:小事也开分身。
"看一下 go.mod 里 Go 的版本",开一个子 Agent,它从零建立上下文、读文件、写报告,比主 Agent 自己读一下 go.mod 贵十倍。工具描述里明确写"不适合简单的事"。
坑 7:以为没给 edit_file,子 Agent 就改不了文件。
它有 run_bash,sed -i、rm、echo > 都能改文件。真要限制,就给子 Agent 单独配一个更严的权限策略,而不是指望"少一个工具"就够了。
一句话总结
subagent 是一个 task 工具,它在 Run 里新建一个拥有全新上下文的 Agent 去干活,只把一份精炼的报告带回来;子 Agent 共享模型、权限和记账,但不共享历史,只给只读工具和终端,不能再开子 Agent;互不影响的调用可以并行;它用多花 token 换主上下文干净,适合"读得多、报得少"的调研,不适合两步就能做完的小事。
全新上下文、只读工具"] C -->|"每个只带回 300 字报告"| M C -.->|"用完就扔"| X["十几万 token 的中间内容"] style M fill:#e8f5e9,stroke:#2e7d32 style X fill:#fff9c4
评论
还没有评论,来说点什么吧。