LLM 请求重试耗尽之后:Dead Letter Queue 工程实践

你的 LLM 应用加了重试。但重试耗尽之后,那条请求去哪了?


一条被遗忘的请求

凌晨两点,我们的报告生成任务静静地失败了。

用户是付费客户,任务是生成一份 20 页的行业分析报告。任务进入队列,调用 LLM API,第一次超时------重试;第二次 503------重试;第三次 rate limit------重试三次全部超时。

然后什么都没发生。

日志里写着 max retries exceeded,metrics dashboard 上多了一个失败计数,告警发出来了。但那条任务------用户的报告需求------就这样消失了。没有任何东西告诉我们"这条请求需要被人处理",也没有任何机制让它在凌晨六点重新跑一遍。

这就是 LLM 应用里最常见的失败模式之一:重试有了,但重试之后没有 DLQ(Dead Letter Queue)


为什么 LLM 应用的失败比普通服务更复杂

传统 Web 服务的失败通常是暂时的:数据库超时、网络抖动,重试几次就好了。但 LLM 请求的失败模式复杂得多:

按原因分类:

失败类型 是否可重试 典型场景
网络超时 / 连接断开 是(立即) API gateway 抖动
503 / 502 是(等待后) 模型服务过载
429 Rate Limit 是(等待后) 超出 TPM/RPM 配额
400 Invalid Request 否(永久失败) Prompt 格式错误、context 超长
500 Internal Error 看情况 模型内部错误
内容安全拒绝 触发内容策略
Token 预算耗尽 看情况 超出账单上限
上下文超长(截断) 否(需改造) 输入超过 context window

问题在于,大多数应用的重试逻辑只处理"可重试"的情况,对"永久失败"和"需要人工干预"的情况根本没有路由。

重试耗尽之后,请求进了 /dev/null


DLQ 的核心思路

Dead Letter Queue 的核心思想很简单:消息处理失败达到阈值后,不要丢弃,把它转移到一个特殊队列。这个队列里的消息:

  1. 不会阻塞主队列的正常处理
  2. 可以被监控、告警
  3. 可以被重新分析(失败原因分类)
  4. 可以被人工处理或异步补偿

在 LLM 场景里,这意味着你需要三条路径,不只是一条:

markdown 复制代码
LLM 请求
    │
    ├─ 成功 ──────────────────────────────► 正常返回
    │
    ├─ 可重试失败(超时/限流)
    │       └─ 重试队列(指数退避)
    │               └─ 重试成功 ──────────► 正常返回
    │               └─ 重试耗尽 ──────────► DLQ
    │
    └─ 永久失败(400/内容安全/截断)────► DLQ(直接,不重试)

四种失败,四种 DLQ 处理策略

进了 DLQ 不等于终点,关键是按失败类型决定后续动作。

策略一:异步补偿(Async Compensation)

适用于:暂时性失败(限流耗尽、模型过载)

逻辑:失败请求进 DLQ,定时任务定期扫描 DLQ,在低峰期重新投递。

python 复制代码
# DLQ 消费者:定时补偿
import asyncio
from datetime import datetime, timedelta

class DLQCompensator:
    def __init__(self, dlq, main_queue, llm_client):
        self.dlq = dlq
        self.main_queue = main_queue
        self.llm_client = llm_client

    async def compensate_batch(self, max_items: int = 50):
        """低峰期批量重投递可补偿的失败请求"""
        items = await self.dlq.peek(
            max_items=max_items,
            filter={"failure_type": "retryable", "retry_after": {"$lte": datetime.utcnow()}}
        )
        
        compensated = 0
        for item in items:
            # 检查原始请求是否仍然有效(用户是否还需要结果)
            if await self.is_request_still_valid(item):
                await self.main_queue.enqueue(item["payload"], priority="low")
                await self.dlq.remove(item["id"])
                compensated += 1
            else:
                # 用户已不需要,直接标记为过期
                await self.dlq.mark_expired(item["id"])
        
        return compensated

    async def is_request_still_valid(self, item: dict) -> bool:
        """判断请求是否仍需要被处理"""
        created_at = item["created_at"]
        ttl_seconds = item.get("ttl_seconds", 86400)  # 默认 24h TTL
        return (datetime.utcnow() - created_at).total_seconds() < ttl_seconds

策略二:降级响应(Graceful Degradation)

