直接套用普通HTTP重试策略可能造成重复生成、双重计费和结果不一致,尤其容易发生在读取超时及下游解析失败时。文章讨论流式响应、幂等边界和错误分类等改进方向。
图书:AI That Answers
系列文章:AI in TypeScript——全套 5 本,从第一次调用 LLM 到让 Agent 投入生产环境——五本都在这里
我的项目:Hermes IDE | GitHub——一款面向开发者的 IDE,帮助你使用 Claude Code 及其他 AI 编程工具交付产品
关于我:xgabriel.com | GitHub
你的代码库里已经有一个重试辅助函数。它封装了不稳定的 HTTP 调用,支持退避重试,而且多年来一直表现可靠。所以,当你添加 LLM 调用时,自然也会用它包装一下。
const res = await withRetry(() =>
client.messages.create({ model, max_tokens: 4096, messages }),
);
这看起来像是严谨负责。但用在模型调用上,它更像是一个埋好的坑,因为你的重试辅助函数赖以成立的三个假设,在这里全都不成立。
对于 REST 调用,重试的成本只是一次网络往返。对于模型调用,重试意味着从头再生成一遍完整内容。如果响应很长,第二次尝试甚至可能成为你的服务在这一小时内成本最高的一次请求。
最棘手的情况是读取超时。你的客户端在 30 秒后放弃,但提供商仍在继续生成,并最终完成响应。无论你有没有读取结果,这次生成都会计费。随后你发起重试,又会被收取一次费用。
// this is two full generations, billed, one of them thrown away
const res = await withRetry(() => client.messages.create(params), {
timeout: 30_000,
});
流式输出基本可以消除超时问题,因为你会持续收到 token,而不必一直等待完整响应。如果非流式调用在生成长内容时超时,通常应该改用流式输出,而不是反复调整超时时间。
幂等的 GET 请求执行两次,会返回相同的响应体。模型调用则不会。
当重试是由成功生成之后的某个环节触发时,这一点尤其重要——比如解析失败、验证错误,或者你在读取响应时遇到了自身网络的短暂故障。重试会生成一个不同的答案。此时,你的日志里会出现同一个请求 ID 对应两个不同输出的情况,而你实际采用的只有其中一个。
// which of these did the user see?
const first = await client.messages.create(params); // succeeded, unread
const second = await client.messages.create(params); // different answer
解决办法是准确界定你究竟在重试什么。把调用和解析拆开:
async function generate(params: MessageCreateParams) {
return withRetry(() => client.messages.create(params), {
retryOn: isTransportError, // 429, 5xx, socket reset — nothing else
});
}
async function extract(doc: string): Promise<Invoice> {
const res = await generate(buildParams(doc));
const parsed = Invoice.safeParse(JSON.parse(textOf(res.content)));
if (parsed.success) return parsed.data;
return repairWithModel(res, parsed.error); // a NEW turn, not a retry
}
传输故障可以重试。内容错误则应通过后续轮次处理,并在其中包含上一次回答和验证错误。这种方式成本更低,成功率也高得多,因为模型能够看到之前究竟出了什么问题。

如果调用只是一次普通的补全,重复调用只会造成浪费。如果调用属于某个 Agent 轮次,而且该轮次已经执行过工具,那么重复调用就会再次触发副作用。
// turn N: model asks to send an email; you send it
// response read fails; retry re-sends the same messages
// model asks to send the email again; you send it again
重试辅助函数根本不知道,它正在重新发送的数组描述了一项已经完成的工作。任何带有副作用的操作都需要一个根据“操作”而非“尝试次数”生成的幂等键。这本身是一个完整的话题,但你至少需要明白:单靠重试包装器,无法保证这类操作安全。
type Attempt = { n: number; err: unknown };
export async function callModel(
params: MessageCreateParams,
opts: { maxAttempts?: number; budget?: Budget } = {},
) {
const max = opts.maxAttempts ?? 3;
let last: unknown;
for (let n = 1; n <= max; n++) {
try {
opts.budget?.assertCanSpend(estimateCost(params));
const res = await client.messages.create(params);
opts.budget?.record(params.model, res.usage);
return res;
} catch (err) {
last = err;
if (!isRetryable(err)) throw err;
if (n === max) break;
await sleep(delayFor(err, n));
}
}
throw new ModelUnavailable(max, last);
}
它做了四件通用辅助函数不会做的事。
isRetryable 的判定范围非常严格:只包括 429、5xx 和 socket 错误。400 表示你的请求格式有误,重试后格式依然有误。内容策略拒绝也不是暂时性故障。重试这两种错误,只会耗尽你的尝试次数,并延迟真正错误的暴露。
const isRetryable = (e: unknown) =>
e instanceof APIError &&
(e.status === 429 || e.status === 408 || (e.status ?? 0) >= 500);
它会遵循 Retry-After。服务器知道什么时候才能重新接受你的请求,而你的退避曲线只是在猜测。
function delayFor(err: unknown, n: number) {
const hinted = retryAfterMs(err);
if (hinted) return hinted;
const base = Math.min(1000 * 2 ** (n - 1), 20_000);
return base + Math.random() * base * 0.3; // jitter
}
在这里,抖动比平时更加重要。速率限制通常作用于整个账户,因此服务中的所有并发请求会在同一时刻收到 429。没有抖动时,它们又会同时重试,再次触发限制——这就形成了由你亲手制造的“惊群效应”。
它会在每次尝试前检查预算。一个大型请求尝试三次,成本就是原来的三倍,而重试循环恰恰是账单失控最容易发生的地方。
它会抛出带类型的错误。ModelUnavailable 携带尝试次数和最后一次失败的原因,调用方可以据此决定是返回缓存答案、切换到更小的模型,还是显示普通的错误页面。

在高负载下,提供商会返回过载或容量不足错误。它是暂时性的,而且在高峰期并不少见。由于这种错误看起来像 5xx,通用重试机制会不断冲击一个已经饱和的服务。
应该把它作为独立情况处理,并设置一个更长的最短等待时间:
if (isOverloaded(err)) {
await sleep(5_000 + Math.random() * 5_000);
continue;
}
如果你准备了备用模型,这正是它发挥价值的地方——对于大多数功能而言,降级到更小的模型总比彻底失败要好。
logger.warn("model retry", {
attempt: n,
status: (err as APIError)?.status,
errorType: err?.constructor?.name,
delayMs: delay,
promptVersion: PROMPT.version,
costSoFarUsd: budget?.spent,
});
重试次数是一项领先指标。如果第二次尝试的比例缓慢上升,说明你正在逐渐逼近速率限制,但还没有真正触及它。这正是你可以有计划地提高并发限制、避免演变成事故的时间窗口。
只重试传输故障,并配合抖动与 Retry-After。内容错误应该作为纠正轮次发回模型,而不是重试。每次尝试前都要检查预算。绝不要让通用辅助函数包装一个已经产生副作用的调用。
你现有的辅助函数没有做到其中任何一点。但在账单寄来之前,它看起来会一直运行良好。
AI That Answers 介绍了首个 TypeScript LLM 应用会遇到的各种故障模式——重试、超时、流式输出、成本核算,以及决定哪些故障能够重试的解析边界。

完整系列位于 xgabriel.com/ai-in-typescript。
若要采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。