一次降级到小模型之后
「回声」的内容摘要有一条看起来很合理的降级链:主模型超时后切小模型,小模型再失败就用模板。上线当天主模型出现超时,链路按预期切到了小模型。
用户看到的摘要是这样的:
摘要:我的订单什么时候到?订单什么时候到?什么时候到?
这不是摘要,是把用户的问题复读了三遍。排查后发现三个原因叠在一起。第一,小模型的提示沿用了主模型的自由生成模板,没有针对「摘要」任务收紧输出格式。第二,链路把「小模型可用」当成了「小模型适合这个任务」,质量下限只写了 0.5,实际上没有拦住任何东西。第三,降级过程对用户完全不可见,用户不知道这是备用通道的输出。
这次事故说明,降级不是「再找一个模型」。降级是一条链,链上的每一步都有质量、延迟、成本和依赖,必须按预算筛选、按质量下限拦截、按顺序执行,还要防止所有请求同时涌向同一个备胎。
文中出现的质量分、耗时和成本,除非标注为本机运行输出,都是案例假设值,用来演示判断过程,不是任何真实产品的实测。
四种兜底手段的分工
把常见的兜底手段放在一起比较,差异比想象中大。
| 手段 | 质量 | 延迟 | 成本 | 依赖 | 主要风险 |
|---|---|---|---|---|---|
| 小模型 | 中高 | 中 | 中 | 模型提供方 | 与主模型同源,故障时一起不可用 |
| 规则 | 中 | 低 | 极低 | 自身服务 | 覆盖面窄,边界情况容易误判 |
| 模板 | 低 | 极低 | 零 | 自身服务 | 输出僵硬,容易被用户识别为敷衍 |
| 排队 | 高 | 高 | 中 | 模型提供方与队列 | 用户等不起,结果过期后没有意义 |
四种手段不是互相替代,而是覆盖不同的失败场景。小模型适合「主模型太慢或太贵,但任务本身仍是生成式」;规则适合「判断逻辑可以枚举」;模板适合「只需要一个可读的占位结果」;排队适合「结果可以晚到,但不能没有」。
一个常见的错误是把小模型当成万能备胎。如果小模型和主模型来自同一家提供方、同一个区域,主模型故障时小模型很可能一起故障。降级链的冗余度不是由步骤数量决定的,而是由依赖是否真的独立决定的。第 2 章讨论过多模型并存的四个来源,到了降级链里,至少要保证确定性步骤(规则、模板)不依赖提供方。
选择顺序是产品决策
链的顺序不应该由公式算出来。质量、延迟、成本之间的换算,取决于产品这一刻更在意什么,这是产品决策,不是工程优化。
「回声」三条链的顺序如下:
| 能力 | 链顺序 | 理由 |
|---|---|---|
| 内容摘要 | 主模型 → 小模型 → 规则 → 模板 | 摘要是可弱能力,优先保质量,最后才接受占位 |
| 内容审核 | 主模型 → 规则 → 阻塞 | 审核不能靠模板放行,规则失败就必须停 |
| 私信回复 | 主模型 → 小模型 → 隐藏 | 回复是可选能力,质量不够时宁可不出现 |
顺序写死还有一个好处:演练时可以逐段验证。如果顺序是动态计算的,故障时很难说清「为什么这次走了规则而不是模板」,复盘会退化成猜测。
链上的每一步都要显式标注 requiresProvider。这不是给执行器看的,而是给风暴保护看的:当连续失败达到阈值,依赖提供方的步骤先被摘掉,只留下规则和模板。否则所有请求会在同一秒涌向小模型,把小模型也打挂。
预算与代价
降级链必须在请求的剩余预算内工作。预算至少有三个维度:剩余时间、剩余成本、可接受的最低质量。
- 剩余时间来自端到端 deadline。排队步骤如果预计等待超过剩余时间,就不该进入链。
- 剩余成本来自租户或请求的费用上限。小模型便宜,但不是免费;降级风暴时成本反而可能上升。
- 最低质量是产品承诺。低于这条线的结果,不如不给。
估算值只用于入场过滤,实际消耗以步骤返回为准。这个区分很重要:估算可能偏乐观,如果执行时按估算扣预算,后面的步骤会看到一个虚假的剩余额度。下面的实现里,estimatedMs 和 estimatedMicros 决定「能不能进链」,StepOutput 里的实际值决定「扣多少」。
还有一个边界:链耗尽时要失败关闭,并带上已经花掉的成本。失败也是一次调用,账不能因为没拿到结果就不记。第 8 章的成本归因在这里同样适用。
避免降级风暴
降级风暴的典型过程是这样的:主模型超时,所有请求切到小模型;小模型瞬间被打到限流;限流触发重试;重试把流量再放大一倍;最后连规则服务都被拖垮。
保护手段有三层:
- 连续失败计数。达到阈值后进入风暴模式,摘掉所有依赖提供方的步骤。
- 确定性步骤优先。风暴模式下只保留规则和模板,它们不依赖外部模型,容量可预测。
- 成功即复位。任意一次成功都把计数清零,避免系统在恢复后长期停留在保守模式。
阈值不能拍脑袋。它应该来自「备用模型能承接多少额外流量」的容量测算,而不是「三次看起来差不多」。如果备用模型的余量只有 20%,阈值和切流比例必须一起设计,否则保护本身也会变成故障源。
把链写成代码
下面的实现分成三层:selectChain 负责排序、预算过滤和风暴保护;runChain 负责逐步执行、失败转移和真实成本记账;recordOutcome 负责维护连续失败计数。
selectChain 不执行任何步骤,因此可以在请求进入前单独测试。runChain 的处理器按 kind 注册,主模型、小模型、规则、模板可以各自实现,执行器不关心它们背后是 HTTP 还是本地函数。
// file: src/degrade/chain.ts
// tier: L1
// verify: node --test test/degrade/chain.test.ts
import { AiError } from "../core/errors.ts";
/** 降级链上允许出现的五种角色,顺序由编排者显式声明。 */
export type StrategyKind = "primary" | "small-model" | "rules" | "template" | "queue";
export interface StrategyStep {
id: string;
kind: StrategyKind;
/** 越小越先执行;质量与成本的取舍是产品决策,不用公式代替。 */
priority: number;
/** 案例估计值,用于进入链之前的预算过滤。 */
estimatedMs: number;
estimatedMicros: number;
/** 0..1 的案例估计值,不是离线评测分数。 */
quality: number;
/** 是否依赖模型提供方;降级风暴时这类步骤先被摘掉。 */
requiresProvider: boolean;
}
export interface DegradeBudget {
remainingMs: number;
remainingMicros: number;
/** 低于该质量的结果不返回给用户。 */
minQuality: number;
}
export interface StormState {
recentFailures: number;
/** 连续失败达到该值进入风暴模式;0 表示关闭保护。 */
threshold: number;
}
export type ChainMode = "normal" | "storm";
export interface ChainDecision {
mode: ChainMode;
chosen: StrategyStep[];
skipped: { id: string; reason: string }[];
}
export interface StepOutput {
text: string;
costMicros: number;
elapsedMs: number;
quality: number;
}
export type StepHandler = (step: StrategyStep) => Promise<StepOutput> | StepOutput;
export interface ChainRun {
text: string;
usedStepId: string;
kind: StrategyKind;
degraded: boolean;
quality: number;
spentMicros: number;
spentMs: number;
failures: { id: string; reason: string }[];
}
function validateStep(step: StrategyStep): void {
if (step.id.trim() === "") {
throw new AiError("invalid-request", "降级步骤必须有 id");
}
if (step.quality < 0 || step.quality > 1) {
throw new AiError("invalid-request", `${step.id}: quality 必须在 0..1 之间`);
}
if (step.estimatedMs < 0 || step.estimatedMicros < 0) {
throw new AiError("invalid-request", `${step.id}: 预算估计不能为负`);
}
}
/** 成功或失败都要更新风暴计数,失败清零意味着模型已经恢复。 */
export function recordOutcome(state: StormState, ok: boolean): StormState {
return { ...state, recentFailures: ok ? 0 : state.recentFailures + 1 };
}
/**
* 选出本次可用的步骤序列。
* 顺序来自 priority;过滤来自预算、质量下限与提供方健康度;
* 风暴模式下额外摘掉依赖提供方的步骤,避免所有请求同时涌向备用模型。
*/
export function selectChain(
steps: StrategyStep[],
budget: DegradeBudget,
storm: StormState = { recentFailures: 0, threshold: 0 },
): ChainDecision {
const seen = new Set<string>();
for (const step of steps) {
validateStep(step);
if (seen.has(step.id)) {
throw new AiError("invalid-request", `${step.id}: 降级步骤重复`);
}
seen.add(step.id);
}
if (budget.minQuality < 0 || budget.minQuality > 1) {
throw new AiError("invalid-request", "minQuality 必须在 0..1 之间");
}
const mode: ChainMode =
storm.threshold > 0 && storm.recentFailures >= storm.threshold ? "storm" : "normal";
const skipped: { id: string; reason: string }[] = [];
const chosen: StrategyStep[] = [];
const ordered = [...steps].sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
for (const step of ordered) {
if (mode === "storm" && step.requiresProvider) {
skipped.push({ id: step.id, reason: "storm" });
continue;
}
if (step.estimatedMs > budget.remainingMs) {
skipped.push({ id: step.id, reason: "deadline" });
continue;
}
if (step.estimatedMicros > budget.remainingMicros) {
skipped.push({ id: step.id, reason: "cost" });
continue;
}
if (step.quality < budget.minQuality) {
skipped.push({ id: step.id, reason: "quality" });
continue;
}
chosen.push(step);
}
return { mode, chosen, skipped };
}
/**
* 按顺序执行降级链。估算只用于入场过滤,实际消耗以返回值为准并继续扣减,
* 这样后面步骤看到的是真实剩余预算,而不是乐观估计。
*/
export async function runChain(
chain: StrategyStep[],
handlers: Partial<Record<StrategyKind, StepHandler>>,
budget: DegradeBudget,
): Promise<ChainRun> {
let remainingMs = budget.remainingMs;
let remainingMicros = budget.remainingMicros;
let spentMs = 0;
let spentMicros = 0;
const failures: { id: string; reason: string }[] = [];
for (const step of chain) {
const handler = handlers[step.kind];
if (!handler) {
failures.push({ id: step.id, reason: "no-handler" });
continue;
}
if (step.estimatedMs > remainingMs) {
failures.push({ id: step.id, reason: "deadline" });
continue;
}
if (step.estimatedMicros > remainingMicros) {
failures.push({ id: step.id, reason: "cost" });
continue;
}
try {
const output = await handler(step);
spentMs += output.elapsedMs;
spentMicros += output.costMicros;
remainingMs -= output.elapsedMs;
remainingMicros -= output.costMicros;
if (output.quality < budget.minQuality) {
failures.push({ id: step.id, reason: "quality" });
continue;
}
return {
text: output.text,
usedStepId: step.id,
kind: step.kind,
degraded: step.kind !== "primary",
quality: output.quality,
spentMicros,
spentMs,
failures,
};
} catch (error) {
failures.push({
id: step.id,
reason: error instanceof Error ? error.message : String(error),
});
}
}
throw new AiError("unavailable", "降级链已耗尽", {
usage: { inputTokens: 0, outputTokens: 0, costMicros: spentMicros },
});
}
有两个设计选择值得说明。
第一,排序只认 priority,不按质量或成本做加权。加权公式看起来更「智能」,但它把产品取舍藏进了一个系数里,评审时没人说得清为什么小模型排在规则前面。显式优先级至少能被讨论和修改。
第二,风暴模式在预算过滤之前判断。一个依赖提供方的步骤即使预算充足,也会在风暴模式下被摘掉;反过来,确定性步骤不会因为风暴被误伤。顺序错了,保护会失效。
测试与真实输出
测试覆盖九种情况:排序、时间与成本过滤、质量下限、风暴模式、成功复位、非法输入、失败转移、实际质量不达标、链耗尽。在实验工程根目录执行:
node --test test/degrade/chain.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 按 priority 排序,不看输入顺序 (0.979666ms)
✔ 预算不足时跳过昂贵步骤,而不是让整条链失败 (0.11625ms)
✔ 质量下限会拦住模板兜底 (0.091375ms)
✔ 风暴模式摘掉依赖提供方的步骤 (0.104167ms)
✔ 成功一次即退出风暴模式,失败继续累加 (0.082916ms)
✔ 重复步骤与非法质量在选链前被拒绝 (0.292167ms)
✔ 执行链按顺序失败转移,并记录真实消耗 (0.203375ms)
✔ 实际质量低于下限时继续尝试下一步 (0.096625ms)
✔ 整条链耗尽时抛出 unavailable 并带上已花成本 (0.178792ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0
「执行链按顺序失败转移」这条测试里,主模型抛错、小模型成功,最终 spentMicros 是 80,spentMs 是 500。这两个数字来自小模型的实际返回,不是估算值。如果执行器按估算扣预算,后面的步骤会看到一个偏小的剩余额度,可能在还有钱的时候提前放弃。
「实际质量低于下限时继续尝试下一步」验证了另一条边界:处理器成功返回不等于结果可用。质量不达标应该记一次失败并继续,而不是把低质量结果直接交给用户。
一次模板兜底把摘要变成复读
回到开头的事故。团队最初的修复方案是「给小模型加一段更严格的提示」,但演练时发现另一个问题:模板兜底同样会复读。
当时的模板是 摘要:{user_question},本意是给一个占位。用户问「我的订单什么时候到」,模板输出「摘要:我的订单什么时候到」,看起来像模型在重复问题。模板没有错,错在它被用在了「用户会认真读」的位置上。
最终修复分三层:
- 小模型换成针对摘要任务微调过的版本,输出格式收紧为三句话以内。
- 模板改成固定话术,例如「摘要生成中,请先查看原文」,不再拼接用户问题。
- 质量下限从 0.5 提到 0.6,模板的质量估计是 0.4,因此不会在正常链里被选中,只在明确接受占位的场景使用。
第三点最容易被忽略。模板兜底的价值是「有一个可读结果」,不是「假装有摘要」。把它的质量估计写低,让它在链里自然被过滤掉,比在代码里写一堆 if 判断更可靠。
小结
降级策略不是「再找一个模型」,而是一条有顺序、有预算、有质量下限、有风暴保护的链。小模型、规则、模板、排队各自覆盖不同的失败场景,选择顺序是产品决策,不应该藏进加权公式。
「回声」的复读事故说明,备用通道的质量估计、提示适配和用户可见性必须一起设计。链上的每一步都要标注是否依赖提供方,连续失败达到阈值时先摘掉这类步骤,避免降级本身变成第二次故障。
下一节处理最后一个问题:这套链在真实故障里到底能不能工作。答案不能靠想象,要靠确定性故障注入、开关过期和恢复断言组成的演练。
阅读导航:上一节:10.1 模型不可用时产品该做什么 · 下一节:10.3 降级演练与用户可见性 。
常见错误与边界
- 把降级理解成「换一个模型」。没有顺序、预算和质量下限的切换,只是把故障转移到了另一个地方。
- 备用模型与主模型同源同区。主模型故障时它们一起不可用,链上的步骤数量不等于冗余度。
- 用加权公式决定顺序。系数会把产品取舍藏起来,评审和复盘都说不清为什么这样排。
- 只按估算扣预算。估算偏乐观时,后面的步骤会看到虚假余额,提前放弃还有预算的机会。
- 质量下限设得过低。低于承诺的结果不如不给,低质量输出会消耗用户对产品的信任。
- 链耗尽时不记成本。失败调用也花了钱,不记账会让成本归因在故障日出现缺口。
- 风暴保护只看请求数,不看依赖。真正该摘掉的是依赖提供方的步骤,而不是所有步骤。
- 成功之后不复位失败计数。系统会长期停留在保守模式,恢复后仍然只走规则和模板。
- 让模板拼接用户原文。模板兜底应该输出稳定话术,而不是制造「复读」的错觉。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。