Agent Mentor Learn
从循环到图:Agent 系统的编排工程 · 第 6 / 6 节

第 6 课:实战:把你的 harness 升级成一张小图

学习目标:

  • 把前五课的路由、扇出、汇合、评审回路、汇报焊进一个 orchestrate.mjs:计划写在代码里,每个节点内部还是本系列第 7 门课那个 stop_reason 循环,中间结果留在脚本变量里
  • 让评审回路真的转起来,并亲眼看到它的两种停法——一条工单按 gate 报告改对了收工,另一条连着两轮交回一模一样的报告、判定不再有进展、标记 needs_human
  • 把整张图的执行留痕落到 run-state.jsonrun.jsonl,用真实运行的汇总表对账:哪个节点花了多少时间、几次模型调用、多少 token、gate 转了几轮

前置要求:读完第 1–5 课,手边能跑起本系列第 7 门课的 harness 循环 | 上一课 第 5 课 <<

先看它跑起来的样子

前五课把零件拆开讲完了:谁持有计划(第 1 课)、链式与路由(第 2 课)、分段与投票加一个有上界的并发池(第 3 课)、编排者-工人与派工提示词的四要素(第 4 课)、评审回路以及把这些模式组合起来的画法(第 5 课)。这一课把它们焊成一个文件。

任务很土:inbox/ 里有六条客服工单,要给每一条写出一份能直接发出去的回复。先看它跑完是什么样:

text
$ node orchestrate.mjsinbox/ 收到 6 条工单:T-1001, T-1002, T-1003, T-1004, T-1005, T-1006[route] T-1001=billing  T-1002=bug  T-1003=other  T-1004=billing  T-1005=bug  T-1006=other[fanout] 并发上界 2,产出 6 份初稿[merge] 写入 out/ 6 份,向下只传引用与一行摘要[review] gate 重写共 2 轮
=== 全图执行汇总 ===节点      耗时    模型调用    token    gate 轮数   状态route     62ms    1           720      -           okfanout    247ms   8           8903     -           okmerge     2ms     0           0        -           okreview    127ms   2           3033     2           ok
=== 逐条工单 ===工单     类别      处理者            gate 轮数   停止原因        状态T-1001   billing   worker:billing    0           gate_pass       passT-1002   bug       worker:bug        0           gate_pass       passT-1003   other     template          0           gate_pass       passT-1004   billing   worker:billing    1           no_progress     needs_humanT-1005   bug       worker:bug        1           gate_pass       passT-1006   other     template          0           gate_pass       pass
产出目录 out/:6 份回复;需要人工接手:1 条  - T-1004(no_progress):工单 T-1004:抬头从个人改成公司,需要…留痕:run-state.json / run.jsonl(run_id=run-mta57gsx)$ echo $?1

这一课里所有终端输出都是这个脚本真实跑出来的,逐行照抄,没有一行是手敲的示例。两个地方每次跑都会变:耗时那几个毫秒数,以及 run_id(它是时间戳转的三十六进制)。其余部分——分类结果、调用次数、token 数、gate 轮数、哪条工单是 needs_human——都是钉死的常量,原因在后面「验证桥段」一节讲。

值得先盯一眼的是最后那个 1。这不是出错,是判定:六条工单里有一条没能自动收工,退出码就不是 0。这张图的每一次运行都会给出一个能被 CI 或者定时任务读懂的结论,而不只是打一堆日志。

这张图长什么样:计划就是 main() 那十几行

先看整个脚本的骨干。用「图」「节点」这套词是第 5 课说过的——这是我们自己的画法,不是官方概念,它的一手依据只有一条:工作流脚本自己持有循环、分支与中间结果1。下面这段就是那句话的字面落实:

第 5 课先画过一张组合图,这张图是它的变体,三处不一样:第 5 课按难度二分「简单 / 复杂」,这里按主题三分 billing / bug / other;第 5 课的扇出是「一条复杂工单分给三个工人再汇合」,这里是「六条工单各派一个处理者」的分段;第 5 课的回边打回一个独立的 [起草] 节点,这里打回原来那个工人。为什么这么变,都收在文末「对表」一节里。

routeddraftsitems 这三个 const 就是整张图的状态。它们是普通的 JavaScript 变量,不是什么类型化的状态对象,也没有合并策略——中间结果留在脚本变量里1,节点之间靠函数返回值传递。没有任何一个模型看得见这些变量的全貌:路由模型只看到六条工单文本,账单工人只看到自己那一条,评审 gate 只看到一份回复文件。