适用于:同步请求路径,用户在等结果

逻辑:主路径失败后进 DLQ,同时立即给用户一个降级响应(缓存/简化版/人工兜底)。

python 复制代码
class LLMRequestHandler:
    async def handle_report_generation(self, user_id: str, params: dict) -> dict:
        try:
            result = await self.llm_with_retry(params, max_retries=3)
            return {"status": "success", "result": result}
        except MaxRetriesExceeded as e:
            # 进 DLQ,异步补偿
            dlq_id = await self.dlq.enqueue({
                "user_id": user_id,
                "params": params,
                "failure_reason": str(e),
                "failure_type": "retryable",
                "retry_after": datetime.utcnow() + timedelta(minutes=30),
                "ttl_seconds": 3600 * 6,  # 6小时内有效
            })
            
            # 立即给用户降级响应
            await self.notify_user(user_id, {
                "status": "queued",
                "message": "生成需要时间,我们将在 30 分钟内通知您",
                "dlq_task_id": dlq_id,
            })
            
            return {"status": "queued", "task_id": dlq_id}
        
        except PermanentFailure as e:
            # 永久失败,进 DLQ 人工审查
            await self.dlq.enqueue({
                "user_id": user_id,
                "params": params,
                "failure_reason": str(e),
                "failure_type": "permanent",
                "requires_human_review": True,
            })
            
            # 立即给用户明确错误
            return {
                "status": "failed",
                "message": "请求无法处理,客服将在 2 小时内联系您",
            }

策略三:自动修复再投递(Auto-Repair + Redrive)

适用于:可识别的结构性失败(Prompt 超长、格式错误)

逻辑:分析失败原因,尝试自动修复后再投递。

python 复制代码
class DLQAutoRepairer:
    async def analyze_and_repair(self, dlq_item: dict) -> bool:
        """分析 DLQ 条目,尝试自动修复"""
        failure_reason = dlq_item["failure_reason"]
        payload = dlq_item["payload"]
        
        # Case 1: Context 超长
        if "context_length_exceeded" in failure_reason or "maximum context length" in failure_reason:
            repaired = await self.truncate_context(payload)
            if repaired:
                await self.main_queue.enqueue(repaired, metadata={
                    "repaired_from_dlq": dlq_item["id"],
                    "repair_action": "context_truncated",
                })
                await self.dlq.remove(dlq_item["id"])
                return True
        
        # Case 2: JSON 格式问题(输出解析失败)
        if "json_parse_error" in failure_reason:
            repaired = self.add_json_format_instruction(payload)
            await self.main_queue.enqueue(repaired, metadata={
                "repaired_from_dlq": dlq_item["id"],
                "repair_action": "prompt_patched_json_instruction",
            })
            await self.dlq.remove(dlq_item["id"])
            return True
        
        # Case 3: 内容安全触发
        if "content_policy_violation" in failure_reason:
            # 无法自动修复,需要人工审查
            await self.dlq.mark_requires_human(dlq_item["id"], reason="content_policy")
            return False
        
        return False  # 无法自动修复

    def truncate_context(self, payload: dict) -> dict:
        """截断 context 到安全长度"""
        messages = payload.get("messages", [])
        # 保留系统提示和最后 N 条用户消息
        system_msgs = [m for m in messages if m["role"] == "system"]
        recent_msgs = [m for m in messages if m["role"] != "system"][-6:]
        payload["messages"] = system_msgs + recent_msgs
        return payload

策略四:人工审查队列(Human Review Queue)

适用于:高价值任务的永久失败,或需要业务判断的情况

逻辑:DLQ 中标记了 requires_human_review 的条目,推送到运营工作台。

python 复制代码
class HumanReviewNotifier:
    async def escalate_to_human(self, dlq_item: dict):
        """将 DLQ 条目推送到人工审查"""
        # 发送告警到 Slack/企业微信
        await self.alert_channel.send({
            "text": f"🚨 LLM 请求需要人工处理",
            "blocks": [
                {"type": "section", "text": f"*Task ID*: {dlq_item['id']}"},
                {"type": "section", "text": f"*用户*: {dlq_item['user_id']}"},
                {"type": "section", "text": f"*失败原因*: {dlq_item['failure_reason']}"},
                {"type": "section", "text": f"*失败时间*: {dlq_item['failed_at']}"},
                {"type": "actions", "elements": [
                    {"type": "button", "text": "重试", "action_id": f"dlq_retry_{dlq_item['id']}"},
                    {"type": "button", "text": "退款", "action_id": f"dlq_refund_{dlq_item['id']}"},
                    {"type": "button", "text": "忽略", "action_id": f"dlq_dismiss_{dlq_item['id']}"},
                ]}
            ]
        })
        
        await self.dlq.mark_escalated(dlq_item["id"])

