练习 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 末尾从建 childRegreturn 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 里写死任何一份具体计划——workflowToolread_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 的加分练习是同一个方向。

加分练习

  1. 给计划加一道检查:第二个阶段起,整个阶段没有一条 prompt 写 {{results}} 就拒绝执行,报错说明理由。写完想一想这条检查会误伤 什么——提示:子任务们共享同一个工作目录,上一阶段用 write_file 落盘、下一阶段用 read_file 捡起来,也是一条合法的数据通道。
  2. 把一份跑通的计划存成 plan.json,给程序加一个 -workflow plan.json 入口:不经过模型、直接执行文件里的计划。跑通之后你会发现编排的 token 成本降到了零——octo 就有这样一层"存下来的 workflow",模型可以 按名字调用现成计划,只往里填参数。
  3. 现在的阶段间是"等齐了才放行":阶段 1 有一个子任务特别慢,阶段 2 里跟它无关的子任务也得陪着等。改成每个子任务链独立流动(子任务 A 的阶段 2 不等子任务 B 的阶段 1),比较一下两种做法下代码复杂度差 多少——octo 的 workflow 两种都提供,等齐的叫 parallel,独立流动的 叫 pipeline。
  4. 给 workflow 加一笔总账:所有子任务的 token 消耗累加,超过一个上限 就不再启动新的子任务,把已完成的结果原样交回并说明中断原因。想想 为什么这笔账对 workflow 比对单发的 sub_agent 更要紧——一份计划是 模型一次性签发的批量授权,签发之后没有人再逐笔把关。