ΓDocsInteractiveNet

22 · 持久化 Effect 幂等(Coordinator Ledger)

目标:让 effect(LLM/HTTP/tool)在 worker failover 与 coordinator 重启后 不因重放而重复调用。当前实现是 at-most-once 调用 + 可复用结果, 不是跨 provider 的严格 exactly-once。

22.1 问题

M4e 之前,effect cache 在每个 worker 的内存里,key 为 run:cell:op:input_hash。这能拦住同一 worker 上的重复投递,但:

  • cell 迁移/节点失联后在新 worker 重放,cache 不在;
  • coordinator 重启后没有共享记录;
  • 真实 LLM/tool 调用可能产生重复费用或副作用。

22.2 设计:effect 在 coordinator 执行

Coordinator ── Deliver(envelope, effects=[...]) ──▶ Cell worker
     ▲                                              │
     │              NeedEffect(key, request)        │  (不提交 cell 状态)
     └──────────────────────────────────────────────┘
     │
     ├─ ledger hit  → 直接返回 output,不再调用 provider
     └─ ledger miss → EffectBus.call → 记录 output+usage → append NDJSON

关键点:

  1. worker 不执行 effect(默认 CellWorkerConfig::delegate_effects = true)。 behavior 只产生 EffectRequest;worker 返回 NeedEffect,且不提交 state/sends/seen,因此协调器可以用更多 effect output 重试同一次 delivery。
  2. delivery 是确定性的:coordinator 依次收集 effect output,并把它作为 effects: [...] 重发给同一个 worker;worker 从上次已提交状态重新执行 behavior, 消费对应 output。这样无需在 worker 保存“半完成”状态。
  3. ledger key:run:cell:op:sha256(input)。同一 run 内重复请求命中同一条。
  4. 持久化:--effect-ledger PATH 追加 NDJSON: {"version":1,"key":"...","output":...,"usage":{...}}。 重启时只加载 key 前缀匹配当前 --run-id 的条目,并把 usage 计入账本。
  5. failover:节点失联 → 从 last ack state 重新 spawn → 重投 envelope → 再次 NeedEffect → ledger 命中。不会再次调用 provider。
  6. coordinator 重启:用相同的 --run-id 与 --effect-ledger 重跑, 已完成的 effect 直接复用;state hash 与首次一致。

22.3 使用

# worker 默认委托 effects,不需要 provider key
ain cell serve --bind 127.0.0.1:7301 --id n1

# coordinator 持有 provider 配置 + ledger
ain cluster run examples/research.flow \
  --nodes n1@127.0.0.1:7301 \
  --run-id research-2026-11-11 \
  --effect-ledger /var/lib/ain/effects.ndjson \
  --openai-base https://api.openai.com/v1 \
  --openai-key "$OPENAI_API_KEY" \
  --openai-model gpt-4.1-mini

--run-id 应可复现且唯一;同一个 run 的恢复必须复用同一个 id。

本地验证:

scripts/effect-ledger-demo.sh   # 待补;当前用 mock-openai 手工验证

22.4 保证与边界

场景 行为
同一 worker 重复投递 worker (run,id)/channel seq 去重;不重跑 effect
worker 在 effect 前崩溃 新 worker 重跑,ledger miss,调用一次
worker 在 effect 后、ack 前崩溃 新 worker 重跑,ledger hit,复用结果
coordinator 在 call 后、append 前崩溃 可能重复调用;需要 provider 幂等键或事务日志
同一 key 并发请求 当前串行调度,无并发;未来需 in-flight 租约
指标口径 ledger 只算本次执行;total_ledger 含历史;cache hit 单独计数/审计
不同 run 复用 ledger 按 run 前缀隔离;不会跨 run 命中
本地 effect(--local-effects) 仍使用 per-worker 内存 cache,不享受跨节点幂等

结论:这是 effect 结果复用 + 至少一次执行 的工程折中。要严格 exactly-once, 需要把 provider 调用也纳入可提交事务(例如 outbox + provider idempotency key), 并处理“调用成功但日志未落盘”的窗口。

22.5 指标口径

一次 cluster run 的输出现在区分三组数:

  • effects / usd:本次 coordinator 真实执行的 effect 与成本。
  • cache_hits:命中历史 ledger 的次数;不会重复计费。
  • total_effects / total_usd:effects + 历史 ledger 中同 run 的记录;用于展示累计成本。
  • state_hash 使用 total_ledger,所以同一 run 重跑(全部命中)仍与首次 hash 一致。

审计里新增 effect_cache_hit 事件(含 saved_tokens/saved_usd)。 这样解决了“加载历史 ledger 把未命中条目也算进 effects”的统计错误。

22.6 代码与测试

  • crates/ain-cluster/src/protocol.rs:CellRequest::Deliver.effects、 CellResponse::NeedEffect。
  • crates/ain-cluster/src/worker.rs:deliver_with_effects,失败不提交状态。
  • crates/ain-cluster/src/cluster.rs:effect_ledger、with_effect_ledger、 with_run_id、NDJSON load/append。
  • 测试:
    • effect_ledger_reuses_results_across_coordinator_restarts:两次独立 worker+coordinator,第二次 effects=0、cache_hits=2、total_effects=2,state hash/输出一致。
    • one_hundred_llm_chain_cells:100 个 effect 全部经 coordinator ledger。
    • effect_cache_prevents_duplicate_calls:本地模式 cache 仍有效。

22.7 后续

  • in-flight effect 租约,避免同一 key 并发重复调用。
  • provider 幂等键透传(OpenAI Idempotency-Key、HTTP Idempotency-Key)。
  • ledger compaction / 按 run GC。
  • ledger 加密与完整性链(当前是明文 NDJSON)。
  • per-cell in-flight credit 与 effect 并发上限(队列 credit 已由 --cell-queue-limit 提供)。

在 GitHub 编辑本文