本页讲 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 参考(与公开文档一致,但选项更完整)。

名称签名作用
agentagent(prompt, opts?) => Promise<any>启动一个子 Agent,返回其结果;失败或被跳过返回 null
pipelinepipeline(items, ...stages) => Promise<any[]>每个 item 独立流经所有 stage,stage 之间无 barrier
parallelparallel(thunks) => Promise<any[]>并发执行一组 thunk,barrier:等全部完成才返回
phasephase(title) => void开启一个阶段,后续 agent() 归入该阶段
loglog(message) => void在进度树上方输出一条 narrator 行
workflowworkflow(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 的全部字段:

选项类型语义
schemaJSON Schema 对象强制结构化输出;校验失败时模型自动重试,超过重试上限报错
labelstring进度视图里显示的名称;不传则用序号
phasestring显式把该 Agent 归入某阶段。在 pipeline()/parallel() 的 stage 内必须用它,而不是全局 phase(),否则并发 stage 会争抢全局阶段状态
modelstring覆盖该次调用的模型。官方建议默认省略——继承会话模型几乎总是对的,只在确信某个阶段需要不同档位时才指定
effort'low' | 'medium' | 'high' | 'xhigh' | 'max'覆盖推理档位。机械性阶段用 low,最难验证/裁判阶段才升档
isolation'worktree'在全新 git worktree 里运行该 Agent。昂贵(每个 Agent 约 200-500ms 建立时间加磁盘开销),只在多个 Agent 并行修改文件会冲突时使用;未产生改动时 worktree 自动删除
agentTypestring使用自定义 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 工具、resumeFromRunIdAgent 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 原语