# 防止 AI 智能体重试请求造成 API 重复写入

AI 智能体犯重试错误的速度比人快。人看到超时后，可能会先暂停，查看工单队列，再判断到底发生了什么。智能体通常看到异常，按照指令重试，然后在第一个请求还未在网络边界之外完成时，就发送第二次写入请求。

这种行为会造成重复工单、重复支付尝试、重复邀请用户，以及同一项变更被部署两次。解决办法不是告诉智能体要小心，而是设计一份 API 契约，让一次预期操作的身份在重试过程中保持不变，发现复用标识符时被隐藏的请求内容发生了变化，并在后果足够严重时要求新的人工确认。

## 重试很正常，重复副作用可以避免

超时并不意味着服务器什么都没做。服务器可能已经创建了工单，只是响应返回途中丢失。它也可能仍在处理请求。负载均衡器可能已经接受连接，但上游服务始终没有收到数据。客户端无法根据套接字错误推断结果。

所以，常见的智能体指令「遇到网络错误就重试」并不完整。它把每个不确定结果都当成了失败操作。对于写入端点，不确定结果有三种可能：

- 服务器没有收到请求
- 服务器接受了请求并完成了操作
- 服务器接受了请求，但尚未完成操作

同一次重试在这三种状态下都应该安全。如果在已完成状态下重试会产生第二个副作用，说明这个端点的重试边界并不安全。

HTTP 不会替你解决这个问题。RFC 9110 将幂等方法定义为：发送一次或多次相同请求后，其预期效果保持不变的方法。它列出了 PUT、DELETE 和安全方法。RFC 还指出，通信失败后，客户端可以重试幂等请求。这很有用，但并不意味着所有使用 PUT 路由的端点都安全。服务器可以在 PUT 处理程序中触发邮件发送、发放额度或启动部署。如果没有专门设计，这些副作用仍可能被重复执行。

POST 需要明确约定。许多 API 使用 POST 执行动作，因为资源标识符由服务器分配，或者请求的含义是「执行这项业务操作」。只有当 API 说明如何在多次尝试中识别同一个操作时，智能体才能安全地重试这类请求。

要区分传输层重试和业务层重试。传输层重试是在结果未知时重新发送同一个操作。业务层重试则是在第一次操作已经明确失败并结束后，开始另一个操作。把两者混在一起，就会出现经典的事故报告：「智能体重试成功了，而且成功了两次。」

## 幂等令牌标识一次预期操作

幂等令牌是由客户端生成的不透明标识符，含义是「所有携带此值的请求，都是尝试执行这一次操作」。智能体应在第一次请求前创建令牌，将它与任务状态一起持久化，并在每次重试时发送完全相同的值。

令牌必须属于逻辑操作，而不是某一次 HTTP 尝试。如果智能体创建支持工单时丢失了响应，然后使用新令牌再次请求，API 就没有依据识别这是一次重试。它应该创建第二张工单，因为调用方明确告诉它这是第二次操作。

使用随机、高熵值。UUID 很常见，但只要调用方无法猜测令牌，且服务器将其视为不透明值，任何格式都可以。可以将它放在 `Idempotency-Key` 请求头中，或放在文档规定的请求字段里。请求头能将操作身份与业务负载分开，也便于中间件将其传递到日志和追踪系统。

一个实际请求如下：

```bash
curl -X POST https://api.example.test/v1/tickets \\
  -H 'Authorization: Bearer $TOKEN' \\
  -H 'Content-Type: application/json' \\
  -H 'Idempotency-Key: 81b59b1a-9e75-4de7-a53b-1bb50969c83c' \\
  -d '{"project":"ops","title":"Rotate staging certificate","priority":"high"}'
```

第一次调用被接受后，服务器会记录令牌、请求的规范化指纹、操作状态，以及最终用于重放的响应。之后，如果请求携带相同令牌和相同指纹，服务器就返回之前的结果，而不是创建另一张工单。

