返回机制实验室

W06 · S36—S42 · 总 Day 126132

Agent 恢复:请求重试不等于副作用重做

模拟副作用成功后丢失确认,观察接收方幂等去重。

作者准备的学习示例 · 不计真实学习进度 · 不代表生产 / GPU / 真机结果

在仓库根目录运行;只输出合成示例,不写文件、不访问网络

npm run learning:p2 -- w06
跳到完整源码 ↓

核心问题

Agent 发出了“创建审核草稿”的请求,接收方已经完成,确认消息却丢了。Agent 超时后看到的不是“失败”,而是“不知道外部是否成功”。如果直接再做一次,就可能创建两个草稿。

对应 S36~S42。把 checkpoint、重试与幂等拆开,理解它们分别保护什么。

1. 一次失败发生在哪个间隙

保存 ready checkpoint
    → 接收方提交副作用
    → 确认丢失 / 调用方崩溃
    → 从 ready 恢复
    → 使用同一 operationId 重试
    → 接收方返回已有 receipt
    → 调用方保存 done

Checkpoint 描述调用方恢复到哪个状态;接收方幂等记录描述某个业务动作是否已经做过。只有调用方保存状态,无法覆盖“外部已成功、内部尚未记账”的窗口。

本例的 operationId 是租户、案例、动作和版本组合,不是每次 HTTP 尝试的随机 ID。相同业务动作多次尝试应复用同一个键;真正的新动作则需要新的业务身份。接收方还比较 payload:同一键不能静默对应不同内容。

2. 运行并跟踪两个计数

npm run learning:p2 -- w06

stableOperationKey 中第一次 reused=false,第二次 reused=true,两次拿到同一 receipt,effects=1freshKeyEveryRetry 每次换键,输出 effects=2。不同之处不是是否进行了重试,而是接收方能否识别这是同一业务动作。

示例把 ready 状态序列化为 JSON 再恢复,接收方 Map 在恢复调用方时保留。它模拟的是“调用方状态丢失、接收方仍活着”,没有真实重启进程、磁盘持久化或数据库事务。每次重新运行脚本,所有状态都会重置。

3. 不要轻易说 exactly once

生产中,接收方通常需要在同一事务里完成“检查键、写副作用、写 receipt”,并处理并发请求、唯一约束、保留期和故障恢复。这里的同步 Map 只能在当前单进程顺序执行下展示去重。

如果先写副作用、后写去重记录,中间崩溃仍可能重复。如果先记已处理、后写副作用,中间崩溃可能丢动作。Outbox 能帮助连接本地事务与消息发送,但接收方仍需明确重复消息的语义。补偿是新的业务动作,通常不能让时间倒流。

对于本来不支持幂等键的外部工具,可以先查询业务状态、改用可幂等接口,或在不确定时交给人处理。不能单靠在 Prompt 里写“不要重复”解决外部一致性。

4. 轻量修改

保持 operationId 不变,只改变第二次 payload,观察接收方拒绝。再思考去重记录被清空会怎样:如果接收方忘了历史,同一键也可能再执行。写一句“当前保证依赖于……”比再做一套框架更有价值。

可选源码对照:仓库 src/agent/durable/checkpointMachine.ts 是另一份教学状态机。比较它保存的状态与这里接收方保存的 receipt,不把二者都叫“持久化”就结束分析。

5. 专业阅读与未来连接

  • Temporal Activity Definition:重点阅读 Activity 幂等性,理解 workflow 恢复并不取消外部副作用的不确定性。
  • MIT 6.5840:选 RPC、事务或复制相关讲义,只研究失败窗口,不做完整分布式课程作业。

长任务 Agent 会重复遇到相同问题。具身动作更不能把“没有收到确认”直接理解为“机械臂没有动过”。这里创建的是内存 receipt,没有真机动作与安全控制;迁移时必须重新设计状态观测、过期命令和人工接管。

配套日课:按需要补充理论

本实验贯穿一周,不要求一天做完。

可运行源码

src/learning/ai-systems/w06-durable-retry.ts · 构建时直接读取源文件,避免讲义代码与实现各自漂移。

export interface Checkpoint { operationId: string; phase: 'ready' | 'done'; receipt?: string }

export function createReceiver() {
  const receipts = new Map<string, { payload: string; receipt: string }>()
  return {
    apply(key: string, payload: string) {
      const previous = receipts.get(key)
      if (previous) {
        if (previous.payload !== payload) throw new Error('Same key with different payload')
        return { receipt: previous.receipt, reused: true }
      }
      const receipt = `receipt-${receipts.size + 1}`
      receipts.set(key, { payload, receipt })
      return { receipt, reused: false }
    },
    get effects() { return receipts.size },
  }
}

export function retryAfterLostAck(operationId: string, payload: string) {
  const receiver = createReceiver()
  const saved = JSON.stringify({ operationId, phase: 'ready' } satisfies Checkpoint)
  const firstAttempt = receiver.apply(operationId, payload)
  // Simulated crash: receiver committed, but the caller lost its acknowledgment.
  const restored: Checkpoint = JSON.parse(saved)
  const secondAttempt = receiver.apply(restored.operationId, payload)
  const completed: Checkpoint = { ...restored, phase: 'done', receipt: secondAttempt.receipt }
  return { saved, firstAttempt, secondAttempt, completed, effects: receiver.effects }
}

export function run() {
  const naive = createReceiver()
  naive.apply('attempt-1', 'create review draft')
  naive.apply('attempt-2', 'create review draft')
  return {
    stableOperationKey: retryAfterLostAck('tenant-a/case-1/create-draft/v1', 'create review draft'),
    freshKeyEveryRetry: { effects: naive.effects },
    boundary: 'Serialized checkpoint + in-memory receiver; no disk durability, concurrency, transaction, or real external effect.',
  }
}