# 揭示重复副作用的 AI 代理重试日志

代理重试不是对某个操作的第二份副本，而是为了完成一个已声明操作而进行的第二次尝试。审计轨迹必须保留这一区别。如果记录里只有一串相似的 HTTP 调用，恢复和重复就会看起来完全一样。

当操作会改变现实世界时，这个问题最严重：创建工单、撤销访问权限、提交付款、推送部署，或通过 SSH 运行命令。代理可能在目标端已经执行操作后才收到超时，也可能在任何字节离开机器之前因为本地故障而重试。这两种情况需要不同的处理方式，但太多系统都会把它们写成同一句：`request failed, retrying`。

我见过团队手动比较时间戳和请求内容，调查所谓的重复操作。这远不如事件模型可靠。操作发生时，就把这些关系写入每条记录。调查人员应该能够选中一个操作，看到它的意图、每次尝试、后续尝试发生的原因，以及最终解决问题的结果。

## 重试属于操作，而不是日志行

每个会产生副作用的代理操作都需要两个身份：操作 ID 用来标识预期结果，尝试 ID 用来标识一次执行尝试。从代理决定要做什么开始，直到关闭或核对这一意图，操作 ID 都应保持不变。每次网络调用或 SSH 执行都要获得新的尝试 ID。

假设代理打算停用一个账户，于是创建操作 `op_7f2c`。第一次请求 `att_01` 到达身份 API，但连接在响应返回前断开。第二次请求 `att_02` 可能是合理的恢复尝试。两条记录都必须指向 `op_7f2c`，而 `att_02` 还必须直接指向触发它的尝试 `att_01`。

不要把会话 ID 当作操作 ID。一次代理会话可能包含多个操作，而一个操作也可能在会话结束后继续存在，由主管进程恢复处理。也不要只使用请求哈希。哈希描述的是字节，操作描述的是预期效果。两个请求可能只有无关紧要的传输细节不同，却仍然属于同一个操作。反过来，即使字节完全相同，代理有意发送两次时也可能代表两个不同的预期操作。

当代理形成类似“停用账户 A”“为警报 B 创建一个事件”“在主机 C 上运行一次迁移”的承诺时，就应使用操作身份。将这一意图记录在结构化字段中。自然语言摘要对人很有帮助，但不能单独作为身份，因为不同代理运行之间的措辞会变化。

清晰的层级应当是这样：

- 会话标识一次代理进程运行。
- 操作标识一个预期的外部效果。
- 尝试标识一次实际执行。
- 观察标识之后收到的证据，例如回调、写后读取检查或操作员决定。

这个层级能解决一个常见的棘手情况：代理发送请求后超时，接着询问另一个端点操作是否已经发生。这个查询不是重试，而是附加到原始操作上的一次观察。如果把它当成另一次尝试，最有价值的证据就会被埋在错误的类别里。

## 未知是一种结果，不是错误消息

超时带来的是不确定性，而不是失败证据。日志需要有一个表示这种不确定性的状态，并且在后续证据解决它之前一直保留这个状态。

许多客户端库会把连接被拒绝、DNS 失败、响应体到达过晚，以及服务器提交写入后连接重置等事件，统统压缩成一个异常。对应用控制流来说，这种便利没有问题。但它不能成为最终审计记录。记录必须说明调用方观察到了什么，并避免声称调用方无法证明的目标端事实。

对于一次尝试，要把本地观察与已确定的操作状态分开。尝试可以是 `not_sent`、`sent_no_response`、`response_received` 或 `execution_error`。操作则可以是 `open`、`succeeded`、`failed`、`unknown` 或 `cancelled`。名称可以不同，但两者必须分开。

`not_sent` 表示客户端在发送前就停止了。例如，本地查找凭据失败可能属于这个状态。由于没有远程请求发出，重试不会造成远程副作用重复。

`sent_no_response` 表示调用方已经发送了操作，但没有拿到可用响应。这是危险状态。只有在目标端具备可靠的去重机制，或者操作本身不可能产生重复效果时，自动重试才可能安全。

`response_received` 也不自动等于成功。服务器可能返回验证错误、冲突响应，或者返回一个描述异步工作的成功响应。保存状态、相关响应指纹以及目标端签发的操作引用，然后根据具体 API 的契约设置操作状态。

