读完这篇你能:分清「编排一批活」和「扔一个后台任务」两种模型;知道一次编排的请求里哪些东西由调用方定、哪些由引擎定;理解为什么「某项失败」和「脚本写错了」必须报成两回事。
前置:26-多 agent 的任务拆解。约 30 分钟。
示例代码:dsh-workflow-lite
DSH 版本:0.2.0-rc.2
26-多 agent 的任务拆解讲的是「把一件事交给一个子 agent」。这一篇讲的是它放大之后的样子:一次要跑好几件、彼此不依赖的活。
放大的时候会多出两个问题——怎么编排,和怎么看待其中某一项失败。DSH 把前者交给 workflow,后者交给一条明确的失败纪律。
目标
- 分清编排(workflow)与后台任务(jobs)各自解决什么。
- 知道一次编排的请求里,
script、meta、args三者的边界在哪。 - 理解「逐项失败」与「钩子误用」为什么要分开报。
- 会给结果做正确的收口:该取消时能取消,该等一下的时候等得住。
DSH 设计
「编排一批活」在 DSH 里先被拆成一个更基础的问题:这类可替换的能力归谁管。DSH 给每一类可替换能力留一条「能力缝」(seam)——一条缝由三件东西组成:一份接口声明、一个实现它的提供方、一群使用它的消费方。「编排」与「托管」在这里是两条独立的缝,分工在定义层就划好了。下面分开看。
先看编排这条缝,它叫 workflow(工作流),管「怎么安排」:一段脚本,一个引擎。调用方交出去的不是一个任务对象,而是一段 JavaScript 脚本文本,引擎在一个受管进程里跑它。脚本里可以调用 agent() 派出子 agent——也就是由当前 agent 派生出去干一件事、拿回结果的另一个 AI 实例——可以并发、分阶段、汇总。和执行 bash 的那条缝一样,一个运行上下文只允许一个引擎提供 ctx.workflowEngine:编排策略是单选,想换引擎就换掉配置里加载的那个插件,不存在按名共存的注册表。还要注意,引擎自己不养子 agent。脚本里每个 agent() 都经由委派子 agent 的那条缝发出,所以启动请求里的 parent 字段必填:这次编排算在谁名下,每个子 agent 的归属都沿这条链传下去。
编排这条缝里有一条专门的失败纪律:「没跑成」与「写错了」必须分开报。脚本里某一项子运行失败是正常业务结果,引擎提供的并发组合函数把那一项兑现成 null,其余各项照常返回,留给脚本自己处理。但脚本把引擎给的接口用错——参数写错、选项不存在、超出上限——是契约违规,会抛出一个带 fatal: true 标记的错误。组合函数遇到它直接重新抛出,整段脚本当场终止,绝不把它降级成某一项的 null。两种失败在类型上就分得开,写脚本的人不会拿兜业务失败的逻辑去掩盖自己的笔误。
用一段小脚本看这两种失败各长什么样:
// 某项子运行失败:那一项兑现成 null,其余各项照常
const settled = await parallel(tasks.map((task) => () => agent(task)))
// settled 里失败的那一项是 null,过滤还是保留由脚本自己定
// 钩子误用:选项名拼错,抛 fatal 错误,整段脚本当场终止
await parallel([() => agent('查 A', { nope: true })])
同一条纪律也体现在结果形态上:一次运行的结果承诺永不以异常告终,脚本没跑好以停止原因兑现。「这项没干成」和「这条通道坏了」从类型上就是两条路。
再看另一条缝,jobs(后台任务),管「甩出去之后还找得到」:一个注册表,一群生产方。这条缝按标准三件套拆开:抽象的注册表是接口声明,进程内注册表是实现它的提供方,模型侧的查询与停止工具是消费方。所有权模型一句话就能说完:注册表拥有身份与生命周期,生产方拥有执行资源。几个推论由此而来。任务 id 按「种类名加序号」生成,完全可以猜到,所以访问控制靠归属会话授权,从来不靠 id 保密。生产方的启动器在注册表做完预检之后只被调用一次,抛错等于什么都没注册,已经启动的那部分要生产方自己收拾。而「活干完了」跟「资源都收拾干净了」是两件事,注册表认的结算是后者。
「一次编排」与「一个后台任务」看着都像「跑个活」,底下却是两种所有权:前者由发起者持有、每条退出路径都要显式清理;后者把身份交给注册表,执行留给生产方。
编排与后台任务
两条路,别看错:
| workflow | jobs | |
|---|---|---|
| 解决什么 | 一批活的编排:并发、阶段、汇总 | 一个活的托管:后台跑、可查、可停 |
| 谁执行 | 引擎在受管的沙箱进程里跑你给的脚本 | 生产方自己跑,运行时只管身份与生命周期 |
| 你交出去什么 | 一段脚本文本 + 身份块 + 入参 | 一个 run() 起始器 |
| 结果怎么来 | 脚本的返回值 | 生产方通过钩子报告 |
一句话:workflow 管”怎么安排”,jobs 管”甩出去之后还找得到”。
脚本编排
调用方交出去的是七个字段里的一部分:
interface WorkflowStartRequest {
script: string
meta: WorkflowMeta
args?: unknown
subagentProvider?: string
maxTotalAgents?: number
parent: Agent
signal?: AbortSignal
}
parent 是必填的,理由很直接:脚本里启动的每个子 agent 都要归属到它,cwd、谱系、深度都靠这条链传下去。
script 是一段顶层可以用 await、结尾 return <json-value> 的 JavaScript 文本。meta 和 args 是普通 JSON 数据——这一条在契约里被专门强调过:引擎绝不会通过对脚本文本求值来取得它们。meta 会先过 schema 校验,不合法的数据在任何工作开始之前就被明确拒绝。
meta 是脚本的身份块:
interface WorkflowMeta {
name: string
description: string
whenToUse?: string
phases?: WorkflowPhase[]
}
name 用短横线命名,既是显示名也是持久化键。phases 值得单独说一句:它只用于进度展示——phase() 调用按标题跟它匹配,给观察者看——不暗示任何执行结构。写了一堆 phase 不代表脚本会按那个顺序走。
另外两个字段是给调用方的策略旋钮:subagentProvider 换掉这次运行的子 agent 提供方,maxTotalAgents 压低本次的子 agent 总数上限。脚本观察不到也换不掉这两项——它们是调用方的决定,不是脚本的。
结果与失败纪律
WorkflowRun.result 会兑现成:
interface WorkflowResult {
value: unknown
stopReason: WorkflowStopReason
error?: string
agentsStarted: number
}
value 是脚本物化后的返回值,只有 completed 的时候才有意义。stopReason 是封闭联合:completed / cancelled / error。非 completed 时 error 里带着失败信息,消费方应该把它映射成一个出错的工具结果,而不是把部分输出当成功上报。
agentsStarted 有个容易被忽略的细节:优雅结算时它是脚本侧的计数(包括还排在并发槽位后面的调用),走到终止路径时(取消、进程失败)它会退化成宿主观察到的计数——因为那时脚本里还在排队的调用已经无从得知了。
然后是这一篇最该记住的那条纪律。
脚本内部的钩子误用会抛 fatal: true 的错误:参数写错、agent() 的选项未知或延迟给出、schema 超出结构化输出子集、超出上限、引擎启动失败、取消——全算。
而 parallel() / pipeline() 这类组合器对 fatal 错误是直接重新抛出,而不是把那一项映射成 null。原文的理由说得很好:
一个拼写错误的选项必须明确报错并终止脚本,绝不能消融为看似普通子 agent 失败的结果。
而逐项的 null 是留给另一类事的:子运行失败(非 completed 的停止原因)和阶段内的普通脚本错误。
这条区分直接决定了你写脚本时的心态:null 是”这项没干成”,fatal 是”我写错了”。前者正常收,后者必须炸。
运行、取消与事件
调用方拿到的句柄是:
interface WorkflowRun {
readonly id: WorkflowRunId
readonly meta: WorkflowMeta
readonly result: Promise<WorkflowResult>
cancel(reason?: string): void
dispose(): Promise<void>
}
两条硬约束:
result不会被拒绝。 脚本失败以stopReason: 'error'兑现,不是异常。这样”脚本没跑好”和”这条通道坏了”在类型上就分得开。- 每个持有者都必须在每条路径上
dispose()。 它会按需取消,再等进程与子 agent 完全清理——而且不会因为脚本卡死而挂住。
取消这件事有个细节值得知道:引擎没有整体经过时间截止,取消时直接中止脚本所在的受管进程,所以即使脚本自己永不结算,result 也会落定为 cancelled——等待它的消费方不会在取消后无限期挂着。没有这一步,”取消”就只是”请求取消”。释放则按各提供方约定等待进程与子 agent 清理,不另设工作流清理截止。
workflow/* 那六个事件(start / phase / log / agent-start / agent-end / end)都是仅供观察的。它们有两个刻意的设计:
- 每个 payload 以
WorkflowRunInfo(id + meta)开头,而不是活跃的WorkflowRun——订阅者因此拿不到cancel和dispose。观察者不该有控制权。 workflow/end刻意省略了结果值——观察结局的监听器不该拿到调用方 result 的可变别名。
事件之间的隔离也做得彻底:某个订阅者抛异常会被记进日志、不向外传播,也不影响后面注册的监听器;每个监听器收到的是自己的 payload 克隆,改它既不会损坏引擎也不会影响别人。
后台任务的另一条路
如果某个活不需要编排、只需要”甩出去还能找回来”,那是 jobs。它的模型要简单得多,但有一条反直觉的设定:
JobId 是按 <kind>-N 生成的,而访问控制依赖拥有者授权,不是 id 的保密性。 也就是说别把 job id 当密码用——真正决定谁能碰它的是那个 owner。
状态是 'running' | 'stopping' | 'completed' | 'killed' | 'failed';生产方特有的事实归进快照——运行期是 progress 一行,结算后是 detail——不往状态机里塞。
生产方的声明里,有几条边界划得很清楚:
interface JobSpec {
kind: JobKind
label: string
owner?: SessionId
outputLimitBytes?: number
output?: readonly JobOutputSource[]
run(job: JobHandle): JobHooks
}
- 运行时在调用
run()之前完成预检,随后提交注册,之后不再执行可能失败的步骤。所以run()只被调用一次,而且抛错就等于什么都没注册——生产方得自己把已经启动的那部分资源清掉。 - 省略
owner会创建一个无主 job,在服务销毁之前对任何调用方开放。这在某些场景下有正当用途,但它是”开放”,不是”默认安全”。 JobHooks.done在生产方释放完资源之后才 resolve,而不只是”活干完了”。这个区别决定了调用方等到的到底是”结果好了”还是”东西都收拾干净了”。- 可选的
output声明拉取源,把”会持续产出、边跑边能读的流式任务”和”只有一份最终结果的任务”在声明时就分开:前者的字节由注册表按自己的节奏泵进输出环,后者把结果放进JobOutcome.result,结算后的第一次读取交给模型。
职责的分界一句话概括:生产方拥有执行资源,运行时拥有身份、访问权限与生命周期状态。
并发派活
demo dsh-workflow-lite 把一批活并发发出去:
const SCRIPT = `
const tasks = args.tasks
phase('调查')
const settled = await parallel(tasks.map((task, index) => async () => {
const finding = await agent('调查第 ' + (index + 1) + ' 件事:' + task)
return { task: task, finding: finding }
}))
// 逐项 null 表示那一项的子运行失败了;这里保留下来交给调用方判断。
return settled.filter((item) => item !== null)
`
注意 args 是脚本里的全局量:调用方通过请求的 args 传进去,引擎校验后原样暴露——不是字符串拼接进去的。
调用侧就三步:
const run = ctx.workflowEngine.start({
script: SCRIPT,
meta: { name: 'fan-out', description: '并发派活并汇总各自的结论', phases: [{ title: '调查' }] },
args: { tasks },
parent,
})
// result 永不 reject:脚本失败会以 stopReason 兑现,不是异常。
const result = await run.result
// 每个持有者都必须在每条路径上 dispose,它同时负责等子 agent 停稳。
await run.dispose()
parent 从工具的执行上下文里拿(exec.agent),这就是”子 agent 归属到谁”的那条链。
script 里那个 settled.filter(...) 是刻意的:逐项失败变成 null,然后被过滤掉——剩下的照常返回。这就是”失败不互相拖累”在脚本层面的样子。
验证
cd code/dsh_plugin/dsh-workflow-lite && ./install.sh
三步,第三步是关键:
# 1. 配置对不对(应当看到一行:我们的工具;引擎由 base 层已挂的 workflow-ptc 提供)
dsh --profile test --dump-config | grep -A3 'id: workflow-lite'
# 2. 并发跑几条
dsh --profile test "调 fan_out,任务分别是:三篇文档的字数、仓库里最大的文件、当前目录的 md 文件数"
# 3. 把其中一条改成必然失败的活
dsh --profile test "调 fan_out,第二条写「读一下 /不存在的路径/x.txt」,另外两条照常"
第三步你应当看到:失败的那一项从结果里消失,另外两项照常返回。这正是”逐项隔离”的可观测证据。
再做一个对照实验,体会那条失败纪律:把脚本里 agent(...) 的调用改成传一个不存在的选项(比如 agent('x', { nope: true }))。这时不是某一项变成 null——整段脚本会当场终止,stopReason 变成 error。一个拼错的选项就该这样,而不是伪装成”这项没跑成”。
注意事项
meta和args是数据,不是代码。 引擎不会对脚本求值来取它们;meta会先过 schema,非法数据在任何工作开始前就被拒。phases只影响展示。 它跟phase()调用按标题配对,供观察者用,不构成执行顺序上的任何保证。value只在completed时有意义。 别拿非completed的部分输出当成功上报——消费方该把它映射成出错的工具结果。fatal与null是两件事。 钩子误用会终止整段脚本;逐项null只留给子运行失败和阶段内的普通脚本错误。别把前者当成后者来兜。result不会拒绝,但dispose必须调。 它在每条路径上都需要,而且它负责等子 agent 停稳。忘了它,资源就留在那儿。- 取消是立即中止受管进程的。 即使脚本永不结算,结果也会落定为
cancelled——所以等result不会无限挂起。引擎没有整体经过时间截止,也没有额外的工作流清理截止。 - 观察者拿不到控制权。
workflow/*事件的 payload 是WorkflowRunInfo而不是活跃 run,workflow/end也不带结果值。想要控制权就得是持有者。 - job id 不是凭证。 访问控制靠 owner 授权。另外
done在生产方释放资源后才 resolve,别把它当成”结果可用”的信号——那是两件事。 run()只调一次,抛错等于什么都没注册。 生产方要负责把已经启动的部分清干净。