更新于

AI 任务重试队列设计


单次调用的重试逻辑(指数退避、读 Retry-After 头)解决的是”这一个请求怎么重试”,具体做法可以参考 LLM 错误重试策略Retry-After 响应头处理指南。但当系统里同时有成千上万个待重试的 AI 任务时,问题就不再是”单个请求怎么重试”,而是”重试队列本身怎么设计”——这是完全不同层面的架构问题,也是本文要聚焦的内容。

一、为什么需要独立的重试队列

把失败任务直接扔回主队列重试,是最容易踩坑的做法。原因有三个:

  • 重试风暴:如果一个下游服务短暂故障,大量任务同时失败、同时重试,会在故障恢复的瞬间制造一波更大的请求洪峰,可能直接把刚恢复的服务打垮。
  • 头部阻塞:失败任务和正常任务混在同一个队列里,重试的任务会占用工作线程处理新任务的资源,拖慢整体吞吐。
  • 无法区分”值得重试”和”该放弃”:主队列的消费逻辑通常是”一次执行成功或失败”,没有专门的空间记录”这是第几次重试”、“上次失败原因是什么”。

独立的重试队列把”失败后怎么办”这个逻辑从主流程里剥离出来,专门处理延迟调度、次数统计和最终放弃这几件事。

二、基础架构:三队列模型

生产环境中比较成熟的做法是拆成三个逻辑队列:

主队列 (main)  →  失败  →  重试队列 (retry)  →  超过上限  →  死信队列 (DLQ)
     ↑                          │
     └──────── 延迟到期后重新入队 ─┘
  • 主队列:正常任务流入的地方,worker 从这里取任务执行。
  • 重试队列:失败任务按延迟时间排序等待,到期后重新推回主队列。
  • 死信队列(Dead Letter Queue):超过最大重试次数仍然失败的任务最终落地的地方,不再自动重试,等待人工介入或专门的批处理修复。

这个模型的核心价值在于:主队列永远只处理”准备好被执行”的任务,重试逻辑完全不侵入主流程。

三、重试队列的关键设计点

1. 延迟调度用什么数据结构

重试队列本质是一个按”下次执行时间”排序的延迟队列。常见实现方式:

方案适用场景优缺点
Redis Sorted Set(score = 下次执行时间戳)中小规模,需要低延迟实现简单,需要额外轮询进程把到期任务移回主队列
消息队列原生延迟特性(RabbitMQ 延迟插件、SQS 的 DelaySeconds)已有成熟 MQ 基础设施免维护,但延迟精度和最大延迟时长受限于中间件
数据库轮询(定时扫描 next_retry_at <= now任务量不大、已有关系型数据库实现门槛低,但轮询间隔限制了重试精度,任务量大时数据库压力明显

选择哪种主要看现有基础设施,没有绝对最优解,但延迟精度不需要追求到秒级——AI 任务的重试间隔通常以十秒到分钟为单位,过度追求精确反而增加系统复杂度。

2. 毒消息隔离(Poison Message Isolation)

“毒消息”指那些无论重试多少次都必然失败的任务——比如输入内容本身就违反了模型的内容策略,或者请求参数格式错误。这类任务如果和”因为限流临时失败”的任务用同一套重试计数逻辑,会浪费大量重试配额。

实用做法是在任务失败时区分错误类型:

def classify_failure(error):
    if error.type in ("rate_limit", "timeout", "server_error"):
        return FailureType.TRANSIENT  # 值得重试
    elif error.type in ("invalid_request", "content_policy"):
        return FailureType.PERMANENT  # 直接进死信队列,不占重试次数
    else:
        return FailureType.UNKNOWN  # 保守按可重试处理,但记录日志复查

永久性失败的任务应该跳过重试队列,直接进入死信队列,避免消耗宝贵的重试预算。

3. 重试预算(Retry Budget),而不是单任务重试次数

只给单个任务设置”最多重试 3 次”是不够的。更成熟的设计是在服务级别设置重试预算,比如”整个系统每分钟最多消耗 N 次重试配额”。这能防止某个下游依赖大规模故障时,重试队列本身把系统压垮:

class RetryBudget:
    def __init__(self, capacity_per_minute):
        self.capacity = capacity_per_minute
        self.consumed = 0
        self.window_start = None

    def try_consume(self):
        self._reset_if_new_window()
        if self.consumed >= self.capacity:
            return False
        self.consumed += 1
        return True

当预算耗尽时,新的重试请求应该直接延迟到下一个窗口,而不是继续尝试消耗系统资源。这个思路和 异步任务队列架构 里讨论的限流层是配合使用的——重试预算控制”重试量”,限流层控制”总调用量”,两者共同保护下游服务。

4. 优先级重排

不是所有重试任务都应该按 FIFO 顺序处理。实践中常见的优先级调整规则:

  • 重试次数越多,优先级越低:已经失败多次的任务大概率还会继续失败,不应该抢占刚失败一次、可能是偶发问题的任务的处理资源。
  • 用户可感知的任务优先于后台任务:比如用户在等待回复的对话请求,应该比批量数据处理任务优先重试。
  • 临近死信的任务可以做最后一次”加急”:如果任务即将超过重试上限,给它更短的延迟,让它尽快得到最后一次机会,而不是排到很晚。

四、死信队列不是终点

很多团队把死信队列当成”失败任务的垃圾桶”,扔进去就不再管。更好的做法是把死信队列当作需要人工或专项处理的信号源

  • 定期(比如每天)汇总死信队列里的失败原因分布,如果某类错误占比突然上升,通常意味着上游依赖或者输入数据出了系统性问题。
  • 对于因为临时性大故障(比如模型服务商大规模宕机)导致大批任务进入死信队列的情况,应该支持”批量重新入队”操作,而不是要求逐条人工处理。
  • 死信队列里的任务也需要设置保留期限和清理策略,避免无限膨胀占用存储。

五、相关阅读

重试队列设计得再精细,也绕不开底层模型调用的稳定性问题——减少不必要的重试,最直接的办法是从源头降低失败率,可以了解 YoTradeApi,提供稳定的 AI API 中转服务,帮助降低限流和超时带来的重试压力。