第 4 章 可靠性与错误处理

学习目标

  • 结合状态码、响应正文中的错误码以及响应头来解读错误,区分应该重试、应该修正,以及需要人工判断的情况。
  • 解释速率限制的结构和429错误可能出现的多种原因,并据此调节流量。
  • 在一个层级中设计包含抖动和预算的重试策略,确定各层的超时以及整体截止时间。
  • 利用幂等键安全地重复执行有副作用的操作,并从结果未知的状态中恢复。
  • 恢复中断的响应与部分失败的任务,防止故障蔓延到其他功能。

引言

凌晨两点,稿件校对任务停止了。负责人早上上班后,将任务从头重新运行。到了中午,编辑们告知,同一章的校对稿都上传了两份。查看日志发现,夜间交替出现了529和429错误,而重试逻辑对所有失败都按1秒间隔无限重复尝试。也就是说,对故障的应对方式,造成了比故障本身更大的损害。

在使用外部API的系统中,失败并不是偶发例外,而是日常情况。重要的是区分失败的类型,为每一种类型采取预先确定的应对措施,并且避免同一操作执行两次。本章从错误分类开始,依次介绍速率限制、重试与退避、超时、幂等性、中断响应与部分失败的恢复,以及故障隔离。

核心概念

成功、失败,以及结果未知

一次请求的结果并非只有两种,而是三种:收到成功响应、收到失败响应,或者没有收到任何响应。第三种情况称为结果未知。在连接断开之前,服务器可能已经完成了操作,也可能根本还没有开始。

如果只是模型调用本身,即使结果未知,也可以重新发送请求。这样只会多花一点费用,并不会改变外部世界。真正的问题出在有副作用的操作上。如果退款、创建预约、发送邮件的结果未知,却将其视为“失败”并重新执行,就可能把同一操作执行两次。对于结果未知的修改操作,应按以下顺序处理。

  1. 使用同一个业务键,查询操作的实际状态。
  2. 如果操作已经生效,就使用该结果,作为成功结果。
  3. 如果能够确认操作尚未生效,就使用同一个幂等键再次执行。
  4. 如果无法查询,不要自动重新执行,而应暂时保留为待处理状态,并通知人工。

错误分类表

各供应商命名不同,但状态码含义相近。

状态 Anthropic OpenAI Gemini(Interactions API) 应对
400 invalid_request_error(包含达到自行设置的支出上限) 无效请求 invalid_request、failed_precondition 修正请求或设置
401 authentication_error 身份验证错误 — 检查、更换密钥
402 billing_error — payment_required 处理付款信息,不重试
403 permission_error 不支持的地区等 permission_denied 检查权限与访问设置
409 conflict_error 冲突 — 解决冲突后重试
413 request_too_large(Messages API 32MB) — — 减小请求大小
429 rate_limit_error 多种原因(见下节) rate_limit_exceeded、too_many_requests、quota_exceeded 依原因而定
500 api_error 服务器错误 api_error 退避后重试
503 — server_is_overloaded service_unavailable 退避后重试
504 timeout_error — deadline_exceeded 考虑流式或批处理,检查客户端截止时间
529 overloaded_error — — 退避后重试

Gemini文档明确不要重试400、402、403等客户端错误,OpenAI也说明,无效请求在修正前会得到相同结果。

筛选可重试错误的决策表

只看状态码不够。还需读取正文错误码与响应头,回答三个问题。

问题 是 否
相同请求过一段时间可能成功吗 速率限制、过载、暂时性服务器错误 格式错误、验证错误、支出上限、付款问题
重复执行安全吗 模型调用、查询、有幂等键的修改 无幂等键的修改操作
还有等待时间吗 距截止时间尚有余量 用户已离开或已过截止时间

仅当三个答案均为“是”时,才自动重试。第一项为“否”,应修正请求或通知人工;第二项为“否”,进入结果未知流程;第三项为“否”,确定失败并转备用路径。

速率限制的结构

速率限制是在一定时间内允许的请求与token限额,各供应商计量单位不同。

