青雲的博客

Article

Claude Code Workflow 实现原理:一个可恢复的 Agent 编排引擎

· 21 分钟阅读

结论先行

@anthropic-ai/[email protected] 的 workflow,不是普通 prompt 模板,而是一个正式暴露给运行时的编排工具

它的核心机制不是”让模型自己记住步骤”,而是把多步任务写成一段受约束的脚本 DSL,再交给运行时做三件事:

  1. 解析与校验:确认脚本结构合法,而且可恢复
  2. 注册为后台任务:把执行请求变成 local_workflow 一类的异步任务
  3. 按 phase 调度子 agent:推进 agent()parallel()pipeline() 等步骤,并把进度、结果、停止状态写回运行态,供 resumeFromRunId 续跑

一句话概括:Claude Code 的 workflow 是一个”可编译、可持久化、可恢复”的多阶段 agent 编排引擎。


分析范围

  • 版本: @anthropic-ai/[email protected]
  • 分析对象:
    • package.json
    • install.cjs
    • cli-wrapper.cjs
    • sdk-tools.d.ts
    • linux_x64/package/claude 的字符串与运行痕迹
  • 分析目标: 只解释最新版 workflow 的实现原理本身
  • 明确不包含: 跨版本对比、历史演进

证据基础

证据位置关键内容直接结论
package.json 第 4-37 行wrapper 包只分发 bin/claude.exeinstall.cjscli-wrapper.cjssdk-tools.d.ts,真正执行体通过平台 optionalDependencies 提供workflow 核心执行逻辑不在公开 wrapper JS,而在平台二进制
install.cjs 第 4-10 行postinstall 会把平台原生二进制复制到 bin/claude.exeworkflow runtime 被封装进原生执行体
cli-wrapper.cjs 第 128-133 行fallback 逻辑本质是 spawnSync(binaryPath, process.argv.slice(2), ...)wrapper 只是定位并拉起原生程序
sdk-tools.d.ts 第 2267-2297 行WorkflowInput 明确要求 export const meta = { name, description, phases } + agent()/parallel()/pipeline()/phase()workflow 使用显式 DSL,而不是自由格式脚本
sdk-tools.d.ts 第 2291-2297、3107-3134 行scriptPath 会被持久化;resumeFromRunId 支持恢复;WorkflowOutput 返回 taskId / runId / scriptPathworkflow 从设计上就是异步、可恢复任务
原生二进制字符串痕迹出现 local_workflowTaskStopresumeFromRunIdworkflow.jssourcesContentsourceMappingURL 等标识workflow 执行链路和脚本编译环境都在原生包内

发布形态与执行边界

先把边界讲清楚,否则后面的 workflow 运行时很容易看偏。

wrapper 包不是执行核心

公开 npm 包只是一个很薄的壳:

  • package.json 决定平台依赖装哪个包
  • install.cjs 负责安装期复制原生二进制
  • cli-wrapper.cjs 是 postinstall 失效时的兜底启动器
  • sdk-tools.d.ts 暴露工具协议

真正的 workflow 逻辑不在这些 JS 文件中。它们负责的是:找到平台正确的二进制、启动它、把公开工具接口描述出来。

workflow 运行时被封装进平台二进制

Linux x64 平台包中的 claude 是单个 ELF 可执行文件,不是普通 shell wrapper,而是完整运行时。

但从二进制字符串中能看到 sourceMappingURLsourcesContentworkflow.js 这类痕迹——它内部仍然承载 JS/TS 打包产物与脚本编译逻辑,只是对外变成了单文件原生分发。

对分析来说,这意味着:

  • 公开可读层:主要是 schema 和 wrapper 边界
  • 真实执行层:在原生二进制里
  • 分析策略:先从 tool schema 确认能力面,再用二进制字符串与运行行为反推 runtime 结构

Workflow 的公开契约

最新版 workflow 的核心入口,是 sdk-tools.d.ts 里的 WorkflowInputWorkflowOutput

WorkflowInput 的四个关键字段

1. script

注释写得明确:

  • 必须以 export const meta = { name, description, phases } 开头
  • meta 必须是纯字面量
  • 脚本体使用 agent()parallel()pipeline()phase()

workflow 不是随便给一段 JS 就能跑,而是要求脚本满足一个固定编排语法面

2. name

