02 · 三个工具,就是全部编排

📅 发布时间:2026/8/22 17:08:31
02 · 三个工具,就是全部编排
上一篇结尾说,主 Agent 的派人干活能力来自三个普通工具。这篇把它们摊开看。先换一个任务。上一篇那个加手机号登录是你大致知道该怎么做的活儿,这次换一个你不知道的:支付回调偶尔重复扣款,线上一天两三次,你查一下为什么,能修就修。停下来想一想:你现在能画出一张任务图吗?画不出来。因为该修什么完全取决于是什么原因——可能是回调根本没做幂等,可能做了但幂等键选错了,可能幂等状态存在进程内存里而线上是多实例,也可能是订单状态机允许了一个非法的重复流转。这四种情况要写的代码毫无共同之处。这就是这篇要讲的事:图不能事前规划,只能一边看一边长。三个工具就是让它长出来的全部装置。工具一:spawnSubagent先看它的完整定义。注意这是一个标准的 ReAct 工具,和readFile的结构没有任何区别:// workflowTools.ts{name:spawnSubagent,description:Create a subagent to complete a task.,inputSchema:{type:object,properties:{task:{type:string,minLength:1},role:{type:string,enum:[explorer,reviewer,planner,executor]},dependsOn:{type:array,items:{type:string,minLength:1}},toolHints:{type:array,items:{type:string,minLength:1}},timeoutMs:{type:integer,minimum:1},},required:[task],},isConcurrencySafe:()true,invoke({input}){constconfigparseCreateSubagentInput(input);returnJSON.stringify({subagentId:orchestrator.createSubagent(config,availableTools)});},}几个细节值得停一下:只有task是必填的。主 Agent 最少只需要说干什么,角色默认是explorer(最安全的只读角色)。invoke是同步的,而且立刻返回。它不等子智能体跑完——只是登记一个节点,拿到subagentId就回来了。这个设计是派人和等结果必须分成两个工具的原因:如果spawnSubagent阻塞到结束,就没法先派三个再一起等,并行也就没了。isConcurrencySafe: () true。还记得上个系列讲的并发分批吗?这个标记让主 Agent 可以在一次模型回合里同时派出多个子智能体。三个spawnSubagent调用会被归进同一个并行批次。工具二:waitForSubagents{name:waitForSubagents,description:Wait for subagents to finish and return their results.,inputSchema:{type:object,properties:{subagentIds:{type:array,items:{type:string,minLength:1}}},required:[subagentIds],},isConcurrencySafe:()true,asyncinvoke({input}){constsubagentIdsparseStringArray(input,subagentIds);constresultsawaitorchestrator.waitForSubagents(subagentIds);returnJSON.stringify({results:Object.fromEntries(results)asRecordstring,SubagentResult});},}这个是async的,会真的等。等的实现很朴素:// workflowOrchestrator.tsasyncwaitForSubagents(ids){constrequestedids.map((id){constentryentries.get(id);if(!entry)thrownewError(Subagent${id}not found);return[id,entry.result]asconst;});constresultsawaitPromise.all(requested.map(async([id,result])[id,awaitresult]asconst));returnnewMap(results);}每个子智能体在登记时就挂了一个 Promise,waitForSubagents就是Promise.all它们。注意这里没有轮询、没有超时参数——超时是子智能体自己的事(第 05 篇会讲),等的这一方只管等。工具三:cancelSubagentinvoke({input}){constsubagentIdparseStringProperty(input,subagentId);if(!orchestrator.cancelSubagent(subagentId)){thrownewError(Subagent${subagentId}was not found or is already finished);}returnJSON.stringify({subagentId,cancelled:true});}值得注意的是取消失败会抛错。主 Agent 如果想撤回一个已经跑完的子智能体,会收到一条明确的Tool error,而不是静默的成功。这让模型能知道自己的世界模型过期了。任务图是长出来的,不是规划出来的这是整个架构最容易误解的地方。很多人想象的多智能体是这样:模型先输出一张完整的任务图(JSON 格式的 DAG),然后引擎照着图执行。LoopAgent 不是这样。它没有先给我一张图这一步。图是主 Agent 在自己的 ReAct 循环里,一次一个节点长出来的。回到开头那个重复扣款的任务:主 Agent step 1: spawnSubagent(task找支付回调入口,看有没有幂等校验) → 图: {A} 主 Agent step 1: spawnSubagent(task查订单状态机允许哪些重复流转) → 图: {A, B} (同一回合,并行) 主 Agent step 2: waitForSubagents([A, B]) → 阻塞,等结果 主 Agent step 3: 看到 A 的结论,才知道该派谁 spawnSubagent(task???, roleexecutor) → 图: {A, B, C} 主 Agent step 4: waitForSubagents([C])第 3 步那个???是这篇的全部重点。它填什么,取决于 A 说了什么:A 的结论C 实际会是什么回调里完全没有幂等校验在入口加幂等键,用out_trade_no做唯一索引有幂等校验,但键用的是自增 id换成业务唯一键,补一条 migration有幂等,但状态存在进程内存的 Map 里挪到 Redis,顺带要处理线上多实例的滚动发布幂等没问题,是状态机允许了重复流转根本不碰回调,改OrderState的迁移表四种结论对应四个完全不同的 C。没有任何一张事前画好的图能覆盖这四种分支——除非你把四个分支全画进去,然后运行时丢掉三个,那不叫规划,那叫穷举。这是 ReAct 的本质优势:它能根据观察结果调整计划。✶ 动态成图 vs 预先规划预先规划的好处是可以做全局优化(比如提前算出最优并行度)。动态成图的好处是能对现实做出反应。在代码任务里,你几乎永远不知道下一步该干什么,直到你真的看了代码——所以动态成图几乎总是对的选择。代价是:你无法在开始时告诉用户这个任务要跑 7 步。每插一个节点,就验一遍图既然图是一个节点一个节点长的,那就有个风险:主 Agent 可能声明出一个循环依赖(A 等 B,B 等 A),或者依赖一个不存在的节点。createSubagent在真正登记之前,先做一次校验:// workflowOrchestrator.tscreateSubagent(config,availableTools){if(entries.sizelimits.maxSubagentsPerRun){thrownewError(Max subagents per run (${limits.maxSubagentsPerRun}) exceeded);}constidsubagent-${nextId};constdependencies[...(config.dependsOn??[])];constnextGraphnewMap(graph);// ← 先拷一份nextGraph.set(id,newSet(dependencies));// ← 在副本上试插constvalidationvalidateDAG(nextGraph,limits);if(!validation.valid)thrownewError(validation.error);// ← 不合法就抛真图未被污染// ...校验通过后才 graph.set(id, ...) 和 entries.set(id, entry)}注意const nextGraph new Map(graph)这一句。校验跑在副本上,不在真图上。所以一个非法的spawnSubagent调用抛错之后,真实的图和之前完全一样——不会留下一个半登记的坏节点。这个先在副本上试,通过了再落地的模式,是保证状态机不会进入中间态的标准做法。校验器做三件事// workflow/dagValidator.tsexportfunctionvalidateDAG(graph:ReadonlyMapstring,ReadonlySetstring,limits:PickWorkflowLimits,maxNestingDepth,):DAGValidationResult{for(constdependenciesofgraph.values()){for(constdependencyIdofdependencies){if(!graph.has(dependencyId))return{valid:false,error:Unknown dependency:${dependencyId}};}}if(detectCycle(graph)){return{valid:false,error:Circular dependency detected in subagent graph};}constdepthcalculateDAGDepth(graph);if(depthlimits.maxNestingDepth){return{valid:false,error:Subagent nesting depth (${depth}) exceeds limit (${limits.maxNestingDepth}),};}return{valid:true};}悬空依赖、环、深度超限。前两个好理解,第三个有个容易误读的地方,值得单独说。maxNestingDepth限制的不是嵌套默认值是 3:// workflowOrchestrator.tsconstDEFAULT_LIMITS:WorkflowLimits{maxSubagentsPerRun:50,maxNestingDepth:3,maxConcurrentSubagents:10,subagentTimeoutMs:60_000,maxSubagentTimeoutMs:300_000,};看名字,你会以为它限制的是子智能体能不能再派子智能体,最多派几层。不是。看深度是怎么算的:// workflow/dagValidator.tsexportfunctioncalculateDAGDepth(graph:ReadonlyMapstring,ReadonlySetstring):number{constdepthsnewMapstring,number();constdepthOf(id:string):number{constcacheddepths.get(id);if(cached!undefined)returncached;constdepthMath.max(0,...Array.from(graph.get(id)??[],depthOf))1;depths.set(id,depth);returndepth;};returnMath.max(0,...[...graph.keys()].map(depthOf));}它算的是依赖链的长度。测试里写得很清楚:// test/workflow/dagValidator.test.tsconstgraphnewMapstring,Setstring([[root,newSet()],[build,newSet([root])],[test,newSet([build])],]);expect(validateDAG(graph,{maxNestingDepth:3})).toEqual({valid:true});expect(validateDAG(graph,{maxNestingDepth:2})).toEqual({valid:false,error:Subagent nesting depth (3) exceeds limit (2),});root → build → test三个平级的子智能体,串成一条依赖链,深度就是 3。它们全都是主 Agent 的直接下属,没有任何嵌套。那真正的嵌套呢?在这套实现里根本不可能发生。因为四个角色的工具白名单里,没有任何一个包含spawnSubagent——子智能体拿不到派人的工具。层级恒定是两层:主 Agent 子智能体。✶ 一个名字带来的误解maxNestingDepth这个名字描述的是设计者当初的意图(限制递归深度),但代码实际实现的是依赖链长度限制。两者在子智能体不能派子智能体的前提下,恰好都是有意义的约束,所以这个偏差一直没暴露成 bug。读代码时遇到名字和行为不一致,信行为,不信名字——这也是为什么上面每个结论我都贴了测试断言。接线:workflow 工具是可选的最后看这三个工具怎么装到主 Agent 上:// model/providerRegistry.tsif(deps.enableWorkflowToolsfalse)returncreateParentRunner(parentTools,deps.requiredToolNames,REACT_SYSTEM_PROMPT);return{async*run(request){consteventscreateAsyncQueueHostToWebviewMessage();constorchestratorcreateWorkflowOrchestrator({/* ... */});constunsubscribeorchestrator.onEvent((event)events.push(toHostMessage(event,request.runId)));constworkflowToolscreateWorkflowTools({orchestrator,availableTools:parentTools,});consttools[...parentTools,...workflowTools];// ← 就是数组拼接// ...},};enableWorkflowTools false时直接返回普通的单智能体 runner,一行编排代码都不执行。开启时,也只是[...parentTools, ...workflowTools]——把三个工具拼进数组。多智能体在这里是一个纯增量能力。这也解释了为什么它能不动 ReAct 循环就接上:对循环来说,工具箱里多三个工具,和多三个文件读写工具,没有任何区别。全景:四层,一个写入口把前面所有零件拼起来看:策略层 · workflow/编排层 · workflowOrchestrator.ts工具层 · workflowTools.tstool_calltool_calltool_call① 在 Map 副本上试插② 合法才放行③ 落地③ 落地④resolveRole selectTools建节点时就定死工具每个 entry 持有一个读快照做决策点名 ready 节点finally · 释放槽位resolveResult()结论回到上下文模型在 ReAct 循环里决定下一个节点是什么spawnSubagent同步,立刻返回 idwaitForSubagentsasync,真的等cancelSubagent失败会抛错createSubagent()graphid → 依赖集合拓扑,只增不改entriesid → 运行时状态含一个待 resolve 的 Promiseschedule()无状态,每次现算start()settle()幂等dagValidator环 / 未知依赖 / 深度roleRegistry角色 → 工具白名单subagentContext状态机 深冻结快照这张图里有两件事值得单独指出来。图只有一个写入口。所有对graph的修改都必须经过createSubagent,而它进门第一件事就是在副本上跑校验。没有任何旁路能往图里插节点——包括子智能体自己,因为上一节说过,四个角色的工具白名单里都没有spawnSubagent。图的形状只由主 Agent 决定。schedule()被两处调用,构成了整个引擎的时钟。一条是图变大了(createSubagent末尾),一条是有节点跑完了(start()的finally)。除此之外没有轮询、没有定时器推动执行。图的可执行状态只会因为这两件事变化,所以只需要在这两个时刻重新点名。图长出来了,节点登记好了。但谁决定哪个节点先跑?下一篇讲调度器。关于 LoopAgent本文代码来自 LoopAgent。涉及的文件:src/extension/agent/workflowTools.ts — 三个工具的定义src/extension/agent/workflowOrchestrator.ts — 编排器主体src/extension/agent/workflow/dagValidator.ts — 图校验src/extension/model/providerRegistry.ts — 接线欢迎点个 star ⭐项目地址:https://github.com/oi12344/loopagent-vscode 上一篇:01 · 一个 Agent 不够用的那一刻 下一篇:03 · 谁能跑,谁得等