深入浅出 DeepSeek Harness 第五部:Goal与多Agent——谁在掌舵 第 35 章

工作流编排:脚本如何编排一批 Agent

父要"并行审 20 个文件",不是把 20 个回合塞进一个 prompt——它写一段脚本交给 PtcWorkflowEngine.start():预检在父进程同步失败(meta/parse/provider),run 一返回 result 永不 reject;脚本本体跑在共享 PTC 执行器新开的 Node 进程里,vm.Context 只挂六个冻结全局;真孩子经 workflowHost bindings 回桥到 ctx.subagents.start,并发走 FIFO 槽位护栏。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,父 Agent 要”并行审 20 个文件”。它的第一反应是写一段脚本交给 workflow 工具——脚本里一行 parallel(files.map(f => () => agent(f)))。你以为 runtime 会把这 20 个回合塞进父自己的消息循环?不会。PtcWorkflowEngine.start() 在父进程里先做三件同步预检:meta 字段合法吗?脚本能 parse 吗(只 parse 不执行)?目标 provider 注册了吗?任何一样不过,当场抛 WorkflowError,连 run 都不产生。预检全过,返回一个 PtcWorkflowRun——它的 result 永不 reject;脚本本体被打成 data:URL,送进共享 PTC 执行器新开的一个 Node 进程里跑;脚本里每次 agent(),都通过一根叫 workflowHost 的 bindings 回桥到父进程的 ctx.subagents.start。你以为”编排”是父的多线程,其实编排是另一个进程里的一个脚本沙箱,加五根回桥管线。

这一章接着第 34 章:那章讲两个 Agent 之间的一根消息线;这章讲父怎样用一段脚本把一大批孩子有纪律地起起来——并发槽、失败归一、取消竞速、过界值清洗,一层都不能少。

Workflow 执行拓扑:左侧父进程 start() 预检(validateMeta→META_INVALID、assertBodyParses→SCRIPT_PARSE、provider 注册→AGENT_START,预检失败无 run);中央 host PtcWorkflowRun 桥:runtime.run(program=data:URL guest、bindings=workflowHost、工作区根),bindings 五函数 begin/startChild/childResult/disposeChild/progress,真孩子走 ctx.subagents.start,cancel 先赢先得;右侧 guest 新 Node 进程:vm.Context 六个冻结全局 agent/parallel/pipeline/phase/log/args,agent() FIFO 并发槽,普通错误 per-item null / fatal WorkflowError 穿透,materializeFromRealm 只放纯 JSON;底部红条:result 永不 reject、contain() 回收丢弃 promise、失败码全 fatal。

start():预检全部在父进程,失败没有 run

start(request) 的前四行定了这本书对”工作流”的纪律理解:

const meta = validateMeta(request.meta)
assertBodyParses(request.script, meta.name)
const subagentProvider = resolveSubagentProvider(this.ctx, this.config.provider, request.subagentProvider)
const maxTotalAgents = resolveMaxTotalAgents(request.maxTotalAgents, this.config.maxTotalAgents)

四道闸,全部同步:

闸失败码拦住什么
validateMetaMETA_INVALIDmeta 块字段非法;meta 是请求数据字段,不是脚本里的语句
assertBodyParsesSCRIPT_PARSE脚本体编译不过;或者——脚本体里写了 export const meta = {...}
resolveSubagentProviderAGENT_STARTprovider 未注册或空白/未 trim 归一
resolveMaxTotalAgentsINVALID_ARGUMENT请求上限超过引擎部署上限

第二道闸有个容易被忽略的细节:META_STATEMENT = /^\s*export\s+const\s+meta\b/ 命中就抛 "workflow meta rides the 'meta' request field, not the script: remove the 'export const meta = {...}' statement from the body"。为什么脚本里夹带 meta 要专门拦?因为 meta 的正确位置是请求的 meta 字段——脚本里再声明一份,就成了脚本自己说了算的影子配置,绕过了 host 的 validateMeta。注释说”meta rides the seam as data here”。

parse 本身也刻意保守:new vm.Script('(async () => {\n' + body + '\n})()', { filename: 'workflow:' + name, lineOffset: -1 })——只 parse,脚本对象直接丢弃,什么都不执行。

为什么预检必须同步、必须在父进程? 因为”发布前失败没有 run”是这本书的一致纪律(见第 33 章)。如果预检本身也送进子进程,失败就要穿越进程边界才能报回来,中间还可能搭上一个已经开起来的 PTC 进程。同步预检把”坏请求”拦在最廉价的一层。

本节源码依据(5 处)

脚本不进父进程:PTC 新进程 + vm.Context 沙箱

预检过后,drive() 做的事是整章的核心:

