taskflow

控制流

条件路由、合并、重试和自愈合 gate。

taskflow 的 DAG 是静态拓扑 —— 边在定义时就固定了。但真实的工作流需要动态行为:当严重程度低时跳过某个阶段,当第一次尝试失败时切换到回退,对一个不稳定的模型重试,或对未通过质量门的输出进行返工。本页的字段在拓扑之上添加这一动态层。

此处的每个字段都由 /tf verify 在任何 agent 运行前以零成本检查。非法运算符、超范围的重试值和矛盾的合并都会被预先捕获 —— 绝不在花费 token 之后。

场景:带条件深度的 PR 审查

想象你在构建一个代码审查 flow。并非每个 PR 都需要相同的处理。一个小型文档调整只需快速摘要;而一个高严重程度的变更应触发深度审计。下面这个 flow 根据分诊决策分支:

conditional-review.json
{
  "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:有条件地跳过阶段

when 表达式是一种微型、安全的布尔语言,在阶段运行之前求值。若为真,阶段运行;若为假,阶段被跳过:无子代理调用、无 token,运行记录显示 status: "skipped"

最简单的守卫将一个 JSON 字段与字面量比较:

when-basic.json
{
  "id": "deep",
  "when": "{steps.triage.json.severity} == high",
  "dependsOn": ["triage"]
}

可以用 &&||! 组合守卫:

when-combined.json
{
  "id": "deep",
  "when": "({steps.triage.json.severity} == high || {steps.triage.json.severity} == critical) && !{steps.triage.json.suppressed}",
  "dependsOn": ["triage"]
}

裸占位符也是守卫

如果上游阶段产出的是一个布尔值,或一个你信任其真值性的 JSON 字段,你可以完全省略运算符:

when-bare.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" 就是解法:

join-merge.json
{
  "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 分支的惯用法:

join-if-else.json
[
  { "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 守卫,或用 reducefrom 列表。


optional:容忍失败

有些工作是尽力而为的。一个 lint 脚本可能失败;一个第三方丰富化调用可能 503。你并不总是想让那中止整个运行。

optional-basic.json
{
  "id": "lint",
  "type": "script",
  "run": "npm run lint",
  "optional": true
}

失败的非 optional 阶段将运行状态设为 failed 并级联:dependsOn 含其的下游阶段被跳过(除非它们 join: "any" 或该依赖本身 optional)。失败的 optional 阶段被容忍 —— 运行继续,且该阶段对 join 而言仍算"满足"的依赖("all""any" 均如此)。

与回退阶段配对

optional + when 一起构建带回退的尽力阶段:

optional-fallback.json
[
  {
    "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 守卫恰好捕获那种情况。

optionalscript 阶段(lint、format、指标)和尽力 agent 调用最有用。对子流程,标记内部 flow 阶段为 optional,而非依赖子流程自身状态。


retry:失败时重试

LLM 提供方会打嗝。速率限制、5xx 和超时是家常便饭。taskflow 分两层处理。

第一层:隐式瞬态自动重试

即使没有显式 retry 块,瞬态提供方错误也会被自动重试,这样一次短暂的 429 会在运行内被吸收,而不是冒泡上去、促使调用方 agent 重新调用整个工具(那会堆叠重复的进度块)。

属性
最大瞬态尝试3(首次之后)
默认基础延迟2000 ms
默认 factor2(指数)
上限60000 ms

若失败信息匹配 rate limittoo many requestsoverloaded429502503504service unavailabletemporarily unavailabletimeouttimed outECONNRESETETIMEDOUTsocket hang up(大小写不敏感),则视为瞬态。确定性失败 —— 显式中止、空闲超时、阶段超时、契约违反 —— 被重试。

第二层:显式逐阶段重试

当你想控制曲线,或也想对失败重试时,加一个显式 retry

retry-explicit.json
{
  "id": "generate",
  "retry": { "max": 5, "backoffMs": 1000, "factor": 2 },
  "task": "..."
}
字段类型默认值约束说明
maxnumber—(必需)020首次尝试之后的最大重试次数。0 = 不重试。
backoffMsnumber0060000尝试间的基础延迟,毫秒。
factornumber1110每次尝试的退避乘数。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 声明"此阶段有不可逆副作用":

idempotent-deploy.json
{
  "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

验证交互

场景验证警告
approvalflow 上的 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: falseidempotent:false under an incremental flow — this phase is excluded from caching and will re-run every invocation.

何时设置

对任何重执行安全的阶段设 idempotent: false —— webhook POST、邮件/通知发送、部署、迁移、DB 写入,任何每次调用都收费或变更外部状态的调用。对只读分析、生成和变换阶段保持 true(默认)—— 它们受益于瞬态重试和缓存。

idempotent: falseretry: { max: 0 } 配对以获得硬性不重试保证。若失败的副作用不应中止整个运行,考虑 optional: true


onBlock:自愈合 gate

onBlock仅 gate 适用的字段。对其他阶段类型无效。

一个 gate 阶段可以发出 VERDICT: BLOCK 来中止 flow。但有时你不想中止 —— 你想让 gate 强制返工并再试一次。这正是 onBlock: "retry" 做的:

onBlock-rework.json
[
  {
    "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(因此 \" 转义引号;\nn,不是换行)
数字423.14-11e3一个 JS number
布尔true / falsetrue / false
Nullnullnull
裸词high(未加引号)视为字符串 "high"(总是加引号更稳妥)

在条件内部,占位符解析为其原始值,而非 task 模板中使用的字符串化形式。因此当 count 是数字时,{steps.x.json.count} > 5 做的是数值比较。在 task 字符串中,同一占位符会被美化打印为 JSON。

真值性

一个裸操作数(无比较运算符)按真值性求值:

为真?
true
false
0NaN
其他任何数字
"""false""0""no""off""null"(大小写不敏感、trim 后)
其他任何非空字符串
空数组 []
非空数组
空对象 {}
非空对象
nullundefined

字符串转义

单引号和双引号都接受。反斜杠按字面转义下一个字符(\""\\\)。所有其他 \X 序列变为字面 X —— \n 变成两个字符 n不是换行。这不同于 JSON/JS 转义,但对条件字符串是正确的 —— 它们只需引号转义。

when-escaping.json
{
  "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

这页内容对你有帮助吗?

帮助我们改进文档,或在社区中提问。

On this page