这就是 workflow 和 agent 的架构区分落在代码上的样子:LLM 与工具被预定义的代码路径编排2,而不是由模型自主决定自己的流程2

五个节点各管一段:

节点干什么谁在干
route一次廉价调用把六条工单分成三类一个模型循环
fanout按类别派给专门化的工人,并发有上界两种模型循环 + 一段纯代码模板
merge产出落盘,只把引用和一行摘要交给下游纯代码
review确定性 gate 先拦,拦下的进查-修-再查纯代码 + 按需回炉的模型循环
report打汇总表、定退出码纯代码

五个节点里只有两个真的调模型。不是每个节点都得是模型——这是这一课最省钱也最容易被忽略的一条:mergereport 是纯函数,fanout 里的 other 类走的是字符串模板,review 的第一道判断是几行 includes。凡是能用确定性代码给出同样答案的地方,就没有理由付一次模型调用的钱和延迟。

节点内部:还是本系列第 7 门课那个循环

先把最里面的东西定下来,图才好谈。每个模型节点内部跑的,是本系列第 7 门课那个 stop_reason 循环,一行没改:

循环体里的四步——push assistant、执行工具、push tool_result、重新赋值 response——和本系列第 7 门课的第 6 课那份逐字相同,连注释都是搬过来的。阀1(最大轮次)也在原来的位置:循环体最前、turns++ 之前。给循环留一个最大迭代数这类停止条件,本来就是把控制权攥在自己手里的常规做法2

和本系列第 7 门课比,有两处变化,都在循环体外面:clientsystem 从模块级常量变成了参数(三个角色要用不同的桩和不同的系统提示词,只能从外面传进来);token 与调用次数的统计从循环体搬到了客户端外面的一层包装里,循环内部一个字没动:

这么改是有代价的,得说明白:本系列第 7 门课的阀2(token 预算)原本靠循环体里那个累加值来判断,累加值不在循环里了,阀2 也就没搬进来。这张图里每个节点的桩响应队列长度固定,队列耗尽会直接报错,跑不飞;但等你把桩换成真 client,请把阀2 装回去——要么让 metered 在超预算时抛错,要么把计数搬回循环体、恢复第 7 门课的原样。阀3(空转检测)和阀4(人工审批)同理没搬,理由在后面「对表」一节列。

工具那半边也是照搬的:一轮响应里有几个 tool_use 块就回几个 tool_result,工具抛错就包成 is_error: true 交回给模型,而不是让整个进程崩掉。

节点一:路由,一次廉价调用,然后把输出收紧

路由做的事是分类后分发到专门化的后续任务2。它是整张图最便宜的一次模型调用:一次请求分完六条,不给工具,不许写回复。

重点在中间那十行,不在模型调用。模型返回的是自由文本,图后面所有分支都要靠这个值走,所以它必须在进入下游之前被收紧成三个合法标签之一:不匹配格式的行直接丢;类别不在白名单里的落到 other;连一行都没匹配上的工单,parsed.get(t.id) ?? "other" 兜底。

桩里我故意让模型把最后一条判成了「投诉」——一个中文词,不在白名单里。真实运行的日志里能看见这次收紧:

text
{"ts":"2026-08-26T13:41:46.130Z","run_id":"run-mta57gsx","node":"route","event":"clamped","ticket":"T-1006","raw":"投诉","category":"other"}

模型给了一个自作主张的标签,代码把它压回 other,并且留了一条记录说明压了什么。下游分支只认代码认过的值——这是路由节点和「让模型直接决定下一步跳到哪」最实际的差别,也是它能被单元测试的原因。

节点二:扇出,三个工人和一个有上界的并发池

扇出走的是分段:把任务拆成互相独立的子任务并行跑2。这里的「独立」是天然的——六条工单之间没有任何依赖,谁先谁后都不影响结果。

三类工单三种处理者,其中只有两种是模型:

并发池就是第 3 课那个池子(那里叫 pool,这里叫 runPool):任务放在一个游标后面,起 limit 个消费者去抢,抢完为止。上界是真的在起作用,不是摆设。把它调成 1 再跑一次,fanout 那一行的耗时会明显变长(调用次数和 token 一个不差,毫秒数照例会抖):

text
$ POOL_SIZE=1 node orchestrate.mjs...=== 全图执行汇总 ===节点      耗时    模型调用    token    gate 轮数   状态route     62ms    1           720      -           okfanout    494ms   8           8903     -           okmerge     2ms     0           0        -           okreview    131ms   2           3033     2           ok