const outcome = await this.runtime.run(this.runtime.resolve({
  program: PROGRAM,
  bindings: [{ global: 'workflowHost', functions: this.bindings() }],
  cwd: this.policy.workspaceRoot,
  sandboxPolicy: this.policy,
  timeoutMs: null,
  signal: this.controller.signal,
}))

三个事实各管一段:

  1. program 是 data:URL 内联的生成 guest:GUEST_URL = data:text/javascript,${encodeURIComponent(WORKFLOW_GUEST_SOURCE)},PROGRAM = const { runWorkflowGuest } = await import(<data:URL>); return await runWorkflowGuest(workflowHost)。guest 源码是构建期生成的自包含 ESM(scripts/gen-workflow-guest.ts)——它在目标进程里才能用 node:vm,所以随数据 URL 进去,不依赖外部文件。
  2. 进程隔离归 PTC 执行器:NodePtcRuntime 的 isolation = 'process',执行说明写着 “Each call runs in a fresh Node process; … sandbox policy and cancellation are delivered with the call”。每次 workflow run = 一个新 Node 进程;脚本炸了、占内存了,炸的是那个进程。
  3. vm.Context 只挂六个冻结全局:guest 里 vm.createContext({ agent, parallel, pipeline, phase, log, args }),随后 Object.freeze 整个 context 和六个值——脚本在 vm 域里拿不到 require、process、Buffer。包裹方式与预检一致:new vm.Script('(async () => {' + body + '})()', { filename: 'workflow:' + meta.name, lineOffset: -1 })。
层谁负责边界
进程隔离 / 取消 / cwd 文件策略共享 PTC 执行器每次调用一个新 Node 进程
脚本沙箱(白名单全局)guest 内 vm.Context六个冻结全局,无 Node API
真孩子的生命周期host 的 bindings → ctx.subagents唯一回桥

模块头注释把分工钉死了:“The VM supplies script helpers; the process applies the calling Session’s file policy.”——别指望 vm 给你进程隔离,也别指望进程给你脚本沙箱。

本节源码依据(5 处)

workflowHost:五根回桥管线

进程之间没有共享内存,脚本能做的全部动作就是调这五个 binding:

bindings: {
  begin: () => json(this.init),                                  // 启动即送 meta/body/limits/args
  startChild: value => this.track(this.startChild(childRequest(value))),
  childResult: value => this.track(this.childResult(this.child(value))),
  disposeChild: async (value) => { await this.disposeChild(this.child(value)); return null },
  progress: value => { for (const event of progressBatch(value)) this.onProgress(event); },
}

每根管线都有 host 侧的类型清洗(childRequest 只收 prompt/provider/model/schema 四个白名单;progress 校验 closed union)。关键在 startChild 的实现——它不是”起一个 vm 里的假 agent”,而是真的调:

const run = await this.subagents.start(this.provider, {
  prompt: [{ type: 'text', text: request.prompt }],
  parent: this.parent,
  signal: this.controller.signal,
  ...request.schema === undefined ? {} : { outputSchema: request.schema },
  ...agentOptions,
})

也就是说 workflow 里的每个 agent() 都是一个正规的子 Agent run——走第 34 章那套 subagent 生命周期,有自己的 Session、自己的工具、自己的账本。脚本只是决定”什么时候起、起几个、等谁的结果”。

startChild 里还有一处刻意的竞速注释:“A provider can publish after the signal fired while startup was pending.”——信号已经 abort、但 provider 那边孩子刚发出来,host 立刻补一刀 disposeChild 并抛 "workflow child started after cancellation"。宁可多拆一个刚发的孩子,也不留一个孤儿。

本节源码依据(4 处)

护栏:并发槽 FIFO 与三道上限

脚本里 agent() 不是想发多少发多少。三层护栏各管一件事:

护栏默认拦什么
maxConcurrentAgentsmin(16, max(1, cores-2))同时在跑的孩子数;acquireSlot FIFO 排队
maxTotalAgents1000一个 run 累计 agent() 上限——跑飞循环的兜底线
maxItemsPerCall4096单次 parallel/pipeline 接受的条目数

acquireSlot 是个公平队列:waitlist 是真 FIFO,释放时 releaseSlot 只唤醒队首一个。为什么要公平而不是”谁先到谁先跑”?因为脚本可能在大数组上 parallel——FIFO 保证顺序不被调度乱序吞掉,也防止后到的小任务饿死前面的大批次。

被拒是显式的:超总数 → AGENT_CAP(“workflow agent limit reached: already started N of M allowed by this run”);超单批 → ITEM_CAP。注释把哲学说透了:“The script holds its own concurrency policy; the engine backstop is the total runtime budget, not an invisible queue.”