用 Redis Streams 实现 LLM DLQ

Redis Streams 是实现 LLM DLQ 最轻量的方案,不需要引入 Kafka 或 SQS。

python 复制代码
import redis.asyncio as redis
import json
from typing import Optional

class RedisLLMQueue:
    def __init__(self, redis_url: str):
        self.redis = redis.from_url(redis_url)
        self.main_stream = "llm:requests"
        self.dlq_stream = "llm:dlq"
        self.group_name = "llm-workers"
    
    async def setup(self):
        """初始化 streams 和 consumer group"""
        for stream in [self.main_stream, self.dlq_stream]:
            try:
                await self.redis.xgroup_create(stream, self.group_name, id="0", mkstream=True)
            except Exception:
                pass  # group already exists
    
    async def enqueue_request(self, payload: dict, ttl_ms: int = 86400000) -> str:
        """将 LLM 请求加入主队列"""
        msg_id = await self.redis.xadd(
            self.main_stream,
            {
                "payload": json.dumps(payload),
                "enqueued_at": str(int(time.time() * 1000)),
                "retry_count": "0",
                "ttl_ms": str(ttl_ms),
            }
        )
        return msg_id
    
    async def move_to_dlq(
        self, 
        msg_id: str, 
        payload: dict, 
        failure_reason: str,
        failure_type: str = "retryable",
    ) -> str:
        """将失败消息从主队列移入 DLQ"""
        dlq_id = await self.redis.xadd(
            self.dlq_stream,
            {
                "original_id": msg_id,
                "payload": json.dumps(payload),
                "failure_reason": failure_reason,
                "failure_type": failure_type,
                "failed_at": str(int(time.time() * 1000)),
                "retry_after": str(int((time.time() + 1800) * 1000)),  # 30min
            }
        )
        # 从主队列确认(ACK)这条消息,避免重复消费
        await self.redis.xack(self.main_stream, self.group_name, msg_id)
        return dlq_id
    
    async def consume_main_queue(self, worker_id: str, count: int = 10):
        """消费主队列(带自动 DLQ 路由)"""
        messages = await self.redis.xreadgroup(
            self.group_name,
            worker_id,
            {self.main_stream: ">"},
            count=count,
            block=5000,  # 5s block
        )
        return messages
    
    async def get_dlq_stats(self) -> dict:
        """获取 DLQ 统计"""
        dlq_len = await self.redis.xlen(self.dlq_stream)
        # 获取最近 100 条,分析失败类型分布
        recent = await self.redis.xrevrange(self.dlq_stream, count=100)
        
        type_counts = {}
        for _, fields in recent:
            ft = fields.get(b"failure_type", b"unknown").decode()
            type_counts[ft] = type_counts.get(ft, 0) + 1
        
        return {
            "total_in_dlq": dlq_len,
            "type_distribution": type_counts,
        }

五个生产踩坑

踩坑 1:DLQ 只有入,没有出

DLQ 建了,但没有消费者。消息堆积起来,告警每天响,没人处理。

解法 :DLQ 必须配套消费策略。在建 DLQ 的时候就要问:谁来消费?什么时候消费?消费失败怎么办?如果答不出来,DLQ 只是一个更漂亮的 /dev/null

踩坑 2:重试逻辑和 DLQ 逻辑耦合在一起

python 复制代码
# 错误示例:retry 和 DLQ 混在一个函数里
async def call_llm_with_everything(params):
    for i in range(3):
        try:
            return await llm.complete(params)
        except Exception as e:
            if i == 2:
                await dlq.enqueue(params, str(e))  # 👎 retry 和 DLQ 耦合
            await asyncio.sleep(2 ** i)

解法:分离关注点。重试逻辑只管"是否还要重试",失败路由只管"重试耗尽后去哪"。

python 复制代码
# 正确示例:分离
async def call_llm(params):
    return await llm_client.complete_with_retry(params, max_retries=3)