494ms 对 247ms,调用次数和 token 一个不差。并发换到的是墙上时间,不是更少的工作量——这一点在真接 API 之后同样成立,只不过那时候你还要考虑供应商的速率限制,上界就更不能省了。

派工提示词:四要素一个不少

三个模型角色的提示词都按第 4 课那四要素写:目标、输出格式、工具指引、任务边界。子代理需要目标、输出格式、工具与来源的指引,以及清晰的任务边界;描述不到位,工人就会重复劳动、留下缺口,或者找不到该找的东西3。账单工人是这样:

四条各有各的活:目标决定它写什么;输出格式让下游的 gate 有东西可查(「开头写工单号」这条直接对应 gate 的第一条规则);工具指引把「金额从哪来」钉死在 lookup_order 上,堵住凭工单描述编数字这条路;任务边界既拦住越权动作,也提前把敷衍词列进禁令。

故障工人那份内容换成查已知问题库、引用问题编号、不许自己编编号;分拣员那份的「工具指引」写的是「这一步不给你任何工具,只看工单文本判断」,对应代码里传进去的空工具数组。这三份提示词的差别本身就是路由的收益:分类之后各写各的,不必把三种活的要求硬塞进一个提示词里——这正是路由带来的关注点分离与更专门化的提示词2

节点三:汇合,传引用不传载荷

merge 是纯代码,一次模型调用都没有。它做两件事:把每份初稿写进 out/,然后收集一份轻量的清单交给下游——{id, category, handler, file, oneLine},一个文件路径加一行摘要,不是六份完整回复。(同一时刻它也在 run-state.json 里给每条工单开一条记录,字段见后面完整代码的第 9 节。)

这是把多 Agent 系统那条工程建议搬进单进程脚本:让专门化的代理把产出存进外部系统,只把轻量的引用传回协调者3。在那篇复盘里,这条建议解决的是「什么都经由主代理转述」带来的上下文膨胀;在这里,它解决的是同一件事的小号版本——评审节点需要的是「哪份文件该被检查」,不是六份全文都堆在一个变量里传来传去。

所以评审节点第一件事是重新从文件读回内容:

这一步看着多余——反正都在同一个进程里,直接把字符串传过去不就行了。但它买到了两样东西:out/ 里的文件成了这条工单唯一的真相,谁改了它评审就查谁;以及,这条边一旦要换成跨进程、跨机器,改的只是 readFileSync 这一行,节点之间的契约不用动。

一道判断题

到这里,图的前三个节点已经成型:路由是代码收紧的,汇合是纯代码的,接下来的 gate 也会是纯代码的。这时候最常听到的一个问题正好可以摆出来。

节点四:评审回路,gate 先拦,拦下的才回炉

评审节点做的是查-修-再查:跑一个检查器,把没过的修掉,重复,直到通过或者不再有进展1。它是这张图里唯一一处「模型的产出会被打回去重写」的地方。

第一道关是确定性的,几行 includes 就写完了:

两条规则,都是本系列第 10 门课说的那种「能确定性判就别请裁判」:回复里必须出现工单号(客服系统靠它对账),不许出现「稍等」「请耐心等待」「尽快处理」这类没有信息量的话。两件事都不需要理解语义,字符串包含就能判,结果每次都一样,还顺便产出了一份能直接喂回给工人的报告字符串。

LLM 裁判在这里能干的活,是判断「这份回复的语气合不合适」「事实有没有超出工具返回的范围」——那些确实没法用 includes 判的东西。但它得排在 gate 后面:gate 是免费的、确定的,先让它把明确的毛病筛掉,剩下的才值得花一次调用去请人评。这张图只装了 gate 这一层,因为这批工单的验收标准恰好都能写成规则;等验收标准里出现「语气得体」这种词,再按本系列第 10 门课的分层判分把裁判那一层加上。

回路本身长这样:

三个 break 对应三种停法,和第 5 课声明的一致:通过(while 条件自然为假)、不再有进展、撞到最大轮数。第三个 if 是个补丁——other 类的回复是纯代码模板生成的,没有可以回炉的工人,真要是模板本身写坏了,只能直接交人。这次运行里它没被走到(模板是常量,它必然过 gate),留着是因为一旦有人改坏了模板字符串,我宁可看见一条 no_rewriter 的记录,也不想看见一个空转的循环。

回炉时喂给工人的东西很朴素:上一版全文 + gate 报告 + 一句「只修报告里点名的问题,重写一版完整回复」(拼装在 callWorker 里)。

两种停法,都在这次运行里真的发生了

桩里我埋了两条剧本,让回路的两个出口各走一次。