客户端需要一个持久的位置保存令牌。如果智能体只把它存放在当前提示或进程内存中，重启后就会丢失操作身份。应将令牌保存在任务记录、作业记录或工作流检查点旁边。如果人员要求智能体有意创建第二张内容相同但彼此独立的工单，智能体必须生成新令牌，因为这是新的意图。

不要让令牌等于可变的任务名称、时间戳或自然语言请求。这些值可能冲突、在重试之间发生变化，或在日志中暴露信息。不透明标识符看起来很无聊，但这正是它们有效的原因。

## 指纹可以发现内容发生变化的重试

令牌回答的是：调用方是否声称两个请求属于同一个操作。请求指纹回答的是：这两个请求实际上是否表达了同一件事。两者都需要。

假设智能体第一次要求部署 API 将提交 `a1b2c3` 发布到预生产环境。请求超时后，智能体读到了更新的任务备注，然后使用相同令牌和提交 `d4e5f6` 重试。如果服务器盲目重放第一次响应，就会掩盖智能体错误。如果服务器执行第二个请求体，就会让同一个操作标识符授权两次不同的部署。

将请求中有意义的部分规范化，然后对结果进行哈希。大多数 API 会包含 HTTP 方法、规范化路由、经过身份验证的账户或租户，以及规范化的 JSON 请求体。有些 API 还会包含会改变业务效果的特定请求头。应排除易变的追踪请求头、连接元数据和幂等请求头本身。

JSON 需要特别小心。如果等价 JSON 使用了不同的属性顺序或空白字符，直接对原始字节哈希就会失败。规范化表示应对对象属性排序，保留数组顺序，采用定义好的数字格式，并省略由服务器分配的字段。更好的做法是在 API 解析默认值并拒绝未知字段后，对已验证的命令对象生成指纹。这样匹配的是服务器将要执行的操作，而不是任意的输入编码。

例如，下面的伪代码会在验证后记录摘要：

```text
command = validate_create_ticket(request.body)
canonical = canonical_json({
  "method": "POST",
  "route": "/v1/tickets",
  "account_id": authenticated_account.id,
  "command": command
})
fingerprint = sha256(canonical)
```

如果令牌已经存在，应在返回或等待之前的结果前先比较指纹。如果指纹不同，就用冲突响应拒绝请求。可以包含已保存的操作标识符和状态，但不要向未经授权的调用方回显受保护的请求详情。

指纹本身不是重复检测器。两个用户完全可能合法地提交两张内容相同的工单。工资服务也可能合法地向两名员工发放相同金额。对请求负载进行哈希并对所有匹配项去重，会悄悄丢弃有效工作。应将去重范围限定在幂等令牌内，只有在业务确实需要时，才使用领域专属的唯一性规则。

使用 SHA-256 等现代函数时，密码学哈希可以让意外碰撞变得不切实际。但它不能证明调用方的意图。令牌承载意图，指纹保证一致性。把两者当成可互换机制的团队，通常会得到一条无法解释的去重规则，最终连合法请求也会被拒绝。

## 服务器必须在执行操作前占用令牌

一张只在副作用完成后才记录结果的幂等表，仍然存在竞态条件。两个并发重试可能同时检查表，发现没有记录，各自创建一张工单，然后再争抢写入结果。我见过有人把这种问题伪装成不稳定的智能体问题，但真正的缺陷是缺少唯一性约束。

服务器必须在执行不可逆工作之前，以原子方式占用令牌。对作用域和令牌设置唯一约束，通常是账户标识符加幂等令牌。在一个事务中，尝试插入一行记录，写入指纹，并将状态设为 `in_progress`。成功插入的请求拥有执行权，其他请求都读取现有记录。

一个简化的表可能包含这些字段：

```sql
create table idempotency_operations (
  account_id text not null,
  token text not null,
  fingerprint text not null,
  state text not null,
  response_status integer,
  response_body jsonb,
  created_at timestamptz not null,
  primary key (account_id, token)
);
```