async def handle_request(params):
    try:
        return await call_llm(params)
    except MaxRetriesExceeded as e:
        return await failure_router.route(params, e)  # 独立的路由层

踩坑 3:不区分永久失败和暂时失败

把所有失败都扔进同一个 DLQ,然后定时全量重试------结果 400 Bad Request 的请求每 30 分钟重试一次,永远不会成功,还在刷 API 成本。

解法 :分类路由。永久失败(400、内容安全)直接打 requires_human_review,不进重试补偿逻辑。

python 复制代码
def classify_failure(error: Exception) -> str:
    if isinstance(error, (BadRequestError, ContentPolicyError)):
        return "permanent"
    if isinstance(error, (RateLimitError, TimeoutError, ServerError)):
        return "retryable"
    return "unknown"

踩坑 4:DLQ 消息里没有足够的上下文

DLQ 消息只存了请求体,没有存:是谁发的、发给哪个模型、当时的 Prompt 版本、重试历史。人工审查时完全不知道发生了什么。

解法:DLQ 消息里要带完整 trace:

python 复制代码
dlq_payload = {
    "request": original_request,
    "metadata": {
        "user_id": user_id,
        "request_id": request_id,
        "model": model_name,
        "prompt_version": prompt_version,
        "trace_id": trace_id,
        "retry_history": [
            {"attempt": 1, "error": "timeout", "at": "..."},
            {"attempt": 2, "error": "503", "at": "..."},
            {"attempt": 3, "error": "timeout", "at": "..."},
        ],
    },
    "failure": {
        "reason": str(error),
        "type": failure_type,
        "failed_at": datetime.utcnow().isoformat(),
    }
}

踩坑 5:DLQ 补偿没有幂等性保证

DLQ 消费者重试一条消息,LLM 返回成功,但在 ACK 之前进程崩了。消息再次被消费,又发起了一次 LLM 调用,结果用户收到了两份报告,账单上多了一次扣费。

解法 :补偿操作必须幂等。使用 request_id 做去重。

python 复制代码
async def compensate_request(dlq_item: dict):
    request_id = dlq_item["metadata"]["request_id"]
    
    # 检查是否已经成功处理过
    if await result_store.exists(request_id):
        # 已有结果,直接 ACK,不重复调用
        await dlq.ack(dlq_item["id"])
        return
    
    result = await llm_client.complete(dlq_item["request"])
    
    # 先存结果,再 ACK(保证结果可见性)
    await result_store.set(request_id, result, ttl=3600)
    await notify_user(dlq_item["metadata"]["user_id"], result)
    await dlq.ack(dlq_item["id"])

监控:DLQ 该看哪些指标

DLQ 本身也要被监控。这是我用得比较顺手的三个关键指标:

python 复制代码
# DLQ 监控指标定义
class DLQMetrics:
    # 1. DLQ 积压量(绝对值告警)
    # 阈值:>100 告警,>500 紧急
    dlq_backlog_total: gauge
    
    # 2. DLQ 入队速率(突增说明服务出问题)
    # 告警:5 分钟内入队速率 > 正常水位 3x
    dlq_enqueue_rate: counter
    
    # 3. 平均在 DLQ 停留时长(补偿是否及时)
    # 阈值:>2h 说明补偿逻辑可能没在运行
    dlq_item_age_p50: histogram
    dlq_item_age_p99: histogram

Prometheus 告警规则示例:

yaml 复制代码
groups:
  - name: llm_dlq_alerts
    rules:
      - alert: DLQBacklogHigh
        expr: llm_dlq_backlog_total > 100
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "LLM DLQ 积压过高 ({{ $value }} 条)"
          description: "DLQ 积压超过 100 条超过 5 分钟,需检查补偿逻辑"
      
      - alert: DLQBacklogCritical
        expr: llm_dlq_backlog_total > 500
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "LLM DLQ 积压严重 ({{ $value }} 条)"
      
      - alert: DLQItemsStale
        expr: llm_dlq_item_age_p99 > 7200  # 2 hours in seconds
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "DLQ 中有消息停留超过 2 小时"
          description: "检查 DLQ 补偿任务是否正常运行"

选哪个基础设施:Redis vs SQS vs Kafka

