第一版只有单次同步调用:请求发出去,等模型返回,再把结果渲染到页面。上线第一周就吃了教训——下午高峰时段用户点”生成总结”,页面经常转圈二十几秒,然后抛出一个光秃秃的错误提示,好几个用户以为功能坏了直接放弃。翻请求日志才发现,故障无非三类:网络层超时、限流(HTTP 429)、服务端 5xx。当时我对照 DeepSeek 的接口文档把每个错误码梳理了一遍,最终把处理策略定成”重试 + 降级 + 兜底文案”三层组合。重试解决瞬时抖动,降级在 DeepSeek 不稳定时切换备用模型,兜底保证即便所有模型都失败,用户也能拿到一段可读结果。改动量不大,总结功能却从高频翻车变成了稳定可用,下面把每一层的取舍和踩过的坑拆开讲。
一、异常分类与处理策略
先分类再谈处理。没有分类的重试很危险:把 400 输入违规当成瞬时故障去重试,只会浪费请求配额,还拖慢用户等待;反过来,把 5xx 当成不可重试,又会白白丢请求。我按”能否重试、是否降级、如何兜底”三个维度给异常分了五类,这张分类表是实际排障时反复调整后定下来的。
| 异常类型 | HTTP | 重试 | 降级 | 兜底 |
|---|---|---|---|---|
| 网络超时 | – | 是 | 切备用厂商 | 提示稍后重试 |
| 限流 | 429 | 按 Retry-After | 切备用模型 | 排队提示 |
| 服务端 5xx | 5xx | 是(≤ 3 次) | 切本地小模型 | 文本摘要 |
| 输入违规 | 400 | 否 | 不重试 | 报错并回退到原字幕 |
| 计费异常 | 402 | 否 | 切免费模型 | 提示充值 |
分类表落地后,重试逻辑变得可以推导:什么错误进重试、重试几次、失败后往哪走,都有一一对应的路径。其中输入违规和计费异常两类,最初没有单列,结果出过一次尴尬局面——账号余额不足,所有请求都被按 429 重试,白白耗光剩余配额。把这五类跑明白,后面的重试、降级、兜底才不是拍脑袋。
二、重试设计
重试的目标不是”多试几次”,而是”用更少的次数换回可用性”。如果所有失败都立刻重试,服务端还在恢复中,请求会像蜂群一样反复冲击,这就是常说的”雷鸣群”问题,反而把后端打得更慢。我在项目里给重试套上了指数退避和随机抖动:第一次失败等 1 秒,第二次等 2 秒,每次再叠加一个随机偏移,让并发重试的请求错开,而不是同时撞上来。
import asyncio, random
async def call_with_retry(payload, max_attempts=3):
delay = 1.0
for attempt in range(1, max_attempts + 1):
try:
return await deepseek_client.summarize(payload)
except TimeoutError:
if attempt == max_attempts:
raise
await asyncio.sleep(delay + random.uniform(0, 0.3))
delay *= 2
这段代码解决的核心问题是”失败后等多久再试”。随机抖动加的是 0 到 0.3 秒的小偏移,成本很低,但能避免多线程同时超时后一起重试的踩踏。实现时有三条硬约束,是我从线上事故里总结出来的:
- 仅对可重试错误(超时、429、5xx)启用;
- 退避基数 ≤ 8s,避免长时间占用用户;
- 携带 trace_id,便于日志定位。
第三条尤其容易漏。没有 trace_id,重试链路断了之后,排查时根本分不清哪次是原请求、哪次是重试,只能靠时间戳猜测,效率很低。
三、限流处理
DeepSeek 限流时会在响应头里带上 Retry-After,告诉客户端等多久再试。起初我按自己写的固定退避等待,效果很差——限流刚恢复,别的实例已经把配额占满,再次撞上 429,白白多等一轮。后来改成直接读取响应头里的数值:
retry_after = int(response.headers.get("Retry-After", "1"))
await asyncio.sleep(retry_after)
服务端给的等待时长比本地猜的准得多。还有一点要注意:DeepSeek 的限流是按账号配额算的,不是每个请求独立,所以指标里要把单日 429 次数盯紧,接近配额 80% 时告警,提前切备用模型,而不是等彻底打满才反应。实际跑下来,这个 80% 预警线帮我们躲过了好几次高峰期的全量失败。
四、降级到备用模型
重试解决不了持续性的故障,比如 DeepSeek 服务端连着 5xx。这时候就要降级。选备用模型时我对比过几个候选,最终定下三条标准:与 DeepSeek 接口兼容(不用改调用代码)、价格更低、长上下文足够支撑视频字幕总结。切换要放在统一 client 后面,让业务层完全无感:
class AISummarizer:
def __init__(self):
self.providers = [
("deepseek", deepseek_client),
("backup", backup_client),
]
async def summarize(self, text):
for name, client in self.providers:
try:
return await client.summarize(text)
except (TimeoutError, RateLimitError, ServerError):
continue
raise AllProvidersFailed()
这段代码把”供应商选择”收敛到一个循环里:主模型不行就轮到备用模型,都失败才抛异常。踩过的坑是备用模型的输出质量差异——有的小模型总结完句子不通顺,所以降级成功后要给结果打上”由备用模型生成”的标记,让用户知道内容可能不如主模型精炼。备用模型的选择也要做成配置,别写死在代码里,方便随时替换。
五、用户层兜底
即便有重试和降级,模型全挂的情况依然存在。我的原则是:用户不能因为后端模型不可用,就看到一个空白页。按失败程度给了三种兜底文案:
| 场景 | 兜底 |
|---|---|
| 完全失败 | 返回原字幕前 200 字 + “稍后整理”提示 |
| 部分失败 | 返回已生成段落 + 缺失段占位 |
| 降级 | 注明”由备用模型生成”,并允许用户重新生成 |
这里的取舍是”宁可给部分结果,也不给空页面”。原字幕本身就是下载工具里已有的数据,把它作为兜底内容几乎零成本,却能保住用户对功能的信任。加上”重新生成”按钮后,用户被安抚的概率明显上升,客诉里”总结不出来”的占比掉了一截。
六、可观测性
故障处理做得再全,看不见故障就等于白做。我们给每次失败都上报了完整字段:
- 上报:traceid、model、status、latency、errortype;
- 看板:失败率、平均重试次数、限流次数;
- 报警:失败率 > 2% 持续 5 分钟。
报警阈值要留余量。最开始设的是失败率 5%,结果发现 5% 已经是用户体验明显变差的水平,等报警出来,用户早就反馈完了。改成 2% 后,一般波动不会误报,真正出问题又能提前介入。
七、配置项
重试、超时、降级这类参数,一开始写在代码常量里,每次调优都要改代码发版,太慢。后来我把它们全部抽到配置文件,调参只需改配置再重载:
ai:
providers:
- name: deepseek
timeout_ms: 12000
max_retries: 3
- name: backup
timeout_ms: 15000
max_retries: 2
fallback_text: "AI 总结暂时不可用,已为你保留原始内容。"
配置文件带来的明显好处是降级策略能随线上表现动态调整。比如某段时间备用模型响应变慢,就把它的超时放宽到 15 秒,不用动一行代码。注意 fallback_text 这类用户可见文案也放这里统一管理,方便运营同学直接改措辞。
八、回归测试
这类容错逻辑担心的是”测试时一切正常,生产一压就露馅”。我给重试、降级链路做了故障注入式的回归测试:
- 用 VCR 录制正常响应,人工注入超时与 429;
- CI 每次跑故障注入,验证重试与降级;
- 压测时把限流阈值调到 80%,观察切换是否平滑。
测试的价值在于把”运气”变成”预期”。VCR 录制的好处是不用真调模型,测试稳定又不花钱;CI 每次提交都跑一遍,防止改业务代码时把容错链路改坏。压测那一步尤其重要,限流阈值调低后,降级切换是否平滑、用户有没有感知到延迟,只有压力下才看得出来。
到这里,DeepSeek 调用失败处理的完整链路就齐了:分类定策略,重试扛瞬时,降级扛持续,兜底保体验,日志做验证。
常见问题(FAQ)
Q1:429 要不要无限重试?
不要。按 Retry-After 等待后最多再试 2 次,避免长时间占用 worker。
Q2:降级后用户会重复扣费吗?
不会。同一任务只扣一次,扣费与降级解耦。
Q3:兜底文案会不会让用户失望?
配合”重新生成”按钮和透明状态,失望感会下降。