不要在实际意思是 `unknown` 时写成 `failed`。这个词能让仪表盘看起来整洁，却会告诉下一个代理或操作员重复一个可能已经发生的操作。事故期间，一个不诚实的字段就可能把一次错误操作变成一连串错误操作。

## HTTP 语义不会让业务操作可以安全重复

HTTP 方法描述的是协议语义，而不是你的业务保证。RFC 9110 规定，如果多个相同请求的预期效果与一个请求的效果相同，那么这个方法就是幂等的。它将 PUT、DELETE 和安全方法列为幂等方法，而 POST 默认并不幂等。

这条指导很有用，但工程师经常把它推得太远。DELETE 请求可能在协议层面是幂等的，因为删除一个不存在的资源仍然会得到不存在的结果。但你的审计问题可能完全不同：代理是否删除了正确的账户，是否两次触发了下游清理，以及第二次调用是否使用了不同的权限？协议标签回答不了这些问题。

PUT 也会带来麻烦。将资源设置为固定表示形式的 PUT 通常可以容忍重试。但如果 PUT 端点每次收到请求都会触发通知、分配记录或运行集成，它就没有提供人们想象中的安全性。阅读目标端的文档契约，并在强制丢失响应的情况下测试行为。方法名称不是证据。

POST 端点通常支持幂等性令牌。发送由操作 ID 派生的稳定令牌，不要使用尝试 ID。如果 `att_01` 和 `att_02` 使用不同令牌，就等于破坏了保护你免受重试影响的功能。

请求信封可能如下所示：

```json
{
  "operation_id": "op_7f2c9c",
  "attempt_id": "att_01",
  "idempotency_key": "op_7f2c9c",
  "intent": {
    "kind": "disable_account",
    "subject_ref": "user:1842"
  },
  "destination": {
    "method": "POST",
    "route_template": "/v1/accounts/{id}/disable",
    "authority_ref": "vault:identity-prod"
  },
  "request_fingerprint": "sha256:...",
  "dispatch_state": "sent_no_response"
}
```

不要记录授权请求头、会话 Cookie、私有 SSH 材料，或包含密钥的请求体。记录凭据引用和规范化请求表示的指纹。指纹能帮助调查人员比较不同尝试，同时不会把审计轨迹变成另一个密钥存储区。

目标端必须遵守幂等性令牌，它才能防止重复效果。如果目标端遵守了令牌，记录其返回的引用，以及响应是否来自之前保存的结果。如果目标端不遵守，令牌就只是一个不起作用的请求头，重试策略必须据此处理。

## SSH 重试可能重复不止一条命令

SSH 让重试统计更难，因为一次连接可以携带 shell 语法、管道、重定向，以及可能部分完成的多个命令。SSH 会话失败，并不能说明远程命令的哪些部分已经运行。

考虑下面的命令：

```sh
create-user deployer \u0026\u0026 install-key deployer /tmp/new.pub \u0026\u0026 restart-service api
```

如果客户端在发送后失去连接，重试可能因为用户已经存在而失败；如果辅助程序会追加密钥，也可能重复安装密钥；或者再次重启服务。Shell 中的 `\u0026\u0026` 只控制一次执行内部的行为，对重新建立连接并再次运行整条命令没有保护作用。

只有在命令不包含任何秘密材料时，才记录完整命令。否则保存脱敏后的显示形式和规范化指纹。记录主机别名或主机密钥引用、远程账户引用、相关时的工作目录、收到的退出状态以及执行边界。边界应说明辅助程序是否启动了命令，以及是否收到退出状态，而不只是本地调用方是否报告了错误。

更安全的模式是使用远程脚本，并让脚本在执行前检查操作标记。标记必须放在目标系统能够原子读取的位置。根据环境不同，数据库事务、部署记录或以独占方式创建的文件都可以胜任。进程崩溃或第二个代理进程运行后，本地代理缓存无法证明任何事情。

例如，部署脚本可以接受 `OPERATION_ID`，在激活前将它写入发布记录；如果记录已经存在，就返回已有结果。这样，审计事件既能捕获本地操作 ID，也能捕获远程记录 ID。调查人员就有了一座连接代理记录与主机证据的桥梁。

