taskflow

预算

运行级成本与 token 上限。

你把一个 map 阶段指向你的仓库,让一个 scout agent 列出变更的文件。scout 返回 500 个。每一个都生成一个安全审查器。四个同时运行。等你注意到时,你已在一个本该花四毛钱的 flow 上花了十二美元。

budget 就是防止这件事的字段。它是对累计成本或 token 的运行级上限。当超出上限时,taskflow 中止运行,保留此前产生的所有部分输出,并返回一个诊断。把它设在任何可能无界 fan-out 的 flow 上 —— mapparallellooptournament —— 它就成了一个被错误发现的数组与失控支出之间唯一的防线。

预算执行不花费额外 token。它读取每个子代理已经写入的 usage 记录,并在每次重试、每个 fan-out 项、每次 DAG 层切换时检查。

在每个 fan-out flow 上设置预算

如果你的 flow 包含 mapparallellooptournament,加一个 budget。这就是全部规则。

budget-basic.json
{
  "name": "summarize-files",
  "budget": { "maxUSD": 1.5 },
  "concurrency": 4,
  "phases": [
    {
      "id": "discover",
      "type": "agent",
      "output": "json",
      "task": "List files under {args.dir}. Output a JSON array of { path }."
    },
    {
      "id": "summarize",
      "type": "map",
      "over": "{steps.discover.json}",
      "as": "file",
      "concurrency": 8,
      "task": "Read {file.path} and give a one-sentence summary."
    }
  ]
}

两个维度都可选。maxUSD 限制累计成本;maxTokens 限制累计输入 + 输出 token。设其一或两者。省略一个维度即关闭该上限。

budget-both-dimensions.json
{
  "name": "fan-out-audit",
  "budget": {
    "maxUSD": 2.0,
    "maxTokens": 2000000
  },
  "phases": [ /* ... */ ]
}

maxUSDmaxTokens 配对以纵深防御。一次模型切换或定价变化可能把花费从成本转移到 token(或反之)。同时设置两者可防止任一上限被击穿。

schema 拒绝未知键(additionalProperties: false)。空的 budget: {} 合法但是空操作 —— maxUSD / maxTokens 至少应设置一个。

触及上限时会发生什么

累计花费超出上限的那一刻,运行时接管。不再生成新阶段。进行中的 fan-out 停止生成项。运行以 blocked 结束 —— 不是 failed —— 并且超限前产生的每个部分输出都被保留。

发生什么
DAG 层剩余阶段被记录为 skipped,带 error: "Budget exceeded: ..."
进行中的 fan-outmap / parallel循环停止生成。未开始的项目成为 stopReason: "budget-skipped",零 usage。
loop 迭代下一次迭代不开始。reflexion loop 将此视为硬停止 —— 失败不作为 reflexion 输入反馈回去。
tournament 评委评委被跳过;第一个存活变体获胜,带一条警告。
重试重试循环中断 —— 不再尝试。
运行状态status 变为 "blocked"finalOutput"Budget exceeded — run halted." 加原因和最终阶段的部分输出。

被阻止的运行不是失败的运行。在超限前完成的每个阶段都保留其 outputjsonusagefinalOutput 由实际产生输出的最后一个阶段组合而成 —— 因此一个预算被阻止的运行仍可向你的对话返回有用的部分工作。

blocked 表示预算或 gate 中止了工作流。failed 表示一个非 optional 阶段报错退出。检查 state.status 和逐阶段的 error / budgetTruncated 字段以区分两者。

比较是严格的

检查用严格 >,不是 >=。恰好落在上限上的运行被阻止 —— 只有超过它的下一个美分或 token 才触发中止。maxUSD: 2.0 的预算允许成本恰好为 $2.000 的运行。

有界超支

在单个 DAG 层内,阶段并发运行至多到 concurrency(默认 8)。预算检查在每个阶段完成后触发,因此同一层中至多有 concurrency - 1 个额外阶段可能在超限被检测到之前就开始。

这种有界超支是设计使然 —— 一旦 budgetBlocked 被设置,它阻止级联到后续层,但无法撤销已经在途的花费。如果你需要零超支的硬上限,将 flow 的 concurrency 设为 1。阶段随后严格顺序运行,守卫在每个开始前重新评估。

