没人敢关的那个开关
「回声」的内容摘要有一套降级开关。主模型不稳定时,值班同学打开 degrade.summary,摘要切到模板;主模型恢复后,再把开关关掉。
一次区域性故障里,开关被打开了。故障两个小时后恢复,但没有人关开关。三天后,产品经理发现所有摘要都是同一句「摘要生成中,请先查看原文」,用户投诉「摘要功能坏了」。排查时值班群里出现了三种声音:
- 「我以为提供方恢复后会自动切回来。」
- 「这个开关是谁加的?不知道能不能关。」
- 「关了万一又超时怎么办,先留着吧。」
开关最终开了三周。它没有造成数据事故,但它让一个降级状态变成了默认状态。复盘时大家同意的结论是:问题不是某个人忘了关,而是开关本身没有过期时间、没有负责人、也没有任何演练能证明关掉是安全的。
文中出现的时长、比例和用户量,除非标注为本机运行输出,都是案例假设值,用来演示判断过程,不是任何真实产品的实测。
演练要回答的三个问题
降级演练不是「把模型停掉看看会不会报错」。它至少要回答三个问题。
| 问题 | 要验证的内容 | 失败后的典型表现 |
|---|---|---|
| 故障时动作对不对 | 能力是否按分级进入 degrade、hide 或 block | 审核故障时发布仍然成功 |
| 用户看到什么 | 文案是否跟随动作出现,而不是空白或旧界面 | 入口消失但没有任何解释 |
| 恢复是否自动 | 故障移除后是否回到 serve,开关是否过期 | 故障结束三天仍在降级 |
三个问题对应三类断言:信号到动作的映射、动作到文案的绑定、故障移除后的恢复。只做第一类,演练会通过,用户仍然一脸茫然;只做第三类,恢复很快,但故障期间可能已经放行了不该放行的内容。
演练脚本里的期望值必须手写,不能从实际结果反推。expect: "healthy" 是演练作者对系统的承诺;如果实际是 unavailable,报告应该失败,而不是把期望改成实际值让报告变绿。
故障注入为什么必须确定性
真实的模型故障不可复现:同一个请求,这次超时,下次可能成功;同一个错误,这次发生在首帧,下次发生在流末尾。用真实网络做演练,失败无法稳定重放,成功了也说明不了什么。
确定性的做法是在提供方外面包一层故障注入器:
- 按能力注入,只影响目标能力,其他能力保持正常。
- 按错误类别注入,
timeout、unavailable、content-blocked覆盖不同分支。 - 支持
skipCalls,先放行前 N 次,验证「故障发生前已经成功」的边界。 - 不 sleep、不访问网络,同样的脚本在任何机器上得到同样的结果。
content-blocked 需要单独说明。它表示模型可用,但输出被内容策略拦截,属于「变弱」而不是「不可用」。如果把它归成不可用,一次正常的拦截会触发阻断型能力的全量阻塞;归成变弱,产品会走降级并提示用户。错误分类是演练的一部分,不是实现细节。
开关必须自带过期时间
「没人敢关」的根因不是勇气,而是开关没有默认结束条件。一个只能手动关闭的开关,在故障结束后会变成组织惰性的载体。
「回声」后来给演练开关加了三条规定:
- 必须有负责人。没有 owner 的开关不允许创建。
- 必须有 TTL。单次最长 15 分钟,到期自动关闭。
- 关闭要留痕。
expire()返回本次被关闭的开关 id,写进演练报告。
TTL 不是「建议」,而是硬约束。故障可能持续超过 15 分钟,那就续期;续期是一个显式动作,会留下记录,也会提醒值班同学「这个降级还在生效」。自动过期解决的是「忘记关」,续期记录解决的是「不知道怎么关」。
把演练写成代码
下面的实现包含三部分:故障注入器、开关板和演练执行器。故障注入器实现统一的 ChatProvider 接口,因此可以直接包在真实提供方外面;开关板注入时钟,过期判断不依赖真实时间;演练执行器按脚本逐步注入故障、探测信号、映射动作与文案,最后强制移除故障并断言恢复。
// file: src/degrade/drill.ts
// tier: L1
// verify: node --test test/degrade/drill.test.ts
import { AiError } from "../core/errors.ts";
import type { AiErrorKind } from "../core/errors.ts";
import type { ChatRequest, ChatResponse, Clock, ModelProfile } from "../core/types.ts";
import type { ChatProvider } from "../protocol/chat.ts";
import { planFor } from "./plan.ts";
import type { Availability, CapabilityPolicy, DegradationPlan } from "./plan.ts";
export interface FaultPlan {
/** 只对该能力注入;省略表示全局故障。 */
capability?: string;
kind: AiErrorKind;
/** 前 N 次调用放行,用于演练“故障发生前的成功路径”。 */
skipCalls?: number;
}
export interface FaultProvider extends ChatProvider {
readonly calls: number;
setFault(plan: FaultPlan | null): void;
}
/** 在真实提供方外面套一层确定性故障注入,演练不依赖网络与 sleep。 */
export function createFaultProvider(
inner: ChatProvider,
initial: FaultPlan | null = null,
): FaultProvider {
let fault = initial;
let calls = 0;
let matched = 0;
return {
id: `drill:${inner.id}`,
get calls() {
return calls;
},
setFault(next: FaultPlan | null) {
fault = next;
matched = 0;
},
async complete(request: ChatRequest, model: ModelProfile): Promise<ChatResponse> {
calls += 1;
if (fault && (fault.capability === undefined || fault.capability === request.capability)) {
matched += 1;
if (matched > (fault.skipCalls ?? 0)) {
throw new AiError(fault.kind, `演练注入 ${fault.kind}`, {
retryAfterMs: fault.kind === "rate-limit" ? 1_000 : undefined,
});
}
}
return inner.complete(request, model);
},
};
}
/** content-blocked 说明模型可用但结果不可用,属于“变弱”而不是“不可用”。 */
export function signalFromError(error: unknown): Availability {
if (error instanceof AiError && error.kind === "content-blocked") {
return "degraded";
}
return "unavailable";
}
/** 跑一次探针:成功看响应的 degraded 标记,失败按错误类别归并。 */
export async function probe(
provider: ChatProvider,
request: ChatRequest,
model: ModelProfile,
): Promise<Availability> {
try {
const response = await provider.complete(request, model);
return response.degraded ? "degraded" : "healthy";
} catch (error) {
return signalFromError(error);
}
}
export interface SwitchInput {
id: string;
enabled: boolean;
/** 相对当前时间的有效期;演练开关不允许永不过期。 */
ttlMs: number;
owner: string;
}
export interface FeatureSwitch {
id: string;
enabled: boolean;
expiresAt: number;
owner: string;
}
export interface SwitchBoard {
set(input: SwitchInput): FeatureSwitch;
isEnabled(id: string): boolean;
/** 关闭所有已到期开关并返回其 id,供演练报告记录。 */
expire(): string[];
snapshot(): FeatureSwitch[];
}
export interface SwitchBoardOptions {
clock: Clock;
/** 单个开关允许的最长有效期,默认 15 分钟。 */
maxTtlMs?: number;
}
export function createSwitchBoard(options: SwitchBoardOptions): SwitchBoard {
const maxTtlMs = options.maxTtlMs ?? 15 * 60_000;
const switches = new Map<string, FeatureSwitch>();
const autoOff = (current: FeatureSwitch): FeatureSwitch => {
if (current.enabled && current.expiresAt <= options.clock.now()) {
const next = { ...current, enabled: false };
switches.set(next.id, next);
return next;
}
return current;
};
return {
set(input: SwitchInput): FeatureSwitch {
if (input.owner.trim() === "") {
throw new AiError("invalid-request", `${input.id}: 开关必须有负责人`);
}
if (input.ttlMs <= 0 || input.ttlMs > maxTtlMs) {
throw new AiError("invalid-request", `${input.id}: TTL 必须在 1..${maxTtlMs}ms`);
}
const next: FeatureSwitch = {
id: input.id,
enabled: input.enabled,
expiresAt: options.clock.now() + input.ttlMs,
owner: input.owner,
};
switches.set(next.id, next);
return next;
},
isEnabled(id: string): boolean {
const current = switches.get(id);
if (!current) {
return false;
}
return autoOff(current).enabled;
},
expire(): string[] {
const expired: string[] = [];
for (const current of switches.values()) {
const next = autoOff(current);
if (current.enabled && !next.enabled) {
expired.push(next.id);
}
}
return expired;
},
snapshot(): FeatureSwitch[] {
return [...switches.values()].map(autoOff);
},
};
}
export interface DrillCase {
name: string;
fault: FaultPlan | null;
/** 演练脚本写死的期望信号,而不是从结果反推。 */
expect: Availability;
}
export interface DrillCheck {
name: string;
expected: Availability;
actual: Availability;
action: DegradationPlan["action"];
/** 用户实际会看到的文案;演练不能只断言动作。 */
message?: string;
ok: boolean;
}
export interface DrillReport {
passed: boolean;
checks: DrillCheck[];
}
export interface DrillOptions {
provider: FaultProvider;
model: ModelProfile;
request: ChatRequest;
policy: CapabilityPolicy;
}
/**
* 按脚本注入故障、探测信号、映射到产品动作,并在末尾强制恢复。
* 恢复检查是报告的一部分,不能靠人工记得关开关。
*/
export async function runDrill(cases: DrillCase[], options: DrillOptions): Promise<DrillReport> {
const checks: DrillCheck[] = [];
for (const item of cases) {
options.provider.setFault(item.fault);
const actual = await probe(options.provider, options.request, options.model);
const plan = planFor(options.policy, actual);
checks.push({
name: item.name,
expected: item.expect,
actual,
action: plan.action,
message: plan.message,
ok: actual === item.expect,
});
}
options.provider.setFault(null);
const recovered = await probe(options.provider, options.request, options.model);
const recoveryPlan = planFor(options.policy, recovered);
checks.push({
name: "移除故障后回到正常路径",
expected: "healthy",
actual: recovered,
action: recoveryPlan.action,
message: recoveryPlan.message,
ok: recovered === "healthy",
});
return { passed: checks.every((check) => check.ok), checks };
}
有三个细节值得注意。
第一,probe 把成功响应里的 degraded 标记也纳入信号。模型可能没有报错,但明确返回了降级结果,这时不应该算 healthy。
第二,DrillCheck 同时记录 action 和 message。只断言动作,可能出现「动作是 hide,但界面是空白」的通过;把文案放进报告,演练才能覆盖用户可见性。
第三,runDrill 在脚本结束后一定会执行恢复检查,并把它追加到报告里。恢复不是可选项,也不能靠人记得做。
测试与真实输出
测试覆盖七种情况:按能力注入与 skipCalls、错误分类、开关过期、开关校验、完整演练与恢复、错误期望、内容拦截走降级。在实验工程根目录执行:
node --test test/degrade/drill.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 故障只注入指定能力,skipCalls 之前的调用放行 (0.772333ms)
✔ content-blocked 归为变弱,其余错误归为不可用 (0.118958ms)
✔ 开关到期自动关闭,expire 记录被关闭的 id (0.476791ms)
✔ 演练开关必须有负责人且在有效期上限内 (0.126833ms)
✔ 演练按脚本注入故障并强制做恢复断言 (0.244167ms)
✔ 期望写错时演练失败,但恢复检查仍然执行 (0.099458ms)
✔ 注入内容拦截时走降级而不是阻塞 (0.107208ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0
「期望写错时演练失败」这条测试值得单独看。脚本期望 healthy,实际注入的是 unavailable,报告 passed 为 false,但恢复检查仍然执行,调用次数是 2。这说明演练失败不会跳过清理,也不会把故障留在系统里。
「开关到期自动关闭」验证的是时间边界:开关在 1000 毫秒到期,时钟推进到 1000 毫秒时 expire() 返回它的 id,isEnabled 变成 false。如果比较写成 > 而不是 >=,到期瞬间仍然会返回 true,演练会漏掉这个边界。
一次演练暴露的可见性问题
「回声」第一次正式演练时,动作全部正确:摘要进入 degrade,私信回复进入 hide,审核进入 block。报告是绿色的。
但产品经理看了一会儿说:「用户不知道发生了什么。」摘要页只是换成了更短的文本,没有「简化模式」的提示;私信入口消失,没有任何说明;审核阻塞页沿用了一个通用的「网络错误」。
问题出在演练只断言了 action,没有断言 message。动作是系统内部状态,文案才是用户看到的事实。修复方式不是加更多人工检查,而是把 message 放进 DrillCheck,让每次演练都记录用户会看到什么。
这也解释了为什么第 10.1 节要把文案写进能力策略。文案如果由调用方临时拼,演练就无法稳定断言;文案跟动作一起由 planFor 返回,演练才能逐条比对。
演练的频率与边界
演练不是上线前做一次就结束。比较现实的节奏是:
- 每次修改降级链或能力分级后,跑一遍对应能力的演练。
- 每月做一次全能力演练,覆盖提供方不可用、超时和内容拦截三类故障。
- 每次真实故障后,把当时的故障序列补成演练用例,防止同类问题再次静默通过。
演练也有边界。它验证的是「策略在给定信号下的行为」,不验证模型质量,也不验证容量。备用模型能不能承接翻倍的流量,要靠压测和容量测算;演练只能告诉你「切过去之后产品会做什么」。两者不能互相替代。
小结
降级演练要回答三个问题:故障时动作对不对、用户看到什么、恢复是否自动。确定性故障注入让这三个问题可以被稳定重放;带负责人和过期时间的开关让降级不会变成新的默认状态;恢复断言让「故障结束」成为一个必须验证的结果,而不是一句口头承诺。
「回声」的开关开了三周,说明只靠人的记性管理降级状态不可靠。把动作、文案和恢复都写进演练报告,才能让降级策略从纸面配置变成可运营的能力。
下一章讨论 AI 接入的另一个边界:密钥、租户与数据出域。降级解决的是「能力弱了怎么办」,下一章解决的是「能力根本不该被谁调用」。
阅读导航:上一节:10.2 降级策略:小模型、规则、模板、排队 · 下一节:11.1 密钥、租户与数据出域 。
常见错误与边界
- 用真实网络做演练。故障不可复现,成功也无法证明策略有效,失败还可能污染真实数据。
- 演练脚本从实际结果反推期望值。报告永远是绿的,却什么也没验证。
- 只断言动作,不断言文案。动作正确但界面空白,用户仍然不知道发生了什么。
- 把
content-blocked归成不可用。一次正常的内容拦截会触发全量阻塞,故障范围被放大。 - 开关没有负责人。出问题时没人敢关,也没人知道能不能关。
- 开关没有过期时间。故障结束后降级状态会长期留存,最后变成默认体验。
- TTL 到期比较写成
>。到期瞬间仍然返回开启,边界用例会被漏掉。 - 演练结束后不强制恢复。故障注入器留在系统里,下一次请求可能莫名其妙失败。
- 把演练当成容量验证。演练只验证策略行为,备用模型能不能扛住流量要靠压测。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。