控制流
条件路由、合并、重试和自愈合 gate。
taskflow 的 DAG 是静态拓扑 —— 边在定义时就固定了。但真实的工作流需要动态行为:当严重程度低时跳过某个阶段,当第一次尝试失败时切换到回退,对一个不稳定的模型重试,或对未通过质量门的输出进行返工。本页的字段在拓扑之上添加这一动态层。
此处的每个字段都由 /tf verify 在任何 agent 运行前以零成本检查。非法运算符、超范围的重试值和矛盾的合并都会被预先捕获 —— 绝不在花费 token 之后。
场景:带条件深度的 PR 审查
想象你在构建一个代码审查 flow。并非每个 PR 都需要相同的处理。一个小型文档调整只需快速摘要;而一个高严重程度的变更应触发深度审计。下面这个 flow 根据分诊决策分支:
{
"name": "conditional-review",
"phases": [
{
"id": "triage",
"type": "agent",
"output": "json",
"task": "Classify the PR. Output JSON {severity, reason}."
},
{
"id": "deep-audit",
"when": "{steps.triage.json.severity} == high",
"dependsOn": ["triage"],
"agent": "risk-reviewer",
"task": "Deep-dive the change. Reason: {steps.triage.json.reason}"
},
{
"id": "quick-summary",
"when": "{steps.triage.json.severity} != high",
"dependsOn": ["triage"],
"task": "Write a one-paragraph summary."
}
]
}每个分支上的 when 字段就是守卫。当分诊解析为 severity: "high" 时,deep-audit 分支运行而 quick-summary 被跳过 —— 不花费 token、不生成子代理。低严重程度的 PR 则相反。
when 是本指南的第一个工具。本页其余部分 walkthrough 六个字段,它们合起来让你能表达几乎所有运行时决策:
`when`
条件守卫 —— 除非表达式为真,否则跳过阶段。
`join`
依赖合并 —— all 与 any,以及 if/else 路由。
`optional`
容忍失败而不中止运行。
`retry`
显式重试策略加瞬态自动重试。
`idempotent`
针对不可逆阶段的副作用分类。
`onBlock`
自愈合 gate —— 在 BLOCK 时重跑上游。
when:有条件地跳过阶段
when 表达式是一种微型、安全的布尔语言,在阶段运行之前求值。若为真,阶段运行;若为假,阶段被跳过:无子代理调用、无 token,运行记录显示 status: "skipped"。
最简单的守卫将一个 JSON 字段与字面量比较:
{
"id": "deep",
"when": "{steps.triage.json.severity} == high",
"dependsOn": ["triage"]
}可以用 &&、|| 和 ! 组合守卫:
{
"id": "deep",
"when": "({steps.triage.json.severity} == high || {steps.triage.json.severity} == critical) && !{steps.triage.json.suppressed}",
"dependsOn": ["triage"]
}裸占位符也是守卫
如果上游阶段产出的是一个布尔值,或一个你信任其真值性的 JSON 字段,你可以完全省略运算符:
{
"id": "b",
"when": "{steps.triage.json.hasFindings}",
"task": "..."
}因为 "0"、"false"、"no"、"off" 和 "null"(大小写不敏感)都为假,一个产出 {"hasFindings": "false"} 的模型仍会正确求值。这是针对 LLM 输出漂移的有意为之的稳健性。不过显式的 == high 比较更清晰 —— 而且能经受未来枚举值的变化。
常见模式
| 模式 | 示例 | 何时使用 |
|---|---|---|
| JSON 枚举上的相等 | {steps.t.json.severity} == high | 基于分类器离散输出路由。 |
| 裸真值性 | {steps.t.json.hasFindings} | 模型很少漂移的布尔标志。 |
| 带覆盖的复合路由 | (A == high || A == critical) && !B | 多条路由汇聚,带一个抑制器。 |
| 数值阈值 | {steps.t.json.score} >= 0.8 | 质量门通过/失败阈值。 |
Fail-open:破损的守卫永不丢弃阶段
如果 when 表达式解析失败(未闭合字符串、缺失 )、尾随 token),阶段仍会运行。一个破损的守卫绝不能意外地压制工作。
错误会出现在阶段的 warnings 和运行记录中,便于你发现和修复。空或全空白的 when(视为 true)以及未解析的占位符也是如此 —— 一个未解析的 {steps.missing.json} 解析为 undefined(为假),但表达式仍正常解析和求值。
完整的运算符表、优先级、比较语义和解析错误见本页末尾的条件参考。
join:阶段如何等待其依赖
默认情况下,一个阶段等待每个声明的依赖完成(join: "all")。这是正确的默认值 —— 大多数阶段确实需要其全部输入。
但回到上面的条件审查 flow。deep-audit / quick-summary 恰好运行一个;另一个被跳过。如果下游阶段依赖两者,它会永远挂起等待被跳过的分支。join: "any" 就是解法:
{
"id": "merge",
"type": "reduce",
"from": ["deep-audit", "quick-summary"],
"join": "any",
"dependsOn": ["deep-audit", "quick-summary"],
"task": "Combine the surviving branch's output into one report."
}有了 join: "any",merge 阶段在任一依赖完成后即继续,而不是等待全部。
If / else 路由
将 when 守卫与 merge 阶段上的 join: "any" 组合,是表达 if/else 分支的惯用法:
[
{ "id": "triage", "type": "agent", "output": "json", "task": "Classify severity." },
{ "id": "deep", "when": "{steps.triage.json.severity} == high", "dependsOn": ["triage"], "task": "Deep analysis." },
{ "id": "quick", "when": "{steps.triage.json.severity} != high", "dependsOn": ["triage"], "task": "Quick summary." },
{ "id": "merge", "type": "reduce", "from": ["deep", "quick"], "join": "any", "dependsOn": ["deep", "quick"] }
]deep / quick 恰好运行一个;merge 在存活分支完成后即继续。
何谓"满足"
一个依赖若以 done 结束,或以 failed 结束且被标记为 optional(见 optional),则视为满足。被跳过或非 optional 失败的依赖不满足。
| 模式 | 行为 |
|---|---|
"all"(默认) | 每个依赖都必须满足。否则阶段被跳过,原因为 "Upstream dependency not satisfied"。 |
"any" | 至少一个依赖须满足。否则阶段被跳过,原因为 "All dependencies failed or were skipped"。 |
陷阱:join: "any" 与下游引用。 当一个分支被跳过时,{steps.skipped-branch.output} 保持为字面占位符 —— 被跳过的阶段排除在插值上下文之外,无输出的失败阶段解析为 "[previous phase failed]"。基于 from: [...] 的 reduce 只看到已完成分支,但手写的引用两个分支的 task 会插值到失败/被跳过的那个。用 when 守卫,或用 reduce 的 from 列表。
optional:容忍失败
有些工作是尽力而为的。一个 lint 脚本可能失败;一个第三方丰富化调用可能 503。你并不总是想让那中止整个运行。
{
"id": "lint",
"type": "script",
"run": "npm run lint",
"optional": true
}失败的非 optional 阶段将运行状态设为 failed 并级联:dependsOn 含其的下游阶段被跳过(除非它们 join: "any" 或该依赖本身 optional)。失败的 optional 阶段被容忍 —— 运行继续,且该阶段对 join 而言仍算"满足"的依赖("all" 和 "any" 均如此)。
与回退阶段配对
optional + when 一起构建带回退的尽力阶段:
[
{
"id": "enrich",
"type": "agent",
"optional": true,
"task": "Call the external API and enrich the record. May fail."
},
{
"id": "fallback",
"when": "{steps.enrich.output} == \"[previous phase failed]\"",
"dependsOn": ["enrich"],
"task": "Produce a minimal record without enrichment."
}
]当 enrich 失败时,其 {steps.enrich.output} 解析为哨兵 "[previous phase failed]",使下游引用不致中断 —— 而 fallback 阶段的 when 守卫恰好捕获那种情况。
optional 对 script 阶段(lint、format、指标)和尽力 agent 调用最有用。对子流程,标记内部 flow 阶段为 optional,而非依赖子流程自身状态。
retry:失败时重试
LLM 提供方会打嗝。速率限制、5xx 和超时是家常便饭。taskflow 分两层处理。
第一层:隐式瞬态自动重试
即使没有显式 retry 块,瞬态提供方错误也会被自动重试,这样一次短暂的 429 会在运行内被吸收,而不是冒泡上去、促使调用方 agent 重新调用整个工具(那会堆叠重复的进度块)。
| 属性 | 值 |
|---|---|
| 最大瞬态尝试 | 3(首次之后) |
| 默认基础延迟 | 2000 ms |
| 默认 factor | 2(指数) |
| 上限 | 60000 ms |
若失败信息匹配 rate limit、too many requests、overloaded、429、502、503、504、service unavailable、temporarily unavailable、timeout、timed out、ECONNRESET、ETIMEDOUT 或 socket hang up(大小写不敏感),则视为瞬态。确定性失败 —— 显式中止、空闲超时、阶段超时、契约违反 —— 不被重试。
第二层:显式逐阶段重试
当你想控制曲线,或也想对硬失败重试时,加一个显式 retry:
{
"id": "generate",
"retry": { "max": 5, "backoffMs": 1000, "factor": 2 },
"task": "..."
}| 字段 | 类型 | 默认值 | 约束 | 说明 |
|---|---|---|---|---|
max | number | —(必需) | 0–20 | 首次尝试之后的最大重试次数。0 = 不重试。 |
backoffMs | number | 0 | 0–60000 | 尝试间的基础延迟,毫秒。 |
factor | number | 1 | 1–10 | 每次尝试的退避乘数。1 = 固定;2 = 指数。 |
第 N 次尝试(0 起始)前的延迟:min(60000, round(backoffMs * factor ** attempt))。
两层如何组合
两种策略组合,而非替换:
- 显式策略覆盖所有失败(瞬态和硬)至
retry.max。 - 瞬态回退仅覆盖瞬态错误,至多 3 次尝试,且仅当
idempotent !== false。 - 总尝试预算为
max(explicitMax, 1 + 3)。 - 退避曲线: 若阶段定义了显式
retry.backoffMs,该曲线用于显式和瞬态重试(用backoffMs: 0可让测试快速)。否则用瞬态默认(2000 ms,factor 2)。
输出契约(expect)违反符合显式重试策略,不符合瞬态回退 —— 契约违反是确定性失败,不是提供方打嗝。
idempotent:分类副作用
有些阶段绝不能重复。一次部署、一个 webhook POST、一次 DB 迁移 —— 在瞬态 429 上重跑这些正是作者想要防止的危害。设 idempotent: false 声明"此阶段有不可逆副作用":
{
"id": "deploy",
"type": "agent",
"idempotent": false,
"task": "Run the production deploy. This must not be repeated on transient errors.",
"retry": { "max": 0 }
}idempotent: false 做什么
| 效果 | 细节 |
|---|---|
| 抑制隐式瞬态自动重试 | 3 次瞬态回退被禁用。部署上的 429 不会被静默重试。 |
| 将结果排除在所有缓存之外 | 有效缓存作用域变为 "off" —— 不从运行内续跑提供,也不从跨运行存储读取或写入。 |
记录 sideEffect: true | 盖戳到阶段状态上以供审计。 |
| 续跑双触发警告 | 续跑时,非幂等阶段重新执行。若先前尝试已完成,记录一条警告。 |
它不做的事
- 它不抑制显式
retry: {}。显式策略是作者声明重复可接受。要禁止所有重试,还需设retry: { max: 0 }。 - 它不改变
optional语义。有副作用的阶段仍可optional: true。
验证交互
| 场景 | 验证警告 |
|---|---|
approval 或 flow 上的 idempotent: false | 空操作 —— 这些阶段不运行子代理(无瞬态重试),且已被排除在跨运行缓存之外。该标志无效。 |
idempotent: false + cache.scope: "cross-run" | idempotent:false overrides cache.scope 'cross-run' — a side-effecting phase is never cached. |
incremental: true flow 下的 idempotent: false | idempotent:false under an incremental flow — this phase is excluded from caching and will re-run every invocation. |
何时设置
对任何重执行不安全的阶段设 idempotent: false —— webhook POST、邮件/通知发送、部署、迁移、DB 写入,任何每次调用都收费或变更外部状态的调用。对只读分析、生成和变换阶段保持 true(默认)—— 它们受益于瞬态重试和缓存。
将 idempotent: false 与 retry: { max: 0 } 配对以获得硬性不重试保证。若失败的副作用不应中止整个运行,考虑 optional: true。
onBlock:自愈合 gate
onBlock 是仅 gate 适用的字段。对其他阶段类型无效。
一个 gate 阶段可以发出 VERDICT: BLOCK 来中止 flow。但有时你不想中止 —— 你想让 gate 强制返工并再试一次。这正是 onBlock: "retry" 做的:
[
{
"id": "implement",
"type": "agent",
"task": "Implement the feature."
},
{
"id": "quality",
"type": "gate",
"onBlock": "retry",
"retry": { "max": 3 },
"dependsOn": ["implement"],
"task": "Does this satisfy all criteria? Output VERDICT: PASS or VERDICT: BLOCK with reasons."
}
]每轮 BLOCK:
gate 重跑其上游 dependsOn。 gate 的 dependsOn 列表中的每个阶段(这里是 implement)再次执行。重跑的上游阶段不会缓存命中 —— gate 刻意传入自身的 prior 状态,使依赖实际重跑。
gate 重新评估。 它针对刚产出的上游输出运行其 task。
重复或结束。 循环继续直到 PASS,或直到 retry.max 轮耗尽(省略 retry 时默认 1)。
| 取值 | 行为 |
|---|---|
"halt"(默认) | 停止 flow。gate 的 failed 状态级联到下游。 |
"retry" | 重跑 gate 的上游 dependsOn 阶段,然后重新评估 gate。重复直到 PASS 或达到上限。 |
onBlock: "retry" 重跑的是上游,不是 gate 自身。 若你的 gate 没有 dependsOn(或其依赖是确定性 script 阶段),循环会针对相同输入反复评估并 BLOCK 直到上限。将 gate 指向你希望被返工的阶段的输出。
防止失控循环的守卫
| 守卫 | 行为 |
|---|---|
| 尝试上限 | retry.max(默认 1)。达到上限后,gate 以其最后 BLOCK 裁决失败。 |
| 嵌套深度上限 | MAX_RETRY_DEPTH = 3。若 gate 的上游依赖本身是带 onBlock: "retry" 的 gate,则在深度 3 之后停止重跑上游 —— 防止指数级重执行。 |
| 中止 | 若运行的 signal 被中止,循环立即中断。 |
| 预算 | 若运行超预算,循环立即中断。 |
评分 gate(score)工作方式相同 —— 循环重跑上游依赖并针对新输出重新评分,共享深度上限和预算/中止守卫。
条件参考
when 表达式语言是一种微型、安全的布尔语法 —— 无 eval / Function,一个手写的递归下降解析器。本节是详尽参考;日常使用上面常见模式的表就够了。
运算符与优先级
从最松到最紧绑定:
| 等级 | 运算符 | 说明 |
|---|---|---|
| 1(最松) | || | 左结合,短路 |
| 2 | && | 左结合,短路 |
| 3 | !(一元前缀) | 应用于后面的比较 —— 见下方说明 |
| 4 | == != > < >= <= | 非结合:a == b == c 是解析错误 |
| 5(最紧) | ( ) | 分组 |
! 绑定比比较更松。 !{steps.x.json.s} == high 解析为 !({steps.x.json.s} == high),不是 (!{steps.x.json.s}) == high。这镜像 Python 的 not,与 C/JavaScript 不同。若你意指后者,加显式括号。
比较是非结合的:a == b == c 是解析错误(trailing tokens)。用显式 && 链式:(a == b) && (b == c)。
比较语义:数值 vs 字符串
==、!=、<、>、<=、>= 按操作数类型分派:
- 若两个操作数都是数值(JSON
number,或可解析为有限数的字符串),比较为数值:1.5 == "1.5"→true;"10" > "9"→true。 - 否则,两个操作数被强制为字符串(
null/undefined→"")并按字典序比较:"high" == "high"→true;"9" > "10"→false。
这意味着将一个解析为数字的占位符与 high 这样的裸词比较时,始终是字符串比较 —— 这正是你对枚举类字段的期望。
操作数
| 操作数 | 语法 | 解析为 |
|---|---|---|
| 占位符 | {steps.x.json.severity} | 占位符的原始值(对象、字符串、数字等)—— 不字符串化 |
| 引号字符串 | "high" 或 'high' | 字面字符串。\X → 字面 X(因此 \" 转义引号;\n → n,不是换行) |
| 数字 | 42、3.14、-1、1e3 | 一个 JS number |
| 布尔 | true / false | true / false |
| Null | null | null |
| 裸词 | high(未加引号) | 视为字符串 "high"(总是加引号更稳妥) |
在条件内部,占位符解析为其原始值,而非 task 模板中使用的字符串化形式。因此当 count 是数字时,{steps.x.json.count} > 5 做的是数值比较。在 task 字符串中,同一占位符会被美化打印为 JSON。
真值性
一个裸操作数(无比较运算符)按真值性求值:
| 值 | 为真? |
|---|---|
true | ✅ |
false | ❌ |
0、NaN | ❌ |
| 其他任何数字 | ✅ |
""、"false"、"0"、"no"、"off"、"null"(大小写不敏感、trim 后) | ❌ |
| 其他任何非空字符串 | ✅ |
空数组 [] | ❌ |
| 非空数组 | ✅ |
空对象 {} | ❌ |
| 非空对象 | ✅ |
null、undefined | ❌ |
字符串转义
单引号和双引号都接受。反斜杠按字面转义下一个字符(\" → ",\\ → \)。所有其他 \X 序列变为字面 X —— \n 变成两个字符 n,不是换行。这不同于 JSON/JS 转义,但对条件字符串是正确的 —— 它们只需引号转义。
{
"id": "d",
"when": "{steps.x.json.label} == \"high-priority\"",
"task": "..."
}裸词 true / false / null 是关键字,不是字符串。要与字面字符串 "true" 比较,请加引号:{steps.x.json.flag} == "true"。
常见解析错误
| 错误 | 原因 | 修复 |
|---|---|---|
trailing tokens | 非结合比较被链式,如 a == b == c。 | 显式分组:(a == b) && (b == c)。 |
unterminated string | 缺少闭合引号。 | 闭合引号或转义它。 |
unterminated placeholder | { 没有匹配的 }。 | 加上闭合 }。 |
unexpected end | 期望操作数但表达式结束。 | 补全表达式。 |
missing ) | 分组不平衡。 | 让每个 ( 都有匹配的 )。 |
unexpected char | 分词器不认识的字符。 | 检查是否有杂散标点。 |
| 阶段在你期望它被跳过时仍运行 | Fail-open:表达式有解析错误。 | 检查阶段的 warnings 找错误信息。 |
下一步
Last updated on