# AI 代理 API 限流：安全的重试策略

代理遇到配额限制后，应放慢速度，记录每一次额外尝试，并在临时拒绝演变成更大范围的运营混乱之前停止。对一个手动点击按钮的人来说，“失败后重试”算得上足够的建议。对一个能以人眼无法跟上的速度发出请求的进程来说，这个建议却很危险。

真正困难的地方不是等待几秒。难点在于，当第一次请求可能在到达服务商之前失败、服务商完成操作后才失败，或多个代理运行耗尽同一份共享配额时，如何保留原始任务的含义。安全的设计应区分这些情况，并把人工介入定义为一种明确结果，而不是令人尴尬的例外。

## 429 是调度指令，不是可以反复猛撞的错误

HTTP 429 表示服务器拒绝请求，因为客户端在服务器控制的某个时间段内发送了过多请求。RFC 6585 定义了这个状态码，并说明响应内容应解释具体情况，也可以包含 Retry-After 标头。这段描述很重要：429 并不表示代理立即重复完全相同的请求就有机会成功。

代理首先应记录服务商、端点、凭据或账户身份、请求类别、响应状态，以及所有限流标头。然后把任务置于延迟状态。不要让语言模型在每次失败后用文字临时决定下一步。传输层需要确定性的行为，因为处于任务压力下的模型往往会尝试相邻端点、修改筛选条件，或创建第二条请求路径。这些临时发挥可能让调用数量成倍增加，却仍然得到同样的拒绝。

拒绝通常适用于比当前请求更大的配额桶。服务商常按账户、项目、令牌、IP 地址、端点族，或这些因素的组合设置限制。一个代理可能收到 429，而使用同一凭据的另一个代理暂时还能继续工作。这并不能证明第一个代理可以安全重试。它可能说明服务商使用了多个配额桶，也可能说明第二个进程正在消耗最后的可用容量。

为每个已知范围维护本地账本。如果服务说明限制按令牌计算，就按令牌分组请求。如果服务只提供账户级指导，在有证据证明情况不同前，应假设该账户的所有工作进程共享同一个配额桶。本地账本无法完全反映服务商的内部计数器，但可以避免自有工作进程盲目争抢。

IETF 的 RateLimit Fields 规范 RFC 9333 定义了 `RateLimit-Limit`、`RateLimit-Remaining` 和 `RateLimit-Reset`。这些字段描述配额状态，但不能消除已经收到的 429。使用它们来控制未来请求的节奏，避免队列增长速度超过服务商的接收能力。应按照服务商文档判断这些字段的适用范围，因为单凭标头可能无法知道它适用于一个端点，还是整个账户。

一个有用的状态记录如下：

```json
{
  "provider": "billing-api",
  "scope": "account:ops-team",
  "request_class": "write:invoice",
  "next_allowed_at": "2025-04-08T14:12:31Z",
  "remaining": 0,
  "reset_after_seconds": 60,
  "source": "HTTP 429 Retry-After"
}
```

代理运行器在派发下一次调用前应读取这条记录。对于小型命令行运行，在单个请求处理器中等待是可以接受的。等到存在多个工作进程、工具或恢复任务后，带有可见 `next_allowed_at` 的队列会更好。它能让调度器选择其他工作，而不是让进程一直占用，同时也能为操作人员清楚解释延迟原因。

## 重试预算可以阻止小故障变成洪峰

重试预算会给任务在调用失败后产生的额外工作设置硬上限。要同时计算尝试次数和经过的等待时间。只有固定次数的限制，在服务要求客户端等待几分钟时会失效；只有时间限制的策略，则可能让快速重试循环在几秒内耗尽账户配额。

为每个任务和每个共享服务商范围分别设置预算。任务预算回答的是：“这个目标可以承受多少不确定性？”范围预算回答的是：“当前所有工作最多可以给这个服务商造成多大影响？”如果十个任务各自重试三次，账户仍然可能收到三十个额外请求。这正是看似保守的策略变成请求洪峰的方式。

