主题
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%,就是容量规划问题,不是重试参数问题。