背景问题:模型 API 不稳定时,真正先坏掉的是调用链路
很多团队接入大模型 API 时,第一版代码通常很简单:应用收到请求,拼好 messages,调用模型,拿到结果再返回。这个阶段看起来能跑,但一到生产环境,问题会迅速暴露。
请求量升高后,调用链路会遇到 429 rate limit、5xx server error、连接超时、供应商临时过载、请求体过大、客户端提前断开、额度耗尽 等情况。更麻烦的是,这些错误不能用同一种策略处理。把所有错误都重试,会放大上游压力;完全不重试,又会把短暂抖动直接暴露给用户。
OpenAI 文档把 429 区分为请求过快和额度耗尽两类,并建议对 rate limit 场景使用带随机性的指数退避;Anthropic 文档列出了 429、500、504、529 等错误,并说明官方 SDK 会对连接错误、限流和 5xx 等临时失败做有限次数的指数退避重试;Gemini API 的排障文档也把 RESOURCE_EXHAUSTED 归为超过 RPM、TPM、RPD 或消费限制等速率/额度类问题。生产系统要做的不是”遇错再试一次”,而是把这些信号转成稳定的客户端控制面。
本文讨论的不是 LLM Gateway,也不是服务端推理集群的自动伸缩,而是应用侧的 LLM API Client Reliability Layer:在业务代码和模型供应商之间加一层可靠调用封装,让错误分级、超时、重试、降级、审计和排障都有统一边界。
核心原则:先分类,再决定是否重试
LLM API 错误大致可以分成五类。
不可重试错误
例如认证失败、权限不足、模型不存在、参数格式错误、请求超过最大大小。这类错误继续重试没有意义,只会浪费 token、拖慢响应和污染日志。正确处理方式是快速失败,并把错误归因到配置、权限、输入校验或发布变更。
可短暂重试错误
例如网络连接失败、服务端 500、部分 503、临时过载、连接池短暂耗尽。这类错误适合在很小的预算内重试,通常 1 到 2 次就足够;如果仍然失败,应切换降级路径或返回可解释错误。
限流错误
429 不应简单等同于”再试”。如果响应头里有 reset 或 retry-after 信息,应优先尊重;如果没有,则使用带 jitter 的指数退避。需要注意的是,OpenAI 文档明确提醒,不成功的请求也可能计入每分钟限制,持续原地重发不会解决限流问题。
容量和过载错误
例如 Anthropic 的 529 overloaded_error,或者 OpenAI 文档中的 503 overloaded / Slow Down。它们通常代表全局或局部高负载。客户端应降低并发、延长退避、触发熔断,而不是让每个业务线程各自重试。
业务侧取消和超时
用户关闭页面、上游 HTTP 超时、任务取消或网关断开时,模型调用即使最终成功,对当前业务也可能已经没有价值。客户端要把取消信号传递给下游,并把这类结果记录为 cancelled,而不是混入 failed。
| 错误类别 | 典型错误码 | 处理策略 |
|---|---|---|
| 不可重试 | 401, 403, 404, 413 | 快速失败,归因配置/权限/输入校验 |
| 可短暂重试 | 连接失败, 500, 部分 503 | 1-2 次小预算重试,失败后降级 |
| 限流 | 429 | 尊重 retry-after,指数退避 + jitter |
| 过载 | 529, 503 overloaded | 降低并发、延长退避、触发熔断 |
| 取消/超时 | 客户端断开、上游超时 | 传递取消信号,记录为 cancelled |
请求账本:让一次模型调用可追踪、可复盘、可补偿
可靠性问题最怕”只剩一条错误日志”。建议为每个逻辑请求建立一条 request ledger,并把每次实际调用模型 API 的尝试记录成 attempt。
一个最小账本可以包含这些字段:
logical_request_id user_or_tenant_id operation_type provider model
prompt_hash input_token_estimate max_output_tokens client_timeout_ms
attempt_id attempt_no started_at finished_at status error_class
provider_request_id retry_after_ms latency_ms output_token_count
fallback_used final_result_pointer
这里的 logical_request_id 表示业务层的一次调用,例如”给这张工单生成摘要”;attempt_id 表示客户端对供应商 API 的一次实际请求。两者不要混在一起。一次 logical request 可能有多个 attempt,也可能在失败后切换到另一个模型。
请求账本不是为了替代 tracing,而是为了补上 LLM 调用特有的排障维度:模型、token 估算、参数摘要、错误类型、重试次数、降级路径和最终产物位置。没有这层账本,事后很难判断一次失败是供应商限流、应用层超时、请求体过大,还是某个租户瞬间打爆了共享 TPM。
超时边界:不要把所有时间都留给模型
大模型调用往往慢在尾部。很多事故不是平均延迟过高,而是少数请求卡住,逐渐占满应用线程、连接池和队列。
建议把一次业务请求拆成三个时间预算:
total_user_budget = 12s
├─ input_build_budget = 1s
├─ model_call_budget = 8s
└─ postprocess_budget = 3s
模型调用内部再拆出单次尝试超时和整体重试预算:
model_call_budget = 8s
├─ attempt_1 timeout = 4s
├─ backoff = 300ms ~ 900ms
├─ attempt_2 timeout = 2.5s
└─ fallback_or_fail = remaining budget
这样做的核心价值是防止”重试成功但用户已经等不起”。对在线交互场景,重试预算不能只看成功率,还要看最终响应是否仍在用户可接受时间内。对后台任务,则可以拉长时间预算,但要把任务放到队列里,避免占用同步请求线程。
重试预算:限制次数,也限制风暴
一个可落地的重试策略可以这样写:
type ErrorClass =
| "bad_request" | "auth_error" | "permission_error"
| "rate_limited" | "quota_exhausted" | "request_too_large"
| "timeout" | "connection_error" | "server_error"
| "overloaded" | "cancelled" | "unknown";
type RetryDecision = {
retryable: boolean;
delayMs?: number;
reason: string;
};
function decideRetry(
errorClass: ErrorClass,
attemptNo: number,
remainingMs: number
): RetryDecision {
if (remainingMs < 1000) {
return { retryable: false, reason: "not_enough_time_budget" };
}
if (["bad_request", "auth_error", "permission_error",
"request_too_large", "quota_exhausted", "cancelled"]
.includes(errorClass)) {
return { retryable: false, reason: "non_retryable_error" };
}
if (attemptNo >= 2) {
return { retryable: false, reason: "retry_budget_exhausted" };
}
if (["rate_limited", "timeout", "connection_error",
"server_error", "overloaded"].includes(errorClass)) {
const base = Math.min(2000, 300 * Math.pow(2, attemptNo));
const jitter = Math.floor(Math.random() * base);
return {
retryable: true,
delayMs: base + jitter,
reason: "transient_retry"
};
}
return { retryable: false, reason: "unknown_error" };
}
这段逻辑不是最终版本,但体现了三个生产原则:
- 不可重试错误必须快速失败 —— 特别是参数错误和请求体过大,重试不会改变结果。
- 重试要有随机抖动 —— 如果所有客户端都在 1 秒、2 秒、4 秒后同时重试,供应商恢复瞬间就会再次被冲击。
- 重试预算要集中治理 —— 不要让业务服务、SDK、HTTP client、队列 worker 各自重试三次。三层各重试三次,最坏会变成 27 次调用,而且排障时很难看出是哪一层放大的。
降级策略:不是所有失败都要返回 500
LLM 调用失败后的降级策略取决于业务类型:
- 对客服摘要、搜索改写、标签分类等辅助任务,可以返回”暂不可用”并保留原始输入,让用户继续主流程。
- 对强依赖生成结果的场景,可以切换到更小模型、缩短输出长度、关闭高成本 reasoning、或返回上一次缓存的稳定结果。
- 对合规、医疗、金融等高风险场景,不应为了成功率而静默切换模型,降级必须进入显式审计。
一个简单但有效的降级顺序是:
primary model
→ same model with shorter max_output_tokens
→ fallback model with explicit quality label
→ async retry job
→ user-visible graceful failure
这里要特别注意:降级模型的结果不能和主模型结果混在一起统计。否则后续分析”为什么质量下降”时,看到的只是一条总体成功率曲线,看不到背后的模型切换。
供应商响应头:把限流信息变成调度输入
OpenAI 的 rate limit 文档列出了诸如 x-ratelimit-limit-requests、x-ratelimit-remaining-requests、x-ratelimit-reset-requests、x-ratelimit-limit-tokens、x-ratelimit-remaining-tokens、x-ratelimit-reset-tokens 等响应头。客户端不应只在报错时才读取这些信息。
更稳的做法是把响应头写入本地的供应商状态表:
provider_model_key remaining_requests remaining_tokens
reset_requests_at reset_tokens_at last_429_at
last_overload_at current_client_concurrency
当 remaining tokens 快速下降时,客户端可以提前降低并发、压缩输出长度或把非实时任务转入离线队列。这样限流不再是被动错误,而是调度输入。
常见误区
误区一:SDK 已经重试,所以业务层不用管
官方 SDK 的默认重试通常只解决局部瞬时错误。业务层仍然需要控制总超时、总重试预算、降级路径、请求账本和租户级并发。否则 SDK 重试成功了,用户体验也可能已经失败。
误区二:429 都应该指数退避
429 可能代表请求速率过快,也可能代表预算或额度耗尽。前者可以退避,后者应该停止并报警。把 quota exhausted 当成 rate limited 处理,会造成无意义重试。
误区三:只记录最终失败,不记录中间尝试
如果只记录最终状态,系统会漏掉大量早期信号。例如一次请求最终成功,但前两次都 503,这说明上游已经有抖动。如果这些 attempt 不入账,等真正失败时,排查会晚很多。
误区四:所有业务共用一个并发池
多租户或多团队共用同一个 API key 时,如果没有租户级并发和预算控制,一个低优先级批量任务可能把在线请求拖入限流。即使供应商限流在组织级或项目级,客户端也应该在本地做分摊和隔离。
上线检查清单
上线前建议至少检查以下项目:
错误分类
确保 400、401、403、404、413、429、500、503、504、529、连接错误、客户端取消都能映射到明确的内部 error_class。未知错误不要默认无限重试。
超时配置
区分连接超时、首包超时、整体响应超时和业务总预算。流式接口还要区分首 token 超时与 chunk 间隔超时。
重试预算
统一限制最大尝试次数、总重试耗时、退避上限和 jitter。检查 SDK、HTTP client、队列 worker 是否存在重复重试。
请求账本
确保每次 logical request 和每次 attempt 都有记录,并能关联 provider request id、模型、参数摘要、token 估算、错误类型和最终状态。
降级路径
明确哪些业务可以降级,降级到哪个模型,是否需要给用户提示,是否需要进入审计。高风险业务不要静默降级。
指标与告警
至少监控以下维度:
- 成功率、错误分类占比、429 占比、5xx 占比
- 超时率、取消率
- 平均尝试次数、重试后成功率、fallback 率
- P95/P99 延迟、每租户 token 消耗
总结
LLM API 客户端的可靠性治理不是”加个重试”就能解决的。它需要从错误分类开始,建立超时边界、重试预算、请求账本、降级策略和供应商状态感知五位一体的控制面。核心思路可以归纳为三点:先分类再重试、用账本替代散落日志、把供应商限流头变成调度输入而非被动告警。做好了这些,调用链路才能在模型 API 不可避免的抖动中保持稳定。