不要因为任意 Shell 命令“基本安全”，就把它归为可重试。应按命令类别管理。只读采集可以自由重试。设置状态的命令需要明确的收敛条件。追加、财务、破坏性或通知类命令，在结果未知后需要远程去重记录或人工决定。

## 每次后续尝试都需要明确原因和父级

重试事件必须说明是哪次尝试触发了它，以及什么条件使再次尝试变得合理。`retry_count: 2` 太弱了。它只能说明之前有调用，却无法指出哪次调用失败、代理是否改变了什么，或是否有人批准了继续操作。

使用受控的原因集合，再单独附加支持细节。可用原因包括 `connection_not_established`、`rate_limited`、`destination_5xx`、`response_lost_after_dispatch`、`credential_refreshed` 和 `operator_requested`。不要让代理自行编写看似让人放心、却无法分类或审查的说明文字。

每次重试都保留以下关联：

```json
{
  "operation_id": "op_7f2c9c",
  "attempt_id": "att_02",
  "retry_of_attempt_id": "att_01",
  "retry_reason": "response_lost_after_dispatch",
  "retry_decision": "destination_idempotency_confirmed",
  "attempt_budget_remaining": 1,
  "request_fingerprint": "sha256:...",
  "prior_request_fingerprint": "sha256:..."
}
```

两个指纹通常应该相同。如果不同，就记录原因。时间戳请求头发生变化可能是预期行为，但账户标识符、金额、主机名、路由或权限发生变化，就不属于通常意义上的重试。这是一个新操作，或者是经过人工修改的意图，审计轨迹必须说明这一点。

代理系统经常在这里误导自己。模型读到错误后修改参数来“修复”问题，然后把下一个请求称为重试。这其实是一个新的决定，也可能产生新的效果。把它关联为重试，会掩盖计划变化，让审查几乎无法进行。

为每个操作限制尝试预算，并记录预算决定。在目标端说明的等待时间后重试限流响应，与超时后重试一个未知写入不同。前者通常有明确的远程响应，后者则需要幂等性证据或核对结果，之后才能重复。

## 幂等性令牌和审计身份解决的是不同问题

幂等性令牌告诉目标端将重复提交视为一个操作。审计操作 ID 告诉你自己的调查人员哪些尝试属于同一个意图。可以同时使用两者，但不要假装一个能替代另一个。

令牌的作用域可能是某条路由、某个商户、某个时间窗口或某个账户。有些 API 只在有限时间内保留令牌。有些 API 会为重复请求返回原始响应，另一些会返回冲突。有些 API 会在请求体发生变化时拒绝重复使用令牌。这些细节应写入连接器契约和测试套件。

操作 ID 的作用更广。它连接代理会话、审批证据、请求构造、传输尝试、远程响应以及后续核对。即使供应商 API 不提供幂等性，即使操作使用 SSH，或者最终由操作员手动完成恢复，它也应继续有效。

不要因为进程内存消失，就在重启后生成新的操作 ID。发送请求前先持久化待处理操作。恢复时检查每个未解决操作，并选择三条路径之一：使用远程证据进行核对，在有文档记录的幂等性保证下重试，或升级给人工处理。重启是工程事件，不是忘记不确定性的许可。

一个常见但错误的建议是，对每个失败的写入都使用指数退避进行重试。退避可以减轻服务压力，却不会把未知写入变成安全写入。能否重复操作，以及目标端如何去重，才决定再次尝试是否可以接受。

## 核对可以消除不确定性，但不会改写历史

核对是收集关于未解决操作的后续证据，不是修改第一次尝试，直到它看起来像成功。

假设代理在请求体中使用客户端提供的引用来创建事件。初始 POST 结束时状态为 `sent_no_response`。在重试之前，代理按该引用查询事件。如果找到匹配记录，就追加一个观察事件，引用原始操作 ID、查询指纹、返回的远程标识符和匹配条件。然后通过核对将操作关闭为 succeeded。

如果查询没有找到任何结果，要谨慎处理。当 API 存在复制延迟、搜索索引延迟或过滤能力有限时，缺少结果几乎不能证明什么。记录这次否定观察，包括使用的时间和端点。只有在目标端契约说明幂等性令牌仍然有效时才重试，否则就等待并请求决定。