供应商 单位 范围
Anthropic 各模型档位每分钟请求(RPM)、每分钟输入token(ITPM)、每分钟输出token(OTPM) 组织,可为工作区设更低限额
OpenAI RPM、RPD、TPM、TPD、每分钟图片数(IPM)等 组织与项目,部分模型族共享限额
Gemini RPM、每分钟输入token(TPM)、每日请求(RPD) 按项目而非API密钥,RPD在太平洋时间午夜重置

Anthropic使用令牌桶算法:水以固定速度持续补入桶中,每次请求取走一部分,而非在固定时刻一次性重置。因此每分钟60次可能按每秒1次执行;即使每分钟总量在限额内,短时集中请求也会收到429。用量骤增可能触及加速限制(acceleration limit),文档建议逐步提高流量。

可通过响应头了解剩余额度。Anthropic提供anthropic-ratelimit-requests-remaining、anthropic-ratelimit-input-tokens-remaining等各项限额、剩余量、重置时间;OpenAI提供x-ratelimit-remaining-requests、x-ratelimit-remaining-tokens等。还要记住计算差异:OpenAI以max_tokens与预估token数中较大者计算token限额,缓存输入也计入TPM;Anthropic OTPM只计实际生成量,大多数模型的缓存读取不计ITPM。

如何解读429

同样是429,不同原因的应对可能完全相反。

原因 识别方法 应对
超过速率限制 通常有retry-after头(也可能没有) 至少等待该时长后重试
流量突增 Anthropic加速限制、OpenAI slow_down 重试,同时放慢流量增长
Anthropic档位支出上限 无retry-after,error.details.error_code为enforced_spend_limit_reached 停止重试,SDK自动重试也会失败,通知负责人
OpenAI余额、支出、使用限额 错误码表明余额耗尽或超限 重试无效,应充值或调整限额
Gemini每日配额 quota_exceeded 等待重置或申请增额

同时记住,Anthropic达到组织或工作区自行设置的支出上限时,返回400 invalid_request_error而非429(Claude Code工作区限额例外,会返回带retry-after的429)。表中Gemini代码针对Interactions API,generateContent API采用RESOURCE_EXHAUSTED等其他格式。应根据具体原因进行分支处理。

指数退避与抖动

确定可重试后,还要决定等待多久。指数退避(exponential backoff)每次尝试将等待时间翻倍;抖动(jitter)加入随机波动,防止遭遇同一故障的大量客户端在同一时刻再次涌入。就像电梯前队伍散乱后,大家都精确等10秒再冲上去,仍会挤成一团。

function backoffMs(attempt: number, retryAfterSec?: number): number | null {
  const base = 500, cap = 30_000;
  if (retryAfterSec !== undefined) {
    const wait = retryAfterSec * 1000 + Math.random() * 1000; // 最低等待时间 + 少量随机延迟
    return wait > cap ? null : wait; // 超出上限则返回null:不重试,确定失败
  }
  const ceiling = Math.min(cap, base * 2 ** attempt);
  return Math.random() * ceiling; // 全抖动:在0与上限之间随机选择
}

OpenAI文档建议,将Retry-After视为最低等待时间,再加少量随机延迟,同时限制尝试次数与总重试时间。还警告,失败请求也计入每分钟限额,不断重发只会使情况进一步恶化。

重试集中一处,控制在预算内

重试可能隐藏在多个层级中。Anthropic官方SDK默认针对连接错误、429和5xx进行2次指数退避重试,并遵守retry-after,可以通过max_retries修改。Gemini Python SDK针对暂时性错误最多重试4次,OpenAI官方SDK则默认针对连接错误及408、409、429、5xx重试2次。如果在此基础上,应用程序封装层、任务队列、API网关再分别增加自己的重试,尝试次数就会相乘。

