大模型 API 失败后,不要先数“这是第几次失败”。先回答五个问题:谁拥有故障、请求是否已经产生不可重复的结果、恢复预算还剩多少、备用模型是否真的等价、当前路由是否健康。只有故障可能短暂、调用可安全重放、预算仍足够时,才重试同一路由;只有备用路由已经通过同一业务验收,才允许切换。其他情况应排队、返回可控降级结果,或直接停止。
| 关卡 | 必须确认的问题 | 不满足时的动作 |
|---|---|---|
| 故障归属 | 是临时网络/上游故障,而不是请求、权限、策略或预算问题吗? | 修正归属方或停止 |
| 提交状态 | 再发一次不会重复 token、写入或外部动作吗? | 先对账或显式恢复 |
| 恢复预算 | 总次数、总耗时、token、费用和用户耐心还够吗? | 排队、降级或停止 |
| 备用等价性 | 备用路由通过了当前业务的 schema、工具、安全、数据和质量测试吗? | 禁止静默切换 |
| 路由健康度 | 主路由值得有限探测,且每次尝试都可观测吗? | 熔断并进入已批准模式 |
“换一个模型再试”不是普通重试。它可能同时改变上下文窗口、结构化输出、工具调用、安全过滤、数据位置、价格和回答质量。把 fallback 当成另一次 retry,恰恰是生产事故容易被隐藏的地方。
同一个 429,可能属于两个完全不同的分支
状态码只能帮助定位,不能直接决定动作。OpenAI 当前的错误码说明把 429 分成两类:发送过快要减速;额度或月度支出耗尽要处理 credits/limits。对第二类做指数退避,并不会凭空产生预算。
生产日志至少要把下面几类拆开:
| 现象 | 默认动作 | 必须保留的证据 |
|---|---|---|
| 上游接收前的连接失败 | 可重放时做一次受限重试 | 连接阶段、客户端 trace、耗时 |
有短 retry-after 的限速 | 按返回窗口等待并降低并发 | 错误子类、限流 header |
| 额度、余额或 spend limit 耗尽 | 停止或等 owner 调整 | project、account、quota 类型 |
| 供应商证据明确归为瞬时故障的 5xx/过载 | 有上限的抖动退避,再考虑已批准备用 | 错误子类/消息、请求形状、request ID、路由健康度 |
| 400/schema/参数不支持 | 修改请求,不原样重发 | validation error、request shape |
| 401/403 | 修复 credential 或 permission | key owner、project、endpoint |
| 安全/策略阻断 | 按 policy 停止或人工复核 | 安全类别,不记录敏感原文 |
| 流式输出半截或工具结果不确定 | 先对账,不透明重放 | 最后事件、operation ID |
还要检查 SDK 是否已经替你重试。Anthropic 当前的错误参考说明,官方 SDK 默认会对连接错误、rate limit 和 5xx 做两次指数退避,并在存在时遵守 retry-after。Gemini 的排障指南也记录了官方 SDK 的自动重试。如果业务层再写“三次重试”,真实调用数往往不是三次。
把所有层的尝试装进一个恢复预算
恢复预算不是 maxRetries=3。它至少包含四个维度:
text总尝试数 = 初始调用 + SDK 内部尝试 + 网关尝试 + 业务层重试 + 备用路由尝试 + 队列重新投递
同时设置最大总耗时、额外 token/费用和可接受质量变化。在线客服可能只允许一次短重试;夜间批处理可以等得更久,但不能无限消耗预算。
AWS 的超时、重试、退避与抖动解释了多层重试为什么危险:如果调用链每一层都各自重试,负载会成倍放大;上游本来过载时,重试还会拖慢恢复。应在一层统一决策,并使用有上限的指数退避和 jitter。
可以给每个业务工作流建立一张策略卡:
yamlworkflow: customer-support-answer max_total_attempts: 3 max_elapsed_ms: 9000 max_extra_cost_usd: 0.02 safe_replay_until: first_visible_token fallback_routes: - approved-support-backup queue_allowed: false stop_on: [auth, policy, unknown_side_effect, unapproved_route]
数字只是示例。正确数值来自用户可接受延迟、成本上限和故障演练,不来自博客里的固定答案。
超时不等于“上游什么都没做”
决定重放前,先找提交边界。
- 上游还没有接收请求:通常最容易安全重放。
- 上游已经接收,但客户端没有拿到结果:结果状态未知。
- 用户已经看到第一个 token:透明切模型可能重复或矛盾。
- 工具、数据库、Webhook、邮件或外部动作已经启动:超时不证明动作失败。
涉及写操作时,把“模型生成”与“业务执行”分开。每个外部动作使用应用自己的 operation ID 或 idempotency key,记录 requested、started、committed、acknowledged 四个状态。状态不确定时先查询或对账,不能让第二个模型再执行一次。
流式输出也要有明确产品规则:首个可见事件前可以清空状态,改用已批准路由;首个事件后应显示“响应中断”、让用户显式重新开始,或使用真正的 resume 协议。把另一个模型的新回答拼在半截输出后,不是无缝容灾。
备用模型要签“等价合同”
“都兼容 Chat Completions”远远不够。每个备用路由必须针对具体工作流通过八项验收:
- 输入:上下文长度、图片/文件、系统指令、语言和请求上限。
- 输出:JSON Schema、拒绝格式、finish reason、引用和截断。
- 工具:工具名、参数 schema、并行调用、tool result 回传和幂等。
- 安全:内容过滤、高风险任务、人工升级和停止规则。
- 数据:保留策略、区域、租户隔离、允许的数据类别。
- 运维:延迟分布、流式行为、request ID、状态可见性。
- 经济:计价单位、缓存、重试费用、quota owner、最大支出。
- 质量:用同一批 fixture 跑评测,并达到该工作流阈值。
一个轻量模型可以胜任标签分类,但未必适合给客户解释退款政策;另一个供应商可能更可用,却不支持当前工具 schema 或数据边界。没有生成式备用通过时,缓存、确定性规则或“稍后重试”往往比未经验证的答案更安全。
把决策写成状态机,而不是散落的 catch
下面的 TypeScript 展示核心边界。provider adapter 负责把错误归一化;策略层只决定动作。
tstype Action = "retry" | "fallback" | "queue" | "degrade" | "fail_closed"; type Failure = { owner: "transient" | "rate" | "quota" | "request" | "auth" | "policy" | "unknown"; commitState: "none" | "committed" | "unknown"; retryAfterMs?: number; }; function decide( f: Failure, budget: { attemptsLeft: number; elapsedMsLeft: number; costLeft: number }, estimate: { retryMs: number; retryCost: number; fallbackMs: number; fallbackCost: number }, routes: { primary: "healthy" | "degraded" | "open"; fallback: "healthy" | "unhealthy" }, fallbackApproved: boolean, queueAllowed: boolean, degradeApproved: boolean ): Action { if (["request", "auth", "policy"].includes(f.owner)) return "fail_closed"; if (f.commitState !== "none") return "fail_closed"; if (f.owner === "unknown") return queueAllowed ? "queue" : "fail_closed"; const canRetry = budget.attemptsLeft > 0 && budget.elapsedMsLeft >= (f.retryAfterMs ?? 0) + estimate.retryMs && budget.costLeft >= estimate.retryCost; const canFallback = budget.attemptsLeft > 0 && budget.elapsedMsLeft >= estimate.fallbackMs && budget.costLeft >= estimate.fallbackCost; if (canRetry && ["transient", "rate"].includes(f.owner) && routes.primary !== "open") { return "retry"; } if ( canFallback && ["transient", "rate", "quota"].includes(f.owner) && fallbackApproved && routes.fallback === "healthy" ) return "fallback"; if (queueAllowed) return "queue"; return degradeApproved ? "degrade" : "fail_closed"; }
retryAfterMs 只约束主路由重试;备用分支使用自己的预计耗时与费用,且 fallbackApproved 已包含备用路由独立的额度检查。主路由要求长时间等待时,只要健康备用仍能落在 workflow SLO 内,就不会被错误拦截。committed 或 unknown 必须在自动分发器之外先对账,或使用已经验证的 resume/idempotency 协议。每次新网络调用前都先持久化 attempt;只有供应商错误子类、消息与请求阶段共同证明它是瞬时故障时才退避。HTTP 500 也可能来自请求本身,例如 Gemini 当前排障文档把输入上下文过长列为一种原因,因此不能只凭状态码把 owner 设为 transient。
用故障注入验收,不用 200 骗自己
在 staging 至少注入这些情况:
- 短
retry-after的 429:只等待一次,并发不升高。 - 主路由
retry-after超过剩余 SLO,但已批准的健康备用能在自身时间与费用预算内完成:选择fallback,不是排队。 insufficient_quota:不原样重试,不暗中切换另一个预算。- 首 token 前的 503:预算内重试,再进入已批准备用。
- 错误 tool schema:返回可修复错误,不切模型掩盖。
- 工具已提交后的 timeout:先对账,不重复执行。
- 已输出 token 后断流:进入明确中断状态,不拼接新回答。
- 备用结果缺少必填字段:output validator 拒绝。
- 熔断打开:普通流量绕开,只有有限 half-open 探测恢复。
每次尝试记录 workflow ID、attempt index、requested route、selected route、provider request ID、错误类、等待、token、费用、提交状态、fallback 原因和最终动作。指标也要拆成 primary success、retry recovery、fallback recovery、degraded response 与 fail closed。否则“最终成功率”会把已经坏掉的主路由藏起来。
从具体错误进入正确下一步
如果你现在拿到的是某一家供应商的 429,先使用对应 owner:OpenAI API rate limit、Claude API 限流或 Gemini API rate limit。先证明故障归属,再让通用策略接管。
如果需要用一个兼容入口测试多个已经批准的模型,可以在 staging 对照当前 LaoZhang AI API 文档验证。统一 endpoint 能减少 adapter 工作,但不会自动证明模型等价,更不会替你定义预算、日志和停止规则。
完成标准不是“最后返回 200”,而是每类故障只进入一个可解释动作、所有重试共享一份预算、每个备用都通过同一业务合同,并且最终成功不会抹掉此前的失败证据。



