练习 19:第一个 subagent
前十八章只有一个模型、一份 history,从头到尾一个人干活。这一章让它
长出第一个分身:一个隔离的子 agent,看不到父对话,自己开一个全新的
循环去干一件独立的任务,干完只把结论带回来——过程中的每一次工具调用,
父 agent 一个字都看不到。听起来像是全新的机制,但落到代码里,它就是
又一个 tool:
一个 definition(),一个 execute(),跟 read_file、bash 长在
同一个接口下——全书从第一页就在讲的那句话,这一章原样成立:agent 和
agent 之间的差别,不在别处,就在工具设计里;子 agent 也不例外,它只是
一个执行起来比较特殊的工具。
敲进去
在练习 18 的代码上继续写。子 agent 有自己的一个迷你循环——故意不跟 主循环共用,因为它不需要会话存盘、不需要压缩、也不需要 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(三种语言都真机跑过,摘 Python 那次):
[round 1] bash({"command": "ls -la notes/ ..."})
[round 2] sub_agent({"description": "筛选并摘要提到 signing 的笔记", "prompt": "你的任务:处理 notes/ 目录下的笔记文件……"})
[子 agent '筛选并摘要提到 signing 的笔记' 结束:内部消耗约 7890 tokens,父对话只收到下面这条回复,约 59 tokens]
处理完了。三份笔记里只有一份提到 **signing**:
- **note2.md** → 新版本的安装包需要加上 code signing,否则 macOS 会拦截安装,这项工作由 Alice 负责。
其余笔记没提到这个词,按规则没有处理,也没汇报它们的内容。
本机 qwen3:4b-instruct,同一个任务:
[round 1] sub_agent({"description":"查找提到 'signing' 的笔记并摘要", "prompt":"你是一个笔记处理助手……"})
[子 agent "查找提到 'signing' 的笔记并摘要" 结束:内部消耗约 3049 tokens,父对话只收到下面这条回复,约 50 tokens]
已处理完成。以下是提到 "signing" 的笔记摘要:
- [notes/note2.md]: 新版本的安装包需要加上 code signing,否则 macOS 会拦截安装。
两边结论都对,都正确忽略了另外两份笔记。子 agent 内部读文件、判断、
组织语言,花掉的 token 是父对话收到的那一条回复的十几到上百倍——这一
整段过程,父 agent 的 history 里从头到尾只多了一条 tool 消息。
实验二,DeepSeek 第一次调用(记规则)之后,把它写进了 MEMORY.md
(沿用练习 15 的机制)。第二次是全新会话,没有 -c,
这次任务原文没有出现“比喻“两个字(JavaScript 那次真机结果):
[round 1] read_file({"path": "MEMORY.md"})
[round 4] sub_agent({"description": "检查 draft.md 是否符合写作风格规则", "prompt": "你的任务是检查本地文件 draft.md 是否符合项目写作风格规则。
【写作风格规则】(以此为准,逐字适用)
- 全文所有比喻只能用一次:同一个比喻不能对同一个概念重复使用。……"})
**发现一处违规。**
- **违规句子:第 2 句**「把仓库里堆积的旧货整理成一份摘要」。
- **原因**:第 1 句已经把「上下文」比作「仓库」,第 2 句是同一个「仓库」
意象的延续,仍指向「上下文」这个概念——同一比喻对同一概念用了两次。
模型没有偷懒把 prompt 写成“检查是否符合我们说的风格规则“,而是把
MEMORY.md 里那条规则的完整文字抄进了子 agent 的 prompt 里,子 agent
也正确抓出了“仓库“这个比喻被用了两次。
本机 qwen3:4b-instruct 在这个实验上给出的是一次干净的反例:第一次调用
只在自然语言里回复“我记下了这条规则“,从头到尾没有调用 write_file,
MEMORY.md 没有被创建;第二次全新会话里,子 agent 的 prompt 里出现
的是模型自己现编的一套“使用主动语态、避免模糊表述“之类的风格规则,跟
真正要求的“比喻只能用一次“毫无关系。隔离机制本身没有失灵——它该做到
的事确实做到了:子 agent 真的看不到上一次对话;只是规则既没有被模型
主动写进 MEMORY.md,也没有被这次任务原样带上,子 agent 手上是空的,
只能编。
发生了什么
这一章最该记住的一点:SubAgentTool(Go 版叫 subAgentTool)本质
就是一个 tool。 回头看它的定义——definition()
声明名字和参数,execute() 接参数、干活、回一个字符串——跟
read_file、bash 一模一样,实现的是同一个 tool 接口/协议,注册进
同一个 registry,模型眼里看到的也是同一种结构:清单里的一条
{"type": "function", ...}。唯一不一样的是 execute() 内部做的事:
读文件工具碰一次磁盘,sub_agent 起了另一整个模型和另一整个循环——
但从父 agent 的角度看,调用它和调用 read_file 没有任何结构上的区别,
都是递参数进去、等一个结果回来。octo 的真实代码里这件事有据可查:
AgentTool(sub_agent 工具的真实实现)和 tools.ToolExecutor 接口
的关系,跟 bash、read_file 那些最朴素的工具完全一样——都实现
ToolExecutor,没有另开一条通道。全书从前言就在讲的那句话,走到这里
依然成立:agent 和 agent 之间的差别,从来不在别处,就在工具设计里;
这一次多出来的能力,说到底就是往工具箱里加了一个“其实是启动另一个
agent“的工具,没有发明任何新机制。
只回终稿,是这一章唯一的硬约束,也是它唯一的价值。 子 agent 内部
读文件、判断、组织语言的每一步,都发生在子 agent 自己的 registry 和
history 这两个局部变量里——run_child_loop/runChildLoop 返回之后,
这些中间过程连同它们占用的 token,一起被丢在了 execute 的栈帧里,
永远不会追加进父 agent 的会话历史。实验一的数字是最直接的证据:
子 agent 内部烧掉几千 token,父对话的账本上只多了几十上百。这跟
“压缩”(练习 13 那种事后总结)性质不同——压缩是先进去再筛,这里
是压根没让它进去过。
“自包含“这道要求为什么重要,答案不在这一章的代码里,在实验二的行为
里。 execute 从没检查过 prompt 里有没有把上下文交代清楚——那句
“prompt 必须自包含”,从头到尾只是 definition() 里的一句话,一条纯粹
的软约束,跟练习 8 的 base prompt 是同一类软约束:代码不强制,模型听不听
全靠它自己。实验二里 DeepSeek 把整条规则原文搬进了子 agent 的 prompt,
这是模型自己选择遵守的结果,这一章的代码没有任何闸门逼它这么做;
qwen3:4b-instruct
那次恰恰相反,规则既没被记住也没被带上,子 agent 只能凭空编——同一套
代码,两种走向,软约束的“软“字就体现在这里。
但这道软约束只是子 agent 拿到上下文的渠道之一。 子 agent 的系统
消息调用的是同一个 compose_system_prompt/composeSystemPrompt——
项目规则、skill 清单、MEMORY.md 里的内容,子 agent 会重新读一遍磁盘,
跟父 agent 看到的是同一份。这意味着哪怕 DeepSeek 那次偷懒、没把规则抄
进 prompt,子 agent 大概率也能从自己的系统提示里读到同一条 MEMORY.md
记录,蒙对答案。隔离切断的只是对话历史,这个项目本身“是什么“这层
信息不受影响——蒸馏自 octo 的说法:子 agent 与父 agent 共享同一个
身份,隔离点只在 history 是全新的。所以“自包含“这句话真正管的是这次
对话里发生过的事(用户刚才说了什么、父 agent 刚查到了什么),项目
积累下来的规则和记忆不算在内——那些内容设计上就是共享的。
qwen3:4b-instruct 那次之所以真的编了假规则,正是因为规则从没被写进过
这份共享状态:隔离墙没有漏洞,问题是墙那一侧压根什么都没写进去。
常见问题
- 子 agent 能看到
MEMORY.md、skill 清单,这不算是“看到了上下文“ 吗,隔离在哪:隔离的是这次对话——用户跟父 agent 聊了什么、父 agent 刚才做了什么——这些只存在于父 agent 的历史里,子 agent 的 历史从一条系统消息加一条用户消息开始,从没见过它们。MEMORY.md/ skill 清单是这个项目的常设状态,不属于“这次对话“,子 agent 读到跟 父 agent 读到,走的都是同一段组装系统提示的代码,跟对话传递无关。 - 子 agent 会不会自己再派生下一层子 agent:不会。构造子 agent 的
registry 时用的是父 agent 那份不含
sub_agent自己的工具列表—— 子 agent 的注册表里压根没有sub_agent这个名字,这是结构上的事实, 不需要靠一句“不要递归“的提示词去拦。 - 子 agent 跑满
CHILD_MAX_ROUNDS还没结论会怎样:把最后一条内容 当部分结果带回去,标记不完整,父 agent 收到的回复会带[未完成:达到轮数上限,以下是部分结果]这行标记——既不是报错, 也不假装它是个完整答案。 - 子 agent 会不会写自己的会话文件、支持
-c续跑:不会。它没有 调用会话存盘的那套函数,子 agent 的 history 只是一个局部变量,execute返回后没有任何引用指向它——蒸馏自 octo 的设计:子 agent 的保活范围是纯内存的,进程退出(这里是execute调用结束)就没了。 - 本机小模型那次没写
MEMORY.md,是不是这一章的代码有 bug:不是。 练习 15 讲过,模型愿不愿意主动往MEMORY.md里写内容,本身就是一条 软约束——这一章只是在子 agent 这个新场景下,又一次撞见了同一条软约束, 跟sub_agent工具本身的隔离机制无关,隔离该做到的事(子 agent 看不 到父对话)在这次跑里同样做到了。
加分练习
- 本机 Ollama 有一次被明确要求“必须调用 sub_agent 工具“才照做,之前
一次同样的要求写在普通任务文本里时,它直接自己读文件分析,完全没
碰
sub_agent。把“复杂任务要考虑派 sub agent“这句话挪进 base prompt(练习 8 的位置),而非临时写在任务里,看合规率会不会 提高——这跟练习 8/14 测过的“软约束听不听“是同一类实验。 - 给子 agent 加一个只读版本:工具集换成去掉写文件/
bash之后的子集, 只留read_file(和skill),蒸馏自 octo 的explorepreset。 用它跑一遍实验一,比较一个只能读的子 agent 是不是已经够用——纯调研 类任务要不要写权限,本来就是个值得单独回答的问题。 - 记一份日志:每次
sub_agent调用都追加一行“任务描述、内部消耗 tokens、父对话收到的 tokens“,攒够十几条之后回头看,哪类任务子 agent 内部消耗特别大——这是判断“这个任务到底该不该交给子 agent“的 实证依据,而非凭感觉。 - 故意让父 agent 在派生子 agent 之前,先自己做一堆不相关的事把 history 撑得很长,再要求它派生一个子 agent 去做一件小事,确认 子 agent 的第一轮输入 token 数不会因为父对话的长度而变化——这是在 验证“隔离“这个说法在长对话里站不站得住,不只是小例子里的巧合。