AI Agent 幂等性:防止因重试导致重复扣款

AI Agent 因重试机制易引发重复扣款风险。本文详解如何利用幂等键(Idempotency Key)设计安全的 API 工具调用,从根本上避免重复写入与计费错误。

用 Apifox,节省研发团队的每一分钟

AI Agent 幂等性:防止因重试导致重复扣款

免费使用 Apifox

相关推荐

最新文章

API

一体化协作平台

API 设计

API 文档

API 调试

自动化测试

API Mock

API Hub

立即体验 Apifox
目录

你的 Agent 调用了支付接口。请求发送成功,扣款也已入账,但在返回的途中响应超时了。Agent 没有收到 200,于是它执行了你在失败时设定的操作:重试。现在,客户被扣了两次款,而在你的日志中却看不出任何异常。

这就是将 Agent 与普通 API 客户端区分开来的失效模式。人类用户点击一次“支付”后,会看着加载图标并等待。而在重试循环中的 Agent 面对没有响应的情况,会再次尝试,有时会连续尝试三到四次,速度远快于人类。你为了提高 Agent 可靠性而添加的每个重试策略,都会增加重复写入的概率。解决办法就是幂等性(idempotency):使重复的请求产生与单次请求相同的结果。

本指南将介绍 HTTP 层面上的幂等性意味着什么、如何生成 Agent 可以实际复用的 Key、服务端需要存储什么来支持它们,以及如何在向真实客户重复计费之前对整套流程进行测试。如果你还没有阅读我们关于 AI Agent 在生产环境中为何会崩溃的核心文章,那么重复写入就是大多数“Agent 执行了两次”报告背后的隐藏失效模式。

Apifox 会在测试部分派上用场。幂等性是需要你构建在 API 和 Agent 工具层中的东西。之后,你需要一种方法来发起两次相同的请求,并证明第二次请求没有改变任何内容,这是一个你可以保存并在 CI 中运行的测试。

AI Coding 交流群

如果你也在用 AI 写代码,或者正在研究 Cursor、Claude Code 这些工具,欢迎加入以下交流群。群里平时会聊一些 AI 编程的实际用法、开发工作流,还有各种新工具和新玩法。

为什么 Agent 比人类更容易破坏幂等性

Agent 流量的三个特点使得重复问题非常普遍。

首先是重试量。Agent 框架默认会进行激进的重试,因为瞬时网络故障是导致运行中断的最常见原因。我们的 Agent 错误恢复指南详细介绍了退避和熔断机制,其中每项技术都会增加特定请求访问服务端的次数。

其次是超时的不确定性。当请求超时时,客户端无法得知服务端是否已处理该请求。来自代理的 504 错误可能意味着写入操作从未发生,也可能意味着写入已发生但响应丢失了。人类通常会在重试前进行确认。Agent 通常不会,因为“先检查”是模型必须决定发起的一次额外工具调用。

第三是循环。任务失败的 Agent 可能会重新启动整个任务,而不仅仅是失败的步骤。如果步骤一创建了订单,而步骤四失败了,那么简单的重启就会创建第二个订单。这就是多步骤 Agent 与脚本截然不同的地方:重试边界是模糊的,并且是由模型而非你的代码来决定从哪里开始。

将这些因素结合在一起,就构成了问题的全貌。这并不是因为 Agent 发送了错误的请求。而是它们会多次发送正确的请求。

幂等性实际保证了什么

当多次执行某个操作的效果与仅执行一次相同时,该操作就是幂等的。在 HTTP 语义规范 RFC 9110 中,GETPUTDELETE 被定义为幂等的。而 POST 则不是,这正是那些危险操作(如创建订单、发送消息、发起转账)往往是 POST 调用的原因。

澄清以下两点可以避免很多混淆。

幂等并不等同于安全(safe)。安全的方法不会改变任何状态。DELETE 是幂等的但具有破坏性:调用它五次与调用一次的效果相同,即资源被删除,但资源确实已经不复存在了。Agent 需要将这两个属性分开处理,这也是我们那篇关于 Agent 最小权限 API key 的文章从凭证角度所阐述的观点。

幂等也不等同于相同的响应。第二次调用可能会返回第一次调用存储的结果,也可能会返回不同的状态码。绝对不能改变的是服务端上的状态。比如:只扣款一次、只生成一个订单、只发送一封邮件。

幂等键(Idempotency keys):让 POST 变得安全的模式

标准的解决方案是随请求发送一个由客户端生成的键。服务端会记录该键与执行结果,后续任何携带相同键的请求都将直接返回记录的结果,而不会重新执行操作。

Stripe 推广了这一 header,Stripe 幂等文档 依然是对其语义最清晰的描述。IETF 也致力于将其标准化为 Idempotency-Key header 字段,在您设计自己的 header 名称之前,非常值得阅读一下该标准。

请求示例如下:

POST /v1/payments HTTP/1.1
Host: api.yourservice.com
Authorization: Bearer sk_live_...
Idempotency-Key: 9f2b7c14-6d3a-4b18-9d55-1e2a7c0b4f31
Content-Type: application/json