T-1005:改对了,收工。 故障工人第一版忘了写工单号(第一条规则不过),gate 交回 missing_ticket_id,工人按报告补上开头那句,第二版通过:

text
{"ts":"2026-08-26T13:41:46.444Z","run_id":"run-mta57gsx","node":"review","event":"gate","ticket":"T-1005","round":0,"pass":false,"report":"missing_ticket_id"}{"ts":"2026-08-26T13:41:46.505Z","run_id":"run-mta57gsx","node":"review","event":"worker_done","ticket":"T-1005","round":2,"calls":1,"tokens":1638}{"ts":"2026-08-26T13:41:46.506Z","run_id":"run-mta57gsx","node":"review","event":"gate","ticket":"T-1005","round":1,"pass":true,"report":""}

T-1004:修了,但没修对,回路自己停了。 账单工人第一版写了「请您稍等」,gate 交回 filler_word:稍等;工人重写了一版,句子完全不同、更长、多了一句解释,可那个词还在。第二轮的报告和第一轮一模一样:

text
{"ts":"2026-08-26T13:41:46.381Z","run_id":"run-mta57gsx","node":"review","event":"gate","ticket":"T-1004","round":0,"pass":false,"report":"filler_word:稍等"}{"ts":"2026-08-26T13:41:46.443Z","run_id":"run-mta57gsx","node":"review","event":"worker_done","ticket":"T-1004","round":2,"calls":1,"tokens":1395}{"ts":"2026-08-26T13:41:46.443Z","run_id":"run-mta57gsx","node":"review","event":"gate","ticket":"T-1004","round":1,"pass":false,"report":"filler_word:稍等"}

这时候 gate.report === lastReport 成立,回路判定不再有进展,停下,把这条工单标成 needs_human。它本来还有两轮预算(MAX_REVIEW_ROUNDS 是 3),但花掉也是白花——同样的报告喂回去,大概率还是同样的回复。「不再有进展」这个出口的价值就在这儿:它比最大轮数更早地止损,而且它给出的是一个有信息量的结论——不是「试了三次还不行」,是「它听不懂这条意见」,这正是该转人工的信号。

两种出口在数据里的区别,一眼可辨:

两条工单的 gate_rounds 都是 1,光看轮数分不出谁成谁败;分水岭在 gate_reports 的长度——它记的是每一次没通过的报告,最后一次也算。T-1005 只留下一条(第二版过了,没有第二条报告),T-1004 留下两条且内容相同,stop 字段把结论直接写死成 no_progress

节点五:汇报与留痕

最后一个节点也是纯代码:把 state.nodes 和逐条工单打成两张表,数一数 needs_human,定退出码。全部通过是 0,有一条需要人工就是 1。

留痕分两份,各司其职。run.jsonl 是本系列第 11 门课那种结构化日志,一行一个 JSON 事件,每条带 tsrun_id,事后随便 grep——这次运行一共 39 行,前面几段引的都是从它里面 grep 出来的原文。

run-state.json 记的是执行留痕(跟前面说的「图的状态=那几个脚本变量」不是一回事),用本系列第 9 门课的家法写:先写 .tmp,再 rename 原子替换,任何时刻被杀,磁盘上要么是上一份完整状态,要么是新的完整状态,不会出现半截 JSON:

落笔时机是「每完成一小步就落一次」:每个节点跑完落一次,评审节点里每判完一条工单再落一次。这么做的理由第 5 课引过——运行时逐步追踪每个代理的结果,正是一次运行能在同一会话内被恢复的前提1;把活儿铺开到许多小代理上的工作流,比一个长代理保住的进度更多1。这张图不是多代理运行时,但同一句话在这里照样成立:六条工单是六份独立的进度,评审阶段中途死掉,已经落盘的那几份不该跟着一起没(扇出阶段还没做到这一点——见对表第 3 项那条差异)。

想看这句话的实际效果,用 STOP_AFTER=merge 在扇出之后、评审之前把进程停掉:

text
$ STOP_AFTER=merge node orchestrate.mjsinbox/ 收到 6 条工单:T-1001, T-1002, T-1003, T-1004, T-1005, T-1006[route] T-1001=billing  T-1002=bug  T-1003=other  T-1004=billing  T-1005=bug  T-1006=other[fanout] 并发上界 2,产出 6 份初稿[merge] 写入 out/ 6 份,向下只传引用与一行摘要[stop] STOP_AFTER=merge:在评审之前停下,本次不做判定$ echo $?2

此刻的 run-state.json(节选):