各阶段类型的成本如何累计

预算核算读取 state.phases[*].usage,不同阶段类型对其填充方式不同:

阶段类型计入预算的内容
agent子代理在每次尝试上的 usage。失败重试在重试决策前被累加。
parallel每个分支 usage 之和。
map每个已开始项目 usage 之和。预算跳过的项贡献零。
loop每次迭代累计的 usage,在循环中折入(而非覆盖)—— 因此 reflexion loop 无法在迭代之间不知不觉地超过上限。
tournament每个变体的 usage 加上评委的(当评委运行时)。
flow子 flow 运行其自身预算(见下方)。父阶段记录子 flow 的累计 usage。
gategate 子代理的 usage
approval零 —— 不运行子代理。
script零 —— 无 LLM token。
reducereducer 子代理的 usage

缓存命中(cacheHit: "cross-run" | "run-only")报告缓存的 usage 但本次运行未花费新 token。cacheRead/cacheWrite/contextTokens 计入 token 上限 —— 缓存读取几乎免费,contextTokens 是时点指标,不可累加。

重试与预算

每次重试尝试在重试决策之前将其 usage 推入累加器。因此一个失败三次才成功的阶段花费了全部三次尝试的 token —— 且第三次尝试本身可能将运行推过上限。

失败的重试确实计入预算。失败尝试没有"退款" —— 提供方已经对其计费。

声明为 idempotent: false 的阶段禁用隐式瞬态重试(运行时安全网),因此一个不稳定的提供方错误不会通过重跑副作用而静默双重计费。但显式 retry: { max } 仍被遵守 —— 且每次显式重试仍计入预算。

idempotent-false-deploy.json
{
  "id": "deploy",
  "type": "agent",
  "idempotent": false,
  "task": "Run the production deploy. Report the rollout ID.",
  "retry": { "max": 0 }
}

子流程中的预算

flow 阶段运行子 taskflow。子流程的预算如何与父预算交互,取决于子流程是内联生成def)还是按名加载use)。

内联 def 子流程 —— 硬钳制

生成的内联定义不能抬高父上限。运行时每个维度取 min(child, parent)

subflow-def-clamped.json
{
  "id": "plan-and-execute",
  "type": "flow",
  "def": {
    "name": "inner-plan",
    "budget": { "maxUSD": 5.0 },
    "phases": [ /* ... */ ]
  }
}

如果父 flow 有 budget: { maxUSD: 2.0 },内层子流程的有效上限变为 $2.0,无论其声明的 $5.0。它只能比父更。如果钳制后没有有限维度剩下,子流程的 budget 被设为 undefined —— 但父的守卫仍能看到子流程的花费,因为父阶段记录了累计的子阶段 usage。

已保存 use 子流程 —— 软继承

use 加载的子流程执行其自身声明的预算。如果未声明,它继承父上限作为软性每流上限。

此继承是尽力而为:花费不跨 flow 边界。父已花费总额从子继承的上限中扣除,因此继承 $2.0 的子流程即使父已花费 $1.50,自身仍可花费至多 $2.0。父自身的守卫在子流程返回时仍会捕获合并总额。

递归被栈检查阻止,因此子流程不能无限嵌套以绕过自身预算。嵌套计数器在 flow 与内联 spawn(ctx_spawn)两条递归轴上统一。

在 fan out 前估算

对昂贵的逐项工作,先运行一个廉价的估算器阶段,让其输出决定是否继续。这把无界的 map 转为有界的。