这里的主键确实发挥了作用。先由应用代码检查、之后再插入，会留下足够大的空隙，让并发工作线程、队列重复投递和急于重试的请求全部穿过去。

占用令牌后，处理程序执行业务操作，并将最终响应写回操作记录。之后，匹配的请求都会收到已保存的状态码和响应体。这样，即使原始处理程序已经成功，但连接在回复前断开，调用方仍能得到稳定答案。

最棘手的情况是：请求已经拥有一行记录，但在操作中途退出。不要因为工作线程超时就删除记录。另一个工作线程可能仍在完成操作，或者外部服务商可能已经接受了请求。应将操作标记为待处理或未知，记录足够的调查信息，并允许调用方查询状态。只有在了解下游系统状态的前提下，修复任务才能解决过期记录。

如果工作跨越数据库和外部 API，应使用 outbox 模式或服务商提供的幂等令牌。数据库事务无法在邮件、支付或云部署离开进程后将其回滚。应在一个本地事务中写入操作意图和 outbox 事件，然后由工作线程携带稳定的下游操作标识符发送事件。这样，恢复代码就有具体对象可以重放，而不必凭空创建第二个操作。

## 确认必须绑定到精确操作

人工确认可以防止另一类故障：智能体可能拥有执行权限，但提议的操作可能出人意料、范围过大，或在上下文变化后被重复执行。一个笼统的「允许部署」按钮无法解决这个问题，因为它允许智能体在同一批准下替换部署内容。

有用的确认内容应说明目标、操作、后果和操作标识符。对于生产部署，应显示环境、制品或提交引用、受影响的服务，以及操作是否可以回滚。对于支付，应显示收款方、金额、货币和发票引用。对于工单，应显示目标项目和标题。

确认记录应绑定请求指纹，并在提议不再是当前提议时过期。如果智能体在人员批准后修改请求体，指纹就会改变，系统必须再次请求确认。请求发生变化后仍复用原批准，是一种隐蔽的权限提升，即使没有人有意这样做。

不要让人员为每一次低风险重试都进行批准，否则正确的幂等设计也会变成确认疲劳。第一次批准可以授权这个带指纹的操作，匹配的重试可以使用同一批准，因为它们无法改变操作含义。请求负载发生变化，就需要新的决定。

有些团队依赖类似「继续吗？」的聊天消息，并把回复视为批准。这在压力下很容易失败，因为记录通常没有包含精确参数，而且智能体可能将较晚的回复误认为是对较早请求的同意。应将操作标识符写入确认记录，并要求执行器在发送写入请求前验证它。

一个简单的批准负载可以清楚展示这种绑定关系：

```json
{
  "operation_id": "op_3f8c",
  "idempotency_token": "81b59b1a-9e75-4de7-a53b-1bb50969c83c",
  "fingerprint": "e5c7...",
  "expires_at": "2025-06-14T15:30:00Z",
  "approved_by": "user_42"
}
```

应将确认视为对某个特定命令的授权，而不是对某一类命令随意操作的许可。这个区别能让重试保持安全，同时不会给智能体一张可以日后重复使用的空白批准。

## 工单系统还需要业务层面的重复检查

幂等令牌可以阻止重复的传输尝试，但工单系统还有另一种重复来源：智能体可能启动多个描述同一问题的独立操作。监控告警到达两次，两个智能体运行过程读取了同一个事件频道，或者调度器崩溃后重新唤醒，在没有原始状态的情况下重放任务。

不要通过标题文本去重。工单标题的变化足以让重复项无法匹配，而相同标题也可能指向不同事件。应先决定工单领域中的身份意味着什么。它可以是告警事件标识符、事件标识符、代码仓库问题引用，或者服务加告警指纹加事件时间窗口这样的组合值。

在 API 中明确表达这个业务标识符：

```json
{
  "source_event_id": "alert-7c91",
  "project": "operations",
  "title": "Certificate expiry alert",
  "description": "Alert event alert-7c91 crossed its threshold."
}
```

