你的 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 的核心思想很简单:消息处理失败达到阈值后,不要丢弃,把它转移到一个特殊队列。这个队列里的消息:
- 不会阻塞主队列的正常处理
- 可以被监控、告警
- 可以被重新分析(失败原因分类)
- 可以被人工处理或异步补偿
在 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 解决的是"重试耗尽之后,请求不能消失"这个更基础的问题。
核心四件事:
- 分类失败:永久失败 vs 暂时失败,路由策略完全不同
- 携带上下文:DLQ 消息要包含完整 trace,不然人工审查是盲盒
- 补偿必须幂等:用 request_id 防止重复处理
- 监控 DLQ 本身:积压量、入队速率、消息停留时长三个指标
DLQ 不是很难的技术,但它要求你在设计时就把"失败是一等公民"这件事放进去------而不是等出了事再补。