Agent 优雅降级实战:模型不可用时的后备方案设计

摘要 :降级是稳定性体系里最容易设计、也最容易失效 的一环------因为降级代码是一条冷路径 :平时从不执行,等到真正需要它的那天,往往已经坏了。本文的核心观点就这一句:没有演练过的降级方案,等于没有降级方案。 内容上给出三层降级体系 (模型降级 / 工具降级 / 功能降级,各自的触发条件与质量代价)、能力清单与降级矩阵 (声明式定义"每个能力在主路径失败时还能怎么办",而不是在代码里写一堆 if fallback)、降级链的四个硬性要求 (必须更简单、必须可感知、必须有终点、必须可观测),以及降级演练机制 (把冷路径变成热路径的唯一方法,含故障注入与定期演练的具体做法)。另外专门讨论一个反直觉风险:降级雪崩------降级后质量下降导致用户重试,重试放大负载,进而触发更多降级。

📌 版本声明 :本文基于 Python 3.11+、pydantic v2 编写,撰写时间 2026 年 10 月。降级框架本身零第三方依赖。熔断与重试机制复用第 37 篇的实现,能力声明的设计参考了第 28 篇中间件的能力注册思路,多模型路由与第 31 篇网关的路由层衔接。各模型的可用性、配额与能力边界请以官方文档为准。

适用边界 :适用于已进入生产、需要保障可用性 的 Agent 系统。若你的 Agent 只有一个模型、没有工具、且功能单一,那么降级的选择空间很小------这时更该做的是明确失败 而非强行降级。另外,降级解决的是"不可用",不是"不好用":质量问题应该走第 36 篇的评估体系迭代,不应靠降级掩盖。