{
  "amount": 4900,
  "currency": "usd",
  "customer_id": "cus_8812",
  "description": "Pro plan, August"
}

该键是一个 UUID。对于服务端而言,除了“这是同一个逻辑操作”之外,它没有任何其他意义。服务端会将该键与请求 body 的指纹以及生成的响应一同存储。

生成 Agent 可以复用的键

这正是大多数 Agent 实现容易出错的地方。如果工具包装器(tool wrapper)在每次调用时都生成一个新的 UUID,那么每次重试时键都会改变,幂等性就无法发挥任何作用。规则是:当 Agent 决定执行某个操作时生成该键,并在该决定的每次重试中都保留它。

import uuid

class PaymentTool:
    def __init__(self, client):
        self.client = client
        self._keys = {}

def charge(self, task_id, step_id, amount, customer_id):
        # One key per (task, step). Retries of the same step reuse it.
        op = f"{task_id}:{step_id}"
        if op not in self._keys:
            self._keys[op] = str(uuid.uuid4())

        return self.client.post(
            "/v1/payments",
            headers={"Idempotency-Key": self._keys[op]},
            json={"amount": amount, "customer_id": customer_id},
        )

使用确定性的 key 同样可行,而且它在进程重启后依然存在,而内存中的字典则无法做到这一点:

import hashlib

def idempotency_key(task_id: str, step_id: str, payload: dict) -> str:
    raw = f"{task_id}|{step_id}|{sorted(payload.items())}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

基于任务运行和步骤来生成 key,绝不要使用时间戳或每次尝试时重新生成的随机值。如果 Agent 重启了整个任务,并且确实想要进行一次新的扣款,那么任务 ID 会发生变化,key 也会随之改变。这正是你想要的行为。

服务端需要做的工作

正确处理 header 不仅仅是进行一次查找。一个有效的实现需要完成四件事:

  1. 在请求到达时,尝试占用该 key。在执行任何操作之前,将其插入到具有唯一性约束的表中。如果插入失败,说明另一个尝试已占用了它。
  2. 如果 key 已经存在,但存储的请求指纹不同,则拒绝请求并返回 422。相同的 key 带有不同的 body 意味着客户端存在 Bug,静默地返回旧的结果会掩盖这个问题。
  3. 如果 key 已经存在,且第一次尝试仍在处理中,则返回 409,以便调用方退避(back off)而不是产生竞态冲突。
  4. 当工作完成后,将状态码和 body 与该 key 关联并存储起来,后续的每次请求命中时直接返回该结果。
CREATE TABLE idempotency_records (
  key             TEXT PRIMARY KEY,
  request_hash    TEXT NOT NULL,
  state           TEXT NOT NULL,      -- in_progress | completed
  response_status INT,
  response_body   JSONB,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at      TIMESTAMPTZ NOT NULL
);

设置一个过期时间。24 小时足以覆盖任何实际的重试窗口,而永久保留 key 会让这张表成为一种负担。Stripe 会在 24 小时后使 key 过期,这是一个值得借鉴的合理默认值。

测试第二次调用不会改变任何状态

构建幂等性只是完成了一半的工作。证明其有效则是另一半工作,而且这往往是被忽略的一半,因为无论该功能是否正常工作,正常流程(happy path)看起来都是一模一样的。

这个测试描述起来很简单:发送请求,记录结果,再次发送完全相同的请求,并断言服务端没有执行两次操作。难点在于最后的断言,因为单靠响应是无法告诉你的。两次成功的扣款都会返回 200

因此,要对状态进行断言,而不是对响应进行断言:

  • 第二个响应 body 与第一个匹配,包括资源 ID。新的 ID 意味着创建了新的资源。
  • 后续对该集合执行 GET 操作应返回一条记录,而不是两条。
  • 任何计数器或余额仅变动一次。

Apifox 中,你可以将其配置为一个测试场景:第一步发送带有固定 Idempotency-KeyPOST 请求,第二步重复该请求,第三步列出资源并断言数量。将第一步中的响应 ID 保存到变量中,并断言第二步返回相同的值。由于整个场景已被存储,因此在支付路径发生任何更改时,它都会在 CI 中运行,而这正是回归问题实际出现的地方。同样的技术也适用于我们 API 契约测试指南中更广泛的模式。

还有另外两个值得关注的案例,因为它们能捕获真正的 Bug:

  • 相同的 Key,不同的 body。期望返回 422,而不是默认成功。
  • 并发重复请求。同时发起两个请求,并确认只有其中一个成功。这能捕获顺序测试永远无法暴露的缺失唯一性约束的问题。

Mock 在这里也很有帮助。如果你仍在构建 Agent 且支付 API 尚不存在,可以使用感知幂等的响应来 mock 它,以便尽早演练 Agent 的重试逻辑。我们关于为什么 Agent 应该访问 mock 而不是生产环境的文章为这一习惯提供了更广泛的论证。

当你无法添加 Key 时

有时 API 并非由你控制,并且它不支持幂等性。你仍然有一些选择,大致按推荐程度排序如下:

使操作天然具备幂等性。客户端选择的资源路径上的 PUT 请求在构建上就是幂等的:PUT /orders/{client_order_id}。如果你能控制 API 设计,相比 POST 加 header,应优先选择这种方式。它不需要额外的表。