如果查询找到两条匹配记录，不要把操作标记为成功后继续。将它关闭为 `duplicate_effect_confirmed`，保留两个远程标识符，并创建一个单独的修复操作。修复操作不能共享原始操作 ID，因为它有不同的预期效果。

将只追加事件作为事实来源，而不是使用可变状态行。你可以为用户界面投影一个方便的当前状态，但证据必须保留完整转换过程：创建意图、发送尝试、响应丢失、执行查询、找到远程记录、解决操作。调查人员需要看到整个顺序，包括已经发生的错误重试。

具备防篡改证据的日志还能提供另一项能力：验证后续进程没有悄悄删除第一次未知的尝试。Sallyport 使用无法读取写入内容的加密哈希链审计日志，记录代理会话和单个操作；`sp audit verify` 可以离线对密文检查哈希链。这有助于保留时间线，但事件结构仍然需要本文所述的操作和尝试关联。

## 代理委托需要一个操作所有者

子代理会让重复效果更容易出现，因为每个进程都可能认为自己拥有任务。让一个进程负责操作 ID，并要求每个被委托的工作进程在操作上下文中携带这个 ID。

规划器可以要求一个工作进程收集信息，再让另一个工作进程执行操作。信息收集调用应该拥有自己的操作，因为它们代表不同的意图。只有在执行进程处理的是同一个已声明效果时，才应将原始操作 ID 传给它。它的每次调用仍使用新的尝试 ID，并标明发起调用的工作进程。

不要让工作进程独立重试未知写入，同时父进程也在重试。父进程必须先收到工作进程的发送状态，再决定下一步。如果工作进程意外退出，就将操作标记为未解决并进行核对。缺少子进程结果，不是主管进程重放命令的理由。

每个会话的审批记录也属于证据链。当代理进程获得执行一组调用的权限时，要将进程身份、审批时间和撤销时间，与操作结果分开记录。审批说明谁获准尝试，但不能证明目标端是否执行了操作。

对于敏感操作，如果第一次尝试的结果未知，就要求对重试本身进行逐次审批。当审批卡写明原始意图、之前的发送状态和计划采用的恢复方式时，人们更容易做出正确判断。只显示“允许 API 调用”的通用提示，会隐藏本应让人放慢脚步的关键事实。

## 调查视图应该展示时间线，而不是一堆请求

调查人员需要一个从意图开始、以最有证据支持的结果结束的单一操作页面或查询结果。按时间排序的请求日志会迫使审查者在压力下重建父子关系，而且经常要跨越多个时钟略有不同的系统。

在顶部显示操作状态，但让证据出现在下方。每次尝试都应显示其编号、发送状态、重试父级、原因、权限引用、目标端、请求指纹、响应摘要和耗时。每次观察都应说明检查了什么，以及证据为何改变或没有改变状态。

不要因为最终效果没有造成伤害，就隐藏重复请求。今天无害的重复，可能在 API 发生变化或集成增加 webhook 后变成昂贵的副作用。记录应让审查者区分“代理正确重试，目标端完成去重”和“代理发送了两次请求，只是碰巧没有造成问题”。

将恢复期间的请求体变化明确记录为分支。原始操作保持打开状态，或接收它已经确定的结果。修改后的操作获得新的操作 ID，并通过 `supersedes_operation_id` 之类的字段建立关联。这个记录如实说明了情况：代理不是简单重试，而是改变了预期操作。

在信任设计之前，先编写一个强制失败测试。让目标端提交一个已知的幂等测试操作，然后丢弃返回给调用方的响应。确认下一次代理运行保留原始操作 ID，使用相同的目标端令牌，记录第一次未知的尝试，执行有文档说明的核对或重试，并用证据关闭操作。如果测试无法回答这些问题，事故也不会替你回答。

我查看代理操作日志时，首先寻找的不是 HTTP 状态，而是稳定的操作 ID。没有它，每次重试调查都只能靠猜。有了它，你就能提出真正重要的问题：代理原本想做什么，哪些内容离开了机器，两次尝试之间发生了什么变化，以及哪些证据支持最终结果。