维度 Redis Streams AWS SQS + DLQ Kafka
配置复杂度 低(改几行代码) 中(需要 AWS 资源) 高(集群运维)
消息持久化 内存优先(可配 RDB/AOF) 持久化,99.9% SLA 持久化,高吞吐
DLQ 原生支持 需要手动实现 原生支持(maxReceiveCount) 需要配 DeadLetterTopic
消息可见性超时 需手动实现 原生支持 需要消费者管理 offset
适用场景 已有 Redis,量不大 云原生应用 高吞吐,已有 Kafka
重投递(Redrive) 手动 原生 Redrive Policy 手动 replay
消息检索/查询 xrange 支持 有限 有限

建议:如果你的 LLM 服务已经用了 Redis,直接用 Redis Streams,入门成本最低。量上来了再考虑 Kafka。


一个完整的 LLM DLQ 处理流程

ini 复制代码
主队列 Worker
    │
    ├─ 调用 LLM API
    │       │
    │       ├─ 成功 ──────────────────────────────────► 返回结果
    │       │
    │       └─ 失败
    │               │
    │               ├─ retry_count < max_retries
    │               │       └─ 指数退避后重试
    │               │
    │               └─ retry_count >= max_retries
    │                       │
    │                       └─ classify_failure()
    │                               │
    │                               ├─ permanent ──────► DLQ(human_review=True)
    │                               │                         └─ 企业微信告警 → 人工处理
    │                               │
    │                               └─ retryable ──────► DLQ(human_review=False)
    │                                                         │
    │                                                         └─ DLQ 补偿 Worker(每 30 分钟)
    │                                                                 │
    │                                                                 ├─ TTL 过期 → 丢弃+通知用户
    │                                                                 │
    │                                                                 ├─ 自动修复成功 → 重新入主队列
    │                                                                 │
    │                                                                 └─ 无法修复 → 升级人工审查

实际效果

在我们的报告生成系统引入 DLQ 后,关键数字:

  • 请求丢失率:从 2.3% 降到 0.1%(主要是 TTL 过期的合理丢弃)
  • 人工处理的 DLQ 工单:每天平均 8 条,主要是内容安全触发
  • 自动补偿成功率:进入 DLQ 的 retryable 条目,约 73% 在 30 分钟内自动补偿成功
  • 用户投诉减少:报告生成相关的"任务消失"投诉从每周 ~15 条降到 0 条

最重要的改变不是数字,是心态:现在当 LLM API 出现故障,我知道那段时间的请求都在 DLQ 里等着,不会凭空消失。告警响了,我看的是"DLQ 积压多少条",而不是"到底丢了多少"。


总结

重试只解决了"暂时失败"。DLQ 解决的是"重试耗尽之后,请求不能消失"这个更基础的问题。

核心四件事:

  1. 分类失败:永久失败 vs 暂时失败,路由策略完全不同
  2. 携带上下文:DLQ 消息要包含完整 trace,不然人工审查是盲盒
  3. 补偿必须幂等:用 request_id 防止重复处理
  4. 监控 DLQ 本身:积压量、入队速率、消息停留时长三个指标

DLQ 不是很难的技术,但它要求你在设计时就把"失败是一等公民"这件事放进去------而不是等出了事再补。

相关推荐
孙启超10 小时前
【大模型应用开发】LLM 到底是什么,以及它是怎么训练的
人工智能·lora·llm·微调·sft·token·rlhf
DigitalOcean16 小时前
2026 LLM 成本计算指南:Token 价格、推理费用与降本方法
llm
武子康19 小时前
DeepSeek Harness、Codex、Claude Code、LangGraph 应该怎么选:先判断你缺的是产品、底盘还是工作流
人工智能·llm·agent
阿弱20 小时前
pi 扩展机制:加载、执行与能力
后端·llm·agent
vivo互联网技术1 天前
Octopus:基于无历史数据的梯度正交化的学习框架|CVPR 2026
深度学习·计算机视觉·llm
leeyi1 天前
Langfuse 集成源码:batch 协议、media 上传与 mock 测试(第89篇-E75)
llm·aigc·agent
修远客1 天前
风格进化:让Agent越来越懂你 — 从"工具"到"助手"的关键跃迁
llm·agent
武子康1 天前
Pi Extension 写完不等于可用:从类型检查到真实 Runtime 的证据阶梯
人工智能·llm·agent
一只积极向上的小咸鱼1 天前
分词器tokenizer
算法·llm