把 n8n、Airflow、LangGraph、Temporal 和 Claude Code 放到同一副透镜下:workflow 的核心是把隐式控制流变成可存、可观测、可调度的节点状态图。
n8n 里你拖着节点连线,Airflow 里你把任务排成一张 DAG,LangGraph 里你给 agent 接节点,Temporal 里你写一段"崩了还能接着跑"的工作流,Claude Code 最近也甩出个 dynamic workflow。这些东西你大概隐约觉得是一类,又说不清到底像在哪。
更尴尬的是,真要有人问你三个具体问题,你未必答得利索:
- 节点的状态该怎么设计?一个节点除了"跑完了"还能是什么?
- 节点之间是怎么流转的?引擎凭什么知道下一个该轮到谁?
- 一个 workflow 到底是怎么写出来的?是画图还是写代码?
这三个问题听着像三件事,其实是同一件事的三个切面。这篇就回答它:这些花样底下是不是同一台机器,它到底干什么,以及落到 Claude Code 这种看不见节点的工作流上,又长什么样。
先把答案撂这儿,后面慢慢拆:所谓 workflow 引擎,本质就干一件事——把藏在代码调用栈里的"控制流"抠出来,变成一张看得见、存得下、调得动的"节点 + 状态"图。 想清楚这句,你再看任何编排系统,包括 Claude Code 那种压根没有可视节点的工作流,都能一眼看穿。
一、所有 workflow 引擎,都在干同一件事
先说"控制流"是什么。你写一段普通程序,if 决定走哪条岔路、for 决定转几圈、函数调函数决定谁先谁后——这些"接下来执行哪一步"的逻辑,就是控制流。
问题是,普通程序里控制流是隐式的。它活在调用栈、活在那个看不见的程序计数器里。你没法把它捞出来端详:"我现在执行到第几步了?"这个信息散在内存的各个角落,进程一崩就全没了,更别说让另一台机器接着往下跑。
workflow 引擎干的第一件、也是唯一一件根本的事,就是把这条隐式的控制流,抠出来变成显式的数据。抠出来之后,它长这样:
- 节点(node):一步活。“拉数据”“发邮件”“调一次模型”。
- 状态(state):这步到哪了。还没轮到?正在跑?跑成了?挂了?
- 边(edge):谁接谁。A 干完了才轮到 B。
就这三样。一旦控制流从"藏在代码里的逻辑"变成了"摆在桌面上的数据",你白捡四样东西,而这四样恰好是普通代码最难搞的:
| 白捡的能力 | 因为控制流变成了数据,所以…… |
|---|---|
| 存得下(断点续跑) | 状态能落盘。跑到一半崩了,捡起状态接着跑,不用从头来 |
| 看得见(可观测) | 状态能渲染。哪个节点在转、哪个红了,一张图全看见 |
| 调得动(并行 / 分布式) | 节点是独立的数据,能甩到不同机器上同时跑 |
| 改得了(动态) | 图是数据,能在运行时改。跑着跑着加个节点、改条边都行 |
这就是为什么各路 workflow 引擎看着五花八门,骨子里是同一台机器:它们都在把控制流数据化,然后享受这四样红利。区别只在皮肤。
节点状态:真正要设计的是那台状态机
回到开头第一个问题——节点状态怎么设计。这才是 workflow 设计里真正的活,比连线难多了。
一个够用的最小状态集,大概长这样:
pending → 还没轮到(上游没干完)
ready → 上游都好了,就等有空跑
running → 正在跑
success → 跑成了(终态)
failed → 跑挂了(可能重试,也可能是终态)
skipped → 被跳过(比如上游挂了,它没必要再跑)
新手最容易合并的,是 pending 和 ready。但这俩必须分开:pending 是"依赖还没满足",ready 是"依赖满足了、但调度器还没腾出手"。把它们分开,调度器才知道哪些节点已经可以跑了——这正是并行的入口。同一时刻所有 ready 的节点,理论上都能一起放出去。
failed 也不是简单一个"挂了"。它后面通常挂着一截小循环:挂了 → 重试 → 回到 ready → 再跑,试够几次还不行才落到终态 failed。还有 skipped:当一个节点的上游挂了,它自己根本没机会跑,引擎得有个状态来表达"它不是成功也不是失败,是压根没轮到"。
你看,把这套状态设计清楚,引擎的一大半就成型了。剩下的,是让某个东西去推动这些状态流转。
流转:调度器不"顺着线走",而是"盯着状态推"
第二个问题——节点怎么流转。
直觉上你可能以为,引擎像个人,拿着流程图,从起点出发顺着箭头一路走到底。不是的。 真正的引擎是一个死循环,它干的事特别朴素:
循环:
扫一遍所有节点
谁的上游全 success 了 → 把它从 pending 置成 ready
把 ready 的节点捞起来跑 → running
跑完按结果更新成 success / failed
再扫一遍……直到没有能跑的了
这个视角一换,两件事就通了。
第一,边不是 goto,是前置条件。 一条 A→B 的边,真正的含义不是"跑完 A 就跳到 B",而是"B 想跑,得先满足 A 已 success"。引擎从不"跳"去哪,它只是反复检查每个节点的前置条件够没够。
第二,并行是白送的。 既然调度靠"谁 ready 谁跑",那同一时刻有三个节点都 ready,就三个一起跑——你压根不用专门写"并行逻辑",它是状态驱动调度的自然结果。这也是为什么用一张依赖图(DAG)来声明任务,比你手画箭头、手写"先做这个再做那个"高级:你只声明谁依赖谁,并行调度引擎自己算。
两个流派:画图,还是写代码
第三个问题——workflow 怎么写。落到具体工具,分两个流派,但底下都是上面那台状态机:
- 声明式·画图:你把节点和边声明出来,引擎负责调度。n8n 是拖拽连线,Airflow 是用代码声明一张 DAG,LangGraph 是
add_node/add_edge把 agent 步骤连起来。你描述"图长什么样",不碰"怎么跑"。 - 命令式·写代码:你写一段看着像普通代码的东西,一个运行时在底下把它拦截、改造成可持久化、可调度的工作流。Temporal 是典型——你就写正常的函数调用,它靠"重放(replay)"在背后把这段代码变成崩了能续的持久流程。Claude Code 的 dynamic workflow 也属于这一派。
把这两个维度——有环还是无环、声明式还是命令式——交叉一下,几个系统的位置就清楚了:
| 声明式(画图) | 命令式(写代码) | |
|---|---|---|
| 无环(DAG) | Airflow、n8n | —— |
| 可带环(循环) | LangGraph | Temporal、Claude Code |
"无环"和"有环"这一栏值得多说半句,因为它正是 AI 把这台老机器逼出新花样的地方。经典数据管道大多是 DAG,A 到 B 到 C,不回头。可 agent 偏偏要回头:找答案不对就反思重试、ReAct 一轮轮观察再行动、“挖到挖不动为止”,这些全是环。所以 agent 时代的编排,把节点状态机这套底盘从"无环 DAG"往"带环图"推了一把。
四个格子里,最特别、也最该讲透的是右下角那个:一个命令式、还能带环的工作流,Claude Code。下面就钻进去。
二、钻进最特别的一格:Claude Code 的 workflow
这里有个反转,也是全文最有意思的地方:Claude Code 的 workflow 压根没有可视节点,没有状态机面板,它就是一段 JavaScript。
那它凭什么也叫 workflow?因为你把上一节那副透镜举起来照它,"节点 + 状态 + 流转 + 调度"那套,一样不少,全藏在代码和运行时底下。我们逐项照。
节点 = 一次 agent() 调用
在 Claude Code 的工作流里,最小的那块活——一个节点——就是派一个分身(subagent)去干一件事。代码里就一行:
const routes = await agent('列出 src/routes 下所有接口及位置')
agent(...) 派出去一个分身,它干完,把结果交回来。你想要结构化结果而不是一段文字,就给它一个 schema,它会被逼着按这个结构吐数据:
const routes = await agent('列出 src/routes 下所有接口', { schema: ROUTES })
一个 agent() 就是一个节点。一段工作流,就是几十上百个这样的节点按某种结构连起来。
状态藏哪?藏在运行时的"日志"里
上一节说,workflow 的命根子是把状态变成数据、存下来,于是能断点续跑。Claude Code 的工作流看不见状态字段,那它的状态在哪?
在运行时的一本**日志(journal)**里。每个 agent() 调用——它的 prompt、它的参数、它返回的结果——都被记下来。于是当一个工作流中途停了、你又让它接着跑时,运行时会拿日志比对:凡是 prompt 和参数没变的调用,直接把上次的结果返回,不重新派分身、不重新烧 token;从第一个改动过的调用起,才真正重新跑。
这就是经典的 durable execution(持久化执行),跟 Temporal 是同一个路子,只是 Claude Code 把它藏得严严实实,你感觉不到。它甚至因此有条怪规矩:工作流脚本里不许用 Date.now()、Math.random() 这类每次都不一样的东西。道理在于,"重放日志、复现结果"要求同样的输入必须给出同样的结果,而这些函数每次都变,会让重放对不上。你看,连这条限制都是"状态变数据、要可重放"这个核心一路推出来的。
照到这儿,第一根支柱落实了:CC 工作流确实把"我执行到哪了"externalize 成了数据,只不过你不直接碰它。
流转 = 代码里的控制流原语
经典引擎靠"扫状态、够前置就推进"来流转。命令式工作流不画图,它的流转就写在代码的控制流里。Claude Code 给了两个最关键的原语,区别一句话能说清,但用错代价不小:
// pipeline:每个 item 各走各的流水线,互相不等
const out = await pipeline(
files,
file => agent(`给 ${file} 写文档`), // 阶段一
doc => agent(`审一遍这段文档:${doc}`), // 阶段二
)
// parallel:一道栅栏,等所有人都干完才往下
const all = await parallel(
files.map(file => () => agent(`给 ${file} 写文档`))
)
parallel 是一道栅栏:所有分身都干完,才一起往下。pipeline 没有栅栏:50 个文件,第 1 个文件可能已经在走"审文档"的阶段二了,第 30 个文件还在阶段一写文档——每个 item 独立地往下淌,谁也不等谁。
为什么默认该用 pipeline?因为栅栏是有代价的。一批活里有快有慢,parallel 会被那个最慢的拖住,跑得快的分身干完了只能干等。pipeline 让每个 item 一干完当前阶段就立刻进下一阶段,整体墙上时间是"最慢的那一条链",而不是"每个阶段最慢的之和"。只有当下一阶段真的需要凑齐上一阶段所有结果时(比如要先把所有发现去个重、再统一核验),才该上 parallel 这道栅栏。
除了这俩,普通的顺序 await、while 循环也都在。带环就是这么来的——一个 while 循环让"找一轮 → 没找到新的就停"反复跑,正是上一节说的"挖到挖不动为止":
const bugs = []
while (budget.total && budget.remaining() > 50_000) {
const r = await agent('再找一轮 bug', { schema: BUGS })
bugs.push(...r.bugs)
}
看得见 = phase() 与那棵进度树
"控制流变成数据"白送的第二样是可观测。Claude Code 用 phase() 给节点分组:
agent('检查这个接口漏没漏鉴权', { phase: 'Review' })
agent('试着推翻这条发现', { phase: 'Verify' })
打上 phase,这些节点就在 /workflows 那棵进度树里被归到对应的组下面。你随时能调出这棵树,看每个阶段几个分身在转、烧了多少 token、跑了多久。这就是这台机器给你的"看得见"——虽然不是一张拖拽出来的流程图,但状态确实被渲染出来了。
编写 = 你说要干嘛,Claude 写这段脚本
第三根支柱,怎么写。命令式流派的答案是"写代码",而 Claude Code 把这一步又往上抬了一层:你用大白话说要干什么,Claude 替你把这段 JavaScript 写出来,运行时再去拦截、执行。
你不用自己写这段脚本,但它长什么样值得看一眼,好让"把计划放进代码"这句话有个具体的样子。每段工作流脚本都以一个 meta 开头,声明名字和阶段:
export const meta = {
name: 'audit-auth',
description: '审计接口鉴权,只报核验过的漏洞',
phases: [{ title: 'Review' }, { title: 'Verify' }],
}
另外它是 JavaScript,不是 TypeScript:脚本在一个受限的运行时里跑,加上前面说的不许用 Date.now() 那类限制,全是为了让它能稳定重放、断点续跑。这些约束看着碍事,其实是它能当一台可靠 workflow 引擎换来的代价。
AI 特有的那道坎:节点"成功",但输出是垃圾
到这儿,四根支柱里的三根都照实了。最后一根,是 AI 把这台老机器逼出来的真正新东西。
经典 workflow 引擎里,一个节点的"失败"定义很干脆:抛了异常。HTTP 超时、SQL 报错、进程崩了,状态置 failed,该重试重试,该告警告警。
可一个 LLM 节点能干一件经典引擎想都没想过的坏事:它成功返回了,状态是 success,但内容是错的、是瞎编的。它不抛异常,它笑眯眯地把垃圾递给你。靠 try/catch 这种"抓异常"的老办法,根本拦不住这种失败。
Claude Code 对这道坎有两手,都值得学:
第一手,把"自由发挥"夹成数据。 前面那个 schema 参数,作用不只是方便解析。它在工具调用那一层做校验,模型吐的结构对不上就被打回去重来。等于给那个本来天马行空的节点套了个笼子,逼它产出形状对的东西,砍掉一大片跑偏的可能。
第二手,专门派一批分身去"推翻"。 这就接上了对抗式核验:找问题的分身报了一条发现,工作流不直接信,而是另派几个分身专门去反驳它,过半数推不翻才算数。写出来大概这样:
const checked = await pipeline(
routes.endpoints,
ep => agent(`检查 ${ep.path} 漏没漏鉴权`, { phase: 'Review', schema: FINDINGS }),
found => parallel(found.suspects.map(s => () =>
agent(`试着推翻:${s.desc}。拿不准就当它不成立`, { phase: 'Verify', schema: VERDICT })
.then(v => ({ ...s, confirmed: !v.refuted })))),
)
// 只留下推翻不掉的
return checked.flat().filter(Boolean).filter(s => s.confirmed)
注意这里 parallel 用得正当:几个"推翻"分身投的票,必须凑齐了才能数过半没过半,这正是该上栅栏的场合。
把这一手翻译回状态机的语言:经典引擎里节点只有"成功/失败"两态,agent 工作流里多出来一个隐形的态:“成功但存疑”。于是它造了一个专门核验的节点去把这个态判掉。这是 LLM 这种不可靠节点逼出来的新设计,经典引擎里没有。
顺带:那几个限制和数字,各治一个毛病
LLM 节点的坏毛病不止"会跑偏"。把 Claude Code 工作流里那些限制和设计列出来,会发现它们一一对着一个毛病:
| LLM 节点的毛病 | 工作流的设计 |
|---|---|
| 慢(一次调用动辄几十秒) | 异步并行调度,pipeline / parallel 同时铺开几十个分身 |
| 贵(按 token 烧钱) | 断点续跑只重跑改动过的,已干完的直接返缓存、不二次付费 |
| 会跑偏(成功却是垃圾) | schema 夹结构 + 对抗式核验投票 |
| 容易失控(脚本一 bug 就疯狂派分身) | 并发上限、单次工作流最多 1000 个分身的硬闸 |
| 不可逆操作有风险(删库、推生产) | 高风险动作留人工卡点,别全自动 |
最后这条尤其要记住:越是自主、没人盯的流程,越要认清一件事:错误不会在中途被你抓住,只会一路往后累积。所以删数据、推生产这种不可逆的动作,该留一个人工确认的节点。这跟信不信任 AI 没关系,是任何自主工作流的通则。
三、回到大图:一副透镜,三个问题
绕了一圈,把 Claude Code 放回那张四象限表:它落在"命令式 + 能带环 + 自带核验"这一格。落在这儿不是凑巧,它就是为 LLM 这种又慢、又贵、又会跑偏、还得反复循环的节点量身长出来的:命令式让 Claude 能直接写编排逻辑,带环支持反思重试,核验节点治"成功但跑偏"。
而这一切,都没跳出第一节那台机器:把控制流变成数据,于是存得下、看得见、调得动、改得了。
所以这篇从头到尾想给你的,其实是一副透镜。下次你再撞见任何一个 workflow 系统、任何一个 agent 框架,别急着学它的 API,先问它三个问题:
- 它的节点是什么? 一个 SQL 任务?一次 HTTP 调用?还是一个 agent 步骤?先认出最小那块活。
- 状态存在哪、能不能断点续跑? 这一条直接决定它是个玩具还是台真引擎。崩了得从头来的,不算。
- 谁来调度,声明式还是命令式? 你是画一张图交给它跑,还是写一段代码让运行时拦截?
这三个问题答下来,再花哨的编排系统也会在你眼前变透明。工具年年换,这台机器的内核不换:把控制流变成数据。拿到这副透镜,你省下的是每出一个新框架就从头懵一遍的时间。
参考
- Claude Code · Orchestrate subagents at scale with dynamic workflows:https://code.claude.com/docs/en/workflows
- Introducing dynamic workflows in Claude Code(发布公告):https://claude.com/blog/introducing-dynamic-workflows-in-claude-code
- Apache Airflow 官方文档(任务状态与 DAG 调度):https://airflow.apache.org/docs/
- Temporal 官方文档(durable execution / 重放):https://docs.temporal.io/
- LangGraph 官方文档(带环的 agent 状态图):https://langchain-ai.github.io/langgraph/