三个节点的账在,六条工单的分类、处理者、产出文件路径都在,out/ 里六份初稿也已经落盘。丢掉的只有评审这一段:所有工单都停在 status: "drafted"stop: null。这份状态足够支撑一次续跑——从 out/ 读回初稿,直接从评审节点开始。注意 T-1005 那条的 one_line 恰好暴露了初稿的毛病:开头没有工单号。评审还没跑,所以这个毛病此刻还没被发现。

STOP_AFTER 只认 merge 这一个值,是本系列第 9 门课那个受控崩溃点的简化版:退出码 0 全过、1 有工单转人工、2 提前停下没做判定、3 是脚本自己崩了——四个码互不重叠,CI 一眼能分清「跑完了但有人要接手」和「跑挂了」。)

完整的 orchestrate.mjs

下面是全文,一整段,可以直接复制粘贴进一个空目录里的 orchestrate.mjs 然后 node orchestrate.mjs。零依赖,不需要 npm i,不需要 package.json.mjs 后缀已经声明了它是 ES 模块),也不需要 API key——模型客户端是桩。第一次运行会自己把 inbox/kb/out/ 建好并写进那六条工单。

六百七十九行,其中约一百九十行是喂给桩的数据(SCRIPTS 那张表、六条工单原文、已知问题库、桩 client),真正的编排逻辑——五个节点、并发池、gate 与入口——约两百五十行,另有四十来行是观测与状态留痕。这个规模是刻意的:一个循环加几个模式,本来就是几行代码能实现的东西2

验证桥段

这一课所有终端输出都是这个脚本真跑出来的,靠的不是「多跑几次挑一次好看的」,而是把两处不确定性提前钉死。

模型换成按固定队列出牌的桩。 SCRIPTS 是一张表,键是「工单 id + 第几版」,值是一串预先写好的响应;每调一次 messages.create 就按顺序吐下一条,队列耗尽还在调就直接报错。这样「哪条工单在哪一轮调哪个工具、模型什么时候收尾」全是常量。桩还留了一道断言:create 必须带 modelmax_tokens,缺一个就抛错——真 client 这两个参数是必填的,桩不替你兜着,免得换成真 client 那天才发现漏了。这套方法从本系列第 8 门课的实战一路用到这里,为的是让被验证的对象是你写的控制逻辑,而不是模型当天的发挥(真实的模型是非确定性的,同样输入也可能给出不同的响应4)。

桩里还加了一个 60 毫秒的固定延迟,代替真实的网络往返。不加的话每个节点都是 0ms,并发池的效果在汇总表里根本看不出来——上面 POOL_SIZE=1 那次对比(494ms 对 247ms)靠的就是它。

两条埋在桩里的回路剧本。 评审回路要真的转起来,就得有东西真的过不了 gate。所以:

  • T-1005#1(故障工人的第一版)故意不写工单号,触发 missing_ticket_idT-1005#2 补上开头那句话,第二版通过——这条演示的是「查-修-再查」正常收工的出口。
  • T-1004#1T-1004#2(账单工人的两版)都带着「请您稍等」。两版的句子完全不同,长度也不同,但 gate 看的是那个词在不在,于是两轮报告字符串一模一样,触发「不再有进展」——这条演示的是止损的出口。

两条剧本的写法是有讲究的:不是让第二版原样重复第一版(那样连人都看得出是死循环),而是让它「改了,但没改到点上」。这才是真实回路里最常见的失败形态,也正是「连续两轮报告相同」这个判据要抓的东西。

受控的提前停止。 STOP_AFTER=merge 让进程在扇出之后、评审之前停下,退出码 2。它是本系列第 9 门课那个 CRASH_AFTER 的简化版:把「在哪一步中断」变成一个能精确指定的参数,而不是靠运气去撞。上面那份 drafted 状态的 run-state.json 就是这么跑出来的。

对表:这张图欠前面几课的账,逐条清一遍

一门课到了收尾的实战,最容易犯的毛病是悄悄推翻前面立过的规矩。所以这里逐条对一遍,有出入的地方明写。

1. 循环本体和本系列第 7 门课一致。 循环体里那四步——push assistant、执行工具、push tool_result、重新赋值 response——与本系列第 7 门课的第 6 课那段逐字相同,注释都没改。阀1 也在原位。差异声明runAgent 的签名多了 clientsystem 两个参数(三个角色要用不同的桩和不同的系统提示词),create 调用里多了一个 system 字段;token 计量从循环体搬到了 metered 包装器里,因此第 7 门课的阀2(token 预算)没有跟着搬,阀3(空转检测)和阀4(人工审批)也没有搬——这张图里工具只有读文件和查订单两个只读操作,没有需要审批的高影响动作;桩队列有限,空转跑不飞。接真 API 之前,这三道阀都要装回去。