map-with-per-item-estimation.json
{
  "name": "bounded-review",
  "budget": { "maxUSD": 3.0, "maxTokens": 3000000 },
  "concurrency": 4,
  "phases": [
    {
      "id": "discover",
      "type": "agent",
      "output": "json",
      "task": "List files under src/. Output JSON: { items: [{ path }] }."
    },
    {
      "id": "estimate",
      "type": "agent",
      "output": "json",
      "context": ["{steps.discover.json}"],
      "task": "Given these files, estimate tokens to review each. Output JSON: { items: [{ path, estTokens }], totalEstTokens }.",
      "expect": {
        "type": "object",
        "required": ["items", "totalEstTokens"],
        "properties": {
          "items": { "type": "array", "items": { "type": "object" } },
          "totalEstTokens": { "type": "number" }
        }
      }
    },
    {
      "id": "review",
      "type": "map",
      "over": "{steps.estimate.json.items}",
      "as": "item",
      "when": "{steps.estimate.json.totalEstTokens} < 2500000",
      "concurrency": 6,
      "task": "Review {item.path} for security risks. List concrete findings."
    },
    {
      "id": "fallback",
      "type": "agent",
      "when": "{steps.estimate.json.totalEstTokens} >= 2500000",
      "task": "The estimated token cost ({steps.estimate.json.totalEstTokens}) exceeds half the budget. Summarize src/ at a high level instead of per-file review."
    }
  ]
}

review 上的 when 守卫在估算超过预算一半时完全跳过 fan-out,为 fallback 阶段留出余量。即使有此守卫,budget 仍能防止低估。

给 loop 和 tournament 各自的余量

reflexion loop 可能在精炼同一输出上花费多次迭代。tournament 运行 N 个变体加一个评委。将预算设得足够覆盖预期的迭代或变体数,再让守卫捕获失控情况。

tournament-with-budget.json
{
  "name": "pick-best-title",
  "budget": { "maxUSD": 0.80 },
  "phases": [
    {
      "id": "compete",
      "type": "tournament",
      "variants": 4,
      "task": "Write a title for: {args.topic}.",
      "judge": "Pick the single best title. Output only the winner."
    }
  ]
}

检查预算用量

持久化到 .pi/taskflows/runs/<flow>/<runId>.json 的每个运行记录都暴露了审计花费所需的信息:

字段告诉你什么
def.budget生效的上限({ maxUSD?, maxTokens? })。
status超出预算时为 "blocked";否则为 "completed" / "failed" / "paused"
phases.<id>.usage该阶段的 { input, output, cacheRead, cacheWrite, cost, contextTokens, turns }input + output 是 token 上限所汇总的。
phases.<id>.budgetTruncatedmap / parallel fan-out 被上限(或 MAX_DYNAMIC_MAP_ITEMS 安全限制)截断时为 true
phases.<id>.attempts包括重试在内的子代理总尝试次数。> 1 时表示发生了重试 —— 且每次尝试的花费都在 usage 中。
phases.<id>.cacheHit由缓存提供时为 "cross-run""run-only"(本次运行无新 token)。
phases.<id>.status从未运行的阶段为 "skipped",带 error: "Budget exceeded: ..."

预算被阻止运行的终态 finalOutput 为:

blocked-final-output
Budget exceeded — run halted.
Reason: cost $2.143 exceeded cap $2.000

<final phase's partial output>

要获得运行级总额而无需自己汇总阶段,读取 executeTaskflow 返回的值 —— result.totalUsage 是跨所有阶段的运行后累计。运行记录不存储顶层 totalUsage;TUI 在读取时计算。

常见错误

错误原因修复
Budget exceeded — run halted. Reason: cost $X exceeded cap $Y累计 cost 超过 maxUSD提高 maxUSD、减少 fan-out(concurrency / over 大小),或加 when 守卫跳过昂贵阶段。
Budget exceeded — run halted. Reason: tokens N exceeded cap M累计 input + output 超过 maxTokens提高 maxTokens、切换更廉价的 model,或预读更少 context(contextLimit)。
N item(s) skipped: budget exceededmap / parallel fan-out 被截断。在运行记录中检查 phases.<id>.budgetTruncated。提高预算或降低 concurrency
judge skipped: run aborted or budget exceededtournament 在超限后到达评委步骤。第一个存活变体被选为赢家。提高预算或减少 variants
运行状态 blockedfinalOutput 看似完整最终阶段在超限前完成,但更后的(非最终)阶段把总额推过了上限。重排让昂贵阶段在最终阶段之前运行,或标记一个更廉价的阶段为 final: true

blocked 运行不是 failed 运行。blocked 表示预算或 gate 中止了工作流;failed 表示非可选阶段报错退出。检查 state.status 和逐阶段的 error / budgetTruncated 字段以区分两者。

Last updated on

这页内容对你有帮助吗?

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

On this page