上一章把新功能的开关按部门切开了,灰度期间用户看不到、也点不到 AI 能力,看起来风险已经控住。但开关只能控制"谁用得到",控制不了"用起来之后出不出事"——接入大模型之后,原来那套靠本地代码和数据库就能跑通的请求链路里,多了一个谁都控制不了的外部调用。模型超时、限流、返回一段前言不搭后语的内容,甚至整个服务不可用,这些都会真实发生。这一章解决一个问题:模型挂了的时候,存量系统的请求往哪走。

新单点:模型调用

改造之前,一个请求从进来到返回,路上的每个节点都是自己的:自己的代码、自己的库、自己的缓存。虽然也会慢、也会挂,但那是内部问题,有监控、有值班、有回滚。接上大模型之后,链路中间插了一段厂商的 API,它的 SLA、限流策略、返回质量都不归你管。这不是"加一个依赖"这么简单,而是往核心路径上放了一个新的单点。

所以降级这件事要放在设计阶段做,不能等线上真挂了再去补。判断标准只有一句话:模型调用这条腿断了,业务还能不能体面地把请求接住。接不住就是硬伤,接得住的方式也要分场景,不能一刀切。

降级的四个层次

把"模型挂了"拆开看,其实是一组不同程度的问题,对应不同的处理方式。按严重程度从轻到重排:

第一层是慢。模型响应偶尔超时,但重试一次大概率能回来。这时候要做的是超时控制和有限重试,而不是直接失败。

第二层是限流。厂商返回 429,告诉你这一分钟调用太多。这层的关键是退避,别在限流窗口里反复撞上去。

第三层是结果不可用。模型返回了,但内容明显不对——格式错了、答非所问、或者直接返回空。这时候要有一个兜底逻辑,而不是把脏结果原样吐给用户。

第四层是彻底不可用。服务宕了或者长时间不可达。这时候必须快速失败,让请求走一条不依赖模型的老路。

很多团队只做了第四层——模型挂了就 catch 个异常返回固定文案。这是最省事的做法,也恰恰是最没用的做法,因为它把"慢"和"限流"这两种高频情况也当成"挂了"处理,而这两种情况本来是可以抢救的。

熔断与重试

重试是有代价的。模型接口超时的时候,上游通常已经积压了一堆请求,你这边再盲目重试,等于把压力翻倍,还可能触发更重的限流。所以重试必须配一个熔断器,先判断"现在该不该试"。

下面是一段把超时、重试、熔断串起来的示例。假设存量系统里有一个封装好的 ask_model 调用,我们在外面包一层:

import time
import random

class ModelGateway:
    """给存量系统的模型调用加超时、重试和熔断。"""

    def __init__(self, failure_threshold=5, cooldown=30):
        self.failure_threshold = failure_threshold
        self.cooldown = cooldown
        self._failures = 0
        self._open_until = 0

    def _circuit_open(self):
        return time.time() = self.failure_threshold:
            self._open_until = time.time() + self.cooldown
            self._failures = 0
        raise ModelUnavailable(str(last_err))

这段代码的核心不是重试本身,而是两个约束:限流用指数退避(第 1 次等 1 秒左右,第 2 次等 2 秒左右,封顶 8 秒),连续失败达到阈值就打开熔断,冷却期内不再发起任何调用。熔断打开以后,后续请求直接抛 ModelUnavailable,不再白白消耗时间和配额。

兜底的形态

熔断抛出来的异常,要有一个地方去接。这里最容易犯的错,是把"兜底"理解成"返回一句固定的抱歉话术"。比如用户问"上个月华东区的退货率是多少",模型挂了,你回一句"服务繁忙,请稍后再试"——这等于把业务问题原封不动地退了回去。

更好的做法是分场景接住。上面那个问题,其实不调模型也能答:退货率是可以从报表里算出来的确定值。这类"有确定答案"的请求,降级路径应该是走规则或查表,而不是走模型。真正必须靠模型理解、归纳的那部分,才需要降级到一个诚实的答复。

下面这个例子把"能算的走规则、不能算的走兜底"拆开:

def handle_question(question, gateway):
    # 先看能不能用规则直接答,规则不依赖模型
    if (metric := parse_metric_query(question)) is not None:
        return query_metric(metric)   # 查报表,跟模型无关

    try:
        return gateway.call(build_prompt(question))
    except ModelUnavailable:
        return {
            "answer": "这段内容暂时生成不了,我先把相关入口列给你。",
            "fallback": list_related_links(question),
            "source": "fallback",
        }

关键在 source 字段:兜底答案要明确标记它来自降级路径,而不是让用户以为是模型认真思考后的回答。这既是诚实,也避免了"用户拿兜底内容当结论"的后续纠纷。规则那条路(query_metric)之所以能走,是因为前面几章已经做了数据摸底和语义层,现在只是把确定值从旧库里直接取出来。

什么场景值得做全套

四个层次不一定要全上,取决于这个请求的重要性和实时性要求。内部报表问答、知识库检索这类,用户容忍度高,慢一点、重试几次没问题,降级到"给入口"也够用。但如果是下单、审批这类有明确时限、错了要担责的流程,模型只是辅助判断,主链路本来就不该被模型卡死——这时候模型调用应该设成"尽力而为",超时就直接走规则校验,绝不让降级路径本身成为新的故障点。

还有一条经验:降级路径也要测试。灰度发布那一章我们学会了切开关,这一章要补的是,每个开关后面那条"模型不可用"的路,得在发布前真的断一次模型再走一遍。很多降级逻辑从写好那天起就没被触发过,真到要用的时候发现里面早就烂了。

下一章要解决的是这一章埋下的另一个问题:模型"活着"但回答质量忽高忽低,光靠熔断和兜底管不住,得给生成结果本身加一道质量闸门。