本页讲 Workflow 这个编排引擎本身怎么工作:脚本在什么环境里跑、有哪些原函数、执行语义和运行时约束。Claude Code 里怎么触发、审批、保存、观察,见 claude-code-usage。
一句话定义
Dynamic Workflow 是一段 JavaScript 编排脚本 + 执行它的运行时:脚本用原函数调度多个子 Agent,运行时在隔离环境里执行脚本,把中间结果留在脚本变量里,只把最终 return 的结果交回宿主。
关键点:计划被移进代码。传统对话式 Agent 由模型逐轮决定下一步,中间结果堆在上下文窗口里;Workflow 把循环、分支、并发和中间状态交给脚本,模型上下文只保留最终答案。
为什么需要脚本编排
单会话 Agent 同时承担规划、执行、验证、汇总四种职责,在复杂长任务上会暴露三个失效模式:
| 失败模式 | 表现 |
|---|---|
| Agentic laziness(Agent 偷懒) | 复杂任务做了一部分就宣布完成,比如审计 50 个文件,前 10 个认真看,后面开始敷衍甚至跳过 |
| Self-preferential bias(自评偏好) | 让同一个模型先生成方案再给自己打分,总倾向给高分,这是模型固有偏差 |
| Goal drift(目标漂移) | 长任务经过多轮对话和上下文压缩后,“不要改 API 签名”这类初始边界约束会逐渐丢失 |
根因相同:一个上下文同时扛了规划、执行、验证、汇总。脚本把规划和验证阶段固化下来,并用独立子 Agent 做交叉验证,从结构上缓解这三类问题。
运行模型
宿主会话
└── Workflow runtime(隔离执行环境)
├── 执行 JavaScript 脚本
├── agent() ──> 子 Agent 1(独立上下文 + 工具)
├── agent() ──> 子 Agent 2
├── ...
└── return 最终结果 ──> 宿主会话- 隔离执行:runtime 在独立环境里跑脚本,与当前对话分离;中间结果存在脚本变量,不回流进模型上下文。
- 脚本落盘:每次运行把脚本写到宿主会话目录下(Claude Code 中是
~/.claude/projects/),可读、可 diff、可编辑后重新运行。 - 结果追踪:runtime 记录每个 Agent 的结果,这是同一会话内可恢复的基础。
- 权限收敛:脚本本身没有文件系统/shell 访问;读写和执行都发生在子 Agent 里,脚本只负责编排。
- 确定性约束:
Date.now()、Math.random()、无参new Date()在脚本内会抛错,保证重跑时agent()调用可复现。需要时间戳就从args传进去。
脚本结构
一个脚本由 meta 块 + 带顶层 await 的 JavaScript 主体组成:
export const meta = {
name: 'audit-routes',
description: 'Audit every route handler for missing auth checks',
}
const found = await agent('List every .ts file under src/routes/.', {
schema: { type: 'object', required: ['files'], properties: { files: { type: 'array', items: { type: 'string' } } } },
})
const audits = await pipeline(found.files, file =>
agent(`Audit ${file} for missing authentication checks.`, { label: file }),
)
return audits.filter(Boolean)meta 必须是脚本的第一条语句,且是只含字面量的普通对象,带 name 和 description;否则宿主不会把它注册成可调用命令。主体是纯 JavaScript,无模块加载(含 import() 的脚本会在运行前失败),需要第三方库的工作要放进子 Agent 的任务里。
原函数与全局
脚本可用的全部原语如下。签名来自 Claude Code v2.1.239 内置的 /workflow-authoring 参考(与公开文档一致,但选项更完整)。
| 名称 | 签名 | 作用 |
|---|---|---|
agent | agent(prompt, opts?) => Promise<any> | 启动一个子 Agent,返回其结果;失败或被跳过返回 null |
pipeline | pipeline(items, ...stages) => Promise<any[]> | 每个 item 独立流经所有 stage,stage 之间无 barrier |
parallel | parallel(thunks) => Promise<any[]> | 并发执行一组 thunk,barrier:等全部完成才返回 |
phase | phase(title) => void | 开启一个阶段,后续 agent() 归入该阶段 |
log | log(message) => void | 在进度树上方输出一条 narrator 行 |
workflow | workflow(nameOrRef, args?) => Promise<any> | 内联调用另一个已保存 workflow,只允许嵌套一层 |
args | 全局变量 | 调用时传入的输入,原样暴露;未传为 undefined |
budget | 全局对象 | 本轮 token 预算,见下文 |
meta | 导出常量 | 声明 name/description/whenToUse/phases,使脚本成为可复用命令 |
agent(prompt, opts?)
最核心的原语。不带 schema 时返回子 Agent 的最终文本字符串;带 schema 时子 Agent 被强制调用 StructuredOutput 工具,agent() 返回校验通过的对象——脚本不需要解析任何自然语言。
返回 null 的两种情形:用户在中途跳过该 Agent,或子 Agent 在重试后仍遇到终态 API 错误。所以调用方必须用 .filter(Boolean) 或显式判空处理。
opts 的全部字段:
| 选项 | 类型 | 语义 |
|---|---|---|
schema | JSON Schema 对象 | 强制结构化输出;校验失败时模型自动重试,超过重试上限报错 |
label | string | 进度视图里显示的名称;不传则用序号 |
phase | string | 显式把该 Agent 归入某阶段。在 pipeline()/parallel() 的 stage 内必须用它,而不是全局 phase(),否则并发 stage 会争抢全局阶段状态 |
model | string | 覆盖该次调用的模型。官方建议默认省略——继承会话模型几乎总是对的,只在确信某个阶段需要不同档位时才指定 |
effort | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 覆盖推理档位。机械性阶段用 low,最难验证/裁判阶段才升档 |
isolation | 'worktree' | 在全新 git worktree 里运行该 Agent。昂贵(每个 Agent 约 200-500ms 建立时间加磁盘开销),只在多个 Agent 并行修改文件会冲突时使用;未产生改动时 worktree 自动删除 |
agentType | string | 使用自定义 subagent 类型(如 general-purpose、code-reviewer),从与 Agent 工具相同的注册表解析;可与 schema 组合,自定义系统提示词后会追加结构化输出指令 |
关于 model 的一条实践纪律:优先省略。多模型路由看起来省钱,但会让 prompt cache 前缀不匹配、增加出错面;只有在明确知道「这个阶段用便宜模型足够」时才指定。
pipeline(items, ...stages)
多阶段工作的默认选择。语义要点:
- stage 之间没有 barrier:item A 可以在 stage 3 时,item B 还在 stage 1。墙钟时间等于最慢的单条 item 链,而不是各 stage 最慢值之和。
- 每个 stage 回调签名是
(prevResult, originalItem, index):后续 stage 可以用originalItem/index做 label,不必把上下文一路穿过前一个 stage 的返回值。 - stage 抛错会把该 item 落成
null并跳过它剩余的 stage,其他 item 不受影响。 - 支持多个 stage:
pipeline(items, a, b, c)一次调用串起整条流水线。
什么时候不该用 pipeline 而是 barrier?只有 stage N 真正需要 stage N-1 的全部结果时才同步:
- 在昂贵的下游工作前做跨 item 去重/合并
- 总数为零时提前退出(「没找到 bug → 跳过整个验证阶段」)
- stage N 的提示词需要引用「其他发现」做比较
以下理由不成立:只是想 flatten/map/filter(放进 stage 里做)、觉得「阶段概念上分开」(pipeline 就是建模这个的)、觉得 barrier 代码更整齐(barrier 的延迟是真实的——5 个查找器里最慢的耗时是快的 3 倍时,barrier 会让快的那 4 个空等 2/3 的时间)。
官方给的反例模式:
const a = await parallel(...)
const b = transform(a) // flatten/map/filter,没有跨 item 依赖
const c = await parallel(b.map(...))中间的 transform 不需要 barrier,应该改写成把变换放进 pipeline stage。
parallel(thunks)
- 参数必须是一组返回 Promise 的函数,即
[() => agent(...), ...]。直接传 Promise 数组会报TypeError: parallel() expects an array of functions, not promises——因为 Promise 在parallel()调用前就已启动,失去并发控制和错误处理。 - barrier 语义:等待全部 thunk 完成才返回,返回数组与输入顺序一一对应。
- 单个 thunk 失败不会让整体 reject:失败位置变成
null,所以使用前要.filter(Boolean)。 - 只在该用 barrier 时用,判据同上。
phase(title) 与 agent({ phase })
phase() 设置一个全局阶段状态,让后续 agent() 归入该阶段。它在串行代码里很方便:
phase("Scope")
const scope = await agent(...) // 归入 Scope但在 pipeline()/parallel() 的并发 stage 里,全局状态会被多个 item 争抢。这时应该改用 agent({ phase: "Fetch" }) 显式声明,同一个 phase 字符串仍归到同一个进度框。
log(message)
在进度树上方输出一条 narrator 行。脚本用它报告状态转换、计数、失败原因。注意不要把大段不可信内容直接写进 log()——真实脚本会 slice(0, 50) 截断后再打印。
workflow(nameOrRef, args?)
内联运行另一个已保存的 workflow 作为子步骤,返回它的返回值:
name走与{ name: "..." }相同的注册表,{ scriptPath }可运行指定文件。- 子 workflow 共享本次运行的并发上限、Agent 计数器、abort signal 和 token 预算,它的 Agent 在
/workflows里归到<name>分组下,token 计入budget.spent()。 args成为子 workflow 的args全局。- 嵌套只允许一层:在子 workflow 里再调用
workflow()会抛错。 - 未知 name、不可读 scriptPath、子脚本语法错误都会抛异常,需要
try/catch处理。
budget
本轮运行的 token 预算,来自用户 +500k 这类指令:
| 属性 | 语义 |
|---|---|
budget.total | 目标 token 数;未设置时为 null |
budget.spent() | 本轮已消耗的 output tokens,主循环和所有 workflow 共享同一个池 |
budget.remaining() | max(0, total - spent());未设置 total 时返回 Infinity |
total 是硬上限而非建议值:spent() 达到 total 后,后续 agent() 调用会抛 WorkflowBudgetExceededError,正在飞行的 Agent 会完成并保留结果。两个典型用法:
// 动态循环:按预算决定还要不要继续
while (budget.total && budget.remaining() > 50_000) {
const result = await agent("Find bugs in this codebase.", { schema: BUGS_SCHEMA })
bugs.push(...result.bugs)
log(`${bugs.length} found, ${Math.round(budget.remaining()/1000)}k remaining`)
}
// 静态缩放:按预算决定舰队规模
const FLEET = budget.total ? Math.floor(budget.total / 100_000) : 5必须同时有硬迭代上限。未设置预算时 remaining() 返回 Infinity,循环会一路跑到 1000 个 Agent 的全局上限才停;运行时专门有一条报错文案指出这个陷阱。
args
调用时传入的输入,原样暴露为全局变量,未传时为 undefined。关键纪律:
- 传真正的 JSON 值(数组/对象/字符串),不要传 JSON 编码后的字符串。字符串化的列表会让
args.filter/args.map抛错。 args也是唯一的非确定性输入通道:需要时间戳或随机种子就从这里传进去。- 常用于参数化保存的 workflow,例如把研究问题、目标路径、配置对象直接传进来。
meta 的字段
| 字段 | 必填 | 作用 |
|---|---|---|
name | 是 | 保存为命令后的斜杠名,也是注册表键 |
description | 是 | 权限对话框里的一行说明 |
whenToUse | 否 | 在工作流列表里展示,给 Claude 判断何时调用 |
phases | 否 | 进度视图的阶段分组;每项可带 title、detail,需要覆盖模型时加 model |
meta.phases 里的 title 必须与 phase() 调用逐字匹配;没有匹配条目的 phase() 调用会自己生成一个进度分组。整个 meta 必须是纯字面量,不能有变量、函数调用、展开或模板插值。
结构化输出是 Workflow 相对逐轮对话的核心优势:Agent 之间传递的是 found.files 这样的数组,不需要再让另一个 Agent 解析自然语言。真实用法见 example-research-simple。
返回值与失败语义
原语的失败设计遵循同一条原则:单个 Agent 失败不应炸掉整个工作流,但失败必须可区分、可统计。
| 情形 | 行为 |
|---|---|
agent() 被用户跳过 | 返回 null |
agent() 重试后遇终态 API 错误 | 返回 null |
agent({ schema }) 校验连续失败超过重试上限 | 抛错(不是返回 null) |
parallel() 中某个 thunk 抛错 | 对应位置为 null,整体不 reject |
pipeline() 中某个 stage 抛错 | 该 item 落 null,跳过剩余 stage,其他 item 继续 |
| 超出 token 预算 | agent() 抛 WorkflowBudgetExceededError;parallel/pipeline 把对应槽位记为 null 并累计「budget dropped」 |
脚本本体 throw | 整个运行失败,/workflows 显示错误和栈 |
| 达到 1000 Agent 上限 | 抛 WorkflowAgentCapError |
实践含义:默认把 agent() 返回 null 当作正常路径处理,用 .filter(Boolean) 或显式的三态裁定;只有 schema 校验失败这类脚本自身契约问题才让它抛出来。真实脚本里常见的做法是把「Agent 没跑」和「业务上判定失败」分开记录——见 example-research-simple 的 Verify 阶段。
编排模式
脚本可以组合出六种模式,运行时按任务现场生成或由预置脚本固定:
| 模式 | 说明 |
|---|---|
| 分类与路由 | 用分类 Agent 判断任务类型后再分流处理 |
| 扇出与综合 | 拆成多个子任务并行执行,最后综合结果 |
| 对抗验证 | 一个 Agent 产出结论,另一个专门挑刺验证,防”自己审自己” |
| 生成与过滤 | 先大量生成再过滤,适合头脑风暴类任务 |
| 锦标赛模式 | 多个方案同台 PK,选出最优 |
| 循环直到完成 | 用于工作量未知的探索性任务,反复迭代到收敛 |
运行时约束
| 约束 | 原因 |
|---|---|
| 运行中不能中途插话 | 只有 Agent 权限提示能暂停运行;需要阶段间签署就把每个阶段拆成单独 workflow |
| 脚本无直接文件系统/shell 访问 | 权限收敛给子 Agent,脚本只负责编排 |
| 不允许模块加载 | 脚本是纯 JavaScript,含 import() 会在运行前失败 |
| 不支持 TypeScript 语法 | 类型注解、interface、泛型会解析失败 |
最多 min(16, 可用 CPU - 2) 个并发 Agent | 限制本地资源占用,CPU 核心少或容器受限时更少;超出的调用排队 |
单次 parallel()/pipeline() 最多 4096 项 | 超长列表直接报错,避免静默丢任务 |
| 单次运行总计上限 1000 个 Agent | 防止失控循环;与 budget.remaining() 返回 Infinity 的循环陷阱直接相关 |
Date.now()/Math.random()/无参 new Date() 抛错 | 保证重跑时 agent() 调用可复现;时间戳从 args 传入 |
| 脚本返回值不能是函数 | runtime 只接受可序列化结果 |
扇出时的 prompt cache
同一轮里模型、effort、agent 类型、工具、输出 schema、工作目录完全相同的 Agent,会共享同一段 tools-and-system-prompt 前缀:
- 后来启动的 Agent 如果匹配上已开始响应的兄弟 Agent,首个请求就能读它的 prompt cache。
- 一次扇出同时启动多个匹配 Agent 时,runtime 会先放行第一个,其余暂缓到第一个开始响应后再一起放行,让它们读共享前缀而不是各自冷处理;暂缓上限默认 5000ms,可用
CLAUDE_CODE_WORKFLOW_PREFIX_STAGGER_MS调整,设为0关闭。 - Workflow Agent 的请求不在主会话 cache TTL 桶内,默认缓存 5 分钟;设
subagentPromptCacheTtl为1h可延长,但 1 小时 cache 写入计费更高。
可恢复性语义
runtime 按 Agent 启动顺序重放一次运行,每个 Agent 要么返回已保存结果,要么重新执行:
- 已完成:返回缓存结果。第一个 prompt 与上次不同的 Agent 开始重跑,其后所有 Agent(含已完成的)都重跑。
- 停止时仍在运行:从头开始;停止整个运行不计任何 Agent 失败。
- 失败:重跑,其后所有 Agent 也重跑;单独停止某个 Agent 视为失败。
含义:扇出中段失败会重跑它后面已完成的工作。同一会话内可恢复,跨会话则取决于宿主是否保留会话目录——Claude Code 的具体操作和 429/402 排障见 recovery。
与 Claude Code 的边界
本页描述的是 Workflow 引擎;Claude Code 是它的一个宿主。边界可以这样划:
| 层次 | 内容 | 归属 |
|---|---|---|
| 编排语义 | 脚本、原函数、执行约束、缓存与重放 | Workflow runtime(本页) |
| 触发与审批 | ultracode 关键词、/effort ultracode、权限提示 | Claude Code(见使用页) |
| 观察与管理 | /workflows 面板、暂停/停止/保存 | Claude Code(见使用页) |
| 脚本落盘 | ~/.claude/projects/、.claude/workflows/ | Claude Code 的目录约定 |
| 程序化入口 | Workflow 工具、resumeFromRunId | Agent SDK |
因此同样的编排语义可以换宿主暴露:Claude Code 用斜杠命令和面板,Agent SDK 用 Workflow 工具和参数。理解原理时关注脚本和原函数;理解操作时看宿主。
相关
- example-research-simple — 真实脚本逐段精读:原函数在 427 行深度研究工作流里的完整用法
- claude-code-usage — Claude Code 中如何使用 Workflow:触发、审批、观察、保存、成本与关闭
- recovery — 中断恢复、429/402 排障、
resumeFromRunId续跑 - vs-lowcode — 与 Dify/扣子等低代码工作流平台的对照
- building-effective-agents — Workflow vs Agent 区分、Orchestrator-Workers 与 Evaluator-Optimizer 理论原型
- claude-code-subagents — 子 Agent 机制,Workflow 编排的 worker 原语