agent() 的参数也是白名单:SUPPORTED_AGENT_OPTIONS = label/phase/schema/provider/model;effort/isolation/agentType 三个进 DEFERRED_AGENT_OPTIONS 透传,未列名的参数直接被 readAgentOptions 丢掉——脚本不能借 workflow 后门塞未经批准的孩子配置。

本节源码依据(4 处)

失败纪律:per-item null 与 fatal 穿透

parallel/pipeline 的失败语义是这章最容易猜错的地方。看 parallel:

const results = await Promise.all(items.map(async (item, index) => {
  try { return await thunks[index]() }
  catch (error) {
    if (isFatalWorkflowError(error)) throw error
    return null   // 普通错误 → 该项失败,不炸整批
  }
}))
  • 普通错误(孩子普通退出、item 自己 throw)→ 该项变 null,其余继续;
  • fatal WorkflowError(撞了护栏、parse 失败)→ 立即穿透整个 combinator——护栏失败不值得继续跑。

pipeline 更细:每个 item 一条独立链,“no cross-stage barrier”——一个 item 在第三阶段炸了(普通错误),返回 null;其他 item 照走自己的流水线。这就是”流式”的含义:批次内没有统一的闸门等齐。

还有一个安静但关键的兜底——contain():

private contain(task: Promise<unknown>): Promise<unknown> {
  this.unsettled.add(task)
  return task.then(() => this.unsettled.delete(task), () => this.unsettled.delete(task))
}

脚本里如果有人 parallel(...) 但忘了 await 返回的 promise(或者 phase(...) 后丢掉),那个 promise 的 rejection 不会变成 Node 的 unhandled rejection——contain() 挂了 noop 回收。宿主进程不能被脚本的粗心带走。

本节源码依据(4 处)

过界清洗:materializeFromRealm 只放纯 JSON

脚本在 vm 域里 return 什么都行,但回 host 之前必须过 materializeFromRealm 这一关。它拒掉:class 实例与原型(“vm realm value must be plain JSON”)、稀疏数组空洞、数组的非索引属性、symbol 键、bigint、裸函数。连 __proto__ 都要用 defineProperty 重放——防止它变成原型污染通道。失败即 RESULT_UNSERIALIZABLE。

为什么这么严? 因为 vm 域和 host 是两个 Realm——那个对象带着 vm Realm 的原型链和内部槽,直接跨进 host 就是一套伪造的”对象”,host 侧的 snapshotJsonValue(lossless JSON 检查)连序列化都兜不住。过界值必须是真·纯 JSON 数据。

收尾契约也因此干净:drive 永不 reject——脚本跑完给 {value, stopReason:'completed', agentsStarted};脚本炸给 {value:null, stopReason:'error', error: renderThrown()};取消给 cancelled。父 Agent 面对的永远是一条可以正常读的结果。

本节源码依据(3 处)

取消与收尾:三层语义,一个 finally

  • 第一层(外部信号):构造函数把外部 abort 接到 this.cancel('workflow signal aborted'),{ once: true }。
  • 第二层(host cancel):cancel(reason) 先赢先得——if (this.terminal || this.cancelReason !== undefined) return;然后 abort controller + 遍历 children 全部 disposeChild。
  • 第三层(finally 排空):drive 的 finally 里 controller.abort('workflow settled')、dispose 全部已发孩子、while (this.pending.size > 0) await Promise.allSettled(...) 把排队中的 binding 全部清空、liveAgents 里每个未收 end 的统一补一条 outcome: 'cancelled' 的 agent-end。

注释说得很清楚:“Disposing published children releases binding waits; pending starts may publish more.”——dispose 会让等待中的 binding 醒来,醒来可能又发新孩子,所以要 while 循环直到 pending 空。这与下一章要讲的 disposal child-first 顺序是同一类纪律:先拆孩子,再收回收尾事件,最后才关门。

本节源码依据(3 处)

小结

  • 预检在父进程:meta/parse/provider/cap 四道闸同步失败,坏请求不产生 run。
  • 脚本不进父进程:data:URL 内联 guest 送进共享 PTC 执行器新开 Node 进程;进程隔离归执行器,vm 域只挂六个冻结全局。
  • 真孩子走真生命周期:workflowHost 五根回桥,agent() = 一次正规 subagents.start。
  • 护栏三层:并发槽 FIFO、总数 1000 兜底、单批 4096 上限;参数白名单。
  • 失败有纪律:普通错误 per-item null,fatal WorkflowError 穿透;contain() 兜底丢弃的 promise。
  • 过界只放纯 JSON:materializeFromRealm 拒绝一切 vm Realm 私货;result 永不 reject。

下一章接最硬的一层:第 36 章——cancel 自上而下、dispose 自下而上,为什么必须 child-first,以及 memoization 怎样让”拆两遍”变成同一件事。