AWS Builders' Library把重试形容为“自私”的行为。因为它为了提高自身请求的成功概率,会额外消耗服务器容量。因此,文档建议只在调用栈中的一个位置进行重试,并且只在依赖服务看起来健康时重试。具体实践方法如下。

  1. 确定唯一负责重试的层级。如果交给SDK,就关闭封装层的重试;如果由封装层负责,就把SDK重试次数设为0。设置项名称随SDK和语言而不同(例如Python为max_retries,TypeScript为maxRetries)。
  2. 设置重试预算,包括尝试次数、总等待时间,以及重试占全部请求的比例(如10%)。可以在客户端设置令牌桶:每次收到正常响应时补入少量令牌,重试时消耗令牌,这样就能防止故障期间重试数量暴增。
  3. 在日志中记录原始请求ID和尝试序号。只有知道一次请求实际被传递了多少次,才能确保重试不超出预算。

分层设计超时

如果用一个超时设置处理所有等待,要么会过早中断,要么会占用连接太久。因此,应将超时分为三层。

层级 含义 确定依据
连接超时 等待TCP、TLS连接建立的时限 设得较短,通常为几秒
空闲超时 等待流中下一个事件到来的时限 以正常响应中测得的事件间隔高百分位数(如p99)为基础,再加上余量
整体截止时间 必须向用户作出答复的绝对时间 根据响应契约中的延迟目标确定

AWS介绍了一种方法:从下游服务的延迟分布中选择较高的百分位数(如p99.9),接受少量正常请求被误判为超时。截止时间应向下游传递。执行工具时,将剩余时间传给工具,不启动那些要到截止时间之后才能完成的操作。

生成时间较长的请求,应改用流式输出或批处理。Anthropic文档对可能超过10分钟的请求给出这一建议,因为某些网络会断开长时间处于空闲状态的连接。SDK会检查非流式请求是否可能超过10分钟,并设置TCP keep-alive;OpenAI SDK的默认超时同样是10分钟。根据OpenAI文档,同步响应可以通过断开连接来取消,但后台模式这类在服务器端执行的任务,必须显式取消才会停止。如果只是中间层断开了连接,而应用服务器向上游发起的请求仍然存活,生成和计费就会继续。

幂等性:按两次也只执行一次

电梯呼叫按钮即使按了很多次,也只会来一部电梯。同一个请求发送多次,结果仍与只发送一次相同,这种性质称为幂等性。HTTP标准(RFC 9110)将PUT、DELETE以及安全方法定义为幂等方法,并说明:客户端若要自动重试非幂等请求,必须知道该请求实际上具有幂等性,或者具备能够确认原始请求尚未生效的手段。AWS也指出,有副作用的API如果不提供幂等性,就无法安全地重试。

在应用程序中建立幂等性,可以采用以下方法。

  1. 为一个操作确定一个键,并在该操作的所有重试中使用同一个键。例如,根据工具调用ID构造键,或使用refund:{订单号}:{退款轮次}这样的业务键。
  2. 接收方应对键设置唯一约束,并保存状态(处理中、完成、失败)、请求内容的指纹(哈希)以及处理结果。对键的占用应原子化(例如利用唯一约束插入),确保即使相同键同时到达,也只处理其中一个。
  3. 相同的键再次到达时,返回已保存的结果。如果键相同、请求内容却不同,则以冲突为由拒绝请求。
  4. 键的保留时间,应长于可能发生重试的整个期间。

模型给出的工具调用ID,每次调用都唯一,因此很适合作为构造幂等键的依据。同一次工具调用因网络错误而重发时,会携带同一个键,接收方由此能够识别重复。如果每次重试都创建新键,这一机制就失效了。另外,如果流程中会重新发送模型请求本身,模型就可能以一次新调用再次提出同一操作。这时,应把订单号等业务标识符也加入键中,让接收方能够识别它们属于同一操作。接收入站请求时也一样。OpenAI webhook偶尔可能将同一事件发送两次,因此文档建议使用webhook-id头来过滤重复事件。

恢复中断响应