工单服务可以在预期范围内对 `source_event_id` 强制唯一。第二次智能体运行就会获得已有工单标识符，而不是向队列再添加一项。这与幂等性是两回事。两个调用可能使用不同的幂等令牌，因为它们来自两个不同的智能体进程，但它们仍可能代表同一个上游事件。

只有在搜索结果拥有可信的稳定身份时，智能体才应在创建前搜索。按标题搜索很诱人，因为不需要修改 API。但只要索引存在延迟、查询排序发生变化，或智能体改写了标题，这种方式就会失效。应将唯一性规则放在实际写入的位置，并返回清晰响应，说明 API 是创建了工单，还是复用了已有工单。

要小心自动评论和状态变更。找到已有工单的操作，可能仍然会追加重复评论或重新打开已解决的事件。应为每个有意义的子操作提供独立标识符，或者让写入命令表达完整的目标状态。含义模糊的「更新这张工单」端点很难安全重试，因为没人能确定更新中的哪一部分已经执行。

## 支付写入需要查询结果，而不是凭乐观判断

支付操作需要更严格的标准，因为重复扣款即使之后退款，也会伤害客户。应用应向支付服务商发送一个稳定的幂等令牌，并将服务商交易引用与本地操作记录一起保存。

客户端超时时，必须将支付视为未知状态。它应根据服务商引用、商户引用或幂等令牌查询状态，具体取决于服务商是否提供对应查询。不能因为智能体没有收到成功响应，就开始另一次支付尝试。

人们经常把两个操作混为一谈：创建支付意图和扣款。它们的重试行为可能不同。服务可以使用令牌安全地创建或获取一个支付对象，然后在检查通过后要求执行一次单独且明确的扣款操作。应公开建模业务状态，而不是隐藏在一个每次调用都试图完成所有事情的单一端点后面。

在生成指纹前，需要规范处理金额。将金额转换为支持的最小货币单位，或其他精确表示，再让请求进入去重层。不要对浮点显示值进行哈希，然后期待等价计算能够可靠匹配。如果业务中存在发票或订单引用，支付请求也应包含它，因为这能让工作人员在网络重试之外识别重复意图。

服务商提供的幂等功能并不能替代你自己的 API 设计。应用仍然需要阻止两个智能体任务为同一订单发起两个不同的服务商请求。应对订单的可支付状态设置唯一约束，使用本地操作记录，并让智能体在结果不确定后查询该记录。

退款同样需要谨慎处理。「重试退款」可能意味着重试同一笔退款请求，也可能意味着发起另一笔部分退款。应为每条退款指令保留稳定标识符，并记录已请求的金额。如果智能体需要发起第二笔退款，应将其作为一条新的、明确授权的指令，并使用新的标识符。

## 部署需要不可变引用和发布锁

只有在部署重试指向同一个发布版本时，它才安全。`main` 这样的分支名称和 `latest` 这样的可变标签不符合要求。超时后重试时，同一个名称可能解析到不同代码，最终看似成功，却部署了审批人从未审核过的内容。

应使用不可变的制品摘要、提交标识符，或发布系统保证不会改变的版本号。将它包含在请求指纹和确认内容中。如果智能体使用同一个幂等令牌提交了不同的制品引用，应将其作为冲突拒绝，而不是当成更新后的重试请求。

你还需要为环境制定并发规则。两个独立操作可以合法地使用不同令牌，但仍然因为都指向生产环境而发生冲突。发布锁、乐观版本检查或部署队列可以将这些变更串行化。幂等性无法决定两个不同部署中的哪一个应该获胜，它只能阻止同一个部署运行两次。

考虑下面的故障过程。智能体为提交 `a1b2c3` 启动部署 `dep-118`，部署控制器接受了它。智能体丢失响应，以为部署失败，于是因为出现了更新的提交，使用提交 `d4e5f6` 启动 `dep-119`。现在两个作业都会修改同一环境。令牌只能阻止对 `dep-118` 的真正重试，发布锁或预期环境版本检查才能阻止第二个相互冲突的计划。

