---
url: /practices/rate-limit-retry.md
description: >-
  前端和大模型联调，429 是最常见的报错。这篇讲清 AI SDK 的 maxRetries 默认值只有 2、退避策略 2 秒起翻倍、Retry-After
  超过 60 秒会被忽略的原因，以及 429 背后三种完全不同的钱问题怎么分开处理，附我自己在用的带 jitter 的重试封装。
---

# 429 到底该不该重试：AI SDK 自带的两次重试为什么经常不够

前端调大模型，最常见的报错不是模型答错，是 429。页面上一个红色提示，控制台一行 `Too Many Requests`，用户什么也看不到。

很多人第一反应是加个 try/catch，然后发现 SDK 自己已经重试过了，而且只重试两次。这篇文章讲清这两次重试的规则，以及它不够用的地方。事实部分核对于 2026-10-26。

## 先说结论：SDK 默认帮你重试 2 次

AI SDK（Vercel 出品的那个 TypeScript SDK）的所有核心函数，`generateText`、`streamText`、`generateObject`，默认都会重试。

参数叫 `maxRetries`，默认值 2。注意是 2 次重试，不是 2 次调用。加上首次一共 3 次请求。

退避策略是 2 秒起，每次翻倍。也就是第 1 次失败等 2 秒，第 2 次失败等 4 秒。三次都挂，抛出来。

```ts
import { generateText } from 'ai';

// 想改次数，显式传参
const { text } = await generateText({
  model: 'openai/gpt-4o-mini',
  prompt: '...',
  maxRetries: 5, // 1 次首发 + 5 次重试
});
```

`maxRetries: 0` 是关掉重试。写单元测试、或者你自己在外面包了重试时，就该关掉它，不然两层重试会相乘。

## 它会读 Retry-After，但有 60 秒的上限

429 响应一般带 `retry-after`（秒）或 `retry-after-ms`（毫秒）头。AI SDK 会读，读到了就按它等，不用自己的指数退避。

但源码里有个判断：算出来的等待时间如果小于 60 秒，才采用；大于等于 60 秒，或者解析失败，就退回默认退避。

我上次调这个接口时，OpenAI 返回 `retry-after-ms: 45000`，SDK 老老实实等了 45 秒。当时以为是网络卡死，后来才知道是在等。日志里什么都不打，这是最坑的地方。

这个 60 秒的设计是合理的。无限期等下去，用户那边的连接早就超时了。但你要知道它存在，否则查问题时完全摸不着头脑。

## 哪些状态码会被自动重试

SDK 内部有个 `isRetryable` 标记，来自状态码。命中这几类才会重试：

* 408（请求超时）
* 409（冲突）
* 429（限流）
* 所有 5xx（服务端错误）

401、403、400 不在里面。这是对的，密钥错了重试一万次也是错的。

Anthropic 的 529（`overloaded_error`）算 5xx，会被重试。但 529 是 Anthropic 自己过载，不是你超了额度，重试多半还是 529。这种情况要么换模型，要么降并发。

## 429 其实是三种病，重试只治第一种

这是最容易被忽略的点。同样是 429，背后的原因完全不同。

第一种是 RPM 超了（每分钟请求数）。你的并发太高，等一会儿就好。重试有效。

第二种是 TPM 超了（每分钟 token 数）。单个请求太大，压垮了配额。重试也有效，但更该做的是切小请求，或者上 prompt caching（提示缓存），把重复的前缀变成缓存读取，不计入输入额度。

第三种根本不是限流，是钱。OpenAI 的 `credit_balance_exhausted`、Anthropic 的 `enforced_spend_limit_reached`，状态码都是 429。这种情况重试一万次也不会好，只会把错误吞掉，延迟真正的报警。

我建议在重试前先看一眼错误体。代码大概这样：

```ts
import { APICallError } from 'ai';

function isFatal429(error: unknown): boolean {
  if (!APICallError.isInstance(error)) return false;
  const body = error.responseBody ?? '';
  return (
    body.includes('credit_balance_exhausted') ||
    body.includes('insufficient_quota') ||
    body.includes('enforced_spend_limit_reached')
  );
}
```

命中就不要再等，直接抛给上层报警。把账单问题当网络问题重试，是最贵的 debug 时间。

## 流式场景的重试是另一套规则

`streamText` 有个单独的参数 `streamRetries`，默认 0。

`maxRetries` 只管请求还没建立连接时的失败。流一旦开始吐字，中途断了它不管。要管，得自己设 `streamRetries`。

```ts
const result = streamText({
  model: 'openai/gpt-4o-mini',
  prompt: '...',
  streamRetries: 2,
});
```

代价是可能重复吐字。官方文档明确写了：失败前已经输出的内容撤不回来，重试的那一步可能把重复的文字追加进去。UI 上要能容忍这个，否则用户会看到一句话说两遍。

我自己在聊天界面里不关 `streamRetries`，但会在前端去重。如果是往数据库写东西，宁可失败重来，也别写重复。

## 主动看余量，比被动重试便宜

429 响应头里带着余量信息，比等失败再重试有用。

OpenAI 用 `x-ratelimit-remaining-requests` 和 `x-ratelimit-remaining-tokens`，Anthropic 用 `anthropic-ratelimit-requests-remaining` 和 `anthropic-ratelimit-tokens-remaining`。两者的区别在重置时间的格式：OpenAI 给时长（`6m0s` 这种），Anthropic 给 RFC 3339 时间戳（`2026-10-26T18:42:30Z`）。

在队列里消费这些头，余量低就自己放慢，比撞墙再退回来省事。批量任务尤其值得这么干。

## 我现在在用的封装

```ts
function jitter(ms: number) {
  return ms / 2 + Math.random() * ms;
}

async function callWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries = 5,
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (e) {
      if (isFatal429(e)) throw e;
      if (i === maxRetries - 1) throw e;
      await new Promise(r => setTimeout(r, jitter(1000 * 2 ** i)));
    }
  }
  throw new Error('unreachable');
}
```

比 SDK 默认多三次机会，加了一层随机抖动。为什么加抖动：所有人都在同一时刻撞到限流，同时按 2 秒、4 秒退避，会在同一个毫秒再次撞上。抖动打散这个同步。

上线的判断标准也简单：把 429 的速率打出来看一眼。长期高于 1%，就是容量规划问题，不是重试参数问题。
