增量重算
FlowIR 编译、观测读集、过期检测与最小重算。
taskflow 的增量重算是一个成本非对称的响应式系统:廉价的效果(计算哪些内容会失效)几乎免费运行;昂贵的效果(真正重新运行一个 LLM 阶段)则被显式确认所把关。本页是其工作原理的精确参考。
该系统分为四层:FlowIR 编译(对 flow 定义做内容寻址)、依赖追踪(声明读集 + 观测读集)、过期检测(传递性前沿计算)、最小重算(强制重跑种子,其余全部复用)。每一层在可能时都是纯函数,绝不向运行过程抛出异常,并在其前置条件不满足时安全降级。
整个重算工具集 —— /tf ir、/tf provenance、/tf why-stale、/tf recompute —— 都不消耗任何 token。只有 recompute --apply 会消耗 token,而且即便如此,也只花在输入确实发生变化的那些阶段上。
FlowIR 编译
每次运行都以将 flow 定义编译为内容寻址的中间表示(FlowIR)作为开端。这次编译是跨运行记忆化的基础:两个结构相同的 flow 会产生相同的哈希,因此一次运行中的某个阶段可以在另一次运行中被复用。
编译接缝
编译通过 compileTaskflowToIR(def) 路由,它返回一个 TaskflowIR:
interface TaskflowIR {
ir?: FlowIR; // The compiled IR (nodes with inject/emits)
meta: TaskflowIRMeta; // Compile-time metadata (declared deps, sidecar)
hash?: string; // Content fingerprint (32-hex SHA-256)
warnings: CompileWarning[]; // Non-fatal advisories
errors: CompileError[]; // Hard compile errors (none in the stub)
usedFallbackHash: boolean; // True when the hash is flowDefHash (not IR-canonical)
}编译是纯函数 + 异步的(使用 Web Crypto 做哈希),且永不抛出异常 —— 哈希失败时 hash 保持未设置,缓存降级为仅基于 flowName 的旧式键(该次运行的跨运行复用被禁用)。
内容寻址
hash 当前由 flowDefHash(def) 生成,它将脱糖后的定义序列化为规范 JSON(递归按键排序、无空白、丢弃 undefined),并用 SHA-256(前 16 字节,小写十六进制)对其哈希。这是 vendored 的 overstory 哈希契约 —— 与 overstory 的 hashIR 算法逐字节一致。
// Canonical JSON: keys sorted by UTF-16 code units, arrays preserve order
function canonicalJson(value: unknown): string;
// SHA-256 of canonical serialization, first 16 bytes hex
async function hashCanonical(canonical: string): Promise<string>;
// Content fingerprint of the desugared Taskflow definition
async function flowDefHash(def: Taskflow): Promise<string>;在当前的桩实现中,usedFallbackHash 始终为 true:该哈希是定义指纹,而非 IR 规范哈希。只有当真正的 overstory 编译器被 vendored 之后,它才会翻转为 false。调用方绝不能把桩哈希误当成规范哈希。
阶段级子指纹
phaseFingerprint(def, phaseId) 只对阶段本身 + 其传递依赖闭包生成一个结构性子指纹。把它折进缓存键(而不是整 flow 哈希)意味着:编辑阶段 B 只会让 B 及其传递依赖者失效 —— 相互独立的兄弟阶段 A 仍然命中缓存。
该指纹在哈希前会剥离策略字段:cache、retry、concurrency 和 final 不影响阶段的 subagent 输出,只影响执行机制或结果选择。把它们算进去会在无实质改动的配置变更上造成虚假的缓存失效。
可靠性门控
阶段级失效只有在某个阶段的真实依赖被静态 dependsOn ∪ from 闭包完整捕获时才是可靠的。有三种情况会破坏这一保证,因此 phaseFingerprint 对它们返回 undefined,调用方退回到整 flow 的 flowDefHash(安全,= pre-M6 行为):
- Shared Context Tree(
def.contextSharing === true或闭包中任一成员shareContext === true):共享阶段可以读取其声明依赖之外的兄弟黑板写入,因此静态闭包欠近似真实读集。 - 闭包中的
flow阶段(type === "flow"):flow阶段的子结构在运行时才解析(内联def)或从已保存的 flow(use)读取,这里无法静态可见。 join: "any"阶段(phase.join === "any"):校验豁免它“{steps.X}必须出现在dependsOn中”的检查,因此它可能读取其静态闭包之外的阶段。
编译期声明依赖
编译还会合成一个声明依赖平面(M2):对每个阶段,collectRefs(phase) 扫描 task、when、until、eval、score.target、score.judge.task、branches[].task、with.* 和 context[] 字段中的 {steps.X.*} 插值引用。这些构成该阶段的声明读集;其写集为 [phase.id](1:1 投影)。
interface DeclaredDeps {
reads: string[]; // Upstream step ids statically referenced
writes: string[]; // Step ids this phase emits (currently [phase.id])
}
interface TaskflowIRMeta {
sourceFlowName: string;
declaredDeps: Record<string, DeclaredDeps>; // Per-phase declared footprint
sidecar: Record<string, unknown>; // Pi-taskflow-specific fields (round-trip)
}声明平面会持久化到 RunState.declaredDeps 以便审计/溯源,但重算会从 def 重新派生它,因此旧运行(H1 之前、没有持久化 declaredDeps)也能获得 union 语义。
依赖追踪
taskflow 在两个平面上追踪依赖:声明平面(编译期、静态)和观测平面(运行期、动态)。过期检测使用它们的并集,因此一个“声明但未被观测到”的边(例如一个从未触发的 when 引用)仍然会传播过期。
观测读集(M3)
每个已完成阶段都记录一个观测 readSet —— 不是它声明依赖的内容(dependsOn),而是它在插值时刻真正读取的内容。每个条目都带有它消费时对应的版本(= 被读阶段的 inputHash),这样后续的过期检查就能判断上游是否已经移动。
interface PhaseState {
// ...
reads?: Array<{
stepId: string; // The upstream phase id
version?: string; // The upstream's inputHash when this phase read it
}>;
}这就是 overstory “observed readSet@version” 的护城河:没有其他编排器记录一个结果真正依赖了什么。一个插值 {steps.scout.output} 和 {steps.analyst.output} 的阶段有两条读记录;一个没有插值引用的阶段则没有。
使用 /tf provenance <runId> 查看一次运行的观测读集。这是“这个阶段到底消费了什么”的真实依据。
声明读集(M2)
声明平面在编译期由 collectRefs(phase) 派生。它捕获阶段可能读取的每一个 {steps.X.*} 引用,包括可能永不触发(因为条件为假)的 when 守卫里的引用,以及可能只在后续迭代才求值的 until 收敛检查里的引用。
// Fold a run's PhaseStates into a read map (drops phases with no reads)
function readMapOf(phases: Record<string, PhaseState>): ReadMap;
// Build a declared ReadMap from a flow definition (collectRefs per phase)
function declaredReadMapOfDef(def: Taskflow): ReadMap;ReadMap 是 Map<phaseId, readonly string[]> —— 阶段 id 到它读取的上游 stepId 列表。观测平面和声明平面都使用这个形状。
Union 语义
当把 declared 提供给 computeStaleFrontier 或 dependentsOf 时,依赖者集合是观测依赖者(来自 reads)与声明依赖者(来自 declared)的并集。这确保:
- 一个从未触发(因为条件为假)的
when引用仍然算作依赖 —— 如果被引用的阶段变化,被守卫的阶段即便没有观测到这次读取,也会被标记为过期。 - 一个只在第 3 次迭代才求值的
until收敛检查,仍会从其引用传播过期,即使第 1、2 次迭代并未观测到它们。 - 旧运行(H1 之前、没有持久化 declaredDeps)同样获得 union 语义,因为重算会从
def重新派生声明平面。
过期检测
/tf why-stale 解释一个被缓存的运行为何过期:哪个阶段变了,以及还有什么被传递性失效。该计算是纯的、确定性的、零 token 的 —— 它读取持久化的 RunState 并遍历依赖图。
过期前沿
computeStaleFrontier(reads, seeds, declared?) 返回当 seeds 变化时过期的阶段的传递闭包。一个读者只要(观测地或声明地)读取了任意一个过期阶段,它就过期(union 语义:存疑时按有依赖处理)。前沿包含种子本身。
function computeStaleFrontier(
reads: ReadMap, // Observed read map
seeds: Iterable<string>, // Phases assumed to have changed
declared?: ReadMap // Declared read map (union when provided)
): Set<string>;算法是对读图的 BFS:把种子入队,然后对每个种子找出它的依赖者(通过 dependentsOf),把它们入队,循环往复。每个阶段至多入队一次(visited 集合),因此病态读图中的环也能终止。复杂度为 O(phases + read-edges)。
解释过期
formatWhyStale(runId, flowName, reads, seeds, declared?) 渲染人类可读的解释:
- 无种子:展示完整的观测依赖图(谁读了谁),仅声明的边标注
(declared)。 - 有种子:展示过期前沿,每个过期阶段列出导致它过期的上游(即它的“为什么”)。种子标记为
(changed — seed);下游阶段列出其过期读取。
/tf why-stale abc123 scoutwhy-stale — run abc123 · flow "review-changes"
Assuming changed: scout
Stale frontier (transitive, 3 phases):
■ scout (changed — seed)
■ analyst ← reads scout
■ summary ← reads analyst前沿是保守的:只要阶段可能受影响,就标记为过期。真正的重算(带 --apply)会检查每个阶段的 inputHash —— 如果上游的新输出恰好与旧输出相等,该阶段命中缓存(early cutoff)并被复用。
最小重算
/tf recompute <runId> <phaseId> 最小化地重算一个过期阶段:强制重跑种子,然后按拓扑序遍历其过期前沿。缓存免费提供 early cutoff —— 一个 inputHash 未移动的下游(因为种子的新输出恰好与旧输出相等)会命中其先前结果并被复用,而不是重新执行。
默认干跑
重算默认故障安全:不带 --apply 时,它不花一个 token 就算出最坏情况下的前沿,并返回一份描述将会发生什么的 RecomputeReport。
/tf recompute abc123 scout # Dry-run: what would change?
/tf recompute abc123 scout --apply # Real recompute: spend tokens干跑报告解释每个阶段的结局:
interface RecomputeReport {
dryRun: boolean;
aborted: boolean; // True if the user cancelled mid-recompute
seeds: readonly string[]; // The phase(s) forced to re-run
rerun: readonly string[]; // Phases that were (would be) re-executed
reused: readonly string[]; // Phases outside the frontier (untouched)
cutoff: readonly string[]; // Frontier phases whose inputHash didn't move
decisions: readonly RecomputeDecision[]; // Per-phase WHY
}
interface RecomputeDecision {
phaseId: string;
outcome: "rerun" | "cutoff" | "reused" | "failed";
reason: string; // Human-readable cause
causedBy?: readonly string[]; // Upstream phases that caused this outcome
}decisions 数组是“可解释的响应式”层 —— 就像 React DevTools 告诉你某个组件为何重渲染。用它来理解一个阶段为何被重跑、被截断还是被复用。
Early cutoff
Early cutoff 是关键收益:过期前沿中那些在上游重跑后 inputHash 未移动的阶段。这发生在:
- 种子的新输出与旧输出逐字节相同(例如一次未改变渲染文档的重构)。
- 种子的输出变了,但下游阶段不插值变化的部分(例如
summary读取{steps.analyst.output}但只用了第一段,而第一段没变)。
Early cutoff 正是让重算比完整重跑更便宜的原因:你只为种子和任何输入确实移动的下游付费;其余全部免费。
拓扑序
真正的重算(--apply)按拓扑序遍历前沿,并尊重 dependsOn ∪ observed reads ∪ declared reads(M2 union)。这确保下游在重新评估其缓存键时,总能看到(已刷新的)上游 —— 不会因为基于陈旧上游状态评估而产生虚假的 early cutoff。
自环会被过滤:一个检查 {steps.thisId.output} 的 loop until 不得在调度图中创建自环(topoLayers 会死锁)。
安全守卫
对含有未观测依赖的 flow,带 --apply 的重算是被门控的。观测 readSet 只追踪 {steps.X.*} 插值引用;它对以下情况是盲的:
- Shared Context Tree(
shareContext、contextSharing、ctx_read/ctx_write) - 子 flow 内部(
flow阶段的use或内联def) - 上下文文件预读(
context: ["src/**/*.ts"]) - 非 steps 插值(
{previous.output}、{args.*}、{item.*})
对这样的运行用 --apply 重算,可能会静默跳过那些依赖在观测前沿之外发生变化的阶段,然后把一个损坏的运行覆盖回原始运行。运行时会抛出:
recompute dryRun:false is unsafe for this run: it contains dependencies
(shareContext, flow/ctx_spawn, context: files, {previous.output}, {args.*}, or {item.*})
that are not tracked by the observed readSet. Use dryRun:true to inspect
the frontier, or change the upstream phase and re-run the whole flow.如果你的 flow 使用了上述任一特性,--apply 会被阻止。请用 dryRun:true 检查前沿,然后 (a) 改变上游阶段并重跑整个 flow,或 (b) 重构以移除未观测的依赖。
跨运行缓存
跨运行缓存(CacheStore)跨运行记忆化阶段结果。一个具有相同 inputHash(插值后的 task + flow 定义哈希 + 指纹)的阶段会以 $0.00 复用先前结果。
指纹解析
阶段可以声明一个 cache.fingerprint 列表,把外部状态折进缓存键。每个条目都被解析为一个确定性字符串:
| Prefix | Resolution | Example |
|---|---|---|
git:<ref> | git rev-parse <ref> → SHA | git:HEAD → git:HEAD=a1b2c3d... |
glob:<pattern> | File list + size + mtime digest | glob:src/**/*.ts → glob:src/**/*.ts=<digest> |
glob!:<pattern> | File list + content SHA digest | glob!:src/**/*.ts → glob!:src/**/*.ts=<digest> |
file:<path> | File content SHA | file:package.json → file:package.json=<sha> |
env:<name> | Environment variable value | env:NODE_ENV → env:NODE_ENV=production |
未知前缀在校验期被拒绝;运行期防御性编码为 <unknown>。缺失的文件、非 git 仓库、不可读路径都会解析为稳定的哨兵值(<missing>、<no-git>、<skip>),以保证键的确定性 —— 资源后续出现只会改变键 → 缓存未命中(安全方向)。
function resolveFingerprint(entries: string[] | undefined, cwd: string): string;对源代码文件使用 glob!:(内容模式)—— 一个 mtime 变了但内容没变的文件(例如 touch)不会让缓存失效。对 mtime 有意义的生成文件使用 glob:(stat 模式)。
缓存条目形状
interface CacheEntry {
key: string; // The full cache key (inputHash)
createdAt: number; // Unix timestamp
output?: string; // Trimmed phase output
json?: unknown; // Parsed JSON (for output: "json" phases)
model?: string; // Model that produced this result
state?: PhaseState; // Full PhaseState (gate, approval, reads, loop, etc.)
flowName?: string; // Provenance
phaseId?: string;
runId?: string;
}完整的 PhaseState 被存储(而不只是 output/json),这样跨运行复用在语义上等价于运行内恢复 —— 只存输出会丢掉 gate、approval、reads、loop、tournament、warnings 等,破坏重算的可靠性和 gate-block 检测。
缓存生命周期
- TTL:条目在
cache.ttl(解析为毫秒)后过期。默认:无 TTL(条目一直保留直到被驱逐)。 - Max age:硬性兜底 —— 超过 90 天的条目无论 TTL 如何都会被丢弃。
- Max entries:每个缓存目录 1000 条;超出时驱逐最旧的。
- Clear:
/tf cache-clear(或action: "cache-clear")移除所有条目。
增量闭环
完整的增量工作流是:编译 → 检查 → 解释 → 重算。
1. 编译 flow
/tf ir review-changes输出:
FlowIR — flow "review-changes"
hash: a1b2c3d4e5f6789012345678 (usedFallbackHash: true)
nodes: 4
■ scout kind: agent inject: [] emits: [scout]
■ analyst kind: agent inject: [scout] emits: [analyst]
■ gate kind: gate inject: [analyst] emits: [gate] when: {steps.analyst.json.issues}
■ summary kind: agent inject: [analyst, gate] emits: [summary]
declaredDeps:
scout: reads: [] writes: [scout]
analyst: reads: [scout] writes: [analyst]
gate: reads: [analyst] writes: [gate]
summary: reads: [analyst, gate] writes: [summary]2. 检查溯源
运行后,检查每个阶段实际读取了什么:
/tf provenance abc123Provenance — run abc123 · flow "review-changes"
■ scout
reads: (none — root phase)
inputHash: 1a2b3c4d5e6f7890
■ analyst
reads:
- scout (version: 1a2b3c4d5e6f7890)
inputHash: 2b3c4d5e6f789012
■ gate
reads:
- analyst (version: 2b3c4d5e6f789012)
inputHash: 3c4d5e6f78901234
■ summary
reads:
- analyst (version: 2b3c4d5e6f789012)
- gate (version: 3c4d5e6f78901234)
inputHash: 4d5e6f78901234563. 解释过期
一周后,summary 看起来过期了。查一下原因:
/tf why-stale abc123 scoutwhy-stale — run abc123 · flow "review-changes"
Assuming changed: scout
Stale frontier (transitive, 4 phases):
■ scout (changed — seed)
■ analyst ← reads scout
■ gate ← reads analyst
■ summary ← reads analyst, gate4. 最小重算
先干跑看看会改变什么:
/tf recompute abc123 scoutRecompute report (dry-run) — run abc123 · flow "review-changes"
seeds: [scout]
rerun: [scout, analyst, gate, summary]
reused: []
cutoff: []
decisions:
■ scout outcome: rerun reason: forced by recompute request (seed)
■ analyst outcome: rerun reason: reads a phase in the stale frontier; may re-run if that upstream's output moves
causedBy: [scout]
■ gate outcome: rerun reason: reads a phase in the stale frontier; may re-run if that upstream's output moves
causedBy: [analyst]
■ summary outcome: rerun reason: reads a phase in the stale frontier; may re-run if that upstream's output moves
causedBy: [analyst, gate]应用重算:
/tf recompute abc123 scout --applyRecompute report — run abc123 · flow "review-changes"
seeds: [scout]
rerun: [scout, analyst, summary]
reused: []
cutoff: [gate]
decisions:
■ scout outcome: rerun reason: forced by recompute request (seed)
■ analyst outcome: rerun reason: input changed — an upstream's output moved
causedBy: [scout]
■ gate outcome: cutoff reason: input unchanged — upstream(s) re-ran but produced identical output (early cutoff)
causedBy: [analyst]
■ summary outcome: rerun reason: input changed — an upstream's output moved
causedBy: [analyst, gate]gate 阶段命中了 early cutoff:它的上游(analyst)重跑了,但 analyst 的输出(问题列表)没变,所以 gate 的 inputHash 未变,复用了先前结果。你只为 3 个阶段付费,而不是 4 个。
具体走查
一个从首次运行到增量重算的真实会话:
/tf verify review-changesverify 在花任何 token 之前就捕获了一个环和一个缺失的 dependsOn。修正 JSON。
/tf run review-changes dir=src运行完成。运行时会:
- 把 flow 编译为 FlowIR(内容寻址哈希)。
- 按拓扑序执行阶段。
- 在插值时刻捕获观测读集。
- 持久化运行状态(包括
flowDefHash、declaredDeps、reads)。
/tf ir review-changes检查哈希和声明依赖。同一 flow 定义下哈希跨运行稳定。
/tf runs # 找到 runId
/tf provenance <runId>查看每个阶段实际读取了什么。这是依赖追踪的真实依据。
/tf why-stale <runId> scoutscout 阶段读取了 src/ 文件;这些文件变了。过期前沿包含 scout 及所有传递性读取它的阶段。
/tf recompute <runId> scout不花 token 看看会改变什么。报告展示哪些阶段会被重跑、复用或截断。
/tf recompute <runId> scout --apply只有种子以及输入确实移动的下游阶段会被重新执行。Early cutoff 为那些上游重跑但产出相同结果的阶段节省 token。
/tf cache-clear移除所有跨运行缓存条目。当你想强制全新运行时使用(例如模型升级之后)。
命令参考
| Command | Description |
|---|---|
/tf ir <name> | 编译为 FlowIR + 内容哈希。零 token。 |
/tf provenance <runId> | 展示一次运行的观测读集溯源。零 token。 |
/tf why-stale <runId> [phaseId] | 解释一个被缓存的运行为何过期。零 token。 |
/tf recompute <runId> <phaseId> | 干跑最小重算。零 token。 |
/tf recompute <runId> <phaseId> --apply | 应用最小重算。只对重跑阶段消耗 token。 |
/tf cache-clear | 清理跨运行记忆化缓存。零 token。 |
接下来
Last updated on