文章

LLM API 客户端可靠性生产实战:用错误分级、重试预算与请求账本稳住调用链路

本文讲解大模型 API 客户端在生产环境中的可靠性治理,覆盖错误分级、超时边界、随机退避、重试预算、请求账本、降级策略与上线检查,帮助团队减少调用链路抖动。

背景问题:模型 API 不稳定时,真正先坏掉的是调用链路

很多团队接入大模型 API 时,第一版代码通常很简单:应用收到请求,拼好 messages,调用模型,拿到结果再返回。这个阶段看起来能跑,但一到生产环境,问题会迅速暴露。

请求量升高后,调用链路会遇到 429 rate limit5xx 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 不应简单等同于”再试”。如果响应头里有 resetretry-after 信息,应优先尊重;如果没有,则使用带 jitter 的指数退避。需要注意的是,OpenAI 文档明确提醒,不成功的请求也可能计入每分钟限制,持续原地重发不会解决限流问题。

容量和过载错误

例如 Anthropic 的 529 overloaded_error,或者 OpenAI 文档中的 503 overloaded / Slow Down。它们通常代表全局或局部高负载。客户端应降低并发、延长退避、触发熔断,而不是让每个业务线程各自重试。

业务侧取消和超时

用户关闭页面、上游 HTTP 超时、任务取消或网关断开时,模型调用即使最终成功,对当前业务也可能已经没有价值。客户端要把取消信号传递给下游,并把这类结果记录为 cancelled,而不是混入 failed

错误类别典型错误码处理策略
不可重试401, 403, 404, 413快速失败,归因配置/权限/输入校验
可短暂重试连接失败, 500, 部分 5031-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. 重试要有随机抖动 —— 如果所有客户端都在 1 秒、2 秒、4 秒后同时重试,供应商恢复瞬间就会再次被冲击。
  3. 重试预算要集中治理 —— 不要让业务服务、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-requestsx-ratelimit-remaining-requestsx-ratelimit-reset-requestsx-ratelimit-limit-tokensx-ratelimit-remaining-tokensx-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 不可避免的抖动中保持稳定。

参考资料

常见问题

LLM API 调用失败后是否都应该自动重试?
不是。只有连接错误、部分 5xx、429 或明确的临时过载错误适合在预算内重试;认证、权限、参数格式、请求过大和配额耗尽通常应快速失败并进入人工或配置处理。
为什么 LLM API 客户端需要请求账本?
因为模型调用常有超时、重试、降级和异步补偿。请求账本可以记录 logical_request_id、attempt_id、模型、参数摘要、错误类型、耗时和最终状态,避免排障时只看到一串分散日志。
重试预算应该按次数还是按时间控制?
生产系统通常同时控制次数和总时长。次数限制防止重试风暴,总时长限制保护用户体验和上游队列,二者缺一都会导致隐藏的尾延迟问题。
LLM API 客户端是否应该自建队列?
在线请求不一定需要完整队列系统,但至少需要本地并发门和租户级限速。非实时任务、批量摘要、离线分类、批量评测更适合进入队列或 Batch API,避免和在线请求抢同一份速率预算。