第四部分 · 多代理与长任务
第 12 章:workflow——模型自写的编排脚本
把多代理编排从提示词里拿出来,变成一段在 worker 线程里跑的 JS
用代码替代提示词编排
第 11 章的子代理解决「一个 agent 不够」;但让模型编排多个子代理时,提示词会迅速膨胀成一种晦涩的方言:「先并行派三个子代理,每个给这样的 prompt,等结果回来再决定下一步……」dsh 的答案是:让模型直接写编排代码。workflow 是一个模型工具:模型提交一段 plain-JS 脚本,脚本里调用 agent()/pipeline()/parallel()/phase()/log() 钩子,把工作扇出到多个子代理,带阶段与结构化结果;脚本在 worker 线程里执行。
工具描述把分工说得很直白(tool-workflow/src/index.ts):
Use the
workflowtool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
「一次委托用 subagent,大规模编排才用 workflow」——两个工具不是竞争,是量级分工。
工具与三件套
workflow 同样是完整的三件套:
| 角色 | 包 | 内容 |
|---|---|---|
| Service Definition | workflow/workflow | ctx.workflowEngine:start()、workflow/* 事件、WorkflowError(带 code 与 fatal 标志) |
| Provider | workflow/workflow-worker-thread | 每个 run 一个新 node:worker_threads Worker + vm 上下文 |
| Consumer | workflow/tool-workflow | workflow 工具:{script, meta, args} 三参数 |
工具参数(tool-workflow/src/index.ts)刻意把「代码」与「元数据」分开:
ctx.tools.register(defineTool({
name: toolName,
description: DESCRIPTION,
parameters: {
script: {
type: 'string',
required: true,
description: 'The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`).',
},
meta: {
type: 'object',
additionalProperties: true,
required: true,
description: 'The workflow identity block (plain JSON — never code).',
properties: {
name: { type: 'string', required: true, description: 'Short kebab-case workflow name.' },
description: { type: 'string', required: true, description: 'One-line description of what the workflow does.' },
whenToUse: { type: 'string', description: 'Optional guidance on when this workflow applies.' },
phases: { /* 阶段声明,由 phase() 精确匹配 */ },
},
},
args: { /* 可选 JSON 输入,暴露为脚本的 `args` 全局 */ },
},
meta 是纯 JSON——脚本里写 export const meta 会被解析器直接拒绝(SCRIPT_PARSE:注释明说「workflow meta rides the meta request field, not the script」)。宿主绝不 eval,脚本只在一个受限的钩子世界里运行。
脚本钩子:五件套
脚本能调用的钩子(workflow-worker-thread/src/runtime.ts)有五个,每个都有严格校验:
| 钩子 | 作用 | 关键约束 |
|---|---|---|
agent(prompt, opts?) | 运行一个子代理到完成 | opts 只支持 label/phase/schema/provider/model;schema 只接受受支持的 JSON Schema 子集;经 message-port RPC 桥到宿主 ctx.subagents.start();结果 JSON 化回传 |
pipeline(items, ...stages) | 每个条目独立流过各阶段,阶段间无屏障 | 阶段 throw 只让该条目变 null 并跳过剩余阶段;fatal WorkflowError 杀死整个 pipeline |
parallel(thunks) | 并发运行零参函数,await 全部 | throw 的 thunk 解析为 null;是屏障(阶段真需要全部结果才用) |
phase(title) | 声明进度阶段(匹配 meta.phases) | 非空字符串 |
log(message) | 叙述进度 | 非空字符串 |
一个真实的脚本长什么样?测试与快照里保留了端到端记录,典型的 fan-out 形态:
// 模型提交的脚本主体(示例)
const results = await parallel([
() => agent("Review the auth flow", { label: "auth-review", phase: "review" }),
() => agent("Audit the payment module", { label: "pay-audit", phase: "review" }),
]);
const merged = await agent(
`Merge these findings into one report: ${JSON.stringify(results)}`,
{ label: "merge", phase: "merge", schema: { type: "object", properties: { report: { type: "string" } }, required: ["report"] } }
);
return merged;
agent() 的实现(runtime.ts 的关键路径)在 worker 里发起 RPC、在宿主侧经 ctx.subagents.start() 跑真子代理、把 SubagentResult 结构化回传;子代理失败不会让脚本崩溃——错误被编码进结果(AGENT_RESULT),脚本自己决定怎么处理。
快照测试里保留了一段模型真实提交的脚本(examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl):
phase('Delegate')
const reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })
return { reply }
随后日志里依次出现 tool-workflow/run-start → agent-start → agent-end → run-end → tool/result,工具结果文本是 workflow "advanced-headless-snapshot" completed (1 agent). Return value: {"reply":"WORKFLOW_CHILD_OK"}——一条完整的「脚本 → 子代理 → 结果 JSON → 模型」闭环。
沙箱本质:API 塑造,不是安全边界
workflow 的隔离模型需要正名:vm + worker 是 API 塑造(API shaping)而非安全边界——脚本与 bash 同信任级(都是模型写的代码,都有宿主权限)。realm 里只看得见六样东西,globals 注入就是全部:
const globals: Record<string, unknown> = {
agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)),
parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),
pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),
phase: (title: unknown) => { this.phase(title) },
log: (message: unknown) => { this.log(message) },
// workerData already performed the real cross-thread structured clone.
args,
}
五个钩子 + args,没有 fs、没有网络、没有定时器、没有 Node 全局。真正的硬限制在协议与配置层:并发上限(maxConcurrentAgents 默认 0 → 自动 min(16, max(1, cores-2)),worker 内 acquireSlot FIFO 信号量)、总 agent 上限(maxTotalAgents 默认 1000)、单次 parallel()/pipeline() 条目上限(maxItemsPerCall 默认 4096,超限抛 ITEM_CAP)、vm 首段同步执行超时(syncTimeoutMs 5000ms)、取消宽限(disposeGraceMs 5000ms,超时 force-settle 并 worker.terminate())。worker 的 env 被清洗:只留平台 TMP/TEMP 与源码模式需要的 TSX_TSCONFIG_PATH,防止宿主凭据泄漏进 worker。还有两条值边界纪律:meta 是数据不是代码——validateMeta 只做形状校验(META_INVALID),宿主绝不 eval 模型写的 meta(防 getter 陷阱在宿主进程里跑);离开 realm 的值必须是无损 JSON(materializeFromRealm 拒绝函数/symbol/循环/稀疏数组/非有限数/嵌套 undefined,__proto__ 用 defineProperty 防原型污染)。钩子的另一端是受第 5 章工具管道与第 11 章子代理全部机制约束的宿主。
与 Claude Code 的 workflow 有一个刻意分歧值得一提(设计笔记里有记载):fatal 错误在 parallel/pipeline 中重抛而非溶解成 null。溶解(Claude 的做法)让「一个子代理炸了」变成「静默的 null 结果」,脚本可能把 null 当有效输入继续;dsh 的 WorkflowError.fatal 让「编排本身失败」作为一等事件上抛,run 的结局是失败而不是被稀释的假成功。
执行生命周期
从工具到结果:execute(args, exec) → ctx.workflowEngine.start({script, meta, args, parent, signal}) → start() 同步校验(validateMeta + 宿主预解析,失败抛 META_INVALID/SCRIPT_PARSE 让模型看到违规清单并改正)→ 清洗 env 起 worker → ready/go 握手(防取消竞态执行脚本首段——host 确认 worker 就绪才放行)→ vm 运行钩子脚本 → agent() 经 message-port RPC 桥到宿主 → 结果 JSON 化回传 → run.result 永不 reject → 工具 await + dispose() → renderResult 把 {runId, agentsStarted, result} 渲染回模型。进度(phase/log)经 workflow/* 事件流到 UI——第 17 章的 ui-workflow-run 就是它的渲染端。
生命周期有三条精确保证值得留意:agent-start/agent-end 恰好一次配对由宿主的 liveAgents 账本保证——worker 死亡或宽限强杀时合成 outcome: 'cancelled' 的 end,UI 永远不会看到「有头无尾」;value 只经 run.result 传递——workflow/end 事件故意不带结果 value,观察者拿不到结果的别名;取消必然在宽限内结算——超时 force-settle 为 cancelled 并 worker.terminate() + 幸存子清理。把模型写的代码当作需要最高可靠性对待的一等公民,是这个引擎的隐式契约。
仓库里还有一个 workflow/tool-ralph 包——一个「fresh-agent 迭代」工具({objective, maxRounds, maxHandoffChars}):每轮起一个无上下文的子代理,只用共享工作区做长期记忆,只把有界的结构化报告跨轮传递。它是 workflow 家族的近亲:同样是把「编排语义」交给模型工具,但赌的是「每轮全新」而非「扇出并行」。
实践应用
- 编排即代码:把「如何并行、如何合并、如何分阶段」从提示词方言变成真 JS——模型写代码比写提示词更精确,宿主校验比解析提示词更可靠。
- 代码与元数据分离:
meta走 JSON 请求字段、脚本禁止export const meta——「什么身份、什么阶段」是数据,「怎么做」是代码,两者不混。 - 失败要上抛不要溶解:fatal 错误在并行/管道中重抛而非变 null——「编排失败」与「子任务失败」必须可区分,静默 null 是假成功。
- API 塑造的边界:脚本的对外通道只有钩子;钩子另一端是完整的工具/子代理机制——「限制 API 形状」比「沙箱化一切」在可组合性上更优(安全边界另说)。
总结
这一章拆完了 workflow:模型自写的编排脚本、五个钩子、worker 线程执行、API 塑造的隔离模型与「失败上抛」的纪律。子代理与 workflow 解决「多 agent」;下一章解决「长任务」——目标(goal)、计划(plan)、待办(todo)与后台任务(jobs)如何让一个 agent 的工作跨越许多轮次、甚至跨越进程重启。