《AI Agent 工程实战》7.2 重试、死信与幂等

上游抖动让 worker 一次失败就判死太脆弱。本节复用卷一 14.1 的错误分类,给队列加上带指数退避与抖动的重试、超过上限进死信队列、以及「重试必须配幂等」的三件套:把 run id 当幂等键,已 succeeded 的 run 直接复用旧结果不重复执行。真跑注入一个「前两次必失败、第三次成功」的假模型打印重试轨迹,再跑一个永远失败的进死信并读回死信记录。

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 定时与延迟任务:周期语料更新与日报 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距