一次模型调用最多等 600 毫秒,最多尝试三次,是否意味着用户最多等 1800 毫秒?未必。连接、排队和两次退避都可能继续消耗时间;如果外层应用与内层 SDK 都在重试,尝试次数还会相乘。

可靠调用要先回答三个问题:整次请求还剩多少时间、这类错误是否值得重试、同一操作是否允许再次执行。 本文用不联网的逻辑时钟实验说明这三道门槛,不提供某家模型 SDK 的默认参数,也不把教学数字当成生产 SLA。

总预算与单次超时不是同一件事

在上下文组装中,预算限制的是模型输入;这里的预算限制的是用户等待时间。检索、重排、生成都应从同一请求截止时刻扣除各自的实际耗时,而不是每到一个阶段就重新获得完整预算。

名称 约束范围 常见误解
总截止时刻 deadline 请求入口到完成,包含排队与退避 每次重试都重置它
单次上限 per_try 当前一次尝试 三次尝试只花三倍这个时间
最小尝试窗口 min_try 决定是否还值得启动新尝试 剩 1 毫秒也继续发出昂贵请求
最大尝试次数 max_attempts 包含首次请求 3 次尝试误写成 3 次重试

总预算使用不因系统校时而倒退的单调时钟。在真实程序里,应把入口截止时刻传给下游;每次请求与退避之前重新计算剩余时间。Python 的 time.monotonic()适合测量经过时间,不能直接拿它与 HTTP-date 的日历时间比较。

队列等待也必须计入这个入口预算,不能等拿到并发许可时才开始计时。接上有界准入与排队预算,还需要区分等待超时、下游取消确认与实际工作结束。

  1. 先确认错误和重放语义

    瞬时错误不自动意味着可以重复执行有副作用的操作。

  2. 再核对次数和等待要求

    首次请求计入尝试次数,不截短已经解析出的 Retry-After。

  3. 始终使用同一总截止时刻

    排队、调用与退避消耗同一预算,下一次尝试不重置时钟。

值得重试不等于可以重放

超时意味着客户端没有在期限内获得可用结果,不证明服务端没有执行,也不证明它已停止计费或写入数据。工具调用如果已经发出付款、邮件或数据库写入,盲目重发可能制造重复副作用。

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 需要先用日历时间解析,再把等待长度交给单调时钟计时。

合成总预算1500 ms

从请求入口起算,不重置。

单次尝试上限600 ms

还要受当前剩余时间限制。

最小尝试窗口100 ms

退避之后必须仍然留够。

1500毫秒合成预算中,第一次尝试400毫秒,等待300毫秒,第二次超时600毫秒,剩余200毫秒不足以同时等待并再尝试
原创逻辑时钟演算图;所有时间均为教学合成值,不是模型服务延迟实测,也不是参数建议。查看原图

两次尝试为什么用掉了 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。第三个脚本响应不会被使用。这里拒绝的是“没有预算的下一次尝试”,不是假装已经拿到第二次尝试的迟到成功。

可运行的纯 Python 模拟器

只依赖标准库,不连接网络、不调用模型、不真实睡眠。duration_ms 是脚本设定的响应耗时;超过本次上限时,响应状态与 Retry-After 都视为未收到。到达总截止边界的成功也不会被接受。trace 仅记录教学状态与时间,不存储 prompt 或用户数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
from dataclasses import dataclass
import json
import math


@dataclass(frozen=True)
class Outcome:
duration_ms: int
status: int | str
retry_after_ms: int | None = None


def integer(value, name, minimum):
if type(value) is not int or value < minimum:
raise ValueError(f"invalid {name}")


def simulate(outcomes, *, total_ms=1500, per_try_ms=600,
max_attempts=3, min_try_ms=100, base_ms=200,
backoff_cap_ms=800, replay_safe=True, draw=lambda: 0.5):
for name, value in (("total_ms", total_ms), ("per_try_ms", per_try_ms),
("max_attempts", max_attempts), ("min_try_ms", min_try_ms),
("base_ms", base_ms), ("backoff_cap_ms", backoff_cap_ms)):
integer(value, name, 1)
if min_try_ms > per_try_ms:
raise ValueError("minimum window exceeds per-try limit")
if type(replay_safe) is not bool or not callable(draw):
raise ValueError("invalid retry policy")
outcomes = list(outcomes)
for event in outcomes:
if not isinstance(event, Outcome):
raise ValueError("invalid outcome")
integer(event.duration_ms, "duration_ms", 0)
valid_status = (type(event.status) is int and 200 <= event.status <= 599)
valid_status |= (type(event.status) is str and event.status in ("timeout", "cancel"))
if not valid_status:
raise ValueError("invalid status")
if event.retry_after_ms is not None:
integer(event.retry_after_ms, "retry_after_ms", 0)

now, attempts, trace = 0, 0, []
ceiling = min(base_ms, backoff_cap_ms)

def finish(reason):
return {"ok": reason == "success", "stop": reason,
"attempts": attempts, "elapsed_ms": now, "trace": trace}

for index, event in enumerate(outcomes):
if total_ms - now < min_try_ms:
return finish("deadline_budget")
limit = min(per_try_ms, total_ms - now)
spent = min(event.duration_ms, limit)
status = "timeout" if event.duration_ms > limit else event.status
after = None if event.duration_ms > limit else event.retry_after_ms
attempts += 1
trace.append({"kind": "attempt", "number": attempts, "start_ms": now,
"limit_ms": limit, "spent_ms": spent, "status": status})
now += spent
if status == "cancel":
return finish("cancelled")
if now >= total_ms:
return finish("deadline")
if type(status) is int and 200 <= status < 300:
return finish("success")
if status not in ("timeout", 429, 502, 503, 504):
return finish("terminal_error")
if not replay_safe:
return finish("unsafe_replay")
if attempts >= max_attempts:
return finish("attempt_cap")
if index + 1 == len(outcomes):
return finish("script_exhausted")
fraction = draw()
if (type(fraction) not in (int, float) or not math.isfinite(fraction)
or not 0 <= fraction <= 1):
raise ValueError("invalid jitter draw")
wait = max(math.floor(ceiling * fraction), after or 0)
if total_ms - now - wait < min_try_ms:
return finish("retry_budget")
trace.append({"kind": "wait", "start_ms": now, "wait_ms": wait})
now += wait
ceiling = min(backoff_cap_ms, 2 * ceiling)
return finish("script_exhausted")


if __name__ == "__main__":
plan = [Outcome(400, 503, 300), Outcome(700, 200), Outcome(20, 200)]
print(json.dumps(simulate(plan), ensure_ascii=False, indent=2))

需要 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 工程专题:回答的依据、质量评测与服务可靠性是相互连接但不相互替代的三种检查。