2. 派工提示词四要素齐全(第 4 课)。 三份提示词——分拣员、账单工人、故障工人——每一份都写全了目标、输出格式、工具指引、任务边界四段,各占一行,可以逐行对照3

3. 并发池有上界,汇合传引用不传载荷(第 3 课)。 runPoollimit 是硬上界,POOL_SIZE=1POOL_SIZE=2 的耗时差已经验过。merge 之后向下游传的是 {id, category, handler, file, oneLine},正文全文留在 out/ 里,评审节点自己从文件读回来3差异声明:第 3 课讲的池子是「同一批子任务并行跑」,这里的池子跨了三种处理者——两种模型工人加一段纯代码模板,模板那条走进池子几乎不耗时。池子的语义没变(在飞的任务数不超过上界),只是任务本身不同质。还有一条第 3 课立过、这里为了让脚本短没有装:第 3 课要求每条泳道单独 try/catch、让单路失败不拖垮整批,runPool 没有这层包裹——代价是扇出阶段任一路抛错,整批初稿都不会落盘。接真 API 之前必须补上,真实网络里一路超时是常态。

4. gate 优先于裁判,回路的停止条件与第 5 课一致。 第一道关是确定性代码,不是模型;这一课没有装 LLM 裁判那一层,因为这批工单的验收标准恰好都能写成规则,装了也是白花钱——本系列第 10 门课的分层判分就是这个顺序:能确定性判的先判完,剩下的才请裁判。回路的停止条件三种:通过、不再有进展、撞上最大轮数1 2,中文概念和第 5 课一一对应。但字段和取值换了写法:第 5 课落在 reason 字段、取值 passed/no-progress/max-rounds,这里落在 stop 字段、取值 gate_pass/no_progress/max_rounds(判据从裁判换成了 gate,连字符也按本课的 snake_case 惯例改成下划线);另外第 5 课的 rounds 数的是生成次数、初稿算第 1 轮,本课的 gate_rounds 数的是重写次数、初稿是第 0 轮,所以同样一条工单,两课的轮数计数起点差一。差异声明:代码里多了第四个出口 no_rewriter(纯代码模板没有可回炉的工人)。这不是第 5 课漏讲的模式,是这张图特有的情况——第 5 课的回路预设了「产出者是个模型」,而这里有一类产出者是模板。这次运行没有走到这条分支。

5. 「图」的措辞和第 5 课的声明一致。 全文的「图」「节点」都是本课自己的工程隐喻,第 5 课引入这套画法时已经明说过,它不是任何一手材料里的官方概念;能站住的一手依据只有那一条:工作流脚本自己持有循环、分支与中间结果1。这一课没有给它添任何新术语——「状态机」「节点间传递的状态对象」一个没用;第 5 课定义过的「边」(谁的输出喂给谁)只在讲 merge → review 那条数据流时出现过一次,不是新词。routed / drafts / items 就是三个普通的局部变量。

6. run-state.json 的原子写与本系列第 9 门课一致。 先写 .tmp,再 renameSync 替换,一步不差。落笔时机也照那门课的口径:每完成一小步落一次,不是跑完才落一次。

7. 观测口径与本系列第 11 门课同形,但粒度更粗。 一行一个 JSON 事件,每条带 tsrun_id,事后随便 grep差异有四条:(a) 第 11 门课的日志器记内容摘要(形状、长度、前若干字符),这一课只记 id、类别、文件名、报告字符串和计数,不记回复正文——正文本来就在 out/ 里;(b) 关联字段第 11 门课叫 trace_id,这里叫 run_id;(c) 那门课的核心是用 span_id/parent_id 把记录串成一棵 trace 树,这张图虽然是 node→worker→tool 三层嵌套,却没有实现父子串联,所以没有 trace 树;(d) initLog() 每跑一次就清空 run.jsonl、只留最近一次运行,要做第 11 门课那种跨运行比对(v-good vs v-bug),得改成按 run_id 分文件追加。想把这张图接进真正的 trace 体系,第 11 门课那套 span 字段要照着补。

8. 编排者-工人这个模式,本课有意没实现(第 4 课)。 第 4 课讲的编排者-工人,关键是「派几份、每份干什么」由模型看着输入现场决定;这张图不是——六条工单怎么分类、每类走哪个工人,在写第一行代码之前就定死在 CATEGORIES 和三份常量提示词里了。这正是第 4 课那条「能预定义就别动态」的直接应用:这批活的形状是已知的,就不该把决定权交回模型。所以严格讲,焊进这个文件的是四个模式(链式、路由、并行化-分段、评审回路),投票在 Level 2 练习里补第五个,编排者-工人则是被这批任务的性质挡在门外的那一个。