对于普通读取请求，我会设置较小的预算，并让截止时间短于答案本身的业务价值。对于写入操作，我会把更少的预算用于盲目重试。等待人工确认的成本，可能低于重复扣款、重复发送通知，或重复执行部署的成本。

把规则表示成代理不能随意覆盖的数据：

```yaml
request_classes:
  read:
    max_attempts: 4
    max_wait_seconds: 90
    retry_statuses: [408, 429, 500, 502, 503, 504]
  idempotent_write:
    max_attempts: 3
    max_wait_seconds: 120
    retry_statuses: [408, 429, 502, 503, 504]
  uncertain_write:
    max_attempts: 1
    max_wait_seconds: 0
    retry_statuses: []
shared_scope:
  max_delayed_requests: 25
  max_concurrent_requests: 2
```

这段配置可以避免一种常见故障：代理提交写入后收到超时，以为操作没有发生，于是向已经承受压力的服务商再次发起请求。`uncertain_write` 类别会强制执行状态检查或升级处理。它不会因为代理坚持重试就奖励它，因为坚持并不会改变结果。

预算必须计算每一次实际尝试，包括库内部发起的重试。我见过应用程序声明了重试控制，但 HTTP 客户端、工作流运行器和代理又分别在底层重试。直到有人检查了这三个组件的默认设置，最终请求数量才不再神秘。应选择一个层负责重试。其他层要么配置为向上报告错误而不重试，要么明确配置其行为，并将其计入总预算。

预算耗尽后应返回带有上下文的终止结果，而不是通用失败。结果应说明原始请求是否已经发送、是否收到响应、发生了多少次尝试、任务等待了多久，以及下一步安全操作是什么。这样，代理可以继续处理无关工作，或给出准确的升级信息，而不是不断对自己说“再试一次”。

## 退避需要抖动和上限

指数退避会逐渐延长连续拒绝后的等待间隔，从而降低压力。抖动可以避免同时失败的多个工作进程在同一波同步返回。即使服务商公布了重置时间，这两者也都应放在客户端中，因为内部并发本身也可能造成惊群效应。

对于尝试次数 `n`，第一次重试时 `n = 1`，先计算上限，再在上限范围内随机选择延迟：

```python
import random

BASE_SECONDS = 1.0
MAX_SECONDS = 60.0

def retry_delay(attempt_number: int) -> float:
    cap = min(MAX_SECONDS, BASE_SECONDS * (2 ** attempt_number))
    return random.uniform(0, cap)
```

这就是完整抖动。它可以避免所有工作进程严格等待 2、4、8 和 16 秒。固定且相同的延迟很受欢迎，因为日志更容易阅读。但它也让协调后的重试变得容易预测，这恰恰与繁忙服务所需要的方向相反。

当响应提供 `Retry-After` 时，应优先采用该值，而不是本地计算出的更短延迟。RFC 9110 允许 `Retry-After` 使用秒数延迟或 HTTP 日期两种形式。两种形式都要解析。如果由于机器时钟与服务商时钟不同，日期已经过期，应应用一个适度的最小延迟，而不是进入紧密重试循环。

不要把退避当作速率控制的替代品。退避在失败后才开始。令牌桶、漏桶或简单的调度器限制，则负责在失败发生前控制速率。如果服务商允许在已知时间段内发送已知数量的请求，就应将请求速率控制在公布额度以下，并为交互式工作保留空间。调度器也应限制并发量。二十个同时发出的请求，可能在任何工作进程读取 `RateLimit-Remaining` 响应之前就耗尽短时间窗口的容量。

一个实用的派发规则很简单：发送调用前，检查共享范围的下一个允许时间和可用并发槽位。收到 429 后，在安排任何重试前更新范围记录。这个顺序很重要。如果工作进程在发布拒绝信息前就安排重试，每个工作进程都可能认为下一个槽位属于自己。

