workflow / schedule / plan / todo:编排原语的取舍
四种编排原语各管什么,为什么没做成一个大而全
本页解决的问题
先给结论「workflow / schedule / plan / todo:编排原语的取舍」要解决的关键问题是什么?
四种编排原语各管什么,为什么没做成一个大而全
让这个结论先证明自己值得留下。 把这一页当成决策工具,而不是需要背下来的定义。把概念连到一个真实任务、一个可观察结果,以及一个能改变你判断的失败上。
写下一个问题:试完这个方法后,你能用什么证据回答它?
结论听起来很完整,却没有检查最关键的假设。
四个真实场景,每个场景选一个原语。选错也没关系,判词会讲清楚为什么。点「播放」可以看自动讲解,自己点卡片可以随时抢答。
docs/subsystems/workflow.zh.md、schedule.zh.md、plan.zh.md 与 packages/todo/tool-todo/README.zh.md,核对日期 2026-08-13。先给结论:这四个原语没有共享一个任务引擎,它们连持久化形态都不一样。workflow 是一次性的:模型写一段 JS 脚本,引擎在 node:worker_threads 的 vm 里执行,脚本里的 agent() 打回宿主起子 Agent,跑完只留结果与展示记录,逻辑本身不落成持久状态机。schedule 是持久的:create、dispatch、delete 都是 schedule/change 会话事件,回放日志就能重建全部提醒状态。plan 更轻,就是一个 plan/mode 布尔事件的日志折叠。todo 是快照:每次 todo_write 整表替换,UI 靠投影渲染最新一份。
大纲里那个问题「多步编排应该是模型写脚本还是框架状态机」,DSH 的回答是两个都要,但分工明确。执行编排交给模型写脚本,因为编排逻辑千变万化,框架预设不完;时间、姿态、展示交给框架状态机,因为这三样需要跨轮次甚至跨重启的确定性,模型的脚本给不了。workflow 文档自己说了,它的 meta 字段词汇与 Claude Code 的 dynamic workflows 对齐(workflow.zh.md 第 41、49 行),思路同源,落点不同。
还有一条容易忽略的纪律。workflow 脚本里拼错一个 agent() 选项,抛的是 fatal: true 的 WorkflowError,parallel() 组合器对它直接重抛、终止整个脚本;只有子 Agent 真实的运行失败才映射成逐项的 null(workflow.zh.md 第 116 行)。写错代码和运行失败是两类错误,混在一起脚本就没法调了。
plan 不是权限plan mode 是软性指引:激活时往系统提示词里加一段 plan:policy,工具目录一个不变(为了请求缓存稳定)。真正拦住写操作的是沙箱和审批,两者都不读 plan 状态,要分别配。
schedule 不出会话提醒只以 followup 轮次回到原会话,没有推送、没有外部通知通道,冷会话不干活。交付语义是至少一次:准入后、落 dispatch 前崩溃,恢复会重复一次提醒。
todo 不驱动执行todo_write 是纯展示状态:整表替换、落日志、投影给 UI。没有部分更新、没有回读工具、没有稳定 id。把它当任务引擎用,是对这个原语最常见的误读。
先看 schedule 的固定速率决策,这是本课唯一值得整段看的代码:会话离线错过了 N 个到期时点,恢复后不逐个补发,一次除法直接算出最新一次到期,再把记录推进到未来。不枚举、不回放、不积压:
const steps = Math.floor((acceptedAt - target) / interval)
const occurrence = target + steps * interval
/* v8 ignore next -- bounded operands and a quotient-derived product stay safe. */
if (!Number.isSafeInteger(occurrence) || occurrence < target || occurrence > acceptedAt) {
throw new ScheduleLogError('every occurrence arithmetic must stay within the accepted interval')
}
const occurrenceAt = new Date(occurrence).toISOString()
const next = occurrence + interval
packages/schedule/schedule/src/domain.ts,核对日期 2026-08-13。代码块保留源码原文。第二条边界是 plan mode 的生效时机,逻辑用文字讲。用户在模型流式输出时点了切换,插件不立刻写日志,选择先挂在进程内存的 pending 里,等下一个轮内 pre-step 边界才动手。顺序讲究得很:监听器先 await next() 问下游这一步收不收,下游拒绝、信号已取消或者没有 pending,都原样放行;三关都过了才把选择追加进日志。追加万一失败,只记一条 warn 日志然后放行这一步,绝不因为一次姿态切换失败就阻塞整个轮次。这也回答了崩溃语义:pending 只活在进程内存,切换还没落日志时崩溃,重启后 plan mode 维持切换前的状态。
出处:packages/plan/plan-mode/src/index.ts 第 205 至 218 行的 agent/pre-step 监听器,核对日期 2026-08-13。
todo 那条最有态度的设计不用贴代码:allowParallelInProgress 是必填配置,schema 里写的是 z.boolean().required(),没有默认值(packages/todo/tool-todo/src/index.ts 第 41 至 43 行)。允不允许多个任务同时进行中,取决于这个部署跑不跑并发子 Agent,工具自己观测不到,所以强制部署方表态。设成 false 后,模型多标一个进行中就吃 Error: invalid todos: at most one task may be in_progress(第 107 至 109 行)。
Claude Code 走的是聚合路线:七种异步工作(shell 命令、本地子 Agent、远程 Agent、Teammate、工作流、MCP 监控、记忆整合)统一挂在一个 Task 框架下,共享 registerTask、updateTaskState、kill 一套生命周期(书稿 study/chapters/06-task-system.md 第 27 至 47 行引 tasks/types.ts)。DSH 相反,subagent 文档明确写着可继续路径「不会创建 Task,也不会创建承载中间结果的包装层」,四个编排原语更是各有各的持久化形态。聚合换来统一的进度 UI 和管理入口,拆分换来每个原语能把自己的语义说到底,比如 schedule 的错过合并、plan 的 pending 切换,塞进统一框架里都得妥协。
todo 这个小工具上的分歧最能看出两家的脾气。Claude Code 的 TodoWrite 在提示词里硬编码了纪律:「Exactly ONE task must be in_progress at any time (not less, not more)」,条目还要求 content 加 activeForm 双形态,执行中显示进行时文案(书稿 study/chapters/14-all-prompts.md 第 1243 至 1293 行引 TodoWriteTool/prompt.ts 原文)。DSH 把同一条纪律做成了必填的部署配置:跑并发子 Agent 的组合选 true,单线程纪律选 false,选了 false 就由代码拒绝而非提示词劝告;条目形状刻意最小,只有 content 和三态 status。一个用提示词约束模型,一个用 schema 约束部署,然后让代码执行。
推演两条边界
其一:模型正在流式输出一大段方案,用户此刻点了「进入 plan mode」,这个选择什么时候真正写进日志、什么时候开始影响模型请求?如果这一轮结束前进程崩了,重启后 plan mode 是开还是关?(提示:pending 只存在于进程内存。)其二:一条 every_seconds: 3600 的提醒,会话离线 5 小时后恢复,恢复瞬间会触发几次提醒、下一次目标定在哪?用本课第一段源码里的 steps 算式手推一遍。
「交互演示 · 原语选择器」的能力藏在每次交接里
「四个真实场景,每个场景选一个原语。选错也没关系,判词会讲清楚为什么。点「播放」可以看自动讲解,自己点卡片可以随时抢答」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「先给结论:这四个原语没有共享一个任务引擎,它们连持久化形态都不一样。workflow 是一次性的:模型写一段 JS 脚本,引擎在 node:worker_threads 的 vm 里执行,脚本里的 agent() 打回宿主起子 Agent,跑完只留结果与展示记录,逻辑本身不落成持久状态机。schedule 是持久的:create、dispatch、delet…」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
成功路径不能代表系统可靠
用「其一:模型正在流式输出一大段方案,用户此刻点了「进入 plan mode」,这个选择什么时候真正写进日志、什么时候开始影响模型请求?如果这一轮结束前进程崩了,重启后 plan mode 是开还是关?(提示:pending 只存在于进程内存。)其二:一条 every_seconds: 3600 的提醒,会话离线 5 小时后恢复,恢复瞬间会触发几次提醒、下一次目…」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「交互演示 · 原语选择器」走到「逻辑拆解 · 四个原语各管一摊」
「交互演示 · 原语选择器」先把问题落在「四个真实场景,每个场景选一个原语。选错也没关系,判词会讲清楚为什么。点「播放」可以看自动讲解,自己点卡片可以随时抢答」上;到了「逻辑拆解 · 四个原语各管一摊」,讨论继续推进到「先给结论:这四个原语没有共享一个任务引擎,它们连持久化形态都不一样。workflow 是一次性的:模型写一段 JS 脚本,引擎在 node:worker_threads 的 vm 里执行,脚本里的 agent() 打回宿主起子 Agent,跑完只留结果与展示记录,逻辑本身不落成持久状态机。schedule 是持久的:create、dispatch、delete 都是 schedule/change 会话事件,回放日志…」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「交互演示 · 原语选择器」:四个真实场景,每个场景选一个原语。选错也没关系,判词会讲清楚为什么。点「播放」可以看自动讲解,自己点卡片可以随时抢答
- 「逻辑拆解 · 四个原语各管一摊」:先给结论:这四个原语没有共享一个任务引擎,它们连持久化形态都不一样。workflow 是一次性的:模型写一段 JS 脚本,引擎在 node:worker_threads 的 vm 里执行,脚本里的 agent() 打回宿主起子 Agent,跑完只留结果与展示记录,逻辑本身不落成持久状态机。schedule 是持久的:create、dispatch、delete 都是 schedule/change 会话事件,回放日志…
- 「最后的要点」:todo_write 是纯展示状态:整表替换、落日志、投影给 UI。没有部分更新、没有回读工具、没有稳定 id。把它当任务引擎用,是对这个原语最常见的误读
最后的「最后的要点」把讨论落到「todo_write 是纯展示状态:整表替换、落日志、投影给 UI。没有部分更新、没有回读工具、没有稳定 id。把它当任务引擎用,是对这个原语最常见的误读」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。
读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。
如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。
还没有这篇文章的讨论。