流式输出即使已经收到200响应,也仍然可能失败。因此,需要在事件层面处理错误,并提前确定连接中断时采用的恢复策略。

  • Anthropic:可以保存已经收到的部分,并请求继续接收。Claude 4.5及更早的模型,把已收到的内容放在一条新assistant消息的开头;Claude 4.6及之后的模型,则将已收到的内容与一条“从中断位置继续写”的user消息一起发送。工具使用区块和思考区块无法部分恢复,因此只能续接到最后一个文本区块,其余部分要重新请求。续写可能出现内容重复或衔接偏差,所以,对于结构化输出这类格式很重要的结果,从头重新请求更加安全。
  • OpenAI:耗时较长的任务,可以用background: true在服务器端运行,再通过轮询查询状态。如果与流式输出结合,可以把每个事件带回的sequence_number保存为游标,在连接中断之后用starting_after从该位置继续接收。在零数据保留(ZDR,zero data retention)项目中,以store=false运行,但响应数据仍会为了轮询而临时保存约10分钟。

展示尚未完成的结果,与确认业务已经完成,是两件不同的事。由中断响应生成的预览,应继续保持“生成中”状态。只有结果已经完整生成并通过校验后,才可以保存。

恢复部分失败

由多个单元组成的任务,经常会出现只有一部分失败的情况。处理原则是:“保留已经成功的部分,只针对失败的部分,按原因重新处理。”

  • 并行工具调用: 如果部分调用失败,返回相应的失败结果,让模型决定下一步行动。对于没有执行的调用,也要返回包含未执行原因的结果(第2章)。
  • 批处理: 将结果分为成功、错误、取消、过期。对于过期、取消以及服务器错误的请求,重新提交;对于请求格式错误,在修正之前不要再次提交。已经成功的条目,不要重新生成。
  • 多阶段流水线: 按条目保存状态(等待、进行中、完成、失败、隔离),并在每个阶段结束时留下检查点。重新启动时,跳过已完成的条目。
  • 不可撤销的操作: 对于发送邮件等无法撤回的操作,另外确定补偿流程(发送更正邮件、人工处理)。

对于反复触发相同错误的输入,应移入隔离队列(dead letter queue,死信队列),避免它们阻塞其他任务,并由人工检查原因。

防止故障蔓延

如果外部API长时间响应缓慢或不断失败,等待这些调用的线程和连接就会越积越多,连无关功能也会被拖慢。因此,需要像船舶舱壁一样,把损害限制在各自区域内的机制。

机制 行为 设置内容
队列准入限制 当等待中的请求超过一定数量时,立即拒绝新请求 最大等待请求数、拒绝时向用户显示的说明
资源隔离(舱壁) 为不同功能分别设置并发执行数量和连接池 每个功能的并发执行上限
熔断器 失败率超过阈值时,暂停发起调用一段时间,之后仅放行一部分请求以确认是否恢复 失败率阈值、停止调用时长、试探请求数
备用路径 使用预先评估过的其他模型或供应商、缩减后的功能,或静态提示 质量标准、切换与恢复原路径的条件

熔断器有三种状态。就像电路一样,闭合时才会有电流通过。在正常(闭合)状态下,允许调用通过;在熔断(断开)状态下,立即转入备用路径;在试探(半开)状态下,若放行的部分请求成功,就再次闭合。等到故障发生后才选择备用模型,已经太迟。应平时就使用同一评估集确认备用模型的质量,并在日志和指标中留下切换记录。

深入阅读

剖析重试风暴

重试次数会在各个层级之间相乘。如果API网关重试1次,应用封装层重试3次,SDK默认重试2次,那么在持续失败时,一个用户请求最多会向API传递(1+1)×(1+3)×(1+2)=24次。故障期间,涌入的流量达到正常流量的数十倍,而失败请求也会消耗限额,因此恢复会更加缓慢。即使加入退避,这种相乘结构也不会改变。

防止重试风暴的顺序如下。首先,将负责重试的层级缩减为一个。其次,按比例设置重试预算:重试相对于正常请求超过一定比例时,就停止重试,尽快确定失败。第三,通过熔断器,在依赖服务不健康时改走备用路径,而不是继续重试。第四,故障结束之后,不要一次性释放所有积压任务。应分阶段提高处理量,避免再次触及加速限制。

