幂等工作流写法
让工作流可以「中断后接着跑」「重复跑一次不重复消费」「调试时只跑某一段」。
外脑的设计哲学是:「跳过已做步骤」由工作流作者用条件分支 + 变量存在检查表达,不是平台硬编码 skip。
本文档讲解推荐写法,以及一些常见反模式。
为什么需要幂等工作流
工作流跑一次和跑十次,结果应该一样;中途崩了重跑,已经做完的步骤不应该重做。常见场景:
- 长跑任务中途崩了想接着跑:调研工作流跑了 30 分钟,第 5 个 Agent 失败,重跑时希望前 4 个 Agent 的结果直接复用。
- 同一工作流跑多次不重复消费 token:每天定时跑一次的工作流,已经处理过的数据不再重新调用 AI。
- 调试时只跑某一段:开发阶段反复迭代后半段逻辑,希望前半段的结果保持稳定。
- API 重试不引入重复副作用:上游系统重试调用同一个工作流,不应该多发一份邮件、多扣一次款。
外脑本身不会自动跳过 Block——这是有意设计:什么算「已做过」、用什么作为缓存键、缓存何时失效,全部由作者控制。
核心 Pattern:检查变量存在 → 跳过
工作流中需要持久化的中间结果,必须显式存进可跨运行的位置(Input 默认值、Storage、知识库、外部 API)。然后在使用前用 CodeBlock 检查 + If-Else 分支 实现「有就用,没有就算」。
为什么不用 If-Else 直接判断变量存在
If-Else 的 condition 比较操作符只有 Equals / NotEquals / Contains / GreaterThan / LessThan 等。它无法直接表达「变量是否定义」:
| 写法 | var = undefined | var = "" | var = "abc" |
|---|---|---|---|
@var Equals "" | false | true | false |
@var NotEquals "" | true | false | true |
undefined !== "" 在 JavaScript 中为 true,所以单纯用 NotEquals "" 无法区分「未定义」和「有值」。必须经过 CodeBlock 包装一层判断。
跨运行如何让结果「曾经存在」
单次运行从空状态启动,Generation Block 的输出不会自动跨运行保留。要做到「重跑时复用上次结果」,必须把上次结果显式持久化,重跑时再读回来:
| 持久化方式 | 适用场景 |
|---|---|
| Input 参数回传 | 上游系统传入「预计算结果」,工作流首次运行时 input 为空,后续重跑时把上次结果再传进来 |
| 知识库 / 文件存储 | 中间结果体积大、需要跨工作流共享 |
| 外部 API(CodeBlock + axios) | 接入 Redis / 业务后端的缓存层。沙箱不支持 fetch,必须在 nodeModules 中声明 axios |
| 数据库表 | 业务必须落库的场景 |
下面以「Input 参数回传」为例展示标准写法(其他持久化方式只是把读取的代码换成 axios / 知识库 API / DB 查询,结构完全一致)。
标准写法
- **cache_step_a** (`object`) — 上次 step_a 的结果(首次运行为空,重跑时由触发方回传) <!-- wn:input {"id":"cache_step_a","name":"cache_step_a","type":"object","required":false} /-->
- **input_query** (`text`, required) — 用户查询 <!-- wn:input {"id":"input_query","name":"input_query","type":"text","required":true} /-->
```wn:javascript {"id":"check_cache","name":"check_cache"}
// CodeBlock 中,未定义或空的变量引用会被序列化为字面量 undefined / null
const cached = @{cache_step_a}
return cached !== undefined
&& cached !== null
&& typeof cached === 'object'
&& typeof cached.value === 'string'
&& cached.value.length > 0
```
<!-- wn:if-else {"id":"branch_1","name":"branch_1"} -->
<!-- wn:if {"id":"if_hit","name":"cache_hit","condition":{"first":{"sourceType":"variable","sourceValue":["check_cache","output"]},"compare":"Equals","second":{"sourceType":"boolean","sourceValue":true}}} -->
缓存命中:@{cache_step_a.value}
<!-- /wn:if -->
<!-- wn:else {"id":"else_run","name":"cache_miss"} -->
请回答:@{input_query}
`step_a`<!-- wn:generation {"id":"step_a","name":"step_a","modelId":"gpt-4o"} /-->
<!-- /wn:else -->
<!-- /wn:if-else -->要点:
- CodeBlock 中引用未传入或为空的变量会被自动展开为字面量
undefined/null,不会抛错。这是判断变量存在性最可靠的方式。 - CodeBlock 主输出变量名是
output(不是__default__),If-Else 必须用["check_cache","output"]引用。 - If-Else 的 condition 必须是结构化对象(
{first, compare, second}),不支持字符串表达式。 - 触发方负责:首次运行后,把
step_a.__default__包成{ value: ..., fetchedAt: ... }存起来;下次调用时通过cache_step_a输入参数传回。
进阶 Pattern:检查内容新鲜度 → 跳过
有些结果有时效性(搜索结果、网页抓取、价格查询),24 小时前的缓存不该用。把时间戳一并存入持久化层,检查时一起判断:
```wn:javascript {"id":"check_fresh","name":"check_fresh"}
const cached = @{cache_input} // { value, fetchedAt } 或 undefined
const ttlMs = 24 * 60 * 60 * 1000 // 24 小时
if (cached === undefined || cached === null) return false
if (typeof cached !== 'object') return false
if (!cached.value || !cached.fetchedAt) return false
const age = Date.now() - new Date(cached.fetchedAt).getTime()
return age < ttlMs
```If-Else 用 check_fresh.output 判断是否仍然新鲜,命中则使用 @{cache_input.value},未命中则重新拉取并把 { value, fetchedAt: new Date().toISOString() } 写回持久化层。
Loop 累加器场景的「断点续跑」
批量处理 N 条数据时,把「已处理到第几条」作为输入传进来,用 Loop 配合内部条件跳过已完成的项:
- **start_index** (`number`) — 起始索引,重跑时传上次中断的位置 <!-- wn:input {"id":"start_index","name":"start_index","type":"number","required":false} /-->
- **items** (`list`) — 待处理项 <!-- wn:input {"id":"items","name":"items","type":"list","required":true} /-->
<!-- wn:loop {"id":"process","name":"process","loopType":"list","list":{"sourceType":"variable","sourceValue":["items","__default__"]}} -->
<!-- wn:if-else {"id":"skip_check","name":"skip_check"} -->
<!-- wn:if {"id":"skip_done","name":"skip_done","condition":{"first":{"sourceType":"variable","sourceValue":["process","index"]},"compare":"LessThan","second":{"sourceType":"variable","sourceValue":["start_index","__default__"]}}} -->
跳过第 @{process.index} 项(已处理过)
<!-- /wn:if -->
<!-- wn:else {"id":"do_work","name":"do_work"} -->
处理第 @{process.index} 项:@{process.item}
`result`<!-- wn:generation {"id":"result","name":"result","modelId":"gpt-4o-mini"} /-->
<!-- /wn:else -->
<!-- /wn:if-else -->
<!-- /wn:loop -->要点:
start_index默认为 0(首次运行)。中断后,外部观察方记录最后成功的 index,重跑时传入续跑位置。- Loop 的内部变量
index/count/item是inside作用域,仅循环内部可引用。 - 若处理过程中需要持久化每条结果以便外部观察方读取「最后成功索引」,请在 Loop 内部把每条结果落到知识库/存储/数据库。
反模式警告
❌ 不要在 Block 内部「判断后跳过自己」
<!-- 反模式:试图在 prompt 里告诉 AI「如果已经跑过就不要回答」 -->
请回答:@{input_query}
如果上次已经回答过(@{agent_a.__default__} 不为空),请输出 "SKIP"。
`agent_a`<!-- wn:generation {"id":"agent_a","name":"agent_a","modelId":"gpt-4o"} /-->为什么不行:Generation Block 仍然会调用 AI、消耗 token。即使 AI 听话输出 "SKIP",下游变量是 "SKIP" 字符串而不是上次的真实结果。这违背了「跳过」的初衷。
✅ 正确做法:在 Block 外用 If-Else 控制是否执行该 Block。
❌ 不要试图修改 Block 行为来跳过自己
外脑没有提供「设置某个 Block 的 skipIf 属性」的能力——这是有意为之。把跳过逻辑写在 Block 之外,工作流的执行路径才显式可读。不要寻找「让 Generation 自己决定是否跳过」的 hack。
❌ 不要依赖运行结束后的变量在下次运行自动存在
每次工作流运行从空状态启动。agent_a.__default__ 在新一次运行的 Block 还没执行前永远是 undefined,与上次运行无关。「曾经跑过」必须显式持久化——通过 Input、知识库、存储、数据库或外部 API。
❌ 不要用 @var NotEquals "" 当作「变量存在检查」
undefined !== "" 为 true,导致首次运行(变量未定义)时也命中「已存在」分支,逻辑反了。判断变量存在必须经过 CodeBlock。
✅ 推荐组合
| 目的 | 推荐组合 |
|---|---|
| 「值存在且非空」判断 | CodeBlock(返回 boolean)+ If-Else(Equals true) |
| 「值新鲜」判断 | CodeBlock(含 TTL 计算)+ If-Else |
| 「批量任务断点续跑」 | Input 接收 start_index + Loop + If-Else(index LessThan start_index) |
| 「多步 Agent 缓存」 | 每步前 CodeBlock 检查持久化层 + If-Else 分流 |
端到端示例
下面两个示例展示了完整的工作流骨架(关键 Block + 分支结构 + 触发方接口)。复制到外脑后,根据自己的持久化层和模型选择微调即可跑通:
- 示例 1:记忆驱动的 Agent Pipeline — 多个 Agent 串行调用,每个 Agent 跑完后把结果存入持久化层,重跑时已跑过的 Agent 自动跳过
- 示例 2:批量数据处理断点续跑 — 处理 N 条数据,记录处理进度,中断后从断点继续
与未来 Resume 能力的关系
外脑长跑工作流路线图中,平台层会逐步提供 resume 原语(断点保存 + 自动续跑)。条件分支 + 持久化检查仍然是首选写法,因为它:
- 更通用:不依赖平台是否实现某种 resume 机制。
- 更可控:作者完全掌握缓存键、TTL、命中条件,不会被平台默认行为「擅自跳过」。
- 更可调试:跳过逻辑显式写在文档里,团队成员看一眼就懂。
未来 resume 能力上线后,复杂场景可以叠加使用——但优先用本文档的写法表达业务意图。
相关阅读
- If-Else Block — 条件分支节点的完整属性、condition 结构和操作符
- Code Block — JavaScript 沙箱、变量注入、
output主输出变量 - Loop Block — 三种循环模式、
item/index/count内部变量 - 变量系统 — 变量作用域、引用语法、可见性