练习 21:编排交给代码还是模型
练习 20 的并发扇出有一个没说破的前提:模型得在同一轮里把几个 sub_agent
调用一起发出来,canFanOut 才有东西可判。这个“一起发“没有任何保证——
它是模型每一轮临场做的决定,这次做对了,下次可能就拆成三轮一个个来。
更麻烦的是跨阶段的结构:像“三份调查全部做完,再拿三份结果去汇总“这种
“先等齐、再放行“的先后关系,模型只能靠多轮对话自己把着——每一道关卡都
要多花一轮,还未必把得住。这一章新增一个 workflow 工具:模型把“分几个
阶段、每个阶段派哪些子任务、结果流向哪里“一次性写成一份计划交出来,
之后的控制流由代码保证——阶段内必并发,阶段间必串行,结果注入必发生,
模型说了不算。
敲进去
在练习 20 的代码上继续写。先把 sub_agent 真正干活的部分从参数解析里
剥出来,单独一个入口:
// run 是子 agent 真正干活的入口:一份自包含的 prompt 进,一条最终回复出。
// execute(模型点名调 sub_agent)和这一章的 workflow(代码按计划调)都走
// 这同一个入口——换的是谁来编排,没换执行机制。
func (t subAgentTool) run(description, prompt string) string {
childReg := newRegistry(t.tools...)
childHistory := []message{
{Role: "system", Content: composeSystemPrompt(t.skills)},
{Role: "user", Content: prompt},
}
fmt.Fprintf(os.Stderr, "[子 agent %q 开始,独立的一份 history,父对话它一个字都看不到]\n", description)
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",
description, tokens, estimateText(reply))
return tag + reply
}
原来 execute 末尾从建 childReg 到 return tag + reply 那一整段,换成
一行 return t.run(in.Description, in.Prompt)。
然后是计划本身——一个新类型,两个常量,一个拼结果的小函数:
// ---- workflow 层:把编排从模型手里拿回代码里 ----
// workflowPlan 是模型一次性交出来的完整计划。阶段之间严格串行,一个阶段
// 的全部子任务跑完才进下一个;同一阶段内的子任务全部并发。计划一旦交到
// execute 手里,控制流就归代码了:哪些一起跑、跑完流向哪里,每一次执行
// 都长一个样——这正是上一章的扇出给不了的东西,那里"要不要一起发"是模型
// 每轮临场的决定。
//
// 形状刻意扁平:一个阶段就是一组 prompt 字符串,没有包一层对象。这份
// JSON 的作者是模型,schema 每多一层嵌套,它写错的机会就多一分——
// 实测嵌套对象版本模型会往数组里塞键值对、写出非法 JSON,扁平版一次写对。
type workflowPlan struct {
Stages [][]string `json:"stages"`
}
// planShapeHint 附在每条参数错误的后面。报错也是发给模型的 prompt:
// 只说"不合法",模型会瞎变形重试;把期望的形状递到它眼前,下一次就写对。
const planShapeHint = `计划的形状:{"stages": [["阶段1的子任务prompt", "..."], ["阶段2的子任务prompt,可写 {{results}}"]]}`
// resultsPlaceholder 是阶段之间唯一的数据通道:下一阶段的 prompt 里写
// 这个占位符的位置,会被替换成上一阶段全部子任务的结果。除此之外阶段
// 之间什么都不共享——和 sub_agent 的隔离规矩一脉相承。
const resultsPlaceholder = "{{results}}"
// formatResults 把一个阶段的全部结果拼成一段编号的文本——它就是占位符
// 替换进去的内容,也是整个 workflow 最后交回给模型的东西。
func formatResults(results []string) string {
var b strings.Builder
for i, r := range results {
fmt.Fprintf(&b, "【子任务 %d 的结果】\n%s\n\n", i+1, r)
}
return strings.TrimSpace(b.String())
}
接着是工具本体。注意它的字段:它拿着一个 subAgentTool,每条 prompt
都交给 run 去跑——执行机制和 sub_agent 完全同一套,这个工具新增的
只有编排:
// workflowTool 复用 subAgentTool 的 run 入口跑每一条 prompt:执行机制
// 和 sub_agent 完全同一套,这个工具新增的只有编排——octo 的 workflow
// 也是同一个做法,agent() 直接复用支撑 sub_agent 的那套派生机制,
// 没有另起炉灶。
type workflowTool struct {
runner subAgentTool
}
func (t workflowTool) definition() toolSpec {
return toolSpec{
Name: "workflow",
Description: "按一份固定的计划执行一批子任务。计划是阶段的列表,每个阶段是一组子任务 " +
"prompt:阶段之间严格按顺序执行,同一阶段内的 prompt 全部并发执行;下一阶段的 " +
"prompt 里写 {{results}} 的位置,会被替换成上一阶段全部子任务的结果;整个 " +
"workflow 交回给你的,只有最后一个阶段的结果。整份计划由代码保证执行,中途" +
"不再经过你。例——\"分头调查 A、B、C,再汇总\"写成两个阶段:" +
`{"stages": [["调查A……", "调查B……", "调查C……"], ["汇总以下调查结果……\n{{results}}"]]}` +
"。不要把要并发的子任务拆到不同阶段,阶段是串行的。适合结构事先想得清楚的任务;" +
"边做边定下一步的探索式任务,继续用 sub_agent。每条 prompt 都交给一个隔离的" +
"子 agent,规矩和 sub_agent 相同:必须自包含,子 agent 看不到本次对话的任何内容。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"stages": map[string]any{
"type": "array",
"description": "按顺序执行的阶段列表。每个阶段是一个字符串数组:这一阶段要" +
"并发派出的子任务 prompt,每条都必须自包含。需要上一阶段结果的地方写 " +
"{{results}}(第一阶段没有上一阶段,不要写)。",
"items": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
},
},
},
"required": []string{"stages"},
},
}
}
最后是执行。阶段内的并发和上一章 dispatchToolCalls 是同一个模式,
连信号量都是同一个常量:
// execute 逐阶段执行计划。阶段内的并发和上一章 dispatchToolCalls 是同一个
// 模式:容量 maxParallelSubAgents 的 channel 当信号量,结果按 index 写回。
// 区别只在谁决定"这一批一起跑"——上一章靠 canFanOut 事后检查模型有没有
// 把调用发在同一轮,这里阶段本身就是并发声明,不存在检查不过的情况。
func (t workflowTool) execute(args string) string {
var plan workflowPlan
if err := json.Unmarshal([]byte(args), &plan); err != nil {
return "错误: 参数不是合法 JSON: " + err.Error() + "。" + planShapeHint
}
if len(plan.Stages) == 0 {
return "错误: 计划里一个阶段都没有。" + planShapeHint
}
var prev []string
for si, prompts := range plan.Stages {
if len(prompts) == 0 {
return fmt.Sprintf("错误: 阶段 %d 一个子任务都没有。%s", si+1, planShapeHint)
}
fmt.Fprintf(os.Stderr, "[workflow 阶段 %d/%d:%d 个子任务,并发上限 %d]\n",
si+1, len(plan.Stages), len(prompts), maxParallelSubAgents)
results := make([]string, len(prompts))
var wg sync.WaitGroup
sem := make(chan struct{}, maxParallelSubAgents)
for i, p := range prompts {
if len(prev) > 0 {
p = strings.ReplaceAll(p, resultsPlaceholder, formatResults(prev))
}
wg.Add(1)
sem <- struct{}{} // 占坑位;坑位不够就阻塞在这一行排队
go func(i int, prompt string) {
defer wg.Done()
defer func() { <-sem }()
results[i] = t.runner.run(fmt.Sprintf("阶段%d-子任务%d", si+1, i+1), prompt)
}(i, p)
}
wg.Wait()
prev = results
}
if len(prev) == 1 {
return prev[0]
}
return formatResults(prev)
}
main() 里注册,加在 subAgent 之后:
// workflow 也排在 subAgent 之后才加——子 agent 的工具集里同样没有
// workflow 这个名字,一份计划里的子任务不能自己再展开一份计划。
toolList = append(toolList, workflowTool{runner: subAgent})
跑起来
go build -o ex21 .
造三个互相独立的项目目录,只有一份 README 里有废弃接口的声明:
mkdir -p projA projB projC
cat > projA/README.md << 'EOF'
# projA
一个日志采集器。对外接口:
- `collect(path)`:采集指定路径的日志
- `flush()`:把缓冲区落盘
两个接口都处于正常维护状态。
EOF
cat > projB/README.md << 'EOF'
# projB
一个配置解析库。对外接口:
- `parse(file)`:解析配置文件(推荐)
- `legacy_parse(file)`:旧版解析入口。**已废弃**,将在 2.0 移除,请迁移到 `parse(file)`
注意:`legacy_parse()` 已经停止修 bug,只保留兼容。
EOF
cat > projC/README.md << 'EOF'
# projC
一个 HTTP 客户端封装。对外接口:
- `get(url)` / `post(url, body)`:常规请求
- `retry_policy(n)`:设置重试次数
接口稳定,没有废弃计划。
EOF
任务措辞明确给出结构——先分头、后汇总:
./ex21 "projA、projB、projC 三个目录下各有一份 README.md。分头并行调查这三个项目——每个项目派一个隔离的子任务,各自回答:这个项目有没有声明已废弃的接口?三个子任务全部完成之后,再汇总三份结果,给出最终结论:哪个项目需要迁移、迁移到什么。"
同一条命令多跑几次——第二个实验就是把它原样再跑一遍。
你应该看到什么
实验一:一份计划,一次跑完
DeepSeek(Python 版真机结果)第一轮先自己确认了一下目录存在,第二轮 就交出计划,但计划一次没写对:
[round 2] workflow({"stages": [[{"description": "调查 projA ……", "prompt": "……"},
{"description": "调查 projB ……", "prompt": "……"}, ……]]})
[round 3] workflow({"stages": [["你是一个隔离的调查子任务……projA/README.md……",
"……projB……", "……projC……"], ["你是一个汇总子任务……{{results}}……"]]})
[workflow 阶段 1/2:3 个子任务,并发上限 4]
[子 agent '阶段1-子任务3' 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent '阶段1-子任务1' 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent '阶段1-子任务2' 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent '阶段1-子任务3' 结束:内部消耗约 3461 tokens,……]
[子 agent '阶段1-子任务1' 结束:内部消耗约 3469 tokens,……]
[子 agent '阶段1-子任务2' 结束:内部消耗约 3698 tokens,……]
[workflow 阶段 2/2:1 个子任务,并发上限 4]
[子 agent '阶段2-子任务1' 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent '阶段2-子任务1' 结束:内部消耗约 2705 tokens,……]
需要迁移的项目:projB
- 旧版解析入口 legacy_parse(file) 已废弃,计划在 2.0 版本移除
- 迁移目标:parse(file)
无需迁移:projA、projC
round 2 里模型往 stages 数组里塞了 {"description": …, "prompt": …}
对象,不是 schema 要求的纯字符串——三种语言的解析层都如实报错(Go 是
json.Unmarshal 类型不匹配,Python/JavaScript 是手写的形状检查),
错误后面跟着形状提示,第二次它就写对了。对着日志核对这一章的三条
保证:三条“开始“打印在同一秒挨在一起,三条“结束“乱序回来(3、1、2)
——阶段内确实在并发跑;“阶段 2/2“那行出现在三条“结束“全部打完之后
——阶段之间确实等齐了才放行;汇总子任务的回复里出现了三份调查各自的
结论,而它 prompt 里那处 {{results}} 正是被这三份结果替换掉的——
注入这条通道端到端走通了。
JavaScript 版这次撞见了更曲折的一轮:模型不仅第一次塞了对象,第二次
纠正时又写成了内容全是空字符串的骨架([[{"description":"","prompt":""}]]),
第三次才彻底写对——planShapeHint 需要几轮才能把模型拉回正确形状,
是模型能力和当次运气的函数,不是固定次数。
实验二:本机小模型——结构写对了,内容两种偷工减料
qwen3:4b-instruct 两种语言第一轮就交出了形状完全正确的计划,但汇总
出来的结论都不对,而且是两种不同的错法。
Python 版:{{results}} 确实写进了汇总阶段的 prompt,注入机制本身
正常工作,但三个调查子任务自己读错了——面对同样的三份 README,它们
汇报“projA 和 projB 因缺少源代码或核心文件,无法确认接口是否已被
废弃“,一份原本清清楚楚写着 legacy_parse() 已废弃的 README,被
读成了“信息不足“:
[round 1] workflow({"stages":[["调查 projA 项目是否声明了已废弃的接口",
"调查 projB 项目是否声明了已废弃的接口","调查 projC 项目是否声明了
已废弃的接口"],["汇总以下调查结果:\n{{results}}\n\n最终结论:……"]]})
……
- projA 和 projB 因缺少源代码或核心文件,无法确认接口是否已被废弃,
因此不具备迁移判断基础。
建议:需补充接口变更记录、版本文档或项目部署状态等信息……
JavaScript 版是另一种偷工减料——汇总阶段的 prompt 干脆没写
{{results}}:
[round 1] workflow({"stages":[["调查 projA 项目是否声明了已废弃的接口",
"调查 projB 项目是否声明了已废弃的接口","调查 projC 项目是否声明了
已废弃的接口"],["汇总三个项目的调查结果,判断哪个项目需要迁移,
以及迁移到什么"]]})
[workflow 阶段 2/2:1 个子任务,并发上限 4]
[子 agent "阶段2-子任务1" 结束:内部消耗约 1301 tokens,……]
我目前没有关于三个项目的调查结果,无法判断哪个项目需要迁移以及
迁移到什么。请提供相关调查结果或详细信息……
代码没有东西可注入,汇总子任务拿到的就是这么一句话,一份调查结果都 看不到,只花了约 1301 tokens 就诚实地“结束“了——这条路径反而是三种 失败里最干净的一种:它没有编造答案,只是承认自己什么都没拿到。
发生了什么
模型驱动的编排,每一步都要“临场做对“;代码驱动的编排,只要求计划
“一次写对”。 这是这一章真正的分界线。练习 20 的扇出,模型要在正确的
那一轮把三个调用同时发出来;要等齐结果,得再撑住一轮不跑偏;轮数越多,
出错的机会越多。workflow 把所有这些“临场“压缩成一个动作:把计划写
出来。计划写对了,剩下的执行是确定的——阶段内必并发,阶段间必等齐,
结果注入必发生,跑一百次是同一个结构,不管这个结构由 Go 的 channel、
Python 的 threading.Semaphore,还是 JavaScript 的一个自制异步
Semaphore 来落实。
但“把编排从模型手里拿回来“的方式,仍然是给模型一个新工具。 这一章
没有在 harness 里写死任何一份具体计划——WorkflowTool 和 read_file
挂在同一张注册表里,什么时候需要一份计划、计划里写什么,还是模型看着
任务自己决定。代码拿回的是执行权,不是决策权:这条线画在“计划交出来
的那一刻“。所以这一章的标题是个假对立:编排交给代码还是模型,答案是
决策交给模型、执行交给代码,而实现这个分工的载体,还是一个 tool
设计决定。
计划是模型写的,所以计划的 schema 是给模型设计的,不是给人设计的。
stages 的形状是“字符串数组的数组“,不是“对象的数组“——三种语言的
真机测试里,DeepSeek 都在第一次或第二次尝试时往数组里塞了
{"description": …, "prompt": …} 这样的对象,说明这不是某一种语言
或某一次运气的偶然,是这份 schema 本身在模型眼里有歧义。拍平成字符串
数组之后,报错里的形状提示能把模型拉回来,但需要几轮不是固定的——
JavaScript 那次真机测试甚至多绕了一轮空字符串骨架才收敛。报错也是
发给模型的 prompt:只说“不合法“,模型会瞎变形重试;把期望的形状递到
它眼前,它才会真的写对。
代码保证的是执行,不是计划的质量,这一点在三种语言的真机测试里
表现出了两种不同的失败形态。 结构对了(两个阶段、三加一),执行也
全对(并发、等齐、注入机制都正常工作),但计划的内容质量始终是模型
能力的函数:Python 版 qwen3:4b-instruct 写对了 {{results}},但三个
调查子任务本身把内容读错了,汇总阶段拿到的是错误的事实、给出的是
错误的结论——这是“计划形状对、执行对、但整条链路上有一环认知错了“;
JavaScript 版 qwen3:4b-instruct 干脆漏写了 {{results}},机制没有
东西可注入,汇总子任务诚实地报告“没有信息“——这是“计划形状对、执行
对、但计划本身缺了一步“。两种失败 workflow 都无法修——它按计划办事,
计划里没有正确的事实或没有要结果,它就给不出正确的事实或不给结果。
workflow 降低的是“编排出错“的概率,不是“计划写差“或“事实读错“的
概率,这两三件事分开看,这一章才算学明白。
{{results}} 是阶段之间唯一的数据通道,这是练习 19 那笔上下文账的
延续。 实验一里三个调查子任务内部总共烧了约一万 tokens,父对话一个
字都没看到——它收到的只有汇总子任务那约两千 tokens 的最终结论。中间
结果在阶段之间流动(通过占位符注入),但从不回流到父对话;整个
workflow 交回去的只有最后一个阶段的结果。隔离切掉的东西和 sub_agent
一模一样,只是现在有了一条代码保证的、定向的传递通道。
常见问题
- 模型根本不调用 workflow,自己把任务干完了:会发生,而且往往是对 的。这个任务如果不写“每个项目派一个隔离的子任务“,模型可能就直接 自己把三份小文件读完了——工具声明里那句“适合结构事先想得清楚的 任务“是建议,不是强制;模型对“这活值不值得开计划“的判断,很多时候 比硬性规则准。
- 计划写歪了怎么办:两种形态,对策不同。形状错(塞对象、写出非
字符串数组)——靠报错里的形状提示拉回来,一般一两轮就收敛,但不
保证一次就好;内容缺或内容错(忘写
{{results}}、子任务读错文件 内容)——代码层面收不住,这是计划质量或模型能力问题,换更强的模型 或者在任务描述里把要求写得更细。分清楚你撞上的是哪种,再决定改工具 还是改措辞。 - workflow 和 sub_agent 都在注册表里,模型怎么选:工具声明里画了
分界——结构事先想得清楚的用 workflow,边做边定下一步的用
sub_agent。真机测试里模型在计划写歪时会自己先读文件、在 workflow 给不出结果时会退回sub_agent补查,都说明这两个工具是互补的两条 路,不是新的替换旧的。 - 子任务触发人工确认时会怎样:和练习 20 同一个洞,原样存在——
workflow 阶段内并发跑的子任务,各自撞上 ask 档的 bash 命令时,多个
并发执行单元同时调用
confirm(),练习 20“发生了什么“里那条关于 三种语言运行时模型差异的结论,在这里同样成立。这一章没有修它。
加分练习
- 给计划加一道检查:第二个阶段起,整个阶段没有一条 prompt 写
{{results}}就拒绝执行,报错说明理由——这是在把 JavaScript 那次 真机测试撞见的“漏写占位符“从“运行时才发现“提前到“计划提交时就 拒绝“。写完想一想这条检查会误伤什么——提示:子任务们共享同一个 工作目录,上一阶段用write_file落盘、下一阶段用read_file捡起来,也是一条合法的数据通道。 - 把一份跑通的计划存成
plan.json,给程序加一个直接执行文件里的 计划的入口:不经过模型、直接执行。跑通之后你会发现编排的 token 成本降到了零——octo 就有这样一层“存下来的 workflow“,模型可以按 名字调用现成计划,只往里填参数。 - 现在的阶段间是“等齐了才放行“:阶段 1 有一个子任务特别慢,阶段 2 里跟它无关的子任务也得陪着等。改成每个子任务链独立流动(子任务 A 的阶段 2 不等子任务 B 的阶段 1),比较一下两种做法下代码复杂度差 多少——octo 的 workflow 两种都提供,等齐的叫 parallel,独立流动的 叫 pipeline。
- 给 workflow 加一笔总账:所有子任务的 token 消耗累加,超过一个上限
就不再启动新的子任务,把已完成的结果原样交回并说明中断原因。想想
为什么这笔账对 workflow 比对单发的
sub_agent更要紧——一份计划是 模型一次性签发的批量授权,签发之后没有人再逐笔把关。