部署 API 应公开一个操作状态资源，报告排队、运行中、成功、失败、已取消或未知状态。超时后，智能体应轮询该状态。不能根据缺失的响应，或不带操作标识符的日志行，推断部署是否完成。

回滚需要自己的操作标识符和批准。把回滚当成部署的重试，会掩盖意图上的重大变化。回滚可以根据明确记录的安全规则自动执行，但必须留下与原始发布不同的记录。

## 智能体工具应跨越边界保留操作身份

智能体工具接口应让安全行为比不安全行为更容易。为智能体提供一个操作，接收稳定的操作标识符、请求负载和声明的重试模式。返回结果时，说明服务是创建了工作、重放了之前的结果、发现操作正在进行，还是拒绝了内容发生变化的重试。

避免工具在每次调用时悄悄生成新的幂等令牌。它们在演示中看起来很方便，却会在第一次真实超时中失败。如果工具负责生成令牌，就必须立即返回令牌，并将它持久化到后续调用可以取回的位置。在大多数系统中，应由工作流层负责令牌，因为它知道哪些调用属于同一个由用户请求的操作。

Sallyport 可以将 API 凭据保留在智能体之外，同时让智能体通过 MCP 连接提交预期的 HTTP 操作。这种分离有助于防止凭据暴露，但下游 API 仍然需要具备幂等行为。受到保护的凭据不会让一个结果不明确的 POST 自动变成安全重试。

应在工具契约中明确智能体的重试规则：

```text
if response is a known success:
    record operation complete
if response is a timeout or connection failure:
    query operation status using the same token
    retry only with the same token if the API permits it
if response says fingerprint conflict:
    stop and request a new operation or human review
if response is a known business failure:
    do not retry until the task changes
```

不要让智能体把指数退避当成状态管理的替代品。退避可以减轻服务压力，这很重要，但它无法回答上一次写入是否成功。智能体必须在等待之前保存操作标识符。

## 日志必须证明有争议的写入发生了什么

当客户说自己被扣款两次，或工程师发现两张工单时，你需要回答四个问题：是哪次智能体运行发出了每个请求，使用了哪个令牌，服务器计算出了什么指纹，以及下游服务返回了什么结果。一般请求日志往往至少缺少其中一项。

在 API 接受操作的边界记录操作信息。记录经过身份验证的主体、令牌、指纹、请求路由、操作状态变化、响应引用，以及存在时的上游服务商引用。不要在常规日志中保存密钥和完整敏感请求体。指纹可以让你在不将每个私密字段写入每个日志系统的情况下比较请求。

当智能体拥有执行外部写入的权限时，追加式审计记录很有帮助。记录应区分已尝试、已批准、已发送、已接受、已完成和已重放。这些状态不能混为一谈。收到已保存响应的重试应标记为 `replayed`，而不是 `created`，否则运维人员会把它统计成第二次操作。

Sallyport 会在加密的哈希链审计日志基础上生成日志，记录智能体会话和单个操作，`sp audit verify` 可以离线验证这条链。这能证明哪些内容经过了操作网关。还应将这份证据与接收服务的幂等记录结合起来，因为真正决定业务操作是否执行的是接收服务。

在信任设计之前，先测试有争议写入的路径。让服务器完成请求后丢弃响应。发送同一个令牌的并发副本。在两次尝试之间重启智能体。使用改变后的请求负载复用令牌。在工作线程占用令牌后、记录完成前终止它。只在干净的成功响应下正常工作的设计，还没有解决重复写入问题。

先从重复后影响最大的一条写入端点开始。添加稳定令牌，在任何副作用发生前以原子方式占用令牌，将它绑定到请求指纹，并为结果未知的调用方提供状态查询。然后让智能体一直携带这个标识符，直到能够证明操作已经到达终止状态。