限制延迟，也限制总等待时间。没有上限的指数曲线可能让任务推迟数小时，并让一个过时的运行在其前提失效很久后仍然存活。代理应在截止时间到达后终止任务，或将任务交给调度器，同时保留原始输入供之后复核。

## 幂等性决定重试是否安全

幂等请求在重复执行时具有相同的预期效果。这并不表示所有使用 HTTP `PUT` 的请求在每个应用中都无害，也不表示所有 `POST` 都危险。RFC 9110 将 `PUT` 和 `DELETE` 等方法描述为意图上的幂等方法，而 `POST` 通常不是幂等的。真正决定运营风险的是服务商的 API 合约。

读取请求通常可以重试，前提是代理接受返回的数据可能已经改变。删除请求也可能适合重试，例如删除已经不存在的资源会得到明确且可接受的结果。创建付款、邀请、支持工单、采购订单或部署，通常无法承受盲目重复。这些调用在超时或连接重置后最需要谨慎处理。

这个领域经常混淆两种不同状态：

- 明确没有到达服务商的请求，如果操作本身允许重复，就可以安全重新发送。
- 结果未知的请求，需要先核对状态，才能再次发送副作用操作。

客户端写入字节后发生连接故障，并不能证明服务商没有执行操作。服务器可能已经完成操作，只是响应连接断开了。代理不能根据没有响应这一点推断结果。

如果服务商支持幂等键，为每个逻辑操作生成一个稳定令牌，并在该操作的每次重试中重复使用。不要每次尝试都生成新令牌。新令牌会告诉服务商每次重试都是独立操作，从而失去保护作用。

```http
POST /v1/transfers HTTP/1.1
Idempotency-Key: transfer-7c41b5b9-7f5b-4f51
Content-Type: application/json

{"source":"acct_17","destination":"acct_42","amount":12500,"currency":"USD"}
```

在发送第一次调用前，将令牌和任务及请求负载一起保存。如果运行器重启，它必须恢复同一个令牌。还要保存服务商返回的操作标识符。之后可以使用这个标识符发起核对调用，查询原始操作，而不必重新创建它。

如果 API 不提供幂等功能，可以寻找“创建后查询”的模式。代理可以在负载中附加客户端生成的外部引用，然后在不确定失败后按该引用搜索。如果 API 既不支持幂等，也没有可靠的查询方式，就不要自动化重复写入。应要求人工检查服务商记录。第一次重复副作用进入无法轻易撤销的系统前，这种方式会显得慢；之后你会发现它其实更快。

## 区分限流、服务故障和错误请求

把所有非成功状态一概视为相同的重试策略，会隐藏缺陷并浪费配额。选择延迟前先对响应分类。状态码、响应内容、服务商错误码和请求方法都很重要。

可以采用以下实用分类：

- `429` 表示降低速率、遵守服务商指导，并从共享限流预算中扣除。
- `408`、连接重置和部分 `5xx` 响应可能适合有限重试，但写入操作仍需要幂等或核对路径。
- `400`、`401`、`403`、`404` 和 `422` 通常需要修正请求、调整授权，或由人工决定。重复发送没有意义。
- `409` 需要按资源处理。它可能表示重复、版本冲突，或另一个工作流持有锁。
- 服务商特定的配额耗尽错误可能要求等到计费周期或每日配额重置，这与短时突发限流不同。

不要把 `503 Service Unavailable` 当作可与 `429` 互换的状态。503 表示服务当时无法处理请求；429 表示客户端超过了限制。两者都可能携带 `Retry-After`，但 429 应让调度器降低受影响范围的本地吞吐量。503 则可能是区域、端点或整个服务商的问题。在指标和操作人员消息中保留这种区别。

