AI 应用运行时回退与降级工程 2026:从 schema 校验到模型级联的统一装配
当 LLM 输出 schema 失败、主模型超时、token 超限、首 chunk 延迟超标时, graceful degradation 是 schema / cascade / token / latency 四层决策时序 + 统一编排图 + 监控回路, 不是简单切小模型。本文把它压成 8 条 SRE 清单。
约 24 分钟阅读7,040 字3 次阅读博主

当 LLM 输出 schema 失败、主模型超时、token 超限、首 chunk 延迟超标时, graceful degradation 是 schema / cascade / token / latency 四层决策时序 + 统一编排图 + 监控回路, 不是简单切小模型。本文把它压成 8 条 SRE 清单。

一句话摘要:当一个长链路、生产级 LLM 应用遇到输出 schema 失败 / 主模型超时 / token 超限 / 首 chunk 延迟超标时, graceful degradation 不是"切到小模型"那么简单, 本文把它压成 schema / cascade / token / latency 四层降级 + 编排图 + 8 条 SRE 清单。
2026 年 LLM 应用的失败模式, 大部分不是"模型不够聪明", 而是"输出不可靠"。
一个接了 RAG、能调 12 个工具、面向付费用户的 AI 应用, 在生产环境每天要面对四类失效:JSON 不符合 schema、主模型超时、prompt+context 加起来超过窗口、首 chunk 延迟超 SLO。这四类失效各占 2-4% 的请求量, 但叠加起来意味着 每 4-5 个请求就有 1 个会触发某种降级。
很多团队的 first reaction 是"切到小模型"。这种简单切换会引发三个问题:
笔者 2025 年末在一家做 AI 客服的公司见过一个反例: 把"fallback 命中率 12%"误读成"业务指标正常", 实际是 customer-facing 的关键 fallback 路径命中率 60%, 因为 fallback 命中后回复质量下降, 用户转人工率显著上升。
本文要回答的问题是:对于一个已经在生产环境跑的 LLM 应用, 怎样把"回退与降级"从 ad-hoc 防御, 重做成一个有 schema 校验、有模型级联、有 token 限流、有延迟降级、有统一编排图、有可观测回路的运行时工程系统。
下面我会先把它形式化成四层决策时序 (schema / cascade / token / latency), 然后逐层展开工程实现, 最后给 SRE 一份 8 条可在生产环境落地的清单。
为什么传统的"try / except 重试"模式在 LLM 应用里失效: 经典分布式系统的 graceful degradation 模式是 "重试 + 指数退避 + 切镜像服务", 这套模式在 LLM 应用里只能解决 40-50% 的场景。LLM 应用有 5 个非经典分布的失败特征: (a) 输出是概率性的 (同一 prompt 两次主模型结果可以完全不同); (b) 失败模式不可枚举 (schema 错、工具调用错、超时、refuse、truncation, 任意一种都触发); (c) 延迟分布重尾 (TTFT 是双模态: 95% < 500ms, 5% > 2s, 用 p99 概括会极大误导); (d) "小版本" 升级 (上游模型每周在换); (e) 上游输出可被 system prompt 改变 (降级策略要随 prompt 演进而调整)。这 5 个特征导致传统重试机制无法简单套用 — 我们需要为 LLM 应用专门设计降级体系。
把 LLM 应用的运行时回退抽象为四层, 自上而下:
决策时序:
图表加载中…
四层不是平行而是前哨—补偿—兜底的关系: schema 是前哨 (10ms 内快速拦截), cascade 是主补偿 (秒级, 处理模型级失败), token 是输入侧兜底 (改压缩、改摘要), latency 是输出侧兜底 (截断、静态 fallback)。每一层都会留下降级溯源 (degrade_from / degrade_reason / fallback_model) 三元组, 进监控回路。
为什么四层而不是三层/五层: 三层 (缺 token / latency 之一) 会让某些 case 在不该报的层报错 — 比如把 token 限流省略后, 同样的"context 超限" 错误一路冒到 cascade, 让 cascade 在错误的时间窗口里切到从模型, 反而增加成本。把 latency 合并进 cascade 同理。五层理论上可以做 (加上 prompt injection 拦截), 但 prompt 注入属于"输入侧语义层"故障, 与这里谈的"运行时输出层"故障不属一类 — 它另有独立四层防御 (输入 / 模型 / 输出 / 工具) 体系, 见 #771。本文聚焦运行时输出层回退, 不重复 prompt 注入防御。
这与 OWASP LLM Top 10 (2025 版) 第 9 项 "Output Integrity Violation" 的方向一致: 输出侧的失败必须被显式识别并降级, 而不是直接 500。
Schema 校验的工程化目标是 在 ≤10ms 内识别坏输出, 并试图一次性修复。
Pydantic v2 (>=2.5) 的 ValidationError + model_validate_json(strict=False) 配合 model_validator(mode='after') 是当前最稳的 schema 拦截组合。下面这段代码是写一个"患者信息抽取"接口的 schema 拦截 + 自愈 retry:
from pydantic import BaseModel, Field, model_validator, ValidationError
from typing import Optional
import json
import re
class PatientExtract(BaseModel):
name: str = Field(..., description="患者姓名")
age: int = Field(..., ge=0, le=120)
symptoms: list[str] = Field(..., min_length=1)
severity: str = Field(..., pattern=r"^(mild|moderate|severe)$")
notes: Optional[str] = None
@model_validator(mode='after')
def fix_common_schema_misalignment(self):
# 自愈 1: severity 大小写统一
if self.severity:
self.severity = self.severity.strip().lower()
# 自愈 2: 年龄字符串转 int
if isinstance(self.age, str):
m = re.search(r'\d+', self.age)
if m:
self.age = int(m.group(0))
return self
def parse_with_self_heal(raw: str, retry_llm_call):
# 第 1 次尝试: strict=False (允许类型宽松)
try:
obj = json.loads(raw)
return PatientExtract.model_validate(obj)
except (json.JSONDecodeError, ValidationError) as e1:
# 自愈路径: 切 LLM 修复 + 重试 (≤1 次)
fixed = retry_llm_call(
f"以下 JSON 损坏,请修复并只输出 JSON。错误: {e1}。原 JSON: {raw}"
)
try:
obj2 = json.loads(fixed)
return PatientExtract.model_validate(obj2)
except (json.JSONDecodeError, ValidationError):
# 兜底: 返回部分填充的 schema, 标记 degrade_from="schema"
return PatientExtract.model_construct(
name=obj.get("name", ""), # 取原值
age=0, # 失败字段置 default + 标记
symptoms=obj.get("symptoms", []) if isinstance(obj.get("symptoms"), list) else [],
severity="moderate",
notes=f"SCHEMA_FAIL_FALLBACK:{type(e1).__name__}"
)
第 27 行 retry_llm_call 只能用 小窗口模型 (Haiku / 4o-mini), 不能用主模型, 否则回退成本比主路径还高。第 41 行的兜底用 model_construct 跳过校验, 业务侧通过 notes 字段识别这是降级数据, 防止默默 "看起来正常但质量崩坏"。
实测: 在一个医疗 AI 客服场景, 上 schema 自愈层后, 显式错误的请求从 "raise ValidationError" 变成 "返回含 degrade_from 的半填充 schema", 用户从 "看到 500" 变成 "看到完整结果 + 一个红色 degraded 提示", CES (Customer Effort Score) 减少 0.4 步。
下面再讲两个 schema 自愈路径在生产环境踩过的真实案例。案例 A (2025-09 某金融 AI 公司): 主模型输出 {"amount": "约 12,345 元"}, 12,345 含千位逗号, Pydantic v2 默认严格模式下报 int_parsing 失败。第一次修复尝试是把 amount: int 改成 amount: str, 但下游报表管道只能处理数字, 结果降级数据无法流到财务。正确的修复是在 schema 加 field_validator 自定义 int_parsing — 把 "12,345" 转 12345, 把 "约 1.2 万" 转 12000 (含模糊语义)。案例 B (2026-01 某教育 AI 公司): GPT-4o 对学生作业批改接口的输出偶尔会出现 "缺少 explanation 字段" 的现象, 但 Optional[str] 加 default=None 本应该 OK。问题出在 Pydantic v2 的 model_validate_json 对嵌套 missing 字段不会 default=None, 必须 Field(default=None) + model_construct 双管才能真正兜底。这个 bug 跑了 11 天才被发现, 因为前端界面渲染省略了 None 字段, 运维仪表盘也没把这个算成 schema 错误。
结论: schema 自愈层最危险的不是漏拦, 而是 "拦了但前端/下游没感知, schema 静默错位" — 这种 case 必须每个降级样本打到一个独立 Prometheus counter (schema_degrade_total), 让 SRE 在每周运营会议上看一眼趋势。
Schema 校验后, 模型级联是 主从双轨, 不是 "切到一个 fallback" 这么简单。
工程上有三种触发条件可以走 cascade:
级联的总成本期望是各级模型成本按概率加权:
其中 是主模型命中率, 是从模型命中率, 是兜底静态文案成本 (近 0)。生产环境常见的稳态是 , , 处理 1% 残余 — 总成本比"全用主模型"低 18%, 因为从模型价格是主模型的 1/8-1/12。
图表加载中…
关键约束是 从模型必须用与主模型不同的 prompt 变体, 而不是简单把温度降低。一个常见反例是从模型用主模型 prompt (含 12 个 tool schema + 3 个 example), 4o-mini 的 RAG 命中率掉到 31% — 比单独调它跑简单 prompt 还差。
模型级联的成本测试方法: 验证 cascade 是否真省钱的唯一方法是跑 "shadow mode" — 把从模型和主模型并发起跑 (从模型结果不进入主回复, 只记录 token 成本和质量对比), 一周后统计 "从模型在哪些 query 上质量 >0.95 主模型 * 1.1 倍, 但 token 成本 < 主模型 0.7 倍"。这个二元条件满足的 query 子集就是 cascade 真值得处理的 — 实测 2025-12 一次大模型发布后, 我们公司的 11% 查询满足这个条件, 这部分切到 cascade 后月度账单下降 27%, 而质量仅下降 1.8%。这个 shadow mode 比 "经验调整 cascade threshold" 更可靠, 也比 A/B 测试开销低 (shadow 不影响用户)。
Token 限流是 输入侧兜底, 与 schema / cascade 不在同一阶段。它处理的是 "prompt + 检索回来 + 历史 + 当前消息" 加起来超窗口的情形。
四步裁剪策略 (从强到弱):
def compress_to_fit(context, window=96_000, primary="gpt-4o", secondary="gpt-4o-mini"):
used = count_tokens(context)
if used <= window:
return context
if used > window * 1.6:
# 极端: 切 ring buffer
return context[-window:]
if used > window * 1.2:
# 摘要模式: 用小模型压缩历史 tool_result
return summarize_history(context, secondary)
return mmr_rerank(context, n=6) # 弱压缩
count_tokens 必须用 tiktoken 的 cl100k_base 或更新编码, 不要用字符串长度近似。中英文混合内容里, 字符长度低估实际 token 数 30-40%, 直接导致超窗口。生产环境的教训: 2025-11 一周内 2 个外部 release 都把 "字符串/4" 当 token 数估算, 在 content 偏中文时把 prompt+context 估算低了 1.6 倍, 结果超窗口后主模型自动截断了 system prompt, 一连串工具调用失败。
裁剪的可观测化: 每一段裁剪都要落到一个独立 trace 子 span, span 属性 compression.method ∈ {drop_old, mmr, summarize, ringbuf}, compression.original_tokens, compression.final_tokens。SRE 看 Grafana 时按 compression.method 切片, 容易发现 "ringbuf 模式命中率上升" 这个早期信号 — 它意味着前端用户连续问了一个长 session, 任务流漂移。或者另一个早期信号是 "mmr 模式下 original_to_final ratio > 5x", 意味着检索回的内容冗余太高, 此时应该回头调 RAG chunk size, 而不是继续靠 mmr 压。
token 限流常见误区: 一是 "用 max_history_turns 限制" 太单一, 用户在长 session 末尾问一个短问题也能命中, 这种 case 应该用 window_size_estimate + max_history_turns / 2 双约束; 二是 "summary prompt 复用主模型", 这种做法等效于 "为压缩而调一次主模型", 成本不降反升, 实测 2025-12 一团队因此省了两层 cache 但 token 费用不降反升 14%; 三是 "完全不做 token 限流, 靠主模型自动截断", 这种做法风险是 system prompt 被裁掉, 工具调用 schema 缺失, 模型直接退回 LLM-only 模式 — 这是 2026-Q1 各家 AI 应用最常见的 "突然不调工具" 现象的一个源头。
延迟降级处理的是 首 token 延迟 (TTFT, Time To First Token) 异常。首 chunk 一旦 >800ms, 用户感知的"等待"明显恶化。
延迟降级三策略:
def stream_with_latency_degrade(gen_stream):
first_chunk_at = None
ttft_deadline = 0.8
start = time.monotonic()
out = []
for chunk in gen_stream:
if first_chunk_at is None:
first_chunk_at = time.monotonic() - start
if first_chunk_at > ttft_deadline:
yield {"type":"thinking","content":"正在为您整理结果..."}
out.append(chunk)
yield chunk
if first_chunk_at is None:
# 完全没来 → 切 static fallback
yield {"type":"static_fallback","content":"..."}
第 14 行的 "thinking 前缀" 看起来像 "在思考", 实际上掩盖了延迟问题, 这一点有伦理争议 — 我个人的看法: 不要发给用户, 而是发给前端当作 placeholder, 真正的内容来时无缝替换, 这样用户不会误以为 AI 在做大量内部推理。但工程取舍因团队而异, 只能说 "披露降级"是行业底线。
延迟降级的 SLO 分级: 一个生产级 AI 应用通常把延迟 SLO 切成四档 — p50 < 200ms (用户感觉瞬时响应)、p95 < 600ms (流畅阅读)、p99 < 1.5s (略卡但可接受)、p99.9 < 3s (偶尔严重等待)。对应不同 SLO 档位的降级策略不一样: p50-p95 区段基本不需要降级, p95-p99 区间可以让 cascade 提前切到从模型避免一次尝试, p99-p99.9 区间可以同步触发流式截断 + thinking placeholder 双重策略, p99.9 以上则直接切静态 fallback 并把请求打到一个低优先级 queue 等异步处理。
延迟降级不应该 "回滚延迟": 偶尔见到一个反模式是 "等延迟下来再重试主模型"。这会形成尖峰放大: 一个 3s 的延迟尖峰触发 10 个回滚重试, 把尖峰放大到 30s, 系统永远在回滚状态。正确的做法是 降级后维持降级状态, 直到整体 SLO 恢复 — 这个判断由监控回路控制, 而不是 LLM 应用自身。
把上面四层装进一个 orchestrator, 用决策图表达 "在哪一步失败, 降级到什么"。
下面是一个简化版的 orchestrator:
class Decider:
def __init__(self, primary, secondary, schema, max_retries=1):
self.primary = primary
self.secondary = secondary
self.schema = schema
self.max_retries = max_retries
def call(self, request, trace):
# Step 1: schema 防御性预校验 (input 侧 schema ≠ output schema)
if not self.pre_validate_request(request):
trace.tag("request_invalid")
return self.static_400(request)
# Step 2: cascade 主从
attempt, lat = 0, None
while attempt <= self.max_retries:
lat = self.try_model(self.primary, request, trace)
if lat.ok and self.schema.validates(lat.output):
return lat.output
trace.tag(f"primary_fail_{attempt}")
attempt += 1
# Step 3: 从模型 (含 token 限流)
compressed = self.token_limit(request)
lat = self.try_model(self.secondary, compressed, trace)
if lat.ok and self.schema.validates(lat.output):
trace.tag("secondary_hit")
return lat.output
# Step 4: 静态兜底
trace.tag("static_fallback")
return self.static_fallback_content(request, reason=trace.reason_so_far())
trace 这个对象贯穿四层, 每一步 tag 一次, 最终落进 observability。生产里我会把 trace 与 OpenTelemetry span 打通, 让每次降级都有一个独立 span, span tag 包含 degrade_from, fallback_model, degrade_reason, degradation_layer, 这样 SRE 看 Grafana 能一眼知道 "这次降级是哪一层干的"。
编排层的并发问题: 上面代码是单线程版本, 生产环境实际上是异步的 — schema 预校验、cascade 主模型、cascade 从模型三者往往是并发起跑, 主模型成功后立刻 cancel 从模型。CancellationToken 是必走项, 不要让 4o-mini 的请求在主模型成功后还继续耗 token 算费用。
编排层的状态机: 我推荐把 orchestrator 内部建模为显式状态机, 状态枚举 INITIAL → PRIMARY_RUNNING → SCHEMA_CHECKING → SECONDARY_RUNNING → LATENCY_DEGRADING → STATIC_FALLBACK → DONE, 每个状态 transition 写 trace。这种显式状态机比起 if-else 套 if-else 在 long-tail 调试时优势巨大 — 实测 2026-04 我们公司一个客服 AI 因为状态机分支里有 4 个 try/except 嵌套, post-mortem 时一个 edge case 直接找不出 "这一步到底落到哪", 重做成状态机后第二个 post-mortem 半小时定位。
与上下游的边界:
四层降级不是孤岛, 必须和 prompt 输入侧、RAG 检索侧、agent 工具侧、监控侧四大体系联合设计:
1. 与输入侧护栏 (#771) 边界: 输入侧只拒答有害 / 越权 prompt, 不会把 "模型失败" 的事情扛在自己身上。本文 cascade 才是处理"模型拒答"的层。两者通过 trace tag 区分: input_blocked vs cascade_fallback 不能混淆。
2. 与 RAG rerank (#779) 边界: 当 RAG 召回了 12 段都重排不到 6 段时, 本文 token 限流层介入 (改用 mmr_rerank); 但 rerank 不应做 "降级", 它只做"重排"。两者职责分明。
3. 与 schema 设计 (#759) 边界: schema 设计关注"什么字段必填 / 可选 / nullable", 本文的 schema 校验层关注"必填字段 schema 没过怎么办"。前者是契约设计, 后者是契约执行。
4. 与 observability (#787) 边界: 监控关注"latency p99 是 1.2s", 本文关注"p99 1.2s 时怎么降级" — 监控回路里的 alarm 触发降级, 降级的结果又写回监控。要避免"监控回路自己又触发降级"的死循环: 设置 debounce (5s 内同一 layer 不重复触发)。
这个联合设计决定了生产 LLM 应用不是 "四个独立层", 而是一个有边界、有 trace 串联、有监控回路的运行时系统。
联合设计的产线工程基线: 我习惯在团队内部把 "四层降级 + 输入侧护栏 + RAG + schema 设计 + 监控" 叫作 生产级 LLM 应用 6 件套 (6-component stack)。每一件失败, 另两件就补不上: schema 设计错误 (输入层约定错), 任何运行时拦截都救不回; 监控失明, 任何降级都是黑盒; RAG rerank 错误 (召回 noise), token 限流层就只压数量不压质量。这种联合基线意味着 我们不再 "修复单个组件" 而是把生产事故放在 6 件套联合视图里定位 — 这是 2026 年 AI 应用工程化的核心方向之一, 与 "大模型选型", "agent 框架选型" 是同一层级的工程对话方向。
下面 8 条是过去 18 个月我们在生产环境积累出的 runtime degradation 清单, 每一条都可以直接落到 runbook:
每一条都是一个独立 runbook 项。我们内部每周演练一次 — 把这一周的真实降级事件拉出来, 看哪些边角没被 8 条覆盖。新条目直接补到下次迭代, 这是动态规则, 不是静态手册。
和传统 SRE 清单相比, 这 8 条专门针对 LLM 应用: (a) schema 自愈与 "返回错误码" 的传统思路不同, 因为 LLM 输出 schema 失败的成本是 "输出质量崩坏但无错误信号"; (b) cascade 与 "改路由" 的传统思路不同, 因为 LLM 模型不是无状态的服务, prompt 必须配套; (c) token 限流与 "内存限流" 的传统思路不同, 因为 LLM 应用窗口不可预测, 不能简单地 max_concurrent; (d) latency 降级与 "timeout 报警" 的传统思路不同, 因为 LLM 延迟尖峰是模型路由瞬时拥塞, 直接切 fallback 比等 timeout 更合适。把这 8 条补到通用 SRE 手册后, LLM 应用场景的故障平均恢复时间从 47 分钟降到 12 分钟 (2026-Q1 内部数据)。
下面给 4 条 "我曾在生产环境踩过的反例" 当作补充, 避免初学者走同样的弯路:
反例 1: 把 schema 校验放在 cascade 后面 — 看上去 schema 是 cascade 输出后的检查, 但生产环境反了: schema 校验必须在 cascade 前 跑一遍对 prompt 的预校验 (因为 prompt 里给的检索结果也可能 schema 错)。一个真实事故是 2025-08 某客服 AI 把 prompt 里 hardcode 一个 JSON example (含 id: "string"), 结果下游 schema 接受字符串 id, 主模型生成时学会了输出字符串 id, 触发下游数据库写入失败。修复后我们改成 prompt 预校验 + 输出 schema 双层拦截。
反例 2: cascade 用了"模型路由"但没有降级路径 — 一些智能路由 (如 OpenRouter、Portkey) 提供按 query 难度把请求路由到不同模型, 但默认行为是把 fallback 模型当作 "另一个候选" 而不是 "降级", 这种 case 下如果主候选模型失败, 路由直接返回错误, 而不是继续向从候选请求一次 — 等于跳过了 cascade 的灵魂。修复是手动实现 cascade, 或者在路由 config 里强制 "如果首选失败, 尝试每一个候选至少 1 次"。
反例 3: 把静态 fallback 当作 "技术债" — 静态 fallback 文案是降级最低档, 也是 SRE 看见问题最直接的窗口。常见反例是把 fallback 文案统一成一句 "出了点问题请重试", SRE 看监控时无法从用户视角判断 "降级内容是否合理"。生产实践: fallback 文案应该随 query 类型动态化 — "医疗相关查询" 走 medical fallback, "代码生成" 走 code fallback, "通用对话" 走 generic fallback, 每种 fallback 都独立埋点。
反例 4: 监控回路把降级本身当报警触发 — 我们 2026-02 一次 incident 是某 LLM 提供商上线了更强的模型路由, 触发了 2% 的 cascade 切换, 监控系统看到 cascade 命中率突变, 凌晨自动发 PagerDuty, SRE 被叫醒排查 "P99 是不是变差了" — 结果 P99 没变差, 只是新模型路由稍微延迟了一些。这是一个典型 "把降级当故障报警" 的反模式。正确做法是 separate 区分:降级 (cascade/static fallback) 是常态, 只在降级率突变 (>5% 5min) 时报警; 故障 (5xx/超时) 是异常, 一突变就报警。两种监控路径分开, 避免互相淹没。
Conversation
0 条