写入前检查。在 Agent 创建记录之前,让其查询是否存在具有相同自然键的现有记录。这种方法效果较弱,因为检查和写入之间的竞态条件仍可能产生两条记录,但它消除了常见的超时情况。

在下游进行去重。如果写入是一个消息或事件,请在消费者端进行去重。附加一个稳定的消息 ID,并让消费者丢弃重复内容。这是事件驱动系统中的标准实践,并与我们可靠的 Webhook 指南中的建议相匹配。

对操作设立门禁。对于真正不可逆且无法实现幂等的操作,引入人工干预。这就是我们关于 AI Agent 防护栏文章中提到的审批门禁(approval-gate)模式,当重复操作的代价足够高时,这是正确的解决方案。

了解哪次运行执行了什么操作

幂等性可以阻止重复。但它不会告诉你哪次尝试创建了该记录,而这正是发生事故后你会被问到的问题。

将运行标识与具体工作关联起来。当 agent 是您自己的服务时,这意味着上述键派生过程中产生的任务 ID 和步骤 ID 会随每次尝试一起记录。当 agent 是执行分配任务的编码运行时(coding runtime)时,平台通常会为您保留该标识:例如在 HiFox 中,每次运行都与其源自的任务(Task)相绑定,其执行状态和结果与评论线程存储在一起。这样,重复的写入可以追溯到特定的某次运行,而不是一次匿名的重试。

发布前的清单

  • Agent 可以调用的每个非幂等工具都需要一个幂等键(idempotency key),并且工具包装器(tool wrapper)在没有该键时会拒绝发送请求。
  • 键应派生自任务和步骤,而不是派生自重试尝试(attempt)。
  • 服务端在执行具体工作之前占用该键,而不是在工作之后。
  • 使用相同的键但携带不同的 payload 会返回错误,而不是返回缓存的响应。
  • 并发重复请求由数据库约束处理,而不是由应用程序的执行时机(application timing)处理。
  • 一个已保存的测试可以证明第二次调用不会改变任何状态,并且该测试会在 CI 中运行。
  • 键会按计划过期,并且对应的数据表会被定期清理。

逐一落实这个清单,重复扣款的情况就不会再发生,这也意味着你的重试策略可以变得更加积极,而不是更加保守。这就是真正的收益:幂等性让你在提高 agent 容错能力的同时,又不会引入安全隐患。

常见问题解答

只读工具需要幂等键吗? 不需要。GET 请求本身就是幂等且安全的,因此重试只会增加一点延迟,没有其他副作用。请将幂等键留给创建、扣款、发送或以其他方式改变状态的调用。

应该在哪里生成键,在 agent 中还是在工具包装器中? 在工具包装器中,并基于 agent 的任务和步骤标识符进行关联。让模型生成键是一个错误:模型在重试时会重新生成值,并且可能会在不同的任务之间产生碰撞。

重复的请求应该返回什么状态码? 返回原始调用中存储的状态,因此如果第一次返回 201POST 请求再次发送,它应该再次返回 201 并包含相同的 body。一些 API 会添加像 Idempotent-Replay: true 这样的 header 来标记这是重复请求,这对于调试很有用,且对忽略它的客户端无害。

键应该保留多久? 24 小时几乎可以覆盖所有的重试窗口。保留更长时间很少有帮助,反而会导致数据表无限增长。如果客户端在重试窗口之后进行重试,请将其视为一个全新的操作。

这能代替事务吗? 不能。幂等键可以防止重复的请求产生重复的影响。而事务则保证单个请求的原子性。这两者你都需要,只要你的数据库允许,就应该在处理具体工作的同一个事务中写入对键的占用声明。

如何在没有真实支付提供商的情况下对此进行测试? 将 Agent 指向一个实现了关键语义的 mock,包括在 payload 不匹配时返回 422。我们关于针对 mock API 测试 AI Agent 的指南涵盖了具体配置,如果您希望将 mock 和重试测试保留在同一个项目中,请下载 Apifox

开发必备:API 全流程管理神器 Apifox

介绍完上文的内容,我想额外介绍一个对开发者同样重要的效率工具 —— Apifox。作为一个集 API 文档、调试、设计、测试、Mock、自动化测试于一体的工具,Apifox 是目前提升研发效率的首选。

如果你正在开发项目,不妨试试其极其友好的界面设计,它完全兼容 Postman 和 Swagger 数据格式,导入数据非常方便,,即使是新手也能很快上手,点击这里即可注册使用

Apifox

值得一提的是,除了个人和常规团队使用,针对有高安全合规要求、或需要在内网环境协作的企业,Apifox 还提供了深度定制的私有化部署方案

获取专属报价与部署方案

icon 详细的私有化部署系统架构与安全白皮书
icon 针对您公司规模的专属报价单
icon 免费的 1v1 专属产品演示 (Demo) 机会
获取部署方案
* 提交后,我们的客户经理将在 1 个工作日内与您联系
林俊锋 企业微信
@Apifox 专属顾问
扫码备注: 私有化 + 公司名