跳至主要内容
LLM 应用上线前,我先把降级路径走一遍

LLM 应用上线前,我先把降级路径走一遍

上线前一周,我干了一件挺笨的事:把线上环境的模型 key 改成了一串乱码,然后刷新页面。

结果页面转了大概三十秒,前端 axios 超时,用户看到的是浏览器默认的错误提示。后端日志里只有一行 ReadTimeout,没有请求 ID,没有降级记录,也不知道有多少用户在那个时间点撞上了。

那一刻我意识到,我之前所谓的「降级方案」,其实只是几个 try/except 加一句 return "系统繁忙,请稍后再试"。这不算方案,只能算把异常藏起来了。

先划出「不能降」的那条线

降级设计最容易犯的错,是一上来就想「模型挂了怎么办」。更该先问的是:这条链路里,哪些东西一旦降级就会变成事故?

我把一个典型的问答链路拆成四段:检索、生成、后处理、写入数据库。然后逐段问一句:这步失败,能不能跳过?

  • 检索失败,可以跳过,退化成纯模型回答,但要在前端明确标注「本次未引用你的文档」。
  • 生成失败,可以换更小的模型,或者返回缓存的近似答案。
  • 后处理里的引用校验、敏感词过滤、格式校验,不能跳。宁可这次不出答案。
  • 权限判断、额度扣减、审计日志写入,绝对不能跟着降级。

我给自己定了一条线:只能降「生成」,不能降「校验」。因为生成的质量差一点,用户看得出来;校验少了一步,用户看不出来,但出事的时候也来不及了。

这条线划清楚之后,降级就从一个技术问题变成了一个产品判断:哪些可见的损失是可以接受的,哪些不行。

降级是一张矩阵,不是一条链

我一开始写的是链式的 fallback:主模型失败就走小模型,小模型失败就走缓存,缓存没有就返回兜底文案。跑了两周发现不对——链式降级会掩盖问题。检索服务挂了和模型限流了,走的是同一条链,日志里看起来一模一样。

后来改成按故障类型分档,每一档都要写清楚三件事:触发条件、用户看到什么、怎么退出来。

故障 触发条件 降级动作 用户可见
主模型超时 连续 3 次超过 8s 换小模型,上下文砍到一半 顶部提示「回答可能更简短」
触发限流 429 或 QPS 超阈值 进队列,先答缓存命中的问题 显示排队位置
检索不可用 健康检查连续失败 纯模型回答,关闭引用 标注「未引用文档」
工具调用失败 同一工具连续 2 次报错 关掉该工具,退化成问答 提示「本次未查询实时数据」
全链路失败 以上都不成立 静态兜底 + 转人工入口 明确说明 + 重试按钮

代码上大概是这样,用一个配置表驱动,而不是散落各处的 if:

PYTHON16 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DEGRADE_LEVELS = [
{"name": "full", "model": "large", "context": 8000, "tools": True},
{"name": "lean", "model": "small", "context": 4000, "tools": True},
{"name": "no_tools", "model": "small", "context": 4000, "tools": False},
{"name": "cache_only", "model": None, "context": 0, "tools": False},
]

def answer(req, level=0):
cfg = DEGRADE_LEVELS[level]
try:
return generate(req, cfg)
except (TimeoutError, RateLimitError) as e:
trace.tag("degraded", cfg["name"], error=type(e).__name__)
if level + 1 < len(DEGRADE_LEVELS):
return answer(req, level + 1)
raise

注意 trace.tag 那行。降级最怕的不是降级本身,是降级了却没人知道。所有走过降级路径的请求,我都会打一个标记,指标面板上单独拉一条曲线。这条曲线长期不为零,说明上游根本不稳,不是「降级方案很成功」。

还有一个细节值得说:退出条件。降级状态是会粘住的。有一次检索服务恢复了两小时,流量还全走纯模型回答,因为我把 degraded 状态存在进程内存里,只有新请求会重新探测。后来改成带半开状态的熔断——每 30 秒放五个请求去探一下主路径,成功了才整体恢复。

开关要真的被拨过一次

上线前的最后一周,我做了一轮破坏性演练,前后大约两小时。

具体的动作很土:

  1. 把模型 key 改成乱码,走一遍主流程。
  2. 把超时阈值从 8s 调到 50ms,制造大面积超时。
  3. 用脚本给上游打满并发,看 429 之后队列有没有堆积、堆到多少会拖垮 web 进程。
  4. 直接把检索服务的容器停掉。
  5. 每一步做完,恢复,再观察十分钟,确认没有卡在降级态。

第一轮演练暴露的问题比我想的多:队列没有上限,上游限流之后任务在内存里堆了四千多条,最后 OOM;兜底文案里写的是「系统繁忙」,用户根本不知道发生了什么;还有个小坑,前端把降级标记当成错误码处理,直接弹了个红色 toast,比原来的报错还吓人。

这些都不是设计阶段能想出来的,只能靠真的拨一次开关。

用户那一侧的话,要提前写好

我以前觉得降级文案是运营的事,后来发现它是设计的一部分。

「系统繁忙,请稍后再试」这句话的问题在于,它把所有降级都说成了同一件事。但用户需要知道的是:我的问题是不是被理解了,答案可不可信,我该不该重试。

所以现在我的文案是这样拆的:

  • 降级但仍然回答了:顶部一条浅色提示,说明哪里弱了(未引用文档 / 未查询实时数据 / 回答可能更简短)。
  • 排队中:给一个位置和大致等待时间,允许离开页面,结果出来发通知。
  • 完全没答上来:说清楚卡在哪一步,给重试按钮和人工入口。

半降级比彻底失败更危险。彻底失败用户知道要重来,半降级用户会以为答案是对的,然后拿去用了。这也是为什么我坚持在界面上标注,而不是悄悄降级。

现在的上线清单

我的上线 checklist 最后一条是固定的:把主模型的 key 删掉,用真实账号再走一遍主流程。

走不通就不发。这个动作比任何一份降级设计文档都有效,因为它不看文档写得好不好,只看用户在最坏情况下会看到什么。

下一轮我想补的是缓存层的一致性——现在降级到 cache_only 时,返回的是历史答案,如果用户的数据在这期间改过,答案就是错的。要不要给缓存加一个数据版本号,或者在降级时直接拒答,我还没有定论。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#LLM 应用 #上线 #降级设计

评论