AI 服务超时与重试 从总预算到安全重放
一次模型调用最多等 600 毫秒,最多尝试三次,是否意味着用户最多等 1800 毫秒?未必。连接、排队和两次退避都可能继续消耗时间;如果外层应用与内层 SDK 都在重试,尝试次数还会相乘。
可靠调用要先回答三个问题:整次请求还剩多少时间、这类错误是否值得重试、同一操作是否允许再次执行。 本文用不联网的逻辑时钟实验说明这三道门槛,不提供某家模型 SDK 的默认参数,也不把教学数字当成生产 SLA。
总预算与单次超时不是同一件事
在上下文组装中,预算限制的是模型输入;这里的预算限制的是用户等待时间。检索、重排、生成都应从同一请求截止时刻扣除各自的实际耗时,而不是每到一个阶段就重新获得完整预算。
| 名称 | 约束范围 | 常见误解 |
|---|---|---|
| 总截止时刻 deadline | 请求入口到完成,包含排队与退避 | 每次重试都重置它 |
| 单次上限 per_try | 当前一次尝试 | 三次尝试只花三倍这个时间 |
| 最小尝试窗口 min_try | 决定是否还值得启动新尝试 | 剩 1 毫秒也继续发出昂贵请求 |
| 最大尝试次数 max_attempts | 包含首次请求 | 3 次尝试误写成 3 次重试 |
总预算使用不因系统校时而倒退的单调时钟。在真实程序里,应把入口截止时刻传给下游;每次请求与退避之前重新计算剩余时间。Python 的 time.monotonic()适合测量经过时间,不能直接拿它与 HTTP-date 的日历时间比较。
队列等待也必须计入这个入口预算,不能等拿到并发许可时才开始计时。接上有界准入与排队预算,还需要区分等待超时、下游取消确认与实际工作结束。
- 先确认错误和重放语义
瞬时错误不自动意味着可以重复执行有副作用的操作。
- 再核对次数和等待要求
首次请求计入尝试次数,不截短已经解析出的 Retry-After。
- 始终使用同一总截止时刻
排队、调用与退避消耗同一预算,下一次尝试不重置时钟。
flowchart LR
A[请求入口确定总截止时刻] --> B[检索与重排消耗同一预算]
B --> C[尝试上限取单次上限与剩余时间的较小值]
C --> D[等待退避也消耗预算]
D --> E{仍有最小尝试窗口}
E -- 是 --> C
E -- 否 --> F[停止并报告原因]
值得重试不等于可以重放
超时意味着客户端没有在期限内获得可用结果,不证明服务端没有执行,也不证明它已停止计费或写入数据。工具调用如果已经发出付款、邮件或数据库写入,盲目重发可能制造重复副作用。
RFC 9110 的幂等性章节说明,自动重试非幂等请求需要明确的语义保证,或者能够判断原请求未生效。不要仅按 HTTP 方法或“加了一个请求编号”就宣布安全。若服务支持幂等操作标识,应遵守其实际范围、有效期、并发与去重契约;本地自造一个 header 并不会让远端自动去重。
| 情况 | 本文教学策略 | 生产中仍需核实 |
|---|---|---|
| 400、401、403 等输入或权限错误 | 不重试 | 服务实际错误分类,避免用退避掩盖配置问题 |
| 429、502、503、504 | 列为候选瞬时错误 | 不是所有服务都把这些状态视为可重试 |
| 超时 | 列为候选,但仍检查可重放性 | 原操作状态、服务端取消与费用 |
| 调用方取消 | 立即停止 | 下游连接、任务和资源是否真正释放 |
| 已向用户输出流式片段 | 不自动当作全新请求续接 | 新生成不保证接上旧内容,需显式恢复与去重协议 |
实验中的 replay_safe 是调用方已经完成语义判断后给出的布尔值,不是模拟器自动判断出来的保证。一次失败被列为“瞬时错误”,还必须同时通过它、次数上限与时间预算三道检查。
退避与 Retry-After 都要算进总时间
指数退避控制重试频率,随机抖动减少客户端在同一时刻集中重发。AWS Builders Library 的超时、重试与抖动文章也提醒,多层重试会放大依赖故障;本例只在一个层级决策。若应用与 SDK 各允许三次尝试,最坏可能有九次底层尝试,而不是六次。
本文采用 full jitter:第 j 次重试的抖动上界为 min(cap, base×2^(j−1)),从 0 到该上界采样。模拟器注入固定抽样值,方便复现;真实系统不能让所有客户端都固定取 0.5。
RFC 9110 的 Retry-After允许秒数或 HTTP-date。本文输入 retry_after_ms 已经是网络适配层解析后的非负毫秒值,不是原始 HTTP header 的单位。实验选取 max(抖动等待, Retry-After);若等待后不够最小尝试窗口,就停止,不把服务要求的等待截短来硬塞进预算。HTTP-date 需要先用日历时间解析,再把等待长度交给单调时钟计时。
从请求入口起算,不重置。
还要受当前剩余时间限制。
退避之后必须仍然留够。
两次尝试为什么用掉了 1300 毫秒
设第一次响应 400 毫秒后返回 503,要求等待 300 毫秒;第二次脚本描述“700 毫秒后成功”。退避基数为 200 毫秒,上限 800 毫秒,固定抽样为 0.5。
| 步骤 | 本步消耗 | 累计耗时 | 判断 |
|---|---|---|---|
| 第一次尝试 | 400 | 400 | 单次上限 600,收到 503 |
| 第一次退避 | max(100,300)=300 | 700 | 等待后剩 800,可以再试 |
| 第二次尝试 | min(700,600)=600 | 1300 | 在 600 毫秒处超时,没有收到脚本里的成功响应 |
| 准备第二次退避 | 计划 200 | 仍为 1300 | 总计只剩 200,等待后没有 100 毫秒尝试窗口,停止 |
最后一行没有真正等待,因此结束时间是 1300,而不是 1500。第三个脚本响应不会被使用。这里拒绝的是“没有预算的下一次尝试”,不是假装已经拿到第二次尝试的迟到成功。
flowchart TD
A[一次尝试失败] --> B{已取消或错误不可重试}
B -- 是 --> Z[停止]
B -- 否 --> C{允许安全重放}
C -- 否 --> Z
C -- 是 --> D{次数仍有额度}
D -- 否 --> Z
D -- 是 --> E[计算抖动与服务要求的等待]
E --> F{等待后还有最小尝试窗口}
F -- 否 --> Z
F -- 是 --> G[等待后启动下一次尝试]
可运行的纯 Python 模拟器
只依赖标准库,不连接网络、不调用模型、不真实睡眠。duration_ms 是脚本设定的响应耗时;超过本次上限时,响应状态与 Retry-After 都视为未收到。到达总截止边界的成功也不会被接受。trace 仅记录教学状态与时间,不存储 prompt 或用户数据。
1 | from dataclasses import dataclass |
需要 Python 3.10 或更新版本。最小尝试窗口不能大于单次上限,这类矛盾配置会被拒绝。空脚本返回 script_exhausted,这是教学数据用尽,不是远端错误。状态码白名单只是本文的显式策略;真实服务需接入自己的错误分类器。模拟器最多处理实际发起的尝试,输入校验会先遍历整份脚本;对 q 条脚本记录与 a 次实际尝试,时间 O(q+a),记录空间 O(q+a)。
从模拟器到真实服务还缺什么
模型没有展示 DNS、连接池排队、TLS、读写超时和协作取消。网络适配层需要确认各阶段是否计入总期限、下游是否接受取消、连接是否释放;不能把“逻辑时钟停止”误当成“服务端计算已终止”。
尤其要检查 SDK 自带重试:要么关闭重复的重试层,要么把其尝试与退避全部纳入同一预算。流式响应应分别记录首字节、首 token 与完整输出时间;已经暴露的内容不能因重试而悄悄替换。工具副作用与模型生成最好分别确定幂等契约,而不是共享一个模糊的“失败就再试”。
自检:服务器要求等待 3000 ms,但请求只剩 500 ms,能截短等待重试吗?
本文策略不能。应停止并报告预算不足;截短会违反已经解析出的最早重试等待要求。是否排队到后台属于新的工作流,需要明确的产品契约,而不是悄悄延长用户请求。
| 必测边界 | 期望 |
|---|---|
| 401 或参数错误 | 一次尝试后停止,无退避 |
| replay_safe=False 的 503 | 停止 unsafe_replay |
| 超时前没有收到响应 | 不使用脚本中迟到的 Retry-After |
| Retry-After 超过剩余预算 | 不睡眠、不截短、不再发出请求 |
| 恰好到总期限才返回成功 | 停止 deadline,不报告成功 |
| 调用方取消 | 不再重试 |
| 最大尝试次数为 1 | 不抽样、不等待 |
| 布尔值冒充毫秒整数、负时间、NaN 抖动 | 拒绝无效输入 |
项目 tests/verify-deadline-retry-article.cjs 提取并执行本文代码,核对固定案例与随机逻辑时钟轨迹;不依赖真实服务成功率,也不会产生网络费用。
上线观测至少区分尝试次数、最终停止原因、总耗时、退避耗时与错误类别。和RAG 回归评测一样,平均值不足以定位退步;“第三次终于成功”也不代表用户在总期限内拿到了结果。避免把完整提示词、凭据或敏感输出写进重试日志。
继续阅读AI 工程专题:回答的依据、质量评测与服务可靠性是相互连接但不相互替代的三种检查。