注入失败以验证恢复策略

仅依靠正常响应进行测试,无法发现重试与去重逻辑中的缺陷。应在模型调用、工具调用之前加入模拟响应的故障注入层,复现以下情形。

注入故障 检查内容
带retry-after的429、无该头的暂时性429、带支出上限错误码的429 前两者退避重试,支出上限立即停止并通知
连续529、500 在预算内恢复或转备用路径
流中途断开 未完成结果是否未被保存
修改成功后立即丢失响应 是否按结果未知流程查询,且无重复执行
批次部分失败、同一webhook两次 是否未重做成功项,同一事件只处理一次

检查标准有三项:同一业务是否未执行两次,单请求实际发送次数是否在预算内,最终显示状态是否与事实一致。将此测试纳入部署流水线,每次修改重试逻辑都能发现回归。

工作场景

清泉净水器:60秒时被截断的报告

净水器租赁公司“清泉净水器”的上门技师应用,会在检查之前为每位客户生成水质历史报告。自从报告变长后,约15%的请求会失败,技师们点击重试按钮,常常使同一份报告生成两三次。实际支出的费用,比已生成报告数量所对应的费用高出了40%。

负责后端的资深工程师奉先生检查发现,应用服务器通过非流式方式调用模型,而企业内部API网关的请求超时设置为60秒。较长的报告需要70~90秒生成,因此连接会被网关中断;但是,应用服务器向模型发起的请求仍然存活,继续生成到结束并计费。此外,网关、应用封装层和SDK还分别进行了重试。

奉先生进行了以下修改。

  1. 将报告处理路径改为流式输出,并在网关中关闭这条路径的缓冲。超时设置改为“事件之间空闲30秒”和“整体截止时间4分钟”。
  2. 只在SDK这一处进行重试,关闭封装层和网关各自的重试。
  3. 技师关闭界面时,同时中断上游的流式请求。
  4. 对于计划次日上门的客户,前一天晚上就提前生成报告。只有当天临时增加的上门任务,才实时生成报告。

将网关超时延长至15分钟的方案没有采用,因为它会长时间占用连接,而且仍有空闲连接被中间网络断开的风险。同时发送两次相同请求、采用先返回答案的方案,则因为成本翻倍而被否决。修改之后,失败率下降到1%以下,重复生成也消失了。

松香出版社:无须从头重跑的校对流水线

出版社“松香出版社”将单行本的稿件按章拆分,生成校对建议,再存入稿件管理系统。某天夜间,处理12本稿件、380个章节时,接连出现529错误,任务因此停止。负责人早上将任务从头重跑。已经保存过的章节校对稿,又被保存了一次,花掉了两天的费用。其中一章包含大量高分辨率扫描图,每次请求都会超过大小限制,以413错误失败,却仍不断阻塞重试队列。

平台工程师陈经理重新设计了流水线。

  1. 为每个章节保存状态(等待、进行中、完成、失败、隔离)。重新启动时,跳过已经完成的章节。
  2. 保存时,使用(稿件ID, 章号, 校对轮次)作为幂等键。相同的键再次到达时,返回已有的结果。
  3. 按照第4章的决策表分类错误。529、500及因速率限制出现的429,在预算内重试;413和400则不重试,直接放入隔离队列。被隔离的章节,由负责人拆分之后重新提交。
  4. 上线之前进行故障注入测试。在任务执行中途强制结束进程,然后重新启动,确认没有重复保存。

团队也考虑过将全部任务迁移到批处理API。这样虽然可以降低费用,但编辑们从早上开始就要按章节顺序审核,而批处理可能最多需要24小时,未能在期限内处理的条目还可能过期。因此,团队决定只在不紧急的再次校对轮次中使用批处理。之后,即使发生故障,重新启动时也只处理剩余章节,重复保存一次都没有再出现。

