练习 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 第一轮就交出了完整计划(prompt 原文很长,这里截关键部分):
[round 1] workflow({"stages": [["调查项目 projA。……读取 projA/README.md……
有没有声明已废弃(deprecated)的接口?……", "调查项目 projB。……", "调查项目
projC。……"], ["以下是三个项目……的三份独立调查结果:\n\n{{results}}\n\n
请汇总这三份结果,输出最终结论……"]]})
[workflow 阶段 1/2:3 个子任务,并发上限 4]
[子 agent "阶段1-子任务3" 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent "阶段1-子任务1" 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent "阶段1-子任务2" 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent "阶段1-子任务3" 结束:内部消耗约 3579 tokens,父对话只收到下面这条回复,约 530 tokens]
[子 agent "阶段1-子任务2" 结束:内部消耗约 3719 tokens,父对话只收到下面这条回复,约 615 tokens]
[子 agent "阶段1-子任务1" 结束:内部消耗约 5426 tokens,父对话只收到下面这条回复,约 478 tokens]
[workflow 阶段 2/2:1 个子任务,并发上限 4]
[子 agent "阶段2-子任务1" 开始,独立的一份 history,父对话它一个字都看不到]
[子 agent "阶段2-子任务1" 结束:内部消耗约 3005 tokens,父对话只收到下面这条回复,约 1055 tokens]
## 调查结论
| 项目 | 是否声明废弃接口 | 是否需要迁移 |
| projA | 否 | 不需要 |
| projB | 是(legacy_parse(file)) | 需要 |
| projC | 否 | 不需要 |
……
> `legacy_parse(file)`(旧版解析入口,已废弃)→ `parse(file)`(解析配置文件,推荐)
[共 2 轮 · 最后一轮输入 4379 tokens(命中缓存 1792)· finish_reason=stop]
对着日志核对这一章的三条保证。三条"开始"打印在同一秒挨在一起,三条
"结束"乱序回来(3、2、1)——阶段内确实在并发跑。"阶段 2/2"那行出现在
三条"结束"全部打完之后——阶段之间确实等齐了才放行。汇总子任务的回复里
出现了三份调查各自的结论、逐字引用了 projB README 的原文,而它 prompt
里那处 {{results}} 正是被这三份结果替换掉的——注入这条通道端到端走通
了。父对话全程只有 2 轮:一轮交计划,一轮把 workflow 的结果整理成最终
回答。轮数和练习 20 的"扇出一轮 + 自己汇总一轮"打平,差别在别处:那里
"三个调用发在同一轮"和"等齐了再汇总"都是模型临场把住的,这里两样都写
在计划里,由代码把住——结构再深一层,模型驱动就要多把住一轮,workflow
仍然是这一次调用。
实验二:计划的作者会手滑
同一条命令原样重跑,这次模型先自己读了一遍三份 README,然后写计划时 连续两轮把对象塞进了字符串数组:
[round 3] workflow({"stages": [[{"description": "调查 projA", "prompt": "……"},
{"description": "调查 projB", "prompt": "……"}, ……]]})
[round 4] workflow({"stages": [[{"description": "调查 projA", "prompt": "……"}, ……]]})
[round 5] workflow({"stages": [["你是一个隔离的子 agent……调查目录 projA……",
"……projB……", "……projC……"], ["你是汇总子 agent。……{{results}}……"]]})
[workflow 阶段 1/2:3 个子任务,并发上限 4]
……(之后与实验一相同:并发调查、等齐、汇总,最终结论正确)
[共 6 轮 · 最后一轮输入 5145 tokens(命中缓存 4736)· finish_reason=stop]
round 3 和 round 4 的参数不是我们 schema 里的形状——阶段应该是字符串
数组,模型往里塞了 {"description": …, "prompt": …} 对象。json.Unmarshal
如实报错(cannot unmarshal object into Go struct field workflowPlan.stages of type string),错误后面跟着 planShapeHint 那行形状示例,一起作为
工具结果发回给模型。第三次它照着形状写对了,后面的执行和实验一分毫不差。
实验三:本机 Ollama——结构写对了,内容偷工减料
qwen3:4b-instruct 第一轮就交出了形状完全正确的计划,但是往里看:
[round 1] workflow({"stages":[["调查 projA 项目中是否声明了已废弃的接口",
"调查 projB 项目中是否声明了已废弃的接口","调查 projC 项目中是否声明了
已废弃的接口"],["汇总三个项目的结果并得出最终结论"]]})
[workflow 阶段 1/2:3 个子任务,并发上限 4]
……三个子任务并发跑完,都正确读了各自的 README……
[workflow 阶段 2/2:1 个子任务,并发上限 4]
[子 agent "阶段2-子任务1" 结束:内部消耗约 1279 tokens,父对话只收到下面这条回复,约 96 tokens]
[round 2] sub_agent({"description":"调查 projA 项目中是否声明了已废弃的接口", ……})
[round 3] sub_agent({"description":"调查 projB 项目中是否声明了已废弃的接口", ……})
[round 4] sub_agent({"description":"调查 projC 项目中是否声明了已废弃的接口", ……})
……
- **需要迁移的项目:projB**
- **迁移到的接口:`parse(file)`**
[共 5 轮 · 最后一轮输入 2598 tokens(命中缓存 0)· finish_reason=stop]
问题出在汇总那条 prompt:"汇总三个项目的结果并得出最终结论"——没有
{{results}}。代码没有东西可注入,汇总子任务拿到的就是这么一句话,
一份调查结果都看不到,只花了 1279 tokens 就"结束"了。workflow 交回去
的是一份没有依据的汇总,模型只好退回老路:round 2、3、4 一轮一个地派
sub_agent 把三个项目重新查了一遍——注意它临场编排时连练习 20 教过的
"同一轮一起发"都没做到,三个调用串行占了三轮。最终结论是对的,但
workflow 那次执行几乎全部作废。
发生了什么
模型驱动的编排,每一步都要"临场做对";代码驱动的编排,只要求计划 "一次写对"。 这是这一章真正的分界线。练习 20 的扇出,模型要在正确的 那一轮把三个调用同时发出来;要等齐结果,得再撑住一轮不跑偏;轮数越多, 出错的机会越多——实验三里那个 4B 小模型临场编排时一轮只发一个调用,就是 活例子。workflow 把所有这些"临场"压缩成一个动作:把计划写出来。计划 写对了,剩下的执行是确定的——阶段内必并发,阶段间必等齐,结果注入必 发生,跑一百次是同一个结构。octo 给它的 workflow 工具写设计文档时, 问题陈述就是这一句:控制流全靠模型在多轮里自己决定,不可重复、不可 确定,没法表达"先全部启动再等全部完成"这种结构。
但"把编排从模型手里拿回来"的方式,仍然是给模型一个新工具。 注意
这一章没有在 harness 里写死任何一份具体计划——workflowTool 和
read_file 挂在同一张注册表里,什么时候需要一份计划、计划里写什么,
还是模型看着任务自己决定。代码拿回的是执行权,不是决策权:这条线画在
"计划交出来的那一刻"。octo 也是这么画的——它那个能跑脚本的 workflow
机制,本体就是一个实现了工具接口的普通工具,和派生子 agent 的工具
共用同一套底层派生机制。所以这一章的标题是个假对立:编排交给代码还是
模型,答案是决策交给模型、执行交给代码,而实现这个分工的载体,还是
一个 tool 设计决定。
计划是模型写的,所以计划的 schema 是给模型设计的,不是给人设计的。
stages 的形状是"字符串数组的数组",不是"对象的数组"——第一版不是这样,
阶段包了一层 {"prompts": [...]} 对象,实测 DeepSeek 面对这个 schema
连续八轮写不出合法 JSON(往数组里塞键值对、整份计划二次编码成字符串、
最外层套上莫须有的字段名,怎么重试都对不上形状),一次 workflow 都没
执行成,直到撞上轮数上限。拍平成字符串数组之后,同一个模型第一轮就
写对了,连 4B 的本机小模型都能一次写对结构。schema 每多一层嵌套,
模型写错的机会就多一分——给模型用的接口,简单不是品味问题,是成功率
问题。报错同理:实验二里模型两次写错两次被拉回来,靠的是错误消息末尾
那行 planShapeHint——只回"不合法",模型会瞎变形重试;把期望的形状递
到它眼前,它下一次就写对。报错也是发给模型的 prompt。
代码保证的是执行,不是计划的质量。 实验三把这条边界摆得很清楚:
结构对了(两个阶段、三加一),执行也全对(并发、等齐、注入机制都
正常工作),但汇总 prompt 里没写 {{results}},机制再正常也没有东西
可注入。这不是 workflow 能修的——它按计划办事,计划里没要结果,它就
不给。计划的质量始终是模型能力的函数:DeepSeek 会在每条调查 prompt 里
写明"读哪个文件、按什么格式回答",小模型就一句话把子任务打发了。
workflow 降低的是"编排出错"的概率,不是"计划写差"的概率——这两件事
分开看,这一章才算学明白。
{{results}} 是阶段之间唯一的数据通道,这是练习 19 那笔上下文账的
延续。 实验一里三个调查子任务内部总共烧了约 1.2 万 tokens,父对话
一个字都没看到——它收到的只有汇总子任务那约 1055 tokens 的最终结论。
中间结果在阶段之间流动(通过占位符注入),但从不回流到父对话;整个
workflow 交回去的只有最后一个阶段的结果。隔离切掉的东西和 sub_agent
一模一样,只是现在有了一条代码保证的、定向的传递通道。
常见问题
- 模型根本不调用 workflow,自己把任务干完了:会发生,而且往往是对
的。这个任务如果不写"每个项目派一个隔离的子任务",DeepSeek 就直接
三次
read_file自己读完了——三份小文件,确实犯不着起四个子 agent。 工具声明里那句"适合结构事先想得清楚的任务"是建议,不是强制;模型 对"这活值不值得开计划"的判断,很多时候比硬性规则准。 - 计划写歪了怎么办:两种形态,对策不同。形状错(实验二那种塞对象、
二次编码)——靠报错里的
planShapeHint拉回来,一般一两轮就收敛; 内容缺(实验三那种忘写{{results}}、prompt 不自包含)——代码层面 收不住,这是计划质量问题,换更强的模型或者在任务描述里把要求写得 更细。分清楚你撞上的是哪种,再决定改工具还是改措辞。 - workflow 和 sub_agent 都在注册表里,模型怎么选:工具声明里画了
分界——结构事先想得清楚的用 workflow,边做边定下一步的用
sub_agent。 实验二里模型先自己读文件再写计划、实验三里模型在 workflow 失效后 退回sub_agent补查,都说明这两个工具是互补的两条路,不是新的替换 旧的。 - 子任务触发人工确认时会怎样:和练习 20 同一个洞,原样存在——
workflow 阶段内并发跑的子任务,各自撞上 ask 档的 bash 命令时,多个
goroutine 同时调用
confirm(),提示照样交错。这一章没有修它, 修法也和练习 20 的加分练习是同一个方向。
加分练习
- 给计划加一道检查:第二个阶段起,整个阶段没有一条 prompt 写
{{results}}就拒绝执行,报错说明理由。写完想一想这条检查会误伤 什么——提示:子任务们共享同一个工作目录,上一阶段用write_file落盘、下一阶段用read_file捡起来,也是一条合法的数据通道。 - 把一份跑通的计划存成
plan.json,给程序加一个-workflow plan.json入口:不经过模型、直接执行文件里的计划。跑通之后你会发现编排的 token 成本降到了零——octo 就有这样一层"存下来的 workflow",模型可以 按名字调用现成计划,只往里填参数。 - 现在的阶段间是"等齐了才放行":阶段 1 有一个子任务特别慢,阶段 2 里跟它无关的子任务也得陪着等。改成每个子任务链独立流动(子任务 A 的阶段 2 不等子任务 B 的阶段 1),比较一下两种做法下代码复杂度差 多少——octo 的 workflow 两种都提供,等齐的叫 parallel,独立流动的 叫 pipeline。
- 给 workflow 加一笔总账:所有子任务的 token 消耗累加,超过一个上限 就不再启动新的子任务,把已完成的结果原样交回并说明中断原因。想想 为什么这笔账对 workflow 比对单发的 sub_agent 更要紧——一份计划是 模型一次性签发的批量授权,签发之后没有人再逐笔把关。