跳至主要内容
LLM 应用降级,不是换个模型重试

LLM 应用降级,不是换个模型重试

上线前两天做压测,主模型开始返 429。前端转了三十秒,最后给了一个 500 页面。错误率 100%,可我们其实什么都没坏——只是没有任何一条备用路径可走。

那次之后我改了个习惯:降级方案不是上线后补的运维手册,它得跟主链路一起写、一起测。因为降级方案真正定义的不是「出事怎么办」,而是这个产品在坏掉一半的时候,还愿意给用户什么。

先分清哪些请求允许降级

这一节必须在写代码之前定,否则后面全是扯皮。

我的判据只有一句话:降级之后的输出如果错了,最坏后果能不能在一屏里说清楚。能说清,就可以降;说不清,就不许降,宁可报错。

按这个标准,我们把入口分了三类:

  • 只读生成:问答、摘要、改写、推荐理由。降级最多是答得糙,可以降。
  • 有副作用的写操作:下单、退款、发消息、改配置。降级会重复执行或者执行错的金额,不降。这类请求连重试都要带幂等键,更别说换模型重跑。
  • 涉及钱和合规的判断:额度审批、风控结论、合同条款解读。不降。宁可返回「当前无法处理,请稍后再试」,也不要一个语气很像样但结论错的回答。

我见过最危险的一种写法,是把降级做在 SDK 封装里,对所有调用一视同仁。写操作和只读查询走同一个客户端,某天主模型抖了一下,备用模型把「确认退款 199 元」理解成「确认退款 199 单」,你就会怀念那个直接报错的版本。

所以入口第一件事是打标:

PYTHON8 行
1
2
3
4
5
6
7
8
@dataclass
class Request:
kind: Literal['read', 'write', 'compliance']
idempotency_key: str | None = None

@property
def degradable(self) -> bool:
return self.kind == 'read'

就这么几行,但它把后面所有的分支都约束住了。

降级链是能力阶梯,不是价格表

我第一版降级链是按价格排的:主模型最贵,挂了换便宜的,再挂换最便宜的。跑了一周就发现不对——最便宜那档连 JSON 都吐不完整,等于把故障从「不可用」转移成了「静默错误」,后者更难排查。

后来改成按「牺牲什么」排序,每一级写清楚丢了哪块能力:

  1. 同档换供应商。能力基本不变,只是换一家扛可用性风险。这一级几乎没有质量损失,应该最先试。
  2. 降一档模型。输出变短、指令跟随变弱。配套动作是把 max_tokens 砍一半、把 few-shot 从六个减到两个——降了模型不改 prompt,失败率反而会上去。
  3. 关掉非核心能力。比如关掉 RAG 检索,只让模型凭常识答,并在回答里明确标一句「未检索知识库」。这一级用户能感知到,但比转圈好。
  4. 静态兜底。一段人写的文案,加一个可点的下一步。

超时预算得自上而下分,不能每一级各写一个 timeout 然后加起来超了三倍。我们端到端定的是 8 秒左右,大致这么分:网关和鉴权 0.3 秒,检索 1.2 秒,主模型 5 秒,序列化和前端渲染 0.5 秒,剩下不到 1 秒留给整条降级链。注意最后这条:如果主模型吃满 5 秒才超时,降级链只剩 1 秒,第三、四级其实根本跑不到,写在那里只是心理安慰。

实际做法是给每级配一个 min_required,剩余预算不够就不进这一级,直接跳到兜底:

PYTHON15 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async def run_with_fallback(req, chain, budget_s):
deadline = time.monotonic() + budget_s
last_err = None
for step in chain:
remaining = deadline - time.monotonic()
if remaining <= step.min_required:
break
try:
return await asyncio.wait_for(
step.run(req), timeout=min(step.timeout, remaining)
), step.name
except (asyncio.TimeoutError, UpstreamError) as e:
last_err = e
metrics.incr('llm.fallback', tags={'step': step.name, 'reason': type(e).__name__})
return static_answer(req, last_err), 'static'

返回值带上 step 名字很重要,否则你永远不知道线上有多少请求其实是靠兜底文案撑着的。

还有一层可以放在阶梯旁边:缓存兜底。同一个问题半小时内被问过、且没有个性化参数,直接把上次结果还回去,标注「缓存结果,可能不是最新」。但带用户上下文的、带权限过滤的请求不能用这招,缓存串号是比超时严重得多的事故。

三条比 429 更容易漏的故障路径

模型限流是最容易想到的,反而好处理。真正上线后咬人的是下面三个。

结构化输出解析失败。 模型没报错,HTTP 200,但 JSON 被 max_tokens 截断在半个括号上。这时候换模型重试是浪费预算,正确顺序是先修一次:把残缺片段和 schema 一起发回去,只让它补全结构、不许改内容;还是失败再进降级链。另外降级后的小模型必须跑同一个 schema 校验,我踩过这个坑——小模型输出「看起来像 JSON」的文本,被下游当成对象解析,报了一个完全无关的错,排查花了大半天。

流式中途断掉。 用户已经看到两百字了,你这时候静默换模型重来,前两百字和后文会对不上,读起来像精神分裂。我们的处理是直接终止流,在末尾插一条「以上内容未完成」,给一个重新生成的按钮。要不要拼接续写我想过,但半截内容来自两个模型,语气和结论都可能打架,我倾向不做。

工具调用半成功。 Agent 调了一个外部接口,超时了,你不知道对面到底执行没执行。如果这一层还被包在降级链里,重试就会变成执行两次。我们后来把带副作用的工具全部移出降级链,只保留「超时后查询状态」这条只读路径,靠幂等键对齐。这块的复杂度比模型本身高得多。

开关、打点,和一次真跑的演练

降级链路最大的问题是它平时不跑。半年不动的东西一定会腐烂:备用模型被供应商下线了、备用 key 的配额早被别的服务吃掉、那级 prompt 改过但没跟着改 schema。

所以我把降级做成了带默认值和过期时间的开关,而不是配置文件里的常量。每个开关必须有 owner 和复查日期,到期自动提醒,没人续就强制走默认值——默认值一律是「不降级」,避免某天有人临时打开忘了关,线上偷偷降了三个月。

然后是最关键的一步:每月挑低峰时段,手动把主链路的开关打开,让 100% 流量走降级链,跑一遍核心流程。不是为了看错误率,是为了确认那条路还活着。第一次演练我们就发现第二级模型的 JSON 模式参数已经被废弃了,配置里写着,实际不生效,降级链在那一层是断的,而监控上它是绿的——因为没人调用。

配套的观测就三个指标:降级触发率(按 step 和 reason 拆)、兜底文案占比、同一次请求内的降级次数。最后一个超过 1,说明你在链上滑得太深,通常意味着一级模型的超时设得太长,把预算吃光了。

下次上线前,我会先把 chain 里的第一级手动关掉,从前端点一遍核心流程,跑不通就不发。这件事花十分钟,比补一份降级预案文档有用得多。

关于本文

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

#LLM工程 #可用性 #降级

评论