代理行为也可能造成无效请求风暴。模型收到 `422` 后，可能不断使用不受支持的筛选条件调用端点，或者在凭据已撤销后重复尝试 `401`。应在重复的相同失败周围设置断路器。例如，如果同一次运行中，相同端点、方法和规范化错误码连续失败多次，就停止这条路径，并把错误详情作为约束返回给代理。不要允许代理通过改变空白、重新排列 JSON 字段，就假装自己在探索新选项。

规范化的请求指纹应排除密钥和易变标头。它应包含方法、端点模板、稳定的负载字段和 API 错误码。这样，运行器可以识别循环，同时不会记录凭据或完整的敏感请求内容。

## 共享凭据需要一个队列，而不是依赖代理自觉

共享 API 凭据带来了单个代理无法靠良好意愿解决的协调问题。除非调度器提供共同预算，否则每个进程只能看到自己的请求。按代理分别退避可以降低请求洪峰的可能性，却无法公平分配账户级的稀缺配额。

在共享凭据范围前放置出站队列，并有意识地分配优先级。经过人工批准的生产变更，可能需要优先于后台库存工作。一个发现一万条记录的长时间运行代理，应按服务商允许的速率逐页处理，而不是一次把所有分页请求塞进队列。

队列需要支持取消。如果代理的父任务结束，应在延迟请求唤醒前丢弃它们。否则，已停止的任务可能在之后继续消耗配额，让审计轨迹变得难以理解。取消时也必须释放预留的并发槽位。

对安全的读取操作使用请求合并规则。如果五次代理运行在短时间内请求同一个不可变记录，就执行一次请求，并将结果分发给所有等待任务。对于新鲜度会改变含义的读取操作，例如余额或审批状态，不要合并请求。目标是消除意外重复，而不是建立一个向决策代理返回过时事实的缓存。

限流常常暴露更深层的规划问题。代理每个项目调用一次详情端点，可能完全遵循了指令，却使用了错误的访问方式。在添加重试前，先寻找批量端点、分页控制、条件请求、webhook、导出任务或服务端搜索端点。这些改动可以在服务商拒绝请求前就减少调用数量。

Sallyport 可以让 API 凭据留在代理进程之外，同时记录每次出站调用，但调用方仍然需要队列和重试控制。凭据隔离可以减少密钥暴露，却不会改变服务商的配额。

## 人工升级应保留不确定性

当系统无法确定副作用是否发生、剩余等待时间超过任务期限、共享配额耗尽，或反复限流指向账户配置问题时，应交由人工处理。升级通知不应只是笼统的“API 失败”。它应是一份简洁的案例记录，让处理人员无需从零散日志中重建运行过程就能做决定。

应向操作人员提供以下事实：

- 任务要求的结果，以及停止执行的确切逻辑操作；
- 服务商、端点、方法、账户范围和经过清理的请求指纹；
- 尝试时间、状态、`Retry-After` 或限流标头，以及已经消耗的预算；
- 操作是否有幂等令牌、服务商操作 ID 或核对查询；
- 下一步安全选项，例如等待、查询状态、增加配额、调整方案或取消。

默认不要在审批卡片中放入原始请求内容。它可能包含个人数据、内部文档内容，或一个看似无害、但不应被错误人员看到的值。显示简洁摘要，将详细访问作为有意执行的审计操作。

审批应请求一个决定，而不是仅仅征求“继续”的许可。对于不确定的写入操作，可以提供“查询现有操作”“使用同一个幂等令牌重试”“取消”，以及在确有必要时的“发送新操作”。最后一个选项应明确说明，它可能产生第二个副作用。当选项如实描述实际风险时，人们更容易做出正确判断。

持续访问权限的批准，与特定外部操作的批准，是两种不同的控制。会话可以继续获得授权，但代理仍可能需要在付款、生产写入，或限流事件后的重复请求前逐次接受审核。界面和日志都应将这两类决定分开。