分寸

这张图管的事情很小:一个进程,一批工单,跑完就退出。它值得建,是因为「工单进来 → 分类 → 按类别处理 → 检查 → 汇报」这五步在写第一行代码之前就已经定死了。要是任务变成「查清楚这个客户过去半年到底遇到了什么问题,需要几步你自己判断」,那这张图就是错的架构——那种事先没法预测需要多少步、也没法硬编一条固定路径的开放式问题,本来就该交回给自主循环2

几处边界,说在明处:

扇出是同步的,规模大了会疼。 fanoutNode 里的池子必须等这一批全部跑完才进 merge。这正是那个真实上线系统承认的瓶颈:同步执行简化了协调,但会在信息流上形成堵点——一个子代理迟迟不完,整个系统就卡在那儿等它3。六条工单、每条最多两次调用,这个瓶颈根本不疼;六百条、每条十次调用,它就会变成「最慢那条决定整批的墙上时间」。要不要改成异步,得算清楚代价:异步能让代理并发工作、按需再开新的,但它会在结果协调、状态一致性、跨子代理的错误传播这三件事上添难3——这三样在同步版本里根本不存在,因为顺序是代码定死的。

评审回路的两条规则很浅,也很脆。 includes("稍等") 会把「不用稍等,已经处理完了」这种句子误判成敷衍。这是本系列第 10 门课提醒过的老问题:过严的确定性验证器会把对的判成错的。真要上线,这两条规则得配一小批真实回复反复校准,或者把它降级成「拦下来交给裁判复判」而不是直接打回重写。

换成真 API,只换桩,结构不动。 makeStubClient(queue) 换成 new Anthropic()SCRIPTS 整张表删掉,其余一行不用改——runAgent 本来就是照真实 API 的 stop_reason / tool_use / tool_result 形状写的,modelmax_tokens 也一直带着。换完有三件事会变:分类结果会抖(同样的工单,两次跑可能落进不同的类),gate 轮数会抖,token 数会抖;跑一次要花钱花时间;本系列第 7 门课那三道没搬的阀得装回去。

每加一层复杂度,都得过「可测量的改进」这道关。 这张图里的每个模式都能被单独摘掉:不做路由,一个通用提示词也能回工单;不做扇出,串行跑六条也能跑完;不做评审回路,人工抽检也是一种办法。摘掉之后指标掉没掉、掉多少,得测了才知道。只有在复杂度确实改善了结果的时候,才值得把它加上去2

💻 练习

小结

  • 四个模式焊进一个文件之后(投票在练习里补第五个,编排者-工人因为分工能预定义而有意缺席),「计划在代码里」这句话有了具体形状:main() 那十几行就是全部的控制流,routed / drafts / items 三个普通变量就是全部的状态。LLM 与工具被预定义的代码路径编排2,脚本自己持有循环、分支与中间结果,模型的上下文里只装它这一步该看的东西1
  • 不是每个节点都得是模型:五个节点里两个调模型,mergereport 和 gate 的第一道关都是纯代码,other 类走的是字符串模板。凡是确定性代码能给出同样答案的地方,就没有理由付一次调用的钱和延迟
  • 路由的价值不在那次调用,而在调用之后那十行收紧代码:模型给的自由文本被压成三个合法标签之一,下游分支只认代码认过的值;专门化的提示词则是分类换来的红利2
  • 扇出的并发要有上界,汇合要传引用不传载荷——产出落盘,只把轻量的引用交给下游3,评审节点自己从文件读回来。同步扇出在这个规模不疼,规模大了它就是瓶颈3,而改成异步要付出结果协调、状态一致性、跨子代理错误传播这三项代价3
  • 评审回路是查-修-再查,直到通过或不再有进展1,外加一道最大轮数兜底2。确定性 gate 排在裁判前面;「连续两轮报告相同」这个判据比最大轮数更早止损,而且它给出的结论更有信息量:不是「试了三次不行」,是「它听不懂这条意见」
  • 逐步留痕带来可恢复:每个节点跑完落一次状态,正是一次运行能在同一会话内被接着跑的前提1(跨进程、跨机器接着跑,是本课把状态落盘后自己加的一层推广);配上先写 .tmprename 的原子替换,任何时刻被杀,磁盘上都是一份能读回来的完整状态
  • 这张图管的是一个进程、一批工单、步骤事先定死的活。步数没法预测的开放式问题该交回自主循环2;每加一层复杂度,都得过「可测量的改进」这道关2

