Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

练习 19:第一个 subagent

前十八章只有一个模型、一份 history,从头到尾一个人干活。这一章让它 长出第一个分身:一个隔离的子 agent,看不到父对话,自己开一个全新的 循环去干一件独立的任务,干完只把结论带回来——过程中的每一次工具调用, 父 agent 一个字都看不到。听起来像是全新的机制,但落到代码里,它就是 又一个 tool: 一个 definition(),一个 execute(),跟 read_filebash 长在 同一个接口下——全书从第一页就在讲的那句话,这一章原样成立: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_fileMEMORY.md 没有被创建;第二次全新会话里,子 agent 的 prompt 里出现 的是模型自己现编的一套“使用主动语态、避免模糊表述“之类的风格规则,跟 真正要求的“比喻只能用一次“毫无关系。隔离机制本身没有失灵——它该做到 的事确实做到了:子 agent 真的看不到上一次对话;只是规则既没有被模型 主动写进 MEMORY.md,也没有被这次任务原样带上,子 agent 手上是空的, 只能编。

发生了什么

这一章最该记住的一点:SubAgentTool(Go 版叫 subAgentTool)本质 就是一个 tool 回头看它的定义——definition() 声明名字和参数,execute() 接参数、干活、回一个字符串——跟 read_filebash 一模一样,实现的是同一个 tool 接口/协议,注册进 同一个 registry,模型眼里看到的也是同一种结构:清单里的一条 {"type": "function", ...}。唯一不一样的是 execute() 内部做的事: 读文件工具碰一次磁盘,sub_agent 起了另一整个模型和另一整个循环—— 但从父 agent 的角度看,调用它和调用 read_file 没有任何结构上的区别, 都是递参数进去、等一个结果回来。octo 的真实代码里这件事有据可查: AgentToolsub_agent 工具的真实实现)和 tools.ToolExecutor 接口 的关系,跟 bashread_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 看不 到父对话)在这次跑里同样做到了。

加分练习

  1. 本机 Ollama 有一次被明确要求“必须调用 sub_agent 工具“才照做,之前 一次同样的要求写在普通任务文本里时,它直接自己读文件分析,完全没 碰 sub_agent。把“复杂任务要考虑派 sub agent“这句话挪进 base prompt(练习 8 的位置),而非临时写在任务里,看合规率会不会 提高——这跟练习 8/14 测过的“软约束听不听“是同一类实验。
  2. 给子 agent 加一个只读版本:工具集换成去掉写文件/bash 之后的子集, 只留 read_file(和 skill),蒸馏自 octo 的 explore preset。 用它跑一遍实验一,比较一个只能读的子 agent 是不是已经够用——纯调研 类任务要不要写权限,本来就是个值得单独回答的问题。
  3. 记一份日志:每次 sub_agent 调用都追加一行“任务描述、内部消耗 tokens、父对话收到的 tokens“,攒够十几条之后回头看,哪类任务子 agent 内部消耗特别大——这是判断“这个任务到底该不该交给子 agent“的 实证依据,而非凭感觉。
  4. 故意让父 agent 在派生子 agent 之前,先自己做一堆不相关的事把 history 撑得很长,再要求它派生一个子 agent 去做一件小事,确认 子 agent 的第一轮输入 token 数不会因为父对话的长度而变化——这是在 验证“隔离“这个说法在长对话里站不站得住,不只是小例子里的巧合。