文章目录

    • 一、为什么降级是稳定性里最容易失效的一环
      • [1.1 降级代码是"冷路径"](#1.1 降级代码是"冷路径")
      • [1.2 降级的三个层次](#1.2 降级的三个层次)
      • [1.3 降级与重试、熔断的关系](#1.3 降级与重试、熔断的关系)
    • 二、核心概念:能力清单与降级矩阵
      • [2.1 能力清单:声明式而非命令式](#2.1 能力清单:声明式而非命令式)
      • [2.2 模型降级的四种形态](#2.2 模型降级的四种形态)
      • [2.3 工具降级的三种形态](#2.3 工具降级的三种形态)
      • [2.4 功能降级的判据:什么样的功能可以关](#2.4 功能降级的判据:什么样的功能可以关)
      • [2.5 降级链的四个硬性要求](#2.5 降级链的四个硬性要求)
      • [2.6 降级的成本陷阱](#2.6 降级的成本陷阱)
    • 三、环境准备
      • [3.1 依赖与结构](#3.1 依赖与结构)
      • [3.2 能力清单的定义](#3.2 能力清单的定义)
    • 四、实战:降级执行器与演练机制
      • [4.1 降级执行器](#4.1 降级执行器)
      • [4.2 输出契约与适配层](#4.2 输出契约与适配层)
      • [4.3 降级状态机](#4.3 降级状态机)
      • [4.4 降级演练:把冷路径变成热路径](#4.4 降级演练:把冷路径变成热路径)
      • [4.5 故障注入:验证降级真的会触发](#4.5 故障注入:验证降级真的会触发)
      • [4.6 降级的用户沟通:三个层次](#4.6 降级的用户沟通:三个层次)
    • 五、进阶
      • [5.1 降级雪崩:降级反而让情况更糟](#5.1 降级雪崩:降级反而让情况更糟)
      • [5.2 上下文降级:被忽略但最常用的一种](#5.2 上下文降级:被忽略但最常用的一种)
      • [5.3 降级的观测:四个必看指标](#5.3 降级的观测:四个必看指标)
      • [5.4 自动恢复:切回去比切过来更难](#5.4 自动恢复:切回去比切过来更难)
      • [5.5 演练的落地:三个常见阻力](#5.5 演练的落地:三个常见阻力)
    • [六、适用边界与风险提示 ⚠️](#六、适用边界与风险提示 ⚠️)
      • [6.1 降级的五个失效模式](#6.1 降级的五个失效模式)
      • [6.2 降级不能解决什么](#6.2 降级不能解决什么)
      • [6.3 什么时候不要做降级](#6.3 什么时候不要做降级)
    • 七、一个可复制的最小骨架
    • 八、总结
    • 参考资料

一、为什么降级是稳定性里最容易失效的一环

1.1 降级代码是"冷路径"

先说三个真实场景,它们的共同点是:降级方案写在文档里、代码里也有,但事故发生时没有一个能用。

场景一:备用模型的 API Key 三个月前就过期了。

团队设计了"主模型不可用时切备用模型"。主模型某天真的出了故障,切换逻辑正常触发,调用备用模型------返回 401。排查发现:备用模型的 Key 是试用申请的,90 天有效期,早就过期了,而没有任何监控能发现这件事,因为这条路径从未被执行过。

场景二:降级路径依赖的服务已下线。

降级方案是"向量检索不可用时,退回关键词检索"。而关键词检索依赖的一个内部服务在两个月前的架构调整中下线了。接口还在(返回空结果),所以没有报错------系统"成功"地降级到了一个永远返回空的实现上,用户拿到一堆毫不相关的结果,没人知道发生了什么。

场景三:降级后的输出格式与下游期望不符。

主模型支持结构化输出(JSON Schema 约束),备用模型不支持。降级切换后,输出变成了自然语言,下游解析器抛异常,整个流程失败。降级本身是成功的,但系统整体依然不可用。

这三个场景的共同根因 :降级路径从未被执行过。它是一条冷路径------代码存在、测试可能也写过,但从那天之后就再没跑过,而它依赖的一切(Key、依赖服务、输出格式约定)都在持续变化。

还有第四个场景,更常见也更隐蔽 :降级链一共只有一档------压根没有备用方案,所谓的"降级"就是 try/except 里返回一个空结果 。这不算降级,这叫用空数据假装成功 。用户拿到一份"没有风险"的审查报告,会比拿到一个错误提示危险得多------前者会让人放心地签字,后者至少会让人去复核。

💡 这是本文最重要的一句话 :降级方案的有效性会随时间衰减,而且是静默地衰减。 主路径每天都在被验证(因为每天都在用),降级路径从未被验证。半年后,主路径的可靠性可能是 99.9%,降级路径可能已经是 0%。

1.2 降级的三个层次

按降级的对象分三层,每层的质量代价与实现难度不同:

层次 降级什么 质量代价 实现难度 典型场景
模型降级 换一个模型 小~中 低 主模型限流/故障
工具降级 换工具或不用工具 中 中 工具超时/不可用
功能降级 关闭某个能力 大 高 核心依赖全面不可用

模型降级最常用,也最容易被认为"很简单" ------不就是换个模型名吗?但 1.1 节场景三说明它并不简单:不同模型的能力边界不同 (结构化输出、上下文长度、函数调用、多模态),换模型等于换了一个能力集 ,下游必须能适配。这也是为什么 2.5 节把"满足输出契约"列为降级链的硬性要求------契约就是用来吸收这些能力差异的。

工具降级的判断标准 :这个工具提供的信息,有没有别的途径获得?有 → 换途径;没有 → 只能走功能降级。常见错误是"工具失败就返回空" ------这等于告诉模型"没有相关信息",模型会据此给出一个基于不完整信息的自信回答,比直接失败更危险。

功能降级是最后一道 ,它的关键不是技术实现,而是产品决策 :这个功能关掉之后,用户还能完成核心任务吗?如果需要人工介入,人工流程准备好了吗?这些问题必须在降级设计阶段回答,不能等到事故发生时。

1.3 降级与重试、熔断的关系

第 37 篇讲了重试与熔断,本篇讲降级。三者的关系是一个递进的应对阶梯:

机制 应对什么 是否消耗上游配额 用户感知
重试 瞬时、随机失败 是 略慢
熔断 持续不可用 否 快速失败
降级 能力不可用 部分(走备用) 质量下降

顺序不能颠倒 :先重试(应对抖动),再熔断(停止无谓消耗),最后降级(提供替代能力)。降级不是熔断的替代品,而是熔断之后的下一步------熔断告诉你"主路径别再试了",降级告诉你"那现在怎么办"。

一个常见的顺序错误 :把降级放在重试之前。这会导致偶发的一次失败就触发降级,用户拿到了低质量结果,而实际上重试一次就能拿到好结果 。降级的触发条件必须比重试严格------通常是熔断开启后 或重试预算耗尽后。


二、核心概念:能力清单与降级矩阵

2.1 能力清单:声明式而非命令式

最常见的降级实现方式是在代码里写 if:

python 复制代码
# ❌ 常见但不好的写法
async def analyze(text: str) -> dict:
    try:
        return await call_primary_model(text)
    except Exception:
        try:
            return await call_fallback_model(text)
        except Exception:
            return {"risks": [], "degraded": True}

这段代码有四个问题 :① 降级关系散落在业务代码里,无法全局查看;② 新增能力时要改多处;③ 无法回答"当前系统里有哪些降级路径";④ 无法测试------你没法遍历所有降级组合。

更好的方式是声明式:把"每个能力有哪些实现、按什么顺序降级"声明成一张表。

python 复制代码
# capabilities.py
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Awaitable, Callable


@dataclass
class CapabilityImpl:
    """一个能力的一种实现。"""
    name: str
    fn: Callable[..., Awaitable]
    quality: float        # 0~1,相对质量分(主观标定,用于排序与观测)
    cost_per_call: float  # 相对成本
    requires: list[str] = field(default_factory=list)   # 依赖的外部资源
    notes: str = ""


@dataclass
class Capability:
    """一个能力 + 它的降级链。

    设计要点:
    - impls 按优先级排列,第一个是主实现。
    - critical 表示"这个能力不可用时整个任务是否还能继续"。
    """
    name: str
    impls: list[CapabilityImpl]
    critical: bool = True          # False 表示可整体跳过
    degrade_notice: str = ""       # 降级时给用户的提示文案

声明式的好处在于"可枚举" :你能回答"系统里一共多少条降级路径"、"哪些能力的降级链只有一档(没有退路)"、"哪些降级实现依赖的外部资源从未被检查过"。这三个问题恰恰是 1.1 节三个场景的根因。

图1:三层降级的对比------模型降级最常用,功能降级代价最大

2.2 模型降级的四种形态

"换个模型"听起来是一件事,实际上有四种形态,代价和适用场景完全不同:

形态 做法 质量代价 成本变化 适用
同厂商降级 强模型 → 同厂弱模型 中 降 该厂商整体限流时无效
跨厂商降级 A 厂商 → B 厂商 小~中 持平或升 最有效,规避单点
规格降级 长上下文 → 短上下文 中~大 降 上下文超限、成本超支
能力降级 结构化输出 → 自然语言 + 解析 中 降 备用模型不支持结构化

跨厂商降级是最有效的 ,因为它规避的是单点故障 ------同厂商的强模型和弱模型共享同一套基础设施和配额,厂商整体出故障时同厂商降级完全无效。1.1 节场景一那个"备用 Key 过期"的团队,备用模型恰好是同一厂商的。

能力降级(第四种)最麻烦,也是 1.1 节场景三的根因。 它需要在降级链里显式声明"我的输出契约是什么",并要求所有实现都满足:

python 复制代码
# 输出契约------所有实现都必须满足,否则下游会崩
class Contract(BaseModel):
    """降级链上所有实现都必须返回这个结构。"""
    risks: list[RiskItem]
    degraded: bool = False
    degrade_reason: str = ""

⚠️ 契约必须包含 degraded 字段,而且下游必须处理它。 这是"降级可感知"的技术前提------如果降级结果和正常结果在数据结构上完全一样,下游和用户都无法知道这是降级结果,于是会按正常质量标准使用它。这正是 1.1 节场景二"接口还在但返回空"的同类问题。

2.3 工具降级的三种形态

形态 做法 例子
换实现 同一能力的不同工具 向量检索 → 关键词检索
换数据源 不同来源的同类信息 实时 API → 缓存快照
去掉工具 不用工具,靠模型自身知识 天气查询失败 → 模型说明无法获取

第三种(去掉工具)有个必须遵守的规则 :必须在给模型的上下文里明确说明"该工具不可用" ,而不是简单地不提供这个工具。原因在第 37 篇讲过------如果模型不知道有这个工具,它不会尝试调用;但如果它以为信息就是没有 ,会给出基于不完整信息的自信回答。这个区别很关键:工具不在列表里,模型压根不知道有这个能力;而明确告知不可用,模型才知道该在回答里说明限制。

python 复制代码
# ✅ 正确的"去掉工具"降级
tools = [t for t in ALL_TOOLS if t.available()]
unavailable = [t.name for t in ALL_TOOLS if not t.available()]
if unavailable:
    system_prompt += (
        f"\n注意:以下工具当前不可用({', '.join(unavailable)})。"
        "不要试图猜测它们会返回什么;如果任务确实需要这些信息,"
        "直接说明当前无法获取。"
    )

2.4 功能降级的判据:什么样的功能可以关

功能降级是产品决策,判断标准有三条:

  1. 关掉它,用户还能完成核心任务吗? 能 → 可降级;不能 → 这个功能是核心链路,必须投入冗余。
  2. 关掉它,是否有明确的用户提示和替代路径? 没有 → 先补提示和人工入口,再谈降级。
  3. 关掉它,数据一致性会被破坏吗? 会 → 不能简单关闭,需要设计"降级期间的数据标记"。

第三条最容易被忽略。 举例:一个"自动打标签"的功能降级后不打标签了,那么这批数据会带着空标签流入下游。下游如果假设标签一定存在,就会出错。 正确做法是用显式的值标记降级期间的数据 (比如 tag = "__DEGRADED__"),而不是留空------留空的数据和"确实没有标签"的数据无法区分。

2.5 降级链的四个硬性要求

综合 1.1 节三个事故与设计经验,一条合格的降级链必须同时满足四条。这四条是本文最可直接套用的检查项:

要求 违反的后果 检查方式
必须更简单 降级实现本身也失败了 降级实现的依赖数应少于主实现
必须可感知 用户拿低质量结果做决策 契约含 degraded 字段且下游处理
必须有终点 无限降级,最后质量趋零 降级链 ≤ 3 档,耗尽即明确失败
必须可观测 降级长期发生却无人知晓 降级率、深度、健康度、质量分四个指标

第一条(必须更简单)最容易被违反。 常见情况是:降级实现为了"保证质量",加了额外的修复步骤、更多的重试、更复杂的 Prompt------于是它比主实现更容易失败 。判断标准很直接:降级实现的外部依赖数量必须少于主实现,否则它凭什么比主实现更可靠?

第二条(必须可感知)的实现成本极低,但漏掉的比例极高。 技术上就是往契约里加一个布尔字段,代价几乎为零;难的是"下游真的处理它" ------从执行器到 API 层到前端,每一层都要把这个标记传下去。只要有一层丢了,可感知就断了。

2.6 降级的成本陷阱

降级不总是省钱。 这是很多人默认错误的一点:

降级方向 成本变化 说明
强模型 → 弱模型 通常降 符合直觉
主厂商 → 备用厂商 可能升 备用厂商没有批量折扣
结构化输出 → 自然语言 + 重试 升 需要多次调用修复格式
工具调用 → 模型推理 可能升 一次工具调用 vs 长 Prompt 推理
关闭功能 → 人工处理 大幅升 人力成本远高于 API

💡 所以降级的目标函数是"可用性 × 质量 / 成本",不是单纯的成本。 一个"省了钱但用户拿到的结果没法用"的降级,比直接失败更糟------用户不知道结果不可靠,会基于它做决策。这也是第 37 篇强调"降级必须可感知"的原因。


三、环境准备

3.1 依赖与结构

bash 复制代码
pip install pydantic==2.* pytest==8.* pytest-asyncio
text 复制代码
degrade/
├── capabilities.py     # 能力清单(声明式降级链)
├── executor.py         # 降级执行器
├── contract.py         # 输出契约(所有实现必须满足)
├── drill.py            # 降级演练(本文重点)
└── observe.py          # 降级观测

3.2 能力清单的定义

python 复制代码
# capabilities.py 示例:合同审查 Agent 的能力清单
from capabilities import Capability, CapabilityImpl

REVIEW = Capability(
    name="contract_review",
    critical=True,
    degrade_notice="当前审查服务降级,结果可能不够完整,请人工复核。",
    impls=[
        CapabilityImpl(
            name="claude_primary",
            fn=review_with_claude,
            quality=1.0,
            cost_per_call=1.0,
            requires=["api_key:anthropic", "model:claude-opus"],
            notes="支持结构化输出",
        ),
        CapabilityImpl(
            name="gpt_cross_vendor",
            fn=review_with_gpt,
            quality=0.85,
            cost_per_call=1.15,
            requires=["api_key:openai", "model:gpt-strong"],
            notes="跨厂商,规避单点",          # ← 关键:跨厂商
        ),
        CapabilityImpl(
            name="cheap_model_repair",
            fn=review_with_cheap_and_repair,
            quality=0.6,
            cost_per_call=0.35,
            requires=["api_key:openai", "model:gpt-cheap"],
            notes="不支持结构化输出,需 repair 步骤",
        ),
    ],
)

RETRIEVE = Capability(
    name="clause_retrieval",
    critical=False,          # ← 不可用时可整体跳过
    degrade_notice="条款检索不可用,本次审查仅基于合同全文。",
    impls=[
        CapabilityImpl(
            name="vector_search",
            fn=vector_search,
            quality=1.0,
            cost_per_call=0.02,
            requires=["service:vector_db"],
        ),
        CapabilityImpl(
            name="keyword_search",
            fn=keyword_search,
            quality=0.55,
            cost_per_call=0.005,
            requires=["service:search_svc"],
        ),
    ],
)

⚠️ requires 字段不是文档,是演练的检查项。 1.1 节场景一(Key 过期)和场景二(依赖下线)都是 requires 里的资源失效了。演练要做的第一件事就是逐个检查 requires 里的资源是否还活着------见 4.4 节。


四、实战:降级执行器与演练机制

4.1 降级执行器

python 复制代码
# executor.py
from __future__ import annotations

import logging
from dataclasses import dataclass

logger = logging.getLogger("degrade")


@dataclass
class DegradeResult:
    value: object
    impl_used: str
    degraded: bool
    attempts: list[str]      # 尝试过的实现,用于观测


class DegradeExecutor:
    """降级执行器------按能力清单里的顺序尝试实现。

    四个硬性要求(对应 1.1 节的三个事故):
    1. 每个实现都必须满足输出契约(否则降级失败而非降级成功)
    2. 降级结果必须标记 degraded(可感知)
    3. 降级链必须有终点(不能无限降级)
    4. 每次降级都要记录(可观测)
    """

    def __init__(self, capability, validator, hooks=None):
        self.cap = capability
        self.validator = validator
        self.hooks = hooks or []

    async def run(self, *args, **kwargs) -> DegradeResult:
        attempted: list[str] = []

        for idx, impl in enumerate(self.cap.impls):
            is_fallback = idx > 0
            try:
                raw = await impl.fn(*args, **kwargs)
                # ---- 要求 1:降级实现也必须满足输出契约 ----
                value = self.validator(raw)
            except Exception as e:
                attempted.append(f"{impl.name}:失败({type(e).__name__})")
                logger.warning("能力 %s 的实现 %s 失败: %s",
                               self.cap.name, impl.name, e)
                continue

            attempted.append(f"{impl.name}:成功")
            result = DegradeResult(
                value=value,
                impl_used=impl.name,
                degraded=is_fallback,
                attempts=attempted,
            )
            if is_fallback:
                logger.error(
                    "能力 %s 已降级到 %s(质量 %.2f)",
                    self.cap.name, impl.name, impl.quality,
                )
                for hook in self.hooks:
                    await hook(self.cap, impl, result)
            return result

        # ---- 要求 3:降级链耗尽 ----
        if not self.cap.critical:
            logger.warning("能力 %s 全部实现失败,但非关键,跳过", self.cap.name)
            return DegradeResult(None, "skipped", True, attempted)

        raise CapabilityUnavailable(self.cap.name, attempted)


class CapabilityUnavailable(Exception):
    def __init__(self, name: str, attempts: list[str]):
        super().__init__(f"能力 {name} 的所有实现均不可用: {attempts}")
        self.name = name
        self.attempts = attempts

💡 validator 这一环是整个降级体系里最关键、也最常被省略的部分。 它强制每个降级实现都满足输出契约------1.1 节场景三的根因就是缺了它 (备用模型输出自然语言,下游解析崩溃)。没有 validator 的降级不是降级,是把一种失败换成另一种失败。

4.2 输出契约与适配层

python 复制代码
# contract.py
from __future__ import annotations

import json
import re

from pydantic import BaseModel


class RiskItem(BaseModel):
    level: str
    quote: str
    reason: str


class ReviewContract(BaseModel):
    """所有实现(含降级实现)都必须返回这个结构。"""
    risks: list[RiskItem]
    degraded: bool = False
    degrade_reason: str = ""


def make_validator(cap):
    """生成契约校验器------把各种实现的输出统一成契约。"""
    def validate(raw) -> ReviewContract:
        if isinstance(raw, ReviewContract):
            return raw

        # 情况 1:已经是 dict,直接校验
        if isinstance(raw, dict):
            return ReviewContract.model_validate(raw)

        # 情况 2:自然语言(能力降级形态),需要抽取
        # 这是 1.1 节场景三的解法:不是拒绝降级,而是补一个适配层
        data = _extract_from_text(raw)
        return ReviewContract.model_validate(data)

    return validate


def _extract_from_text(text: str) -> dict:
    """从自然语言里抽取结构化字段。

    注意:这是"兜底的兜底",质量不高。但它保证契约不被破坏,
    下游至少不会崩溃。真正的质量问题由 degraded 标记暴露给用户。
    """
    blocks = re.split(r"\n\s*(?:\d+[.、]|[-*])\s*", text)
    risks = []
    for b in blocks:
        b = b.strip()
        if not b or len(b) < 8:
            continue
        level = "high" if any(k in b for k in ["高", "严重", "重大"]) else "medium"
        risks.append({"level": level, "quote": b[:60], "reason": b})
    return {"risks": risks, "degraded": True, "degrade_reason": "自然语言解析"}

4.3 降级状态机

#mermaid-svg-MsYkXjWfN3CXVD7w{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MsYkXjWfN3CXVD7w .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MsYkXjWfN3CXVD7w .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MsYkXjWfN3CXVD7w .error-icon{fill:#552222;}#mermaid-svg-MsYkXjWfN3CXVD7w .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MsYkXjWfN3CXVD7w .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MsYkXjWfN3CXVD7w .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MsYkXjWfN3CXVD7w .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MsYkXjWfN3CXVD7w .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MsYkXjWfN3CXVD7w .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MsYkXjWfN3CXVD7w .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MsYkXjWfN3CXVD7w .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MsYkXjWfN3CXVD7w .marker.cross{stroke:#333333;}#mermaid-svg-MsYkXjWfN3CXVD7w svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MsYkXjWfN3CXVD7w p{margin:0;}#mermaid-svg-MsYkXjWfN3CXVD7w defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-MsYkXjWfN3CXVD7w g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-MsYkXjWfN3CXVD7w g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-MsYkXjWfN3CXVD7w g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-MsYkXjWfN3CXVD7w g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-MsYkXjWfN3CXVD7w g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-MsYkXjWfN3CXVD7w .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-MsYkXjWfN3CXVD7w .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-MsYkXjWfN3CXVD7w .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-MsYkXjWfN3CXVD7w .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-MsYkXjWfN3CXVD7w .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-MsYkXjWfN3CXVD7w .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-MsYkXjWfN3CXVD7w .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-MsYkXjWfN3CXVD7w .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MsYkXjWfN3CXVD7w .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MsYkXjWfN3CXVD7w .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MsYkXjWfN3CXVD7w .edgeLabel .label text{fill:#333;}#mermaid-svg-MsYkXjWfN3CXVD7w .label div .edgeLabel{color:#333;}#mermaid-svg-MsYkXjWfN3CXVD7w .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-MsYkXjWfN3CXVD7w .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-MsYkXjWfN3CXVD7w .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-MsYkXjWfN3CXVD7w .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-MsYkXjWfN3CXVD7w .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-MsYkXjWfN3CXVD7w .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MsYkXjWfN3CXVD7w .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MsYkXjWfN3CXVD7w #statediagram-barbEnd{fill:#333333;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MsYkXjWfN3CXVD7w .cluster-label,#mermaid-svg-MsYkXjWfN3CXVD7w .nodeLabel{color:#131300;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-MsYkXjWfN3CXVD7w .note-edge{stroke-dasharray:5;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-note text{fill:black;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram-note .nodeLabel{color:black;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagram .edgeLabel{color:red;}#mermaid-svg-MsYkXjWfN3CXVD7w #dependencyStart,#mermaid-svg-MsYkXjWfN3CXVD7w #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-MsYkXjWfN3CXVD7w .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MsYkXjWfN3CXVD7w :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 主实现可用
主实现失败

切换到备用实现
全部实现失败

且该能力为关键能力
主实现恢复
继续降级到下一档
降级链耗尽
全部恢复
Normal
Degraded
Unavailable
结果必须标记 degraded

并附带用户可见提示

同时记录使用的实现名
非关键能力:跳过并继续

关键能力:整个任务失败

绝不能静默返回空

"非关键能力跳过" vs "关键能力失败" 这个分支是设计的核心。 它把"这个能力能不能没有"变成了一个显式声明(critical 字段),而不是散落在代码里的 try/except。

4.4 降级演练:把冷路径变成热路径

这是本文的核心。演练(drill)就是把降级路径定期真实执行一遍,验证它还活着。

python 复制代码
# drill.py
from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field

logger = logging.getLogger("drill")


@dataclass
class DrillReport:
    capability: str
    resource_checks: dict[str, bool] = field(default_factory=dict)
    impl_results: dict[str, tuple[bool, str]] = field(default_factory=dict)
    contract_violations: list[str] = field(default_factory=list)

    @property
    def healthy(self) -> bool:
        return (
            all(self.resource_checks.values())
            and any(ok for ok, _ in self.impl_results.values())
            and not self.contract_violations
        )


class DrillRunner:
    """降级演练。

    三件事,缺一不可:
    1. 检查 requires 里的外部资源是否还活着(Key、依赖服务)
    2. 真实调用每个降级实现,验证能跑通
    3. 验证输出满足契约
    """

    def __init__(self, capability, validator, sample_input,
                 resource_probes: dict[str, callable]):
        self.cap = capability
        self.validator = validator
        self.sample = sample_input
        self.probes = resource_probes

    async def run(self) -> DrillReport:
        report = DrillReport(capability=self.cap.name)

        # ---- 1. 资源探测:抓 1.1 节场景一(Key 过期)和场景二(依赖下线)----
        for impl in self.cap.impls:
            for res in impl.requires:
                if res in report.resource_checks:
                    continue
                probe = self.probes.get(res.split(":")[0])
                if probe is None:
                    report.resource_checks[res] = True   # 无探针则跳过
                    continue
                try:
                    report.resource_checks[res] = await asyncio.wait_for(
                        probe(res), timeout=10.0
                    )
                except Exception as e:
                    report.resource_checks[res] = False
                    logger.error("资源 %s 探测失败: %s", res, e)

        # ---- 2 & 3. 真实调用每个实现并校验契约 ----
        for impl in self.cap.impls:
            missing = [r for r in impl.requires
                       if report.resource_checks.get(r) is False]
            if missing:
                report.impl_results[impl.name] = (False, f"依赖缺失: {missing}")
                continue
            try:
                raw = await asyncio.wait_for(
                    impl.fn(self.sample), timeout=60.0
                )
                self.validator(raw)          # 契约校验
                report.impl_results[impl.name] = (True, "ok")
            except asyncio.TimeoutError:
                report.impl_results[impl.name] = (False, "超时")
            except Exception as e:
                report.impl_results[impl.name] = (False, f"{type(e).__name__}: {e}")
                # 契约违规单独记录------这是 1.1 节场景三的检测点
                report.contract_violations.append(f"{impl.name}: {e}")

        return report

⚠️ 演练必须用真实调用,不能用 Mock。 用 Mock 的演练只能验证"代码能跑通",验证不了"Key 有没有过期"、"依赖服务有没有下线"、"输出格式有没有变"------而这三样恰恰是降级失效的三大主因(1.1 节三个场景)。演练的价值 100% 来自它的真实性。

频率建议:每日一次。 演练成本很低(每个能力几次调用),但如果一个月才跑一次,你可能在长达一个月的时间里处于"有降级但实际上没有"的状态。演练失败必须告警,且告警级别等同于线上故障------因为它意味着你的灾备能力已经失效。
告警 契约校验 降级实现 外部资源 演练执行器 定时任务 告警 契约校验 降级实现 外部资源 演练执行器 定时任务 #mermaid-svg-JXL7H9VmU7j6V55z{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JXL7H9VmU7j6V55z .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JXL7H9VmU7j6V55z .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JXL7H9VmU7j6V55z .error-icon{fill:#552222;}#mermaid-svg-JXL7H9VmU7j6V55z .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JXL7H9VmU7j6V55z .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JXL7H9VmU7j6V55z .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JXL7H9VmU7j6V55z .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JXL7H9VmU7j6V55z .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JXL7H9VmU7j6V55z .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JXL7H9VmU7j6V55z .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JXL7H9VmU7j6V55z .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JXL7H9VmU7j6V55z .marker.cross{stroke:#333333;}#mermaid-svg-JXL7H9VmU7j6V55z svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JXL7H9VmU7j6V55z p{margin:0;}#mermaid-svg-JXL7H9VmU7j6V55z .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JXL7H9VmU7j6V55z text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-JXL7H9VmU7j6V55z .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-JXL7H9VmU7j6V55z .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-JXL7H9VmU7j6V55z .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-JXL7H9VmU7j6V55z .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-JXL7H9VmU7j6V55z #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-JXL7H9VmU7j6V55z .sequenceNumber{fill:white;}#mermaid-svg-JXL7H9VmU7j6V55z #sequencenumber{fill:#333;}#mermaid-svg-JXL7H9VmU7j6V55z #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-JXL7H9VmU7j6V55z .messageText{fill:#333;stroke:none;}#mermaid-svg-JXL7H9VmU7j6V55z .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JXL7H9VmU7j6V55z .labelText,#mermaid-svg-JXL7H9VmU7j6V55z .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-JXL7H9VmU7j6V55z .loopText,#mermaid-svg-JXL7H9VmU7j6V55z .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-JXL7H9VmU7j6V55z .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-JXL7H9VmU7j6V55z .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-JXL7H9VmU7j6V55z .noteText,#mermaid-svg-JXL7H9VmU7j6V55z .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-JXL7H9VmU7j6V55z .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JXL7H9VmU7j6V55z .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JXL7H9VmU7j6V55z .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JXL7H9VmU7j6V55z .actorPopupMenu{position:absolute;}#mermaid-svg-JXL7H9VmU7j6V55z .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-JXL7H9VmU7j6V55z .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JXL7H9VmU7j6V55z .actor-man circle,#mermaid-svg-JXL7H9VmU7j6V55z line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-JXL7H9VmU7j6V55z :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Key 是否有效 依赖服务是否在线 模型是否仍支持该能力 此时降级链已不完整 即使主实现还正常 降级实现输出格式已变 下游会崩溃 alt 契约违规 契约满足 alt 资源失效 资源正常 每日触发演练 探测 requires 中的资源 资源状态 立即告警(P1) 真实调用每个实现 返回原始输出 契约校验 校验失败 告警(P1) 通过 记录健康度

图2:降级演练的三件事------资源探测、真实调用、契约校验,缺一件就有盲区

演练报告长这样(能直接定位 1.1 节的三个场景):

text 复制代码
能力: contract_review
  资源检查:
    api_key:anthropic        ✅
    model:claude-opus        ✅
    api_key:openai           ❌   ← 场景一:备用 Key 已过期
    model:gpt-strong         ✅
  实现验证:
    claude_primary           ✅ ok
    gpt_cross_vendor         ❌ 依赖缺失: ['api_key:openai']
    cheap_model_repair       ✅ ok
  契约违规: 无
  健康度: ❌ 降级链不完整(主实现之外的可用档位仅剩 1 个)

能力: clause_retrieval
  资源检查:
    service:vector_db        ✅
    service:search_svc       ❌   ← 场景二:依赖服务已下线
  实现验证:
    vector_search            ✅ ok
    keyword_search           ❌ 依赖缺失: ['service:search_svc']
  契约违规: 无
  健康度: ⚠️ 降级链只剩一档

4.5 故障注入:验证降级真的会触发

演练验证"降级路径能用",还需要验证"降级真的会被触发 "。这两件事是不同的------如果触发条件写错了,降级路径再健康也不会生效。

python 复制代码
# inject.py ------ 故障注入
class FaultInjector:
    """在受控环境里让主实现失败,验证降级链被正确触发。"""

    def __init__(self, targets: set[str]):
        self.targets = targets      # 要注入故障的实现名

    async def __call__(self, impl_name, fn, *args, **kwargs):
        if impl_name in self.targets:
            raise SimulatedFault(f"注入故障: {impl_name}")
        return await fn(*args, **kwargs)


async def test_degrade_is_triggered():
    """验证:主实现失败时,确实降级到了备用实现。"""
    injector = FaultInjector({"claude_primary"})
    cap = with_injection(REVIEW, injector)      # 包装所有 impl

    ex = DegradeExecutor(cap, make_validator(cap))
    result = await ex.run(SAMPLE_CONTRACT)

    assert result.degraded is True
    assert result.impl_used == "gpt_cross_vendor"
    assert result.value.degraded is True        # 契约里也必须标记


async def test_degraded_result_is_marked():
    """验证:降级结果对用户可感知------这是最容易被漏掉的一条。"""
    injector = FaultInjector({"claude_primary", "gpt_cross_vendor"})
    cap = with_injection(REVIEW, injector)
    ex = DegradeExecutor(cap, make_validator(cap))

    result = await ex.run(SAMPLE_CONTRACT)
    assert result.impl_used == "cheap_model_repair"
    assert "降级" in result.value.degrade_reason

💡 第二条测试(降级结果可感知)是全套里最重要的。 它的失败模式极隐蔽:降级成功执行、结果也返回了,但没标记 degraded------用户拿到低质量结果却毫不知情。这类问题在功能测试里完全看不出来(因为结果"看起来正常"),只有这条断言能抓住。


4.6 降级的用户沟通:三个层次

技术上做好了降级,还要把它传达出去。分三个层次,缺一层都会出问题:

层次 传达对象 形式 漏掉的后果
数据层 下游系统 契约里的 degraded 字段 下游按正常质量标准使用
接口层 调用方 响应头 X-Degraded: true 调用方无法感知
界面层 终端用户 明确的提示文案 用户对低质量结果照单全收
python 复制代码
# 三层都要有,缺一层就断链
result = await executor.run(contract)
result.value.degraded = True                      # ① 数据层
headers["X-Degraded"] = "true"                    # ② 接口层
headers["X-Degrade-Reason"] = result.impl_used
# ③ 界面层由前端根据 degraded 渲染提示

界面层文案的三个原则:

  1. 说清"能用但变差了",不要说"系统繁忙"。 后者会让用户以为完全不可用而反复重试(加剧雪崩),前者让用户自己判断是否需要复核。
  2. 指明变差在哪。 "结果可能不够完整"比"质量下降"有用------用户知道该重点检查什么。
  3. 不要吓唬人。 "服务严重故障"会让用户彻底放弃,而实际上降级结果可能完全够用。

💡 还有第四个层次常被忽略:内部告警。 降级率超过阈值时,运维和算法团队必须知道 ,否则降级会变成常态而无人察觉------见过系统悄悄降级了三个月,所有人都以为一切正常,直到某天有人对比历史结果才发现。

五、进阶

5.1 降级雪崩:降级反而让情况更糟

这是本文最反直觉的一节。降级提升可用性,但也可能引发新一轮过载。

链条是这样的:
#mermaid-svg-FLe0a5n4isRwQZfi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FLe0a5n4isRwQZfi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FLe0a5n4isRwQZfi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FLe0a5n4isRwQZfi .error-icon{fill:#552222;}#mermaid-svg-FLe0a5n4isRwQZfi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FLe0a5n4isRwQZfi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FLe0a5n4isRwQZfi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FLe0a5n4isRwQZfi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FLe0a5n4isRwQZfi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FLe0a5n4isRwQZfi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FLe0a5n4isRwQZfi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FLe0a5n4isRwQZfi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FLe0a5n4isRwQZfi .marker.cross{stroke:#333333;}#mermaid-svg-FLe0a5n4isRwQZfi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FLe0a5n4isRwQZfi p{margin:0;}#mermaid-svg-FLe0a5n4isRwQZfi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FLe0a5n4isRwQZfi .cluster-label text{fill:#333;}#mermaid-svg-FLe0a5n4isRwQZfi .cluster-label span{color:#333;}#mermaid-svg-FLe0a5n4isRwQZfi .cluster-label span p{background-color:transparent;}#mermaid-svg-FLe0a5n4isRwQZfi .label text,#mermaid-svg-FLe0a5n4isRwQZfi span{fill:#333;color:#333;}#mermaid-svg-FLe0a5n4isRwQZfi .node rect,#mermaid-svg-FLe0a5n4isRwQZfi .node circle,#mermaid-svg-FLe0a5n4isRwQZfi .node ellipse,#mermaid-svg-FLe0a5n4isRwQZfi .node polygon,#mermaid-svg-FLe0a5n4isRwQZfi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FLe0a5n4isRwQZfi .rough-node .label text,#mermaid-svg-FLe0a5n4isRwQZfi .node .label text,#mermaid-svg-FLe0a5n4isRwQZfi .image-shape .label,#mermaid-svg-FLe0a5n4isRwQZfi .icon-shape .label{text-anchor:middle;}#mermaid-svg-FLe0a5n4isRwQZfi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FLe0a5n4isRwQZfi .rough-node .label,#mermaid-svg-FLe0a5n4isRwQZfi .node .label,#mermaid-svg-FLe0a5n4isRwQZfi .image-shape .label,#mermaid-svg-FLe0a5n4isRwQZfi .icon-shape .label{text-align:center;}#mermaid-svg-FLe0a5n4isRwQZfi .node.clickable{cursor:pointer;}#mermaid-svg-FLe0a5n4isRwQZfi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FLe0a5n4isRwQZfi .arrowheadPath{fill:#333333;}#mermaid-svg-FLe0a5n4isRwQZfi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FLe0a5n4isRwQZfi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FLe0a5n4isRwQZfi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FLe0a5n4isRwQZfi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FLe0a5n4isRwQZfi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FLe0a5n4isRwQZfi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FLe0a5n4isRwQZfi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FLe0a5n4isRwQZfi .cluster text{fill:#333;}#mermaid-svg-FLe0a5n4isRwQZfi .cluster span{color:#333;}#mermaid-svg-FLe0a5n4isRwQZfi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FLe0a5n4isRwQZfi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FLe0a5n4isRwQZfi rect.text{fill:none;stroke-width:0;}#mermaid-svg-FLe0a5n4isRwQZfi .icon-shape,#mermaid-svg-FLe0a5n4isRwQZfi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FLe0a5n4isRwQZfi .icon-shape p,#mermaid-svg-FLe0a5n4isRwQZfi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FLe0a5n4isRwQZfi .icon-shape .label rect,#mermaid-svg-FLe0a5n4isRwQZfi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FLe0a5n4isRwQZfi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FLe0a5n4isRwQZfi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FLe0a5n4isRwQZfi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 重试
改用人工
主模型限流
触发降级
用弱模型,质量下降
用户发现结果不好
请求量增加
更多请求触发降级
人工工单激增
人工也过载
输出格式错误增多
修复重试增加调用

三条反馈回路都会放大负载。 其中最隐蔽的是第三条(弱模型格式错误率更高 → 修复重试 → 更多调用)------它不需要用户参与就能自我放大。

四个有效的阻断手段:

手段 阻断哪条回路 做法
降级期间限流更严 用户重试 降级状态下把用户级配额降到 50%
降级期间禁止自动重试 格式修复重试 降级状态下关闭 repair 重试,直接返回
全局降级比例上限 全部 同时降级的请求不超过 20%,超出直接失败
降级状态全局可见 人工工单 前端展示"服务降级中",减少无效重试
python 复制代码
class DegradeGuard:
    """降级守卫------防止降级雪崩。"""

    def __init__(self, max_degrade_ratio: float = 0.2):
        self.max_ratio = max_degrade_ratio
        self.total = 0
        self.degraded = 0

    def allow_degrade(self) -> bool:
        """是否允许这次降级。

        注意:不允许降级的请求会直接失败,而不是用主实现硬扛------
        因为主实现已经不可用了,硬扛只会更糟。
        """
        if self.total < 20:
            return True
        return self.degraded / self.total < self.max_ratio

    def record(self, degraded: bool) -> None:
        self.total += 1
        if degraded:
            self.degraded += 1

💡 allow_degrade 返回 False 时直接失败,这个选择是刻意的。 听起来很残酷(明明有降级方案却不用),但**"20% 的用户拿到可用的降级结果"优于"100% 的请求压垮系统导致 0% 可用"**。这是负载丢弃(load shedding)的思路------宁可部分失败,不可整体崩溃。

图3:降级雪崩的三条反馈回路与四个阻断手段------降级反而会让情况更糟

5.2 上下文降级:被忽略但最常用的一种

上下文超限是最常见的降级场景,但它常被当成"错误"处理(截断或报错),而实际上它应该是一条正式的降级链。

策略 做法 质量代价
截断 砍掉超出部分 大(可能砍掉关键信息)
摘要 用模型压缩历史 中
检索 只保留相关内容 小~中
换长上下文模型 升级模型 小(成本升)
python 复制代码
async def context_degrade(messages: list, max_tokens: int) -> list:
    """上下文降级链:检索 > 摘要 > 截断。

    顺序依据是质量代价:截断代价最大,所以放最后。
    """
    if count_tokens(messages) <= max_tokens:
        return messages

    # 1. 优先保留相关内容(质量代价最小)
    try:
        kept = await retrieve_relevant(messages, max_tokens)
        if count_tokens(kept) <= max_tokens:
            return mark(kept, "retrieval")
    except Exception:
        pass

    # 2. 摘要压缩
    try:
        kept = await summarize_history(messages, max_tokens)
        if count_tokens(kept) <= max_tokens:
            return mark(kept, "summary")
    except Exception:
        pass

    # 3. 最后才截断,且必须保留首尾(首部有指令,尾部有最新输入)
    return mark(truncate_keep_head_tail(messages, max_tokens), "truncate")

⚠️ 截断时"保留首尾"这一条很重要。 很多实现直接 messages[-n:] 保留最近的部分,结果把 System Prompt 和任务指令丢掉了 ------模型拿到一堆对话历史却不知道要干什么。头部(指令)和尾部(最新输入)都不能丢,中间才是可压缩的。

5.3 降级的观测:四个必看指标

指标 为什么 告警阈值建议
降级率 降级率 >5% 说明容量或上游有问题 >5% 警告,>20% 严重
降级链深度 降到第 3 档说明前两档都坏了 出现第 3 档即告警
演练健康度 降级链失效的唯一提前信号 任何一项失败即告警
降级后质量分 验证降级结果是否真的可用 低于阈值需人工介入
python 复制代码
# observe.py ------ 降级事件的记录结构
@dataclass
class DegradeEvent:
    capability: str
    from_impl: str | None      # None 表示首次就降级(主实现直接失败)
    to_impl: str
    depth: int                 # 降级到第几档
    quality: float             # 该档的质量分
    reason: str
    correlation_id: str        # 第 27 篇的三段 ID,用于串联

💡 from_impl=None 这种情况值得单独统计。 它表示"主实现第一次就失败了"------这意味着主实现可能已经完全不可用 ,而不是偶发抖动。如果这类事件的占比突然升高,通常说明主模型出现了持续性故障,此时应该考虑主动把主实现切走,而不是让每个请求各自去试一遍。

5.4 自动恢复:切回去比切过来更难

降级切换通常实现得很利索,但切回主实现这一步常被忽略,导致系统长期停留在降级状态而不自知。

三种典型的错误做法:

做法 问题
永不切回 主实现早就恢复了,用户却一直在用弱模型
立即切回 主实现刚恢复还不稳定,反复横跳,状态抖动
全量切回 主实现有问题时会一次性影响全部流量

正确做法是"连续成功计数 + 灰度放量":

python 复制代码
class RecoveryController:
    """降级恢复控制器:滞后确认 + 灰度放量。"""

    def __init__(self, success_threshold: int = 10, step: float = 0.1):
        self.success_threshold = success_threshold
        self.step = step
        self.consecutive_ok = 0
        self.primary_ratio = 0.0        # 主实现的流量占比

    def on_probe(self, ok: bool) -> None:
        """探测结果------探测流量走主实现,不影响用户。"""
        if ok:
            self.consecutive_ok += 1
            if self.consecutive_ok >= self.success_threshold:
                # 达到阈值,开始灰度放量
                self.primary_ratio = min(1.0, self.primary_ratio + self.step)
        else:
            self.consecutive_ok = 0
            self.primary_ratio = 0.0     # 立刻退回全量降级
            logger.warning("主实现探测失败,回退到全量降级")

    def use_primary(self, request_id: str) -> bool:
        """按哈希决定这次请求走主实现还是降级实现。"""
        h = int(hashlib.md5(request_id.encode()).hexdigest()[:8], 16)
        return (h % 1000) / 1000 < self.primary_ratio

💡 "探测流量不影响用户"是这里的关键设计。 恢复判断应该基于独立的探测调用 (用演练的样本输入),而不是拿真实用户请求去试探------后者会让一部分用户成为试验品。探测归探测,放量归放量,两者分开才安全。

5.5 演练的落地:三个常见阻力

演练机制推不动,通常是因为这三个问题没解决:

阻力 实际顾虑 解法
"演练要花钱" 每天真实调用有成本 用最小样本(1 条输入)+ 最便宜档位;月成本通常不到评估成本的 5%
"演练会产生脏数据" 真实调用会写入业务数据 用专门的演练账号与沙箱输入 ,且演练调用打 drill=true 标记
"失败了也没人管" 告警疲劳 演练失败只告警到值班,且必须有人认领;不进普通告警渠道

第三条最致命。 演练告警如果和普通告警混在一起,一定会被淹没。演练失败意味着"你的灾备能力已经没了",这是 P1 级别的事,需要独立的告警通道和明确的值班责任。

还有一个更隐蔽的阻力:没人愿意认领演练告警。 因为演练失败通常不是"当前正在发生的事故",而是"未来可能出事",容易被排到后面。解法是把演练健康度做成团队级别的可见指标 (比如放进周会的一页纸),让"降级链不完整"这件事持续暴露在管理者视野里------它不需要立刻修,但不能被忘记。


六、适用边界与风险提示 ⚠️

6.1 降级的五个失效模式

① 降级路径从未被演练。 1.1 节全部三个场景。这是本文的核心论点,不赘述。

② 静默降级。 结果标记缺失,用户不知道拿到的是降级结果。用低质量结果做决策比没有结果更危险。

③ 降级实现不满足输出契约。 场景三。必须有 validator 兜底。

④ 降级触发条件太宽松。 偶发失败就降级,导致用户明明能拿到好结果却拿到了差的。降级应该在熔断开启或重试预算耗尽之后。

⑤ 降级链没有终点。 一路降到底,最后一档质量极低,还不如明确失败。建议:降级链最多 3 档,降低到最后一档仍失败就明确报错。

6.2 降级不能解决什么

  • 降不了质量问题。 如果主模型在你的场景上准确率就是不够,换更弱的模型只会更差。这属于第 36 篇评估体系的范畴,靠迭代 Prompt / 工具 / 数据解决。
  • 降不了数据缺失。 如果核心依赖的数据源没了,任何降级都变不出数据。这时只能走功能降级 + 人工流程。
  • 降不了成本失控。 2.5 节讲过,降级可能更贵。成本控制靠预算和限流,不靠降级。

6.3 什么时候不要做降级

  • 只有一个模型、没有备选:降级空间为零,不如把精力放在"快速失败 + 明确提示 + 人工入口"上。
  • 任务对质量要求极高且无人工复核 (如医疗、法务终审):降级结果可能造成错误决策,宁可不可用。
  • 原型阶段:先把主路径做对,降级是生产化的事。
  • 降级成本高于直接失败时 :某些场景降级所需的备用资源(如自建备用集群)成本极高,而业务对短暂不可用可以容忍。这时"快速失败 + 明确提示 + 稍后重试"比昂贵的降级更合理。

七、一个可复制的最小骨架

python 复制代码
# minimal_degrade.py ------ 约 40 行
import logging

log = logging.getLogger("minidegrade")


async def run_with_degrade(impls, validator, *args, **kw):
    """最小降级执行器。

    impls: [(name, quality, fn), ...] 按优先级排列
    """
    for i, (name, quality, fn) in enumerate(impls):
        try:
            raw = await fn(*args, **kw)
            value = validator(raw)          # 契约校验,不能省
            if i > 0:
                log.error("已降级到 %s(质量 %.2f)", name, quality)
                value["degraded"] = True    # 可感知标记,不能省
                value["degrade_reason"] = name
            return value
        except Exception as e:
            log.warning("实现 %s 失败: %s", name, e)
            continue
    raise RuntimeError("所有实现均不可用")


# 配套的演练------20 行,但这是整套体系的灵魂
async def drill(impls, validator, sample, probes=None):
    """演练:验证降级链还活着。必须真实调用,不能用 Mock。"""
    report = {}
    for name, _q, fn in impls:
        try:
            validator(await fn(sample))
            report[name] = "ok"
        except Exception as e:
            report[name] = f"失败: {type(e).__name__}"
            log.error("演练失败 %s: %s", name, e)   # ← 必须告警
    return report

这个骨架只有两个函数,但包含了降级体系的两个核心 :执行时的契约校验 + 可感知标记 ,以及平时的真实演练 。前者保证"降级真的能用",后者保证"降级现在还能用"------这两句话看起来很像,实际是完全不同的两件事,而绝大多数团队只做了第一件。


八、总结

本文只有一句话要说:没有演练过的降级方案,等于没有降级方案。

五个最该记住的结论:

第一,降级路径会静默衰减。 主路径每天被验证,降级路径从未被验证。半年后主路径 99.9%、降级路径可能已经是 0%------Key 过期、依赖下线、输出格式变了,这三样都不会自己告诉你。

第二,演练必须真实调用,不能用 Mock。 Mock 只能验证"代码能跑通",验证不了上面那三件事。演练的价值 100% 来自真实性,频率建议每日一次,失败告警级别等同线上故障。

第三,降级实现必须满足输出契约。 没有 validator 的降级不是降级,是把一种失败换成另一种失败(1.1 节场景三:备用模型输出自然语言,下游解析崩溃)。

第四,降级结果必须可感知。 静默降级最危险------用户会基于低质量结果做决策,而系统以为一切正常 。技术上就是契约里那个 degraded 字段,它必须存在且下游必须处理。

第五,降级可能引发雪崩。 质量下降 → 用户重试 → 负载上升 → 更多降级。降级期间应该限流更严、关闭自动重试、并设置全局降级比例上限。其中第三条回路(弱模型格式错误率更高 → 修复重试 → 更多调用)最隐蔽,因为它不需要用户参与就能自我放大。

最后一点 :降级是产品决策,不只是技术决策。 "关掉这个功能用户还能不能用"、"降级提示怎么说"、"人工流程准备好了吗"------这些问题必须在降级设计阶段回答。一个技术上完美的降级方案,如果没有配套的用户提示和人工入口,在真实事故中依然是一场混乱。

还有一个判断值得记住:降级体系的健康度,应该以"演练通过率"来衡量,而不是以"降级功能写没写"来衡量。 很多团队的稳定性文档里写着"支持三级降级",但演练一跑发现只有第一档能用。文档上的降级能力是承诺,演练通过率才是实际能力------这两者的差距,就是事故发生时你会承受的惊吓。

落地自检清单(Checklist)

  • ✅ 降级链声明式定义 (能力清单),而非散落的 if fallback
  • ✅ 每个实现声明 requires(依赖的外部资源),作为演练检查项
  • ✅ 有输出契约校验(所有降级实现都必须满足)
  • ✅ 降级结果标记 degraded 且下游会处理
  • ✅ 降级链最多 3 档,有明确终点
  • ✅ 降级触发条件严于重试(熔断后或重试预算耗尽后)
  • ✅ 跨厂商备用(同厂商降级无法规避厂商级故障)
  • ✅ 工具降级时明确告知模型"该工具不可用",而非静默移除
  • ✅ 每日演练,真实调用,失败告警到值班
  • ✅ 有故障注入测试,验证降级真的会被触发
  • ✅ 有降级率/降级深度/演练健康度/降级后质量分四个观测指标
  • ✅ 有降级雪崩防护(更严限流、关闭自动重试、全局降级比例上限)
  • ✅ 功能降级有配套的用户提示与人工入口
  • ✅ 降级期间的数据有显式标记(不用空值,避免与"确实没有"混淆)

常见问题(FAQ)

Q1:演练要花多少钱?

通常不到评估成本的 5%。 演练只需每个能力 1 条样本输入 × 每档 1 次调用。以 10 个能力、每个 3 档计算,每天 30 次调用,一个月 900 次------相比一次降级失效导致的事故损失,这个成本可以忽略。

Q2:演练会产生脏数据吗?

会,所以要隔离。 三个做法:① 用专门的演练账号;② 用固定的沙箱输入(不写业务库);③ 调用打 drill=true 标记,让下游能识别并跳过副作用。第三条最重要------即使前两条漏了,标记也能防止污染。

Q3:备用模型要不要一直预热?

要。 两层含义:① Key 和配额要保持在有效状态 (演练覆盖);② 如果是自建部署或需要预置并发的平台,备用实例要保持热状态,否则切换过去会经历冷启动,降级反而变慢了。

Q4:降级后要不要自动切回主实现?

要,但要有滞后。 主实现恢复后立即切回,可能因不稳定而反复横跳。建议:主实现连续成功 N 次(比如 10 次)后才切回,并且切回过程要灰度(先 10% 流量)。

Q5:降级链多长合适?

最多 3 档。 超过 3 档时,最后一档的质量通常已经低到不如明确失败,而维护成本却线性增长。如果你的场景需要超过 3 档,通常说明应该做容量规划而不是降级设计。

Q6:怎么向用户表达"服务降级中"?

明确但不惊悚。 建议格式:"当前 XX 服务降级,结果可能不够完整,建议人工复核。"不要只说"系统繁忙" ------那会让用户以为是完全不可用而反复重试,反而加剧雪崩。说清"能用但质量下降",用户会自己判断是否需要复核。

Q7:降级和熔断谁先?

熔断先。 熔断判断"主路径还能不能用",降级回答"不能用时怎么办"。顺序错了会有两种后果:先降级 → 偶发失败就切换,用户白白拿到低质量结果;先重试不熔断 → 持续消耗配额。

Q8:小团队要不要建降级体系?

至少做两件事 :① 一个跨厂商的备用模型(成本最低、收益最高的单一改动);② 每日演练(哪怕只有一个探针脚本)。这两件事加起来一天能做完,却能防住"备用方案早就失效了"这个最普遍的坑。


参考资料

  1. Nygard, M. T. --- Release It! (稳定性模式:熔断、降级、舱壁隔离的系统性论述):https://pragprog.com/titles/mnee2/release-it-second-edition/
  2. Google SRE Book --- Handling Overload (负载丢弃与优雅降级):https://sre.google/sre-book/handling-overload/
  3. 《Site Reliability Engineering》第 22 章 --- Addressing Cascading Failures(级联故障与降级雪崩):https://sre.google/sre-book/addressing-cascading-failures/
  4. Netflix Tech Blog --- Fault Injection 与混沌工程实践(故障注入验证降级触发):https://netflixtechblog.com/
  5. AWS Well-Architected Framework --- Timeouts, retries and backoff with jitter :https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
  6. AWS 官方文档 --- AWS Fault Injection Service (受控故障注入):https://docs.aws.amazon.com/fis/
  7. OpenAI 官方文档 --- 模型能力、上下文长度与结构化输出支持矩阵:https://platform.openai.com/docs/models
  8. Anthropic 官方文档 --- 模型对比与能力边界:https://docs.anthropic.com/en/docs/about-claude/models
  9. Kubernetes 官方文档 --- Graceful degradation 与探针设计(liveness/readiness 的启发来源):https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
相关推荐
用户2018792831672 小时前
大模型是怎么"看"图、跑命令、调 MCP 的?我把 gemini-cli 源码扒了个底朝天
agent
Flynt2 小时前
一夜涨近2000星的"开源Grok Bot",我在1核2GB小机上装了一遍:壳是真的,脑子得自备
开源·agent·claude
用户943254758373 小时前
你的 Agent 会重复扣钱吗?我给 LangGraph 加了一层"步骤日志"
agent
小田学Python4 小时前
DeepSeek Harness 完整上手指南:安装部署 + 必装插件 + 用一周就回不去的功能
大数据·大模型·agent·ai工具·deepseek
程序员阿晟4 小时前
10 分钟搭一套自己的智能客服系统:果唯云客服 + Hermes Agent 实战
agent
去伪存真4 小时前
AI Agent Harness 深度测评与技术选型实战
前端·agent
七夜zippoe5 小时前
Agent 超时与幂等性:重复请求、副作用控制与状态一致性
ai·agent·超时·控制·幂等性
用户460381291945 小时前
ChatMemory 要被弃用了?Spring AI 2.1 用事件溯源重做 Agent 记忆
llm·agent
秋饼5 小时前
Computer Use Agent Java 落地实战:感知-行动循环、执行器与安全围栏生产级实现
java·ai·技术分享·后端开发