7.2 重试、死信与幂等
7.1 让 run 能安全地投给 worker 了,但那条队列还假设「worker 一跑就成」。真实世界不是这样:模型网关会限流、上游会 503、网络会抖动。如果 worker 遇到一次失败就把 run 判成 failed,用户会看到大量本可自愈的「失败报告」——而这些失败,重试一次大概率就没了。
本节把 ResearchCopilot 推进到
researchcopilot/retry.py:复用卷一 14.1 的错误分类,给 worker 加一层「退避重试 → 耗尽进死信」的韧性,并把「重试必须幂等」落到实处——以 run id 为幂等键,已成功的 run 绝不重复执行。
7.2.1 两类错误:可重试与不可重试
重试的第一步不是「怎么重试」,而是「该不该重试」。卷一的 14.1 分类错误与重试 已经把判据说透:这次失败是「暂时的」还是「确定的」。这里原样复用它的分类函数——HTTP 状态码本身就是一份现成的分类表:
| 状态码 | 含义 | 归类 | 理由 |
|---|---|---|---|
| 400 / 401 / 403 | 请求错 / 未认证 / 无权限 | Fatal | 请求本身有问题,重试必然一样 |
| 404 | 资源不存在 | Fatal | 重试也不会出现 |
| 408 | 请求超时 | Retryable | 网络层瞬时问题 |
| 429 | 限流 | Retryable | 等一会儿就能过 |
| 500 / 502 | 服务端错误 | Retryable | 服务端瞬时故障 |
| 503 / 504 | 不可用 / 网关超时 | Retryable | 典型临时故障 |
from dataclasses import dataclass
class ModelError(Exception):
def __init__(self, msg: str, status: int | None = None):
super().__init__(msg)
self.status = status
@dataclass(frozen=True)
class Retryable:
reason: str
@dataclass(frozen=True)
class Fatal:
reason: str
RETRY_STATUS = {408, 429, 500, 502, 503, 504}
def classify(exc: Exception) -> Retryable | Fatal:
status = getattr(exc, "status", None)
if status in RETRY_STATUS:
return Retryable(f"HTTP {status}")
if isinstance(exc, (ConnectionError, TimeoutError)):
return Retryable(type(exc).__name__)
return Fatal(type(exc).__name__)
默认落到 Fatal 是刻意的保守选择:分类器认不出的错误,宁可判死也不要乱重试。对不可重试的错误重试,只是把同一次失败拖长几分钟,还多烧了额度;对可重试的错误不重试,则把一个本可自愈的抖动变成了用户可见的失败。
7.2.2 带指数退避与抖动的重试器
分类做完,重试器就很短了。它只在 Retryable 上重试,退避间隔指数增长,并加入抖动避免重试风暴:
import random
import time
def with_retry(fn, *, max_attempts=3, base=0.05, cap=0.5, jitter=0.2,
sleep=time.sleep, rng=None, trace=None):
rng = rng or random.Random(7)
for attempt in range(1, max_attempts + 1):
try:
return fn(), attempt
except Exception as exc:
tag = classify(exc)
if isinstance(tag, Fatal) or attempt == max_attempts:
if trace is not None:
trace.append(f" attempt {attempt} 失败({tag.reason})→ 放弃")
raise
delay = min(cap, base * 2 ** (attempt - 1))
delay *= 1 + rng.uniform(-jitter, jitter)
if trace is not None:
trace.append(f" attempt {attempt} 失败({tag.reason})→ 退避 {delay:.3f}s")
sleep(delay)
三个参数各有讲究:base 是首次退避基准(0.05–1s,够短以免拖慢);cap 封顶防指数失控(0.5–30s);jitter 是 ±20% 的随机波动,打散重试尖峰——如果一千个 worker 被同时限流、又都精确等 0.05 秒后重发,就会形成第二波、第三波尖峰。base * 2 ** (attempt - 1) 让第 1 次等 base、第 2 次等 2×base,因为越往后越可能是持续过载,急着再来只会火上浇油。
注意 with_retry 返回的是 (结果, 尝试次数)——把尝试次数一并交出来,写库时可以直接记进 runs.attempts,第 13 章做可观测性时也能进指标。
trace 参数是给演示用的:真实代码里这些行应该走结构化日志(第 13 章),而不是拼字符串打印。但无论怎么输出,「第几次尝试、失败原因、退避多久」这三件事都必须被记录下来——排查线上「为什么这个 run 跑了 40 秒才成功」时,重试轨迹是唯一的证据。
7.2.3 重试必须配幂等:run id 就是幂等键
这是本节最容易漏、后果最严重的一点:重试的前提是「重试是安全的」,而安全的前提是「幂等」。设想一个不带幂等的 worker:任务执行到一半、结果已写入、但在「确认完成」之前崩了——于是任务被重投,同一个 run 被执行了第二次。研究助理的 run 若是带副作用的(发了邮件、写了外部系统、扣了额度),第二次执行就是重复副作用。
解法是把 run id 当幂等键,在执行前先设一道「幂等门」:
def process_run(r, conn, run_id, prompt, model, *, max_attempts=3):
# 幂等门:run 已完成就直接复用旧结果,绝不重复执行
with conn.cursor() as cur:
cur.execute("SELECT status FROM runs WHERE id=%s", (run_id,))
status = cur.fetchone()[0]
if status == "succeeded":
print(f"[idempotent] run={run_id} 已 succeeded,跳过执行(幂等命中)")
return
# 原子认领 queued -> running
with conn.cursor() as cur:
cur.execute("UPDATE runs SET status='running', started_at=%s "
"WHERE id=%s AND status='queued' RETURNING id",
(now(), run_id))
claimed = cur.fetchone()
conn.commit()
if claimed is None:
print(f"[skip] run={run_id} 非 queued")
return
两道防线叠在一起:第一道是状态检查——succeeded 的 run 直接返回,连模型都不调;第二道是原子认领——WHERE id=%s AND status='queued' 保证即使两个 worker 同时拿到同一个 run,也只有一个能把它翻成 running,另一个拿到空 RETURNING 直接退出。幂等键不需要额外发明,run id 天生就是——它唯一标识一次执行,状态机保证它只会从 queued 迁移一次。
这里有个常见误解:以为「重试」只是「再调一次 model.complete」。其实重试的边界应当是整个「执行 + 写结果」的单元,而不只是模型那一次调用。如果只重试模型调用、却在写结果时崩了,任务照样会半途而废。所以本节把幂等门放在任务入口——不管失败发生在模型调用、工具执行还是写库,重新投递这个 run 时,幂等门都会先检查它是否已完成,从而让整个单元可安全重放。
7.2.4 重试耗尽 → 死信队列
重试不是万能的:上游要是彻底挂了,重试三次、十次都是失败。这时不能无限重试(会打爆下游、占住 worker),也不能假装成功。正确做法是把任务连同失败原因搬进死信队列,留给人工或异步处理:
def process_run(r, conn, run_id, prompt, model, *, max_attempts=3):
# ...(幂等门与原子认领同上)
trace: list[str] = []
try:
result, attempts = with_retry(
lambda: model.complete(prompt), max_attempts=max_attempts, trace=trace)
except Exception as exc:
print("\n".join(trace))
reason = f"{type(exc).__name__}: {exc}"
dead = {"run_id": run_id, "reason": reason, "attempts": max_attempts}
r.lpush(DEAD, json.dumps(dead, ensure_ascii=False))
with conn.cursor() as cur:
cur.execute("UPDATE runs SET status='failed', error=%s, attempts=%s, "
"finished_at=%s WHERE id=%s",
(reason, max_attempts, now(), run_id))
conn.commit()
print(f"[deadletter] 进死信:{json.dumps(dead, ensure_ascii=False)}")
return
# ...(成功分支:写 succeeded + assistant 消息)
死信记录里三样东西缺一不可:run_id(是哪个任务)、reason(为什么失败)、attempts(试了几次)。同时把 run 本身置为 failed 并写上 error——死信不是终点,是「需要人看」的队列;run 的终态必须是确定的 failed,绝不能永远卡在 running。
死信和「重试」是同一件事的两端:能重试的在队列里继续跑,不能重试的进死信等人。一个健康的系统,死信队列平时应该是空的——它一旦增长,要么是某个上游在持续故障,要么是重试参数定得太激进(把本可自愈的抖动也判了死)。所以死信长度本身就是一个该进看板的指标,而不是一个「存起来就忘」的垃圾堆。
7.2.5 真跑一:前两次失败、第三次成功
脚本化一个「前两次抛 503、第三次成功」的假模型,看退避是否按指数走、尝试次数是否停在 3:
== 场景一:前两次失败、第三次成功 ==
attempt 1 失败(HTTP 503)→ 退避 0.046s
attempt 2 失败(HTTP 503)→ 退避 0.086s
[ok] run=6ba6871b-82f7-4812-ba45-e6eb375494ae succeeded(第 3 次尝试):围绕「对比 pgvector 与 Qdrant」,命中 3 条本地语料,结论见引用。
两次退避是 0.046s 与 0.086s——基准 0.05s 与 0.10s 各叠了 ±20% 的抖动,正好呈指数关系(第二次约是第一次的两倍)。第三次成功,attempts 停在 3,没有多跑。
紧接着模拟「worker 崩在确认之前、任务被重投」——同一个 run id 再执行一次:
== 场景二:同一 run 被重复投递(worker 崩后重投)==
[idempotent] run=6ba6871b-82f7-4812-ba45-e6eb375494ae 已 succeeded,跳过执行(幂等命中)
库中 run 状态: ('succeeded', 3)
第二次投递被幂等门直接挡下:状态已是 succeeded,一行模型代码都没执行,attempts 仍是 3(不是 4 或 6)。这就是幂等键的价值——重投多少次,效果都等价于执行一次。注意这里没有用「去重表」之类额外的结构:run id 本身就是天然的幂等键,runs 表的状态字段就是幂等门的判据。
7.2.6 真跑二:永远失败进死信
换一个「永远 503」的假模型,重试三次后放弃:
== 场景三:永远失败 -> 死信 ==
attempt 1 失败(HTTP 503)→ 退避 0.046s
attempt 2 失败(HTTP 503)→ 退避 0.086s
attempt 3 失败(HTTP 503)→ 放弃
[deadletter] 进死信:{"run_id": "f42a32a8-0853-45f3-9d64-ae17fd288f9c", "reason": "ModelError: upstream 503", "attempts": 3}
库中 run 状态: ('failed', 3, 'ModelError: upstream 503')
死信队列 queue:runs:dead: ['{"run_id": "f42a32a8-0853-45f3-9d64-ae17fd288f9c", "reason": "ModelError: upstream 503", "attempts": 3}']
读这份输出:前两次退避后重试,第三次到达 max_attempts 时直接「放弃」并抛出;捕获后写死信、把 run 置为 failed、error 记下原因。死信队列里那条记录可以原样读回,运维据此定位「是哪些 run 卡住了、卡在什么原因上」。注意 run 的终态是 failed 而不是 running——失败也要有明确终点。
7.2.7 重试参数决策表
重试参数不是拍脑袋,每个都有适用区间和「不该重试」的边界:
| 参数 | 作用 | 建议取值 | 说明 |
|---|---|---|---|
max_attempts | 总尝试次数 | 3–5 | 再多也救不回的依赖,早进死信比拖着强 |
base | 首次退避基准 | 0.05–1s | 太短等于空转,太长拖慢正常路径 |
cap | 单次退避上限 | 0.5–30s | 防指数在多次重试后涨到分钟级 |
jitter | 退避随机波动 | 0.1–0.3 | 打散重试尖峰,避免惊群 |
| 不重试:400/401/403 | 请求本身错 | 立即 failed | 重试必然一样 |
| 不重试:已产生副作用 | 可能重复执行 | 带幂等键才重试 | 无幂等键的写操作绝不自动重试 |
| 不重试:模型拒答 | 语义级失败 | 交给上层换策略 | 不是环境问题,重试无用 |
最后三行是关键:「能不能重试」的最终判据是「这次调用是不是幂等的」。一个「发报告」的 run 超时了,你不知道它到底发没发——此时重试可能发两份。所以带副作用的操作要么带幂等键重试,要么干脆不自动重试、直接进死信交人工判断。
还有一个反模式值得点名:把重试写成 while True。没有 max_attempts 上限的重试,在上游持续故障时会把 worker 全部占死、把下游打得更惨,最后连正常请求都进不来。重试的终点必须是「有限的」——要么成功,要么在明确次数后进死信,绝不允许无限循环。
7.2.8 真实环境会怎样
本节的 503 是脚本注入的、status 也是手动填好的,真实系统还有几处不同:
- 真实状态码来自 SDK。Claude API 会把状态码放在
anthropic.APIStatusError.status_code上,限流时还会带retry-after头——这个头才是权威的退避时间,比任何指数公式都准,真实适配器应优先读它、只在缺失时退回本节的计算。 - 重试要和熔断配合。如果某个依赖连续失败很多次,应「断开」一段时间直接拒绝、不再重试,等冷却后放少量探测请求。重试解决偶发抖动,熔断解决依赖整体挂掉——没有熔断,一个彻底宕机的下游会被无休止的重试持续冲击。
- 死信要有消费方。死信队列不消费就只是垃圾堆;真实系统会给它配一个看板告警(死信数超阈值就报警)和一条重放通道(人工确认后把任务重新投回主队列)。
- 退避要算进总预算。3 次重试、base 0.05、cap 0.5 的最坏情况是几秒;真实 base 到秒级时,一次 run 的重试累计可能几十秒,必须计入第 8 章的 run 墙钟超时。
本节验收清单:
- 可重试错误(429 / 5xx / 网络异常)走退避重试,不可重试错误(4xx)立即失败
- 退避间隔指数增长且带抖动
- 重试耗尽后进死信队列,记录
run_id/reason/attempts - 死信任务对应的 run 终态是
failed,不残留running - 以 run id 为幂等键:重复投递的 run 不重复执行
到这里,一次 run 已经能「失败可重试、重试有上限、上限有归宿、重复投递不出错」了。但队列里除了「用户提问触发的 run」,还需要一类不由用户触发的任务:每 N 分钟重建一次语料索引、run 完成后延迟几分钟生成摘要。下一节 7.3 定时与延迟任务:周期语料更新与日报 会把调度这一层补上。想回看队列本身的取任务机制,翻 7.1 任务队列:run 丢给 worker 异步执行 。
阅读导航:上一节:7.1 任务队列:run 丢给 worker 异步执行 · 下一节:7.3 定时与延迟任务:周期语料更新与日报 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。