练习 19:第一个 subagent
前十八章只有一个模型、一份 history,从头到尾一个人干活。这一章让它
长出第一个分身:一个隔离的子 agent,看不到父对话,自己开一个全新的
循环去干一件事,干完只把结论带回来——过程中的每一次工具调用,父 agent
一个字都看不到。听起来是个新东西,但落到代码里,它就是又一个 tool:
一个 definition(),一个 execute(),跟 read_file、bash 长在
同一个接口下——全书从第一页就在讲的那句话,这一章原样成立:agent 和
agent 之间的差别,不在别处,就在工具设计里;子 agent 也不例外,它只是
一个执行起来比较特殊的工具。
敲进去
在练习 18 的代码上继续写。子 agent 有自己的一个迷你循环——故意不跟
main() 里那个大循环共用,因为它不需要会话存盘、不需要压缩、也不需要
resume,这些都是"一场会话"才有的复杂度:
// ---- subagent 层:隔离出一个全新的对话去跑子任务 ----
// childMaxRounds 是子 agent 自己的循环预算,比父 agent 的 maxRounds 更
// 紧——子任务应该是聚焦的一件事,不该是另一场需要十轮才能收尾的长对话;
// 真撞上限,runChildLoop 把这当一次不完整的结果处理,不是错误。
const childMaxRounds = 6
// runChildLoop 是子 agent 自己的一个迷你 agent loop:发请求、有
// tool_calls 就分发、没有就返回。故意不跟 main() 里那个大循环共用——
// 子 agent 不需要会话存盘(纯内存,这次调用完就没了)、不需要压缩
// (任务足够聚焦,轮数上限本身就比触发压缩的量级小得多)、也不需要
// resume。蒸馏自 octo 的说法:子 agent 的保活范围纯 in-memory,生命
// 周期只有一次调用,不写盘、不进 session、不跨进程。
func runChildLoop(base, apiKey, model string, reg *registry, history []message) (reply string, totalTokens int, complete bool, err error) {
for round := 1; round <= childMaxRounds; round++ {
r, sendErr := send(base, apiKey, model, history, reg.definitions())
if sendErr != nil {
return "", totalTokens, false, sendErr
}
totalTokens += r.Usage.PromptTokens + r.Usage.CompletionTokens
msg := r.Choices[0].Message
history = append(history, msg)
if r.Choices[0].FinishReason != "tool_calls" {
return msg.Content, totalTokens, true, nil
}
for _, tc := range msg.ToolCalls {
result := reg.execute(tc.Function.Name, tc.Function.Arguments)
history = append(history, message{Role: "tool", ToolCallID: tc.ID, Content: result})
}
}
// 跑满轮数没个结论,不是异常——蒸馏自 octo 的 max-turns 处理:把最后
// 一条内容当部分结果带回去,标记不完整,让父 agent 自己判断怎么办。
last := history[len(history)-1]
return last.Content, totalTokens, false, nil
}
父 agent 唯一能看到的分身入口是一个新工具。防递归不是靠模型自觉,是
靠它拿到的工具列表里根本没有 sub_agent 这个名字:
// subAgentTool 是父 agent 唯一能看到的分身入口。tools 是子 agent 能用
// 的工具集——调用方负责传一份"父的工具集去掉 subAgentTool 自己"的列表,
// 这就是防递归:子 agent 的注册表里根本没有 sub_agent 这个名字,不是
// 靠它自己克制。
type subAgentTool struct {
base, apiKey, model string
tools []tool
skills map[string]skill
}
func (t subAgentTool) definition() toolSpec {
return toolSpec{
Name: "sub_agent",
Description: "派生一个隔离的子 agent 去完成一个独立子任务。子 agent 看不到这次对话到" +
"目前为止的任何内容——prompt 必须自包含,把它需要知道的一切都写进去。你只会拿到" +
"子 agent 最后的结论,它中途调用了哪些工具、读了哪些文件,都不会进入你的上下文。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"description": map[string]any{"type": "string", "description": "这个子任务的一句话标签,仅用于日志"},
"prompt": map[string]any{"type": "string", "description": "子任务的完整描述,自包含——子 agent 看不到别的上下文"},
},
"required": []string{"description", "prompt"},
},
}
}
func (t subAgentTool) execute(args string) string {
var in struct {
Description string `json:"description"`
Prompt string `json:"prompt"`
}
if err := json.Unmarshal([]byte(args), &in); err != nil {
return "错误: 参数不是合法 JSON: " + err.Error()
}
if strings.TrimSpace(in.Prompt) == "" {
return "错误: prompt 不能为空——子 agent 看不到别的上下文,全靠这一份"
}
childReg := newRegistry(t.tools...)
childHistory := []message{
{Role: "system", Content: composeSystemPrompt(t.skills)},
{Role: "user", Content: in.Prompt},
}
reply, tokens, complete, err := runChildLoop(t.base, t.apiKey, t.model, childReg, childHistory)
if err != nil {
return "错误: 子 agent 执行失败: " + err.Error()
}
tag := ""
if !complete {
tag = "[未完成:达到轮数上限,以下是部分结果]\n\n"
}
fmt.Fprintf(os.Stderr, "[子 agent %q 结束:内部消耗约 %d tokens,父对话只收到下面这条回复,约 %d tokens]\n",
in.Description, tokens, estimateText(reply))
return tag + reply
}
在 main() 里接上——subAgentTool 拿到的工具列表,是加它自己之前的
那份快照:
// subAgentTool 拿到的是此刻的 toolList——不含它自己,因为这一行还
// 没把它加进去。子 agent 的注册表由这份切片构造,天生没有 sub_agent
// 这个名字,递归在结构上就不成立,不是靠模型自觉不去调用它。
subAgent := subAgentTool{base: base, apiKey: apiKey, model: model, tools: toolList, skills: skills}
toolList = append(toolList, subAgent)
reg := newRegistry(toolList...)
跑起来
go build -o ex19 .
实验一:只回终稿。 造三份笔记,只有一份跟接下来的任务有关:
mkdir -p notes
cat > notes/note1.md << 'EOF'
# 周三站会
讨论了发布流程,决定把 CI 跑测试的顺序调整一下,先跑单测再跑集成测试。
EOF
cat > notes/note2.md << 'EOF'
# 安全评审纪要
新版本的安装包需要加上 code signing,否则 macOS 会拦截安装。负责人:Alice。
EOF
cat > notes/note3.md << 'EOF'
# 咖啡机坏了
茶水间的咖啡机又坏了,已经报修。
EOF
./ex19 "我们在整理旧笔记,规则是:只挑出提到 'signing' 这个词的笔记,把它的内容摘要成一句话给我;不提这个词的笔记不用管,也不用告诉我它们讲了什么。派一个 sub agent 去 notes/ 目录下看看三份笔记,按这个规则处理。"
实验二:自包含的 prompt,边界在哪。 先建立一条项目规则,再另起一个
全新会话(不用 -c)去检查一份违反这条规则的文档:
cat > draft.md << 'EOF'
# 关于上下文管理
模型的上下文就像一个仓库,东西堆多了就找不到重要的。
压缩这一步,是把仓库里堆积的旧货整理成一份摘要,腾出空间。
记忆系统则完全不同,它更像一本随身携带的笔记本,你写下的东西下次翻开还在。
EOF
./ex19 "我们这个项目的写作风格要求是:全文所有比喻只能用一次,不能对同一个概念重复使用同一个比喻。记住这条规则,后面我会让你检查文档是否符合。"
# 全新会话,不带 -c,这次任务里没有一个字提到"比喻"或那条规则
./ex19 "麻烦你派一个 sub agent 去检查一下 draft.md 是否符合我们项目的写作风格要求,告诉我具体哪句话违反了。"
你应该看到什么
实验一,DeepSeek:
[round 2] sub_agent({"description": "筛选并摘要 notes/ 下提到 signing 的笔记", "prompt": "你的任务:处理本地 notes/ 目录下的三份笔记文件……"})
[子 agent "……" 结束:内部消耗约 3452 tokens,父对话只收到下面这条回复,约 116 tokens]
sub agent 处理完了,结果如下:
**提到 'signing' 的笔记:note2.md**
- note2.md(安全评审纪要):新版本安装包需要加上 code signing……
note1.md 和 note3.md 没提到 signing,按规则没有处理。
本机 Ollama,同一个任务:
[子 agent "Scan notes for 'signing' mentions" 结束:内部消耗约 7390 tokens,父对话只收到下面这条回复,约 18 tokens]
New version install package needs code signing to avoid macOS interception.
两边结论都对,都正确忽略了另外两份笔记。子 agent 内部读了三份文件、
判断、组织语言,花掉的 token 是父对话收到的那一条回复的 20 到 400 倍
——这一整段过程,父 agent 的 history 里从头到尾只多了一条 tool 消息。
实验二,第一次调用(记规则)之后,DeepSeek 把它写进了
MEMORY.md(练习 15 的机制,不是这一章特意安排的)。第二次是全新会话,
没有 -c,这次任务原文没有出现"比喻"两个字:
[round 1] read_file({"path": "draft.md"})
[round 2] sub_agent({"description": "检查 draft.md 是否符合写作风格规则", "prompt": "你的任务是检查本地文件 draft.md 是否符合项目写作风格规则……
项目写作风格规则(来自项目约定):
- 全文所有比喻只能用一次:同一个概念不能在文档里重复使用同一个比喻。……
请以简洁的结论形式返回:先给出总体结论……"})
检查完成,结论是:draft.md 违反了项目的写作风格规则。
违反规则的具体句子:
> 句1:「模型的上下文就像一个仓库,东西堆多了就找不到重要的。」
> 句2:「压缩这一步,是把仓库里堆积的旧货整理成一份摘要,腾出空间。」
模型没有偷懒把 prompt 写成"检查是否符合我们说的风格规则",而是把
MEMORY.md 里那条规则的完整文字抄进了子 agent 的 prompt 里,子 agent
也正确抓出了"仓库"这个比喻被用了两次。
发生了什么
先说这一章最该记住的一件事:subAgentTool 不是一种新机制,是一个
tool。 回头看它的定义——definition() 声明名字和参数,execute()
接参数、干活、回一个字符串——跟 readFileTool、bashTool 一模一样,
实现的是同一个 tool 接口,注册进同一个 registry,模型眼里看到的
也是同一种东西:清单里的一条 {"type": "function", ...}。唯一不一样
的是 execute() 内部做的事:readFileTool 碰一次磁盘,subAgentTool
起了另一整个模型和另一整个循环——但从父 agent 的角度看,调用它和调用
read_file 没有任何结构上的区别,都是递参数进去、等一个结果回来。
octo 的真实代码里这件事有据可查:AgentTool(sub_agent 工具的真实
实现)和 tools.ToolExecutor 接口的关系,跟 bash、read_file 那些
最朴素的工具完全一样——都实现 ToolExecutor,没有另开一条通道。全书
从前言就在讲的那句话,走到这里依然成立:agent 和 agent 之间的差别,
从来不在别处,就在工具设计里;这一次多出来的能力,不是因为发明了
新机制,是因为往工具箱里加了一个"其实是启动另一个 agent"的工具。
只回终稿,是这一章唯一的硬约束,也是它唯一的价值。 子 agent 内部
读文件、判断、组织语言的每一步,都发生在 childReg/childHistory
这两个局部变量里——runChildLoop 返回之后,这些中间过程连同它们占用
的 token,一起被丢在了 subAgentTool.execute 的栈帧里,永远不会追加
进父 agent 的 sess.History。实验一的数字是这件事最直接的证据:子
agent 内部烧掉几千 token,父对话的账本上只多了几十到一百多。这不是
"压缩"(练习 13 那种事后总结),是压根没让它进去过。
"自包含"这道要求为什么重要,答案不在这一章的代码里,在实验二的行为
里。 subAgentTool.execute 从没检查过 prompt 里有没有把上下文
交代清楚——那句"prompt 必须自包含",从头到尾只是 definition() 里
的一句话,一条纯粹的软约束,跟练习 8 的 basePrompt 是同一种东西:
代码不强制,模型听不听全靠它自己。实验二里 DeepSeek 把整条规则原文
搬进了子 agent 的 prompt,这是模型自己选择遵守的结果,不是这一章的
闸门逼出来的。
但这道软约束不是子 agent 唯一能拿到上下文的渠道。 childHistory
的系统消息调用的是同一个 composeSystemPrompt(t.skills)——项目规则、
skill 清单、MEMORY.md 里的内容,子 agent 会重新读一遍磁盘,跟父
agent 看到的是同一份。这意味着哪怕 DeepSeek 那次偷懒、没把规则抄进
prompt,子 agent 大概率也能从自己的系统提示里读到同一条 MEMORY.md
记录,蒙对答案。隔离切断的是对话历史,不是这个项目本身"是什么"这层
信息——蒸馏自 octo 的说法:子 agent 与父 agent 共享同一个身份
(System),隔离点只在 history 是全新的。所以"自包含"这句话真正管
的是这次对话里发生过的事(用户刚才说了什么、父 agent 刚查到了什么),
不是这个项目积累下来的规则和记忆——那些东西设计上就是共享的。
常见问题
- 子 agent 能看到
MEMORY.md、skill 清单,这不算是"看到了上下文" 吗,隔离在哪:隔离的是这次对话——用户跟父 agent 聊了什么、父 agent 刚才做了什么——这些只存在于父 agent 的History里,子 agent 的History从一条系统消息加一条用户消息开始,从没见过它们。MEMORY.md/skill 清单是这个项目的常设状态,不属于"这次对话",子 agent 读到跟父 agent 读到,走的是同一段composeSystemPrompt代码, 不是通过对话传递的。 - 子 agent 会不会自己再派生下一层子 agent:不会。
subAgentTool构造childReg时用的t.tools,是父 agent 那份不含subAgentTool自己的工具列表——子 agent 的注册表里压根没有sub_agent这个名字, 这是结构上的事实,不是靠一句"不要递归"的提示词拦住。 - 子 agent 跑满
childMaxRounds还没结论会怎样:runChildLoop把最后一条内容当部分结果带回去,complete=false,父 agent 收到的 回复会带[未完成:达到轮数上限,以下是部分结果]这行标记——不是 报错,也不是假装它是个完整答案。 - 子 agent 会不会写自己的会话文件、支持
-c续跑:不会。它没有 调用newSessionFile/sess.save,childHistory只是一个局部变量,execute返回后就没有任何东西指向它——蒸馏自 octo 的设计:子 agent 的保活范围是纯内存的,进程退出(这里是execute调用结束)就没了。
加分练习
- 本机 Ollama 有一次被明确要求"必须调用 sub_agent 工具"才照做,之前
一次同样的要求写在普通任务文本里时,它直接自己读文件分析,完全没
碰
sub_agent。把"复杂任务要考虑派 sub agent"这句话挪进basePrompt(练习 8 的位置),而不是临时写在任务里,看合规率会不会 提高——这跟练习 8/14 测过的"软约束听不听"是同一类实验。 - 给
subAgentTool加一个只读版本:tools换成去掉write_file/edit_file/bash之后的子集,只留read_file(和skill),蒸馏自 octo 的explorepreset。用它跑一遍实验一,比较 一个只能读的子 agent 是不是已经够用——纯调研类任务要不要写权限, 本来就是个值得单独回答的问题。 - 记一份日志:每次
sub_agent调用都追加一行"任务描述、内部消耗 tokens、父对话收到的 tokens",攒够十几条之后回头看,哪类任务子 agent 内部消耗特别大——这是判断"这个任务到底该不该交给子 agent"的 实证依据,而不是凭感觉。 - 故意让父 agent 在派生子 agent 之前,先自己做一堆不相关的事把
History撑得很长,再要求它派生一个子 agent 去做一件小事,确认 子 agent 的第一轮输入 token 数不会因为父对话的长度而变化——这是在 验证"隔离"这个说法不是只在小例子里成立。