查看升级案例时，应先做状态核对。通过幂等键、外部引用或操作标识符查询服务商。只有当查询显示操作没有完成，或服务商保证会抑制重复时，才进行重试。相比盲目重新发送，这个顺序只多花一次网络往返，却比修复已经进入下游账本的重复操作更快。

## 审计记录必须解释尝试的操作和等待过程

有用的审计记录回答的不只是“请求是否返回 200”。它应显示代理尝试了什么、什么授权允许了该操作、远程服务返回了什么，以及重试控制器如何做出反应。没有决策记录，一系列调用看起来就像粗心的重复，即使调度器确实遵守了文档中的 `Retry-After` 值。

在派发前记录一个事件，然后追加结果和调度事件。请求元数据中不要保存明文凭据。一段紧凑的序列可能如下：

```text
14:11:02 action_requested  task=sync-482 method=POST route=/records
14:11:02 action_sent       attempt=1 idempotency=rec-91f2
14:11:03 action_result     status=429 retry_after=30 scope=account:ops
14:11:03 retry_scheduled   attempt=2 due=14:11:33 budget_wait=30
14:11:33 action_sent       attempt=2 idempotency=rec-91f2
14:11:34 action_result     status=201 provider_id=r_893
```

这段序列把逻辑操作与传输尝试区分开来。如果有人问为什么出现了两次 `POST`，记录会显示它们共享同一个幂等令牌，并遵守了服务商要求的延迟。如果第二次响应发生超时，下一个事件应是 `reconciliation_required`，而不是另一个自动的 `action_sent`。

防篡改日志在事故复盘中很有用：团队可以确认运行过程没有在事后删除不利的尝试。Sallyport 会从加密的哈希链审计日志中生成会话和调用日志，`sp audit verify` 可以在离线状态下检查链条，无需保管库密钥。当操作人员需要区分服务商限流、代理循环或较晚发生的人工重试时，这一点很有价值。

日志还需要保留期限和访问边界。即使令牌从未出现，请求路径、服务商账户标识和时间模式也可能暴露敏感的运营活动。记录足以重建决策的信息，然后限制可以搜索和导出记录的人员。

## 在代理于生产环境遇到故障前测试失败路径

限流设计只有在测试证明它会停止调用、保留幂等性并升级不确定状态后才算完整。只模拟一次 429 不够。还需要测试并发工作进程，以及远程服务可能已经执行操作之后才发生的故障。

在测试环境或可控的虚假 API 上运行以下流程：

1. 启动三个使用同一模拟账户范围的代理任务，并让它们发出相同的安全读取请求。
2. 对第一个请求返回带有 `Retry-After: 10` 的 `429`，然后确认调度器会延迟该范围内的所有工作，而不是让另外两个任务继续全速运行。
3. 延迟后返回成功，并检查重试时间存在轻微差异，而不是同时形成一个洪峰。
4. 使用稳定的幂等令牌发送写入请求，模拟虚假 API 保存记录后连接断开，并确认运行器会查询状态，而不是发起新的创建请求。
5. 耗尽配置的时间预算，并确认操作人员收到经过清理的升级记录，同时已取消的工作不会在之后重新唤醒。

在虚假 API 处测量出站尝试次数，而不只是代理内部的函数调用次数。内部计数器会漏掉 HTTP 库或包装器增加的重试。还要测试重启：第一次不确定的写入后停止运行器，恢复持久化状态，并确认它会使用原始幂等令牌继续进行状态核对。

不要因为测试代理最终获得了成功响应，就给它奖励。应奖励它发出正确数量的调用、在得到指示时等待，并在掌握证据前保持不确定的写入未解决。一个通过重复外部操作来“完成任务”的代理，没有通过测试。

首先应实现的是带有明确终止结果的共享重试预算。它会把限流从“继续尝试”的邀请，变成一个有记录、有截止时间，并且在远程状态不确定时有人可以介入的运营决策。
