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
关键点:
- worker 不执行 effect(默认
CellWorkerConfig::delegate_effects = true)。 behavior 只产生EffectRequest;worker 返回NeedEffect,且不提交 state/sends/seen,因此协调器可以用更多 effect output 重试同一次 delivery。 - delivery 是确定性的:coordinator 依次收集 effect output,并把它作为
effects: [...]重发给同一个 worker;worker 从上次已提交状态重新执行 behavior, 消费对应 output。这样无需在 worker 保存“半完成”状态。 - ledger key:
run:cell:op:sha256(input)。同一 run 内重复请求命中同一条。 - 持久化:
--effect-ledger PATH追加 NDJSON:{"version":1,"key":"...","output":...,"usage":{...}}。 重启时只加载 key 前缀匹配当前--run-id的条目,并把 usage 计入账本。 - failover:节点失联 → 从 last ack state 重新 spawn → 重投 envelope →
再次
NeedEffect→ ledger 命中。不会再次调用 provider。 - 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、HTTPIdempotency-Key)。 - ledger compaction / 按 run GC。
- ledger 加密与完整性链(当前是明文 NDJSON)。
- per-cell in-flight credit 与 effect 并发上限(队列 credit 已由
--cell-queue-limit提供)。