易错点

  • 所有429只要等待就能解除。 因支出上限或每日配额产生的429,在限额恢复或调整之前,会持续失败。应通过响应正文中的错误码区分原因。
  • 重试越快、次数越多越好。 失败的请求同样会消耗限额。因此,需要退避、抖动、次数和时间上限,以及重试预算。
  • 400错误也可以多发几次试试。 请求格式的问题,在修正之前,无论发送多少次,都会得到相同结果。
  • 发生超时,就说明服务器没有执行操作。 这时的状态是结果未知。对于修改操作,应先查询实际状态,再决定重新执行。
  • 延长超时就能解决重复执行。 这样只能降低发生频率。根本的解决方法,是使用幂等键。
  • 每次重试时都要创建新的幂等键。 一旦键改变,接收方就无法知道它们是不是同一个操作。同一操作的所有重试,都应使用同一个键。
  • 在SDK重试之外再加应用程序重试,会更安全。 尝试次数会相乘,反而扩大故障。重试只能由一个层级负责。

要点速览

主题 需要记住的内容
结果状态 成功、失败、结果未知。结果未知的修改先查询
重试判断 以后可能成功吗、重复执行安全吗、还有时间吗
429 速率或骤增可等待重试;支出或配额应停止并通知。Anthropic自行设置的限额为400
速率限制 令牌桶也会限制短时突发,应逐步增加流量
退避 指数增长+抖动+上限,Retry-After是最低等待
重试层 仅一处,检查SDK默认值(Anthropic为2次)
超时 连接、空闲、整体截止。长请求用流式、批处理、后台
幂等性 全程沿用同键(工具调用ID、业务标识)+唯一约束+保存结果。入站事件也去重
部分失败 保留成功,按原因处理失败,反复失败则隔离
故障隔离 准入限制、舱壁、熔断器、预先评估的备用路径

查阅官方文档

自测

4-1. 说明为什么对于未收到响应的修改请求,应当将其视为“结果未知”而不是“失败”,并按顺序写出在结果未知状态下应做的事情。

4-2. 决定是否自动重试时,除HTTP状态码之外,还需要检查哪两类信息?请分别举一个会因此改变判断的例子。

4-3. 解释令牌桶方式的速率限制,与在固定时间重置的方式有什么不同,并说明为什么每分钟总量仍在限额之内,也会出现请求被拒绝的情况。

4-4. 分别定义连接超时、空闲超时和整体截止时间,并说明对于生成时间较长的请求,空闲超时为什么比单一请求超时更合适。

4-5. 写出接收幂等键的一方(付款或预约服务器)需要保存哪些信息,以及遇到键相同、内容却不同的请求时应如何处理。

4-6. 实时客服、文档摘要和夜间分析三个功能,共同使用同一个LLM API。请设计一张表,确定各功能的重试预算、并发执行上限、超时设置,以及发生故障时的行为。

实操练习

练习

故障注入重试封装(60分钟)

  1. 在模型调用之前,创建一个能够插入模拟响应的层。随机混入以下响应:带retry-after的429、没有该响应头的暂时性429、带有支出上限错误码的429、529、500和400。
  2. 实现本章的决策表和退避函数,并关闭SDK的自动重试。为尝试次数、总等待时间和重试比例分别设置预算。
  3. 发送100个请求,按错误类型统计重试次数、最终结果,以及每个请求实际被传递的次数。

完成标准: 400和带支出上限错误码的429没有被重试过一次;其余错误在预算内得到恢复,或进入备用路径;每个请求实际传递次数的最大值没有超过设计值,即为完成。

练习

带检查点与幂等保存的流水线(60分钟)

  1. 创建一项任务,用模型逐个处理30份文档并保存结果。为每份文档在文件或DB中记录状态(等待、进行中、完成、失败、隔离)。
  2. 保存时以(文档ID, 处理轮次)作为幂等键,让相同的键再次到达时,直接返回已有结果。
  3. 让其中两份文档始终产生不可重试的错误,并送入隔离队列。在处理第15份文档的过程中强制结束进程,然后重新运行。

完成标准: 重新运行后,已经完成的文档没有被再次处理,存储中不存在重复结果,而且能够列出被隔离的两份文档及隔离原因,即为完成。