十二门课到这里走完了。

回头看,你手里现在有的东西是一件一件攒起来的:本系列第一门课你写下第一个 prompt,学会把要求说清楚;然后是工具调用、工作流、技能、多 Agent 协作,一路到第 7 门课——那门课让你自己写了一个循环,while (response.stop_reason === "tool_use"),从那天起 Agent 对你不再是一个黑盒,而是一段你读得懂的代码。第 8 门课教你管住它的上下文,别让循环转着转着把窗口撑爆。第 9 门课教你让它经得起中断,被杀掉也能从上次停下的地方接着干。第 10 门课教你验它的产出,把「看起来做完了」和「做完了」分开。第 11 门课教你看清它的过程,出了事有日志、有 trace 可查。这门课教你把多个循环编成一张自己持有计划的图。

这六件东西是同一件事的六个侧面:**你在自己写的代码里,控制着一个非确定性的东西。**循环是你写的,上下文是你管的,检查点是你存的,验收标准是你定的,日志是你打的,计划是你排的。模型很强,但它在你这套控制代码里干活。

最后一步落在具体动作上:把 orchestrate.mjs 里的 makeStubClient(queue) 换成 new Anthropic(),删掉 SCRIPTS 那张表,把本系列第 7 门课那三道没搬的阀装回去,然后把你自己工作里真正堆着的那批任务——真实的工单、真实的日志、真实的待办——倒进 inbox/,跑第一遍。它多半会有几条落进 needs_human,那正是这张图该有的样子。

Footnotes

  1. Orchestrate subagents at scale with dynamic workflows — Claude Code 官方文档 — https://code.claude.com/docs/en/workflows 2 3 4 5 6 7 8 9 10

  2. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2 3 4 5 6 7 8 9 10 11 12 13 14 15

  3. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3 4 5 6 7 8 9

  4. Writing effective tools for agents — with agents — Anthropic Engineering — https://www.anthropic.com/engineering/writing-tools-for-agents

练习

01

下面是这张图一次完整运行的汇总表,以及 run-state.json 里两条工单的记录(真实运行结果,毫秒数和 run_id 每次会变):

Level 1:读图诊断——回路里到底发生了什么
text
=== 全图执行汇总 ===节点      耗时    模型调用    token    gate 轮数   状态route     62ms    1           720      -           okfanout    247ms   8           8903     -           okmerge     2ms     0           0        -           okreview    127ms   2           3033     2           ok
=== 逐条工单 ===工单     类别      处理者            gate 轮数   停止原因        状态T-1001   billing   worker:billing    0           gate_pass       passT-1002   bug       worker:bug        0           gate_pass       passT-1003   other     template          0           gate_pass       passT-1004   billing   worker:billing    1           no_progress     needs_humanT-1005   bug       worker:bug        1           gate_pass       passT-1006   other     template          0           gate_pass       pass

不写代码,回答三个问题:(1)六条工单里哪几条进过评审回路、各转了几轮,你是从哪个字段看出来的?(2)T-1004 和 T-1005 的 gate_rounds 都是 1,为什么一条 pass、一条 needs_human?证据在哪个字段,怎么读?(3)假设进程在扇出跑完、评审还没开始的那一刻被杀掉,run-state.json 里能保住什么、丢掉什么?重启之后能从哪一步接着跑?

完成标准 · 本地勾选
02

other 类里有一条工单语气不好拿捏——T-1006:「用了三个月,问题提了好几次都没下文,这产品到底还有没有人维护?」。一段固定模板回它,多半是不合适的:太冷淡显得敷衍,太热情又容易过度承诺。

Level 2:给图加一个投票节点

给这张图加一个投票节点:同一条工单、同一个任务,用两种角度各跑一次[^S1],然后用纯代码比较两版,选优的那版进汇合。比较规则只有两条,都不许请模型:先用 gate 那套确定性规则淘汰(有禁用词或缺工单号的直接出局),活下来的里面选更短的(客服回复不啰嗦)。

要求:两个角度的提示词都要配齐四要素;两次调用要老老实实走 runAgent(也就是走完整的循环),桩里各给一条响应队列;选优过程要在终端和 run.jsonl 里都留下痕迹,让人知道为什么选了这一版。写完真的跑一遍,把输出贴出来。另外回答一个问题:这里为什么用纯代码比较,而不是叫一个模型来评哪版更好?

完成标准 · 本地勾选