patterns/agents/ 放了四组实现:basic workflows、orchestrator-workers、evaluator-optimizer,以及后来的 async multi-agent orchestration。表面看,它们无非是把 llm_call 串行、并行或循环调用几次。真正的区别不在调用次数,而在一件更具体的事:程序把哪一种未知留到运行时,再让模型决定。
这条线能把四组代码排得很清楚:
| chain / parallel | 没有结构性未知 | 只生成内容 |
| route | 不知道走哪个分支 | 从固定分支里选一个 |
| orchestrator-workers | 不知道要拆出哪些分支 | 生成任务图 |
| evaluator-optimizer | 不知道要迭代几轮 | 决定何时停止 |
| async subagents | 不知道参与者和通信顺序 | 生成并维护运行时拓扑 |
这比“工作流和 Agent 的区别”更准确。是否用了工具、是否有多个模型、是否并发,都不是分界线。分界线是:模型输出有没有进入控制流。
从生成内容到生成控制流
chain 和 parallel 里的模型只负责填内容。调用图已经由代码写死:chain 依次执行一组 prompt,parallel 对一组输入执行同一个 prompt。模型答得再离谱,也不会改变下一步有几个节点、程序走哪条边、什么时候结束。
route 第一次跨过这条线:
route_key = extract_xml(route_response, "selection").strip().lower()
selected_prompt = routes[route_key]
这里的模型输出不再只是文本,而是字典的键。它决定下一次调用采用 billing、technical、account 还是 product 的提示词。控制权仍然很小:分支是开发者预先写好的,模型只能选择。这是一个有限状态机,模型只负责判定下一状态。
orchestrator_workers 再往前一步。orchestrator 返回一组 <task>,代码按返回数量启动 worker:
tasks = parse_tasks(tasks_xml)
for task_info in tasks:
worker_response = llm_call(...)
模型不再从现成的分支里选,而是现场生成分支。任务数量、任务类型、任务描述都到运行时才出现。它生成的不是答案,而是一张很小的任务图。
但这份实现只有“拆”和“跑”,没有“合”。worker 的结果最后只是组成一个列表,没有 synthesis 阶段,也没有验证各子任务是否覆盖原问题。于是 orchestrator 的一次漏拆会直接成为最终结果的盲区,后面没有任何环节能发现。这个缺口比“worker 没有并行执行”重要得多:并行只影响延迟,漏拆影响答案本身。
evaluator_optimizer 把另一种决定交给模型:何时停止。生成器写一版,评估器返回 PASS、NEEDS_IMPROVEMENT 或 FAIL,只要不是 PASS 就继续:
while True:
result = generate(...)
evaluation, feedback = evaluate(result)
if evaluation == "PASS":
return result, chain_of_thought
这个模式的核心不是“让另一个模型提意见”,而是把终止条件改成语义判定。普通循环的停止条件是数字、状态或布尔值;这里的停止条件由一个模型阅读结果后临时判断。它允许程序处理无法写成规则的质量标准,同时也让“是否完成”失去确定性。
因此,这类循环至少需要三个外部约束:最大轮数,防止永不收敛;独立于生成器的验收标准,防止两个模型共享同一盲点;历史压缩策略,防止每轮把全部旧答案继续塞回 prompt。notebook 三个都没有。尤其是最后一点,它把每次旧答案完整加入 Previous attempts,循环越久,输入越长,评估器早期指出的问题也越容易淹没在历史里。
动态工作流
orchestrator_workers 和 动态工作流属于同一类思想,但不是同一个东西。
共同点是:模型在运行时根据具体任务决定怎么拆,而不是开发者提前写死所有子任务。
区别在于模型产出的东西:
- orchestrator_workers 产出一份任务列表
- 动态工作流产出一段可执行程序
动态工作流让模型生成 JavaScript:
const files = await agent('列出所有路由文件')
const audits = await pipeline(files, file =>
agent(`检查 ${file} 的鉴权`)
)
const checked = await pipeline(audits, finding =>
agent(`复核这个发现:${finding}`)
)
return checked.filter(Boolean)
模型不仅决定有哪些子任务,还决定:
- 串行还是并行
- 上一步结果传给谁
- 是否循环
- 何时停止
- 如何过滤和汇总结果
- 失败后继续、丢弃还是重试
生成完脚本后,父模型退出。循环、变量、中间结果和条件分支都由 JavaScript 运行时持有。
可以把它们写成两个类型:
orchestrator_workers:
Task → List[Subtask]
dynamic workflow:
Task → Program
前者生成节点,执行图由 Python 预先写好。后者生成节点、边和控制结构,执行图由模型现场写出来。
orchestrator_workers 可以看成动态工作流的一个受限特例。它大致等价于模型每次只能生成这种程序:
const tasks = await orchestrator(originalTask)
const results = []
for (const task of tasks) {
results.push(await agent(task))
}
return results
而动态工作流还能生成:
// 并行
const results = await parallel(
tasks.map(task => () => agent(task))
)
// 多阶段
const results = await pipeline(
tasks,
task => agent(`执行:${task}`),
result => agent(`验证:${result}`)
)
// 循环至收敛
while (true) {
const result = await agent(…)
if (result.ok || noProgress(result)) break
}
所以更准确的关系是:
orchestrator_workers 是“动态任务分解”,动态工作流是“动态程序生成”。
但动态工作流也没有想象中那么“动态”,官方六个例子大多仍是同一个结构的变体:
发现列表 → 对每项并行处理 → 可选验证 → 汇总
实际主要用到的是:
map + pipeline + while
它很少真的生成复杂 DAG,也没有运行中重新规划、根据 worker 发现改变拆分方式。因此它虽然比 orchestrator_workers 更有表达力,但多数时候只是把固定的 Python for 循环升级成模型生成的数据处理管道。
async 版本改变了什么
前三组实现里,Python 仍然握着执行顺序。即使任务由模型拆出来,代码仍按一个 for 循环依次执行。async_multi_agent_orchestration 才真正把“谁在什么时候和谁交互”交给模型。
它提供五个工具:创建 subagent、查询状态、终止 subagent、发送消息、等待消息。lead 可以在运行中生成参与者,subagent 也可以互相发送消息。此时程序不再执行一张预先存在的任务图,而是在运行中维护一张会变化的通信图。
代码里最值得保留的设计是消息投递。每个 agent 有一个 inbox 和一个 asyncio.Event。消息到达后不会强迫 agent 中断当前工作,也不要求它不断轮询;系统在任意工具调用结束时,把 inbox 追加到最后一个 tool result:
inbox = hub.drain(name)
if results:
results[–1]["content"] += hub.render(inbox)
这样,工具调用同时成为一个安全的消息接收点。agent 忙时消息先排队,下一次与模型交互时再进入上下文;只有完全无事可做时才调用 wait_for_message。它解决的是异步系统里一个很实际的问题:怎样让消息及时进入模型上下文,又不为每条消息重启一次推理。
这套机制也有明确边界。Hub 只保存 inbox、event 和 status,没有任务依赖、消息确认、幂等键、全局完成条件。这套实现适合演示 Agent 如何异步发消息,不保证消息可靠送达。大家互相等待时只能靠 60 秒超时醒来;消息一旦从 inbox 取出,处理失败也不会重发;lead 结束时,外层会取消所有尚未结束的 helper。代码没有强制 lead 在返回前收齐结果,正常示例依靠 prompt 维持这个顺序,而不是由调度器保证。
因此它展示的是通信机制,不是可恢复的任务调度系统,证明了动态 spawn 和异步通信怎样接进标准 tool-use loop,但没有解决分布式执行最难的三件事:完成判定、失败恢复、结果一致性。
工具循环还要延续模型状态
extended_thinking/ 的两篇 notebook 大部分在讲旧版 thinking API:预算、流式内容块、签名和错误处理。这些接口细节不值得写,但 extended_thinking_with_tool_use.ipynb 暴露了 tool-use loop 中一个容易漏掉的状态边界。
一次带工具的响应并不只有 tool_use:
assistant: [thinking block, tool_use block]
user: [tool_result block]
assistant: [text or next tool_use]
程序执行工具后,必须把上一条 assistant response 原样放回消息历史,再追加对应的 tool_result。不能只保存工具名和参数,也不能自行改写、删掉或重排其中的 thinking / redacted-thinking block。那些 block 带有签名,API 会校验它们是否仍是模型当时产生的内容。
这件事和“保存思维链供下一轮参考”不是一回事,在连续的工具调用中,模型不会在每个 tool_result 后重新生成一份 thinking block;前一轮返回的 block 是本轮协议状态的一部分。应用层可以不展示它,却不能在回放上下文时把它当日志丢掉。
因此,Agent 的上下文也不能简化成聊天文本。一个可靠的循环至少要保存有类型的内容块及其顺序:text、thinking、redacted_thinking、tool_use、tool_result。如果记忆压缩或消息持久化只提取可见文字,恢复后的会话可能保留了语义摘要,却破坏了 API 继续执行工具循环所需的结构。
这补充了 async 实现的另一面:Hub 负责 agent 之间的消息状态,而每个 agent 自己的 runner 还要负责模型调用之间的协议状态。前者丢失会漏掉协作结果,后者丢失会让一次尚未结束的工具回合无法正确继续。
三千词的 prompt 在做什么
research_lead_agent.md 有 3488 个词,远长于前三个 notebook 的控制代码。它不是执行器,无法创建线程、维护状态或强制终止任务,它是一层调度策略。
这份 prompt 规定了模型如何把问题分成 depth-first、breadth-first 和 straightforward,何时创建几个 subagent,怎样避免任务重叠,何时停止继续搜索,哪些工作必须由 lead 自己完成。Python 负责“能不能创建”和“最多允许多少”,prompt 负责“此刻该不该创建”以及“创建什么”。前者是机制,后者是策略。
这种分层很像操作系统:内核提供进程和通信原语,调度策略决定资源给谁。区别是这里的策略不是一段确定性算法,而是一篇模型每轮都要重新解释的文字。它能处理模糊任务,也会产生几个特有问题。
第一,规则会互相冲突。lead prompt 一面说琐碎任务不要创建 subagent,一面又说任何简单任务都至少创建一个;subagent prompt 一面要求按复杂度设 research budget,一面又硬性要求至少五次工具调用。碰到简单问题时,模型只能自己决定哪条优先。
第二,文字预算没有强制力。“困难任务约 10 次调用”只是建议;真正能截停进程的仍然是代码里的上限。可靠的设计应把 prompt 中的软目标和 runtime 中的硬限制分开:模型决定如何使用预算,程序保证不会越界。
第三,策略越长,局部规则越容易失效。三千词并不等于三千词都在每次决策中生效。规则散落在多个章节,还存在重复和交叉;模型可能记住“默认用 3 个 subagent”,却忽略后面“收益递减就停止”。这不是靠继续加粗或写 MUST 能解决的,应该把关键状态——已用调用数、剩余预算、活动 agent、未覆盖子问题——作为结构化数据在每轮显式提供。
citations_agent.md 展示了另一种更可靠的做法。它把“加引用但不改正文”拆成独立阶段,然后由程序删除引用后与原文比较。模型负责难以规则化的引用位置,程序负责可以精确验证的文本一致性。这里的亮点不在 prompt 写得严,而在任务被重新切分后终于有了机械验收条件。
两个 bug 暴露了同一个问题
orchestrator_workers 里有两个已经随执行结果提交进仓库的 bug。
parse_tasks 手写字符串切片,<description> 的左右偏移各错一个字符,实际解析结果是:
>Write a detailed, feature-focused description…<
尖括号被留在了任务描述里,但 worker 仍然读懂了,所以流程没有报错。
另一个 bug 更隐蔽。示例向 process() 传了 target_audience 和 key_features,两个 prompt 模板却没有对应占位符。Python 的 str.format 会静默忽略多余参数,于是这些上下文从未进入模型。保存的输出里 millennials 和 plastic-free 都没有出现,但生成结果看起来仍然像一份完整的水瓶文案。
两处错误都没有让程序失败,只让输入发生了轻微偏移。下游模型凭语言理解把偏移吸收了,最终输出仍然流畅。传统软件会用崩溃暴露接口错误,LLM 管道反而可能用“还能读懂”掩盖接口错误。
这意味着 LLM 系统不能只测最终文本是否像样,还要验证信息是否真的穿过每个边界:传入的字段有没有出现在构造后的 prompt,orchestrator 生成的每个任务是否原样交给 worker,worker 的每项结论是否进入最终 synthesis。否则模型越强,管道里的接线错误越难被发现。
async_multi_agent_orchestration 改用带 schema 的工具参数,避免了手写 XML 切片,但 schema 只能保证形状,不能保证信息没有丢。真正需要测试的是数据流,而不只是输出格式。
这个目录留下的判断
四组实现展示的不是一串“越来越高级”的 Agent 技巧,而是控制权逐步外移的过程:先让模型写内容,再让它选分支、生成任务图、判断何时停止,最后决定运行时有哪些参与者以及它们怎样通信。
每交出去一种决定,都要补一种不同的约束。选择分支需要枚举校验,生成任务图需要覆盖性检查和结果合成,语义终止需要轮数上限和独立验收,动态通信需要完成判定、失败恢复和消息一致性。不能用一句“加重试和日志”概括,因为它们不是同一种风险。
这也是读这个目录最有用的方式:别记 chain、route、orchestrator 这些名字,问每段代码一个问题——模型输出在这里仅仅是内容,还是已经变成了下一步怎么执行的指令? 一旦是后者,重点就不再是 prompt 写得好不好,而是那条控制边界有没有验证、预算和失败语义。