用来引用预定义 workflow。两种来源:

  • built-in workflow
  • **.claude/workflows/** 下的本地 workflow

运行时里存在一个统一的 workflow 解析入口:不管是内置模板还是工作区模板,最终都要被转换成”可执行脚本文本”。

3. scriptPath

最值得注意的字段之一。

每次 Workflow 调用都会把脚本持久化到 session 目录,并把 scriptPath 返回给调用方。后续如果要继续迭代,应当优先修改这个文件并再次以 scriptPath 调用,而不是反复发送整段脚本。

这个设计说明 workflow 不是”提示词即程序”,而是显式地把脚本对象落地成文件资产。

4. resumeFromRunId

  • 恢复是 same-session only
  • 已完成且 (prompt, opts) 未变化的 agent() 调用,可直接命中缓存
  • 只有新增或修改过的调用才重跑
  • 恢复前必须先 TaskStop

这几条组合起来,说明 workflow runtime 不是简单顺序执行器,而是带节点级缓存与恢复语义的执行引擎。


Workflow 脚本 DSL 的本质

Claude Code 没有把 workflow 做成 YAML,也没有做成纯 JSON 配置,而是做成一套轻量 DSL。

graph TD
    subgraph "Workflow DSL 结构"
        META[meta: name + description + phases] --> PHASE1[phase '规划']
        META --> PHASE2[phase '实现']
        META --> PHASE3[phase '验证']

        PHASE1 --> AG1["agent('分析需求')"]

        PHASE2 --> PAR["parallel()"]
        PAR --> AG2["agent('写前端')"]
        PAR --> AG3["agent('写后端')"]

        PHASE3 --> PIPE["pipeline()"]
        PIPE --> AG4["agent('跑测试')"]
        AG4 --> AG5["agent('修复问题')"]
    end

最小组成:

  • meta:声明 workflow 的名字、描述、阶段信息
  • phase():建立阶段边界
  • agent():定义一个实际由子 agent 执行的工作单元
  • parallel():在同一个阶段中并发派发多个子任务
  • pipeline():让前一步输出进入后一步

从工程角度看,这个 DSL 有两个核心作用:

它不是给用户读的,而是给 runtime 编译的

agent()/parallel()/pipeline()/phase() 这些原语,不只是语义标签,而是执行器可以识别的结构节点。workflow 运行时不是”先把脚本转成自然语言计划,再由模型理解”,而是会根据这些原语构造实际执行拓扑。

它天然支持调度语义

  • phase() 提供阶段性的同步点
  • parallel() 提供并发 fork 点
  • pipeline() 提供数据依赖链
  • agent() 提供执行单元边界

换句话说,这个 DSL 的真正价值是:把 prompt 编排问题,提升成结构化调度问题。


从调用到执行:workflow 主链路

flowchart TD
    A[主 Agent 发起 Workflow tool call] --> B{来源归一化}
    B -->|scriptPath| C[读取磁盘脚本]
    B -->|name| D[解析 built-in / .claude/workflows/]
    B -->|script| E[使用 inline 脚本]
    C --> F[编译前检查]
    D --> F
    E --> F
    F -->|结构检查| G{meta 合法?}
    G -->|否| H[报错: Invalid workflow script]
    G -->|是| I{确定性检查}
    I -->|使用 Date.now/Math.random| J[报错: breaks resume]
    I -->|通过| K[任务注册]
    K --> L[分配 taskId + runId]
    L --> M[持久化 scriptPath]
    M --> N[后台执行器推进]
    N --> O[按 phase 调度]
    O --> P[agent 节点执行]
    O --> Q[parallel 并发分支]
    O --> R[pipeline 数据依赖]
    P --> S{所有 phase 完成?}
    Q --> S
    R --> S
    S -->|否| O
    S -->|是| T[TaskCompleted]

1. 入口:由主 agent 发起 Workflow tool call

workflow 在运行时中表现为一个正式 tool。调用方通过 tool schema 传入:

  • script / name / scriptPath(三选一)
  • 可选 args
  • 可选 resumeFromRunId

这一步的输出不是完整任务结果,更像是一个”启动成功的任务句柄”。

2. 归一化:把不同来源解析成脚本文本

运行时需要先把三种来源统一起来:

  1. scriptPath 优先,直接读取磁盘脚本
  2. 若没有,则看 name,解析成 built-in 或 .claude/workflows/ 对应脚本
  3. 若仍没有,则使用 inline script

这一步不只是”取脚本”,而是在确定后续所有恢复和缓存的锚点:

  • 脚本文本决定要执行什么
  • scriptPath 决定以后怎么重新加载
  • runId 决定当前执行实例是谁

3. 编译前检查:验证脚本是否满足恢复要求

二进制中能看到几条关键的错误提示:

export const meta = { name, description, phases } must be the FIRST statement in the script
meta must be a pure literal
Invalid workflow script:
Date.now() / new Date() are unavailable in workflow scripts (breaks resume)
Math.random() is unavailable in workflow scripts (breaks resume)

三层检查:

  1. 结构检查meta 是否在第一句、字段是否合法
  2. 语法检查:脚本是否可编译成 workflow.js
  3. 确定性检查:是否使用了破坏恢复能力的时间/随机接口

一个很硬的结论:恢复能力是 workflow 设计的前提,不是附加能力。

4. 任务注册:把 workflow 变成后台任务

WorkflowOutput.status 暴露的是 async_launchedremote_launched。调用 workflow 后,运行时更关心的是”任务是否成功发车”,而不是”是否已经跑完”。

二进制里出现 local_workflowLocalWorkflowTaskTaskCreatedTaskCompletedTaskStop 等字符串,说明 workflow 在本地执行时会进入一个专门的任务生命周期:

  1. 创建任务 → 分配 taskId → 分配 runId
  2. 持久化脚本与执行上下文
  3. 交给后台执行器继续推进

workflow 调用更像 launch,而不是 call-and-wait

5. 执行推进:按 phase 调度子 agent

从 DSL 与公开返回结构看,执行期语义已经清楚:

  • agent() 是最小执行单元
  • phase() 把多个单元组织成阶段
  • parallel() 让一个阶段内出现并发分支
  • pipeline() 明确输出依赖关系

运行时至少要维护:

  1. 当前 phase 指针
  2. 每个 agent() 节点的状态
  3. 并发分支的完成情况
  4. 每个节点的输出缓存
  5. 整个 run 的停止/恢复信息

workflow 本质上不是”单次大模型推理”,而是一个真正的任务图执行器


为什么它能恢复

恢复能力是最新版 workflow 最值得研究的部分。

flowchart LR
    subgraph 首次执行
        A1[脚本持久化 scriptPath] --> A2[执行生成 runId]
        A2 --> A3[节点逐个执行]
        A3 --> A4[每个节点缓存 prompt+opts+result]
    end

    subgraph 恢复流程
        B1[TaskStop 停止旧 run] --> B2[传入 resumeFromRunId]
        B2 --> B3[装载 scriptPath]
        B3 --> B4[重新生成任务图]
        B4 --> B5{对比节点签名}
        B5 -->|prompt+opts 未变| B6[命中缓存 ✓ 跳过]
        B5 -->|新增或修改| B7[重新执行]
    end

    A4 -.->|状态持久化| B2

scriptPath 是”程序”锚点

恢复时不能只靠 runIdrunId 只能告诉 runtime”你要接哪次执行”,但不能告诉它”程序定义是什么”。

所以运行时把脚本持久化成 scriptPath,让恢复过程可以重新读到同一份 workflow 脚本,再进行重新解析和编译。

runId 是”实例”锚点

runId 负责定位某次具体执行:

  • 哪些 phase 已经完成
  • 哪些 agent() 节点已经成功
  • 哪些结果已经写入缓存
  • 当前停止点在哪里

没有它,就无法在后续调用里指定”从哪次执行继续”。

恢复不是全量重跑,而是增量重放

resumeFromRunId 的核心机制:

已完成且 (prompt, opts) 未变化的 agent() 节点直接复用缓存;只有修改过或新增的节点才重跑。

runtime 内部至少保留了:

  • 每个 agent() 节点的输入签名
  • 节点执行结果
  • 节点完成状态
  • 这些状态与特定 runId 的绑定关系

恢复的实际语义不是”从头跑一遍”,而是:

  1. 重新装载脚本
  2. 重新生成任务图
  3. 根据旧 runId 对齐已完成节点
  4. 命中未变化节点的缓存
  5. 只对新增或变更部分继续执行

为什么禁用 Date.now()Math.random()

因为这两类接口会破坏”相同脚本 + 相同输入 = 相同执行边界”的假设。

一旦允许时间和随机性,运行时就很难判定:

  • 一个节点是否真的”没有变化”
  • 旧缓存是否仍然安全可复用
  • 恢复后跳过哪些步骤不会导致语义漂移

这类限制不是保守,而是恢复系统成立的基础条件。


TaskStop 的角色

resumeFromRunId 明确要求:恢复前要先 TaskStop 停掉旧运行。

TaskStop 在 workflow 体系里的意义不只是”停止执行”,而是:

  1. 给当前 run 一个明确的终止边界
  2. 确保不会出现两个执行器同时推进同一个 workflow
  3. 把当前已完成状态稳定写回运行态
  4. 为后续 resume 留下合法切点

更接近 checkpoint-aware interrupt,而不是简单 kill。


Built-in 与本地 workflow 的统一抽象

name 字段注释和二进制里的 autopilotbugfix 等文本痕迹看,运行时内部存在内置 workflow 库。

关键在于,这些内置 workflow 并没有走独立的硬编码分支,而是被统一纳入 name → script → Workflow runtime 这条链路。

系统在设计上追求的是:

  • 定义方式统一:都以脚本形式存在
  • 解析方式统一:都进入相同的编译/校验流程
  • 执行方式统一:都被注册成 workflow 任务
  • 恢复方式统一:都可绑定 scriptPath/runId 语义

只有统一到同一条管道,workflow 才能真正扩展,不会出现”内置逻辑一套、用户自定义一套”的双轨系统。


Runtime 形态判断

代码中明确存在的部分

  1. workflow 是独立 tool,不是提示词隐式模式
  2. workflow 使用显式 DSL:meta + phase()/agent()/parallel()/pipeline()
  3. 运行结果是异步任务句柄,不是同步最终值
  4. 支持 scriptPath 持久化与 runId 恢复
  5. 恢复机制依赖节点级缓存与受控停止
  6. 运行时明确要求脚本确定性

高可信推断

  1. runtime 内部维护的是 phase / agent 节点级状态图
  2. parallel() 会被真正映射成并发调度,不是 prompt 上的”你并行处理一下”
  3. pipeline() 会被当作数据依赖边,不是普通顺序列表
  4. agent() 节点对应独立子任务或子会话执行单元
  5. 缓存命中逻辑至少依赖 (prompt, opts),可能还会叠加脚本或上下文签名

最终判断

这套 workflow runtime 更像:

DSL 编译器 + 后台任务系统 + 节点级缓存恢复机制 的混合体。

不是传统有限状态机,也不是简单 planner,更不是只靠模型记忆流程。它真正的价值在于:把 agent 编排转成一个可被运行时管理的程序。


工程取舍

用脚本约束换取恢复能力

得到:可以 resume、可以缓存 step 结果、可以做增量重跑

代价:脚本不能随意写、不能依赖随机数和当前时间、meta 和 DSL 结构必须严格受控

用后台任务换取长流程编排能力

得到:可承载多阶段任务、可停止/恢复/观察、不阻塞前台交互线程

代价:需要维护任务生命周期、调用方拿到的是句柄不是即时结果、状态管理复杂度上升

用统一脚本抽象换取扩展性

得到:built-in 与本地 workflow 共用执行链路、扩展新 workflow 不必改主执行器、用户自定义和官方内置共享调度机制

代价:运行时需要维护脚本解析、校验、执行、恢复整套体系


可复用的 5 个设计模式

  1. 把多步 agent 任务升格为 DSL — 只要有 phase / parallel / pipeline / agent 这类原语,系统就能从”聊天策略”进化为”程序化编排”

  2. 把恢复能力前置到脚本语义层 — 禁止非确定性接口,比事后补 checkpoint 更稳

  3. 把”程序定义”和”运行实例”拆开scriptPath 表示定义,runId 表示实例。利于调试、恢复和审计

  4. 缓存到节点级,不缓存到整体级 — 只有这样,恢复时才能跳过已稳定节点,只重跑变化部分

  5. 统一内置模板与用户扩展的执行管道 — 这是 workflow 系统能长期扩展的关键


下一步最有价值的实验

如果要继续深入,最有效的是直接做一次动态实验:

  1. 构造一个最小 workflow 脚本
  2. 跑一次
  3. 在中途 TaskStop
  4. resumeFromRunId 恢复
  5. 观察 session 目录、脚本持久化文件、任务状态和 transcript 的实际落盘

这一步能直接验证:scriptPath 实际存在哪、runId 如何关联状态、恢复时哪些节点被跳过、phase 进度如何持久化。


一句话总结

最新版 Claude Code 的 workflow,本质上不是”模型自己分步骤完成任务”,而是运行时把受约束的 workflow 脚本编译成一个可恢复的后台任务图,再用 phase/subagent 级状态推进执行、停止与续跑。

Keep Reading

相关文章

评论