98.12% 的成功率,怎么就成了事故
「回声」的摘要节点换了一版提示,输出合同同时新增了一个必填字段。上线两小时后,三能力链路的成功率从 99.86% 掉到 98.12%。单看这个数字,它仍然高于很多团队给编排定的 95% 设计目标;但对外承诺的 SLO 是 99%,错误预算只剩 1%。
按 99% 目标计算,错误预算消耗倍数是 (1 - 98.12%) ÷ (1 - 99%) = 1.88。也就是说,两个小时烧掉了接近两个月的预算。更麻烦的是,单步失败预算算出来是 13.02%,摘要的 12% 还没有越过它——设计目标说「可以接受」,运营 SLO 说「已经在事故里」。
这正是 10.2 讲过的分裂:节点目标、设计预算与对外 SLO 是三套口径。事故响应不能从「谁的错」开始,而要从「先让错误预算停止燃烧」开始。文中成本、成功率与错误预算数字来自 ai-lab 示例样本上的运行结果;时间线是用于演示的示例值,不是生产实测。
止血顺序:先停新增,再修局部
编排事故与单请求事故最大的区别是:一次事故会继续产生新的工作流。只要入口还开着,每个新任务都会重复同一条失败路径,错误预算按流量持续燃烧。因此第一步不是查日志,而是停新增。
止血顺序固定为五步:
| 顺序 | 动作 | 进入条件 | 完成标志 | 不这样做的后果 |
|---|---|---|---|---|
| 1 | 停新任务 | 确认事故影响一条以上链路 | 入口拒绝新运行,存量运行可 checkpoint | 新任务持续放大损失 |
| 2 | 降级节点 | 已停新增,且有可用替代产物 | 问题节点走兜底,用户可见标记 | 等待回滚期间继续失败 |
| 3 | 回滚版本 | 已降级,且定位到版本边界 | 模型/提示/编排版本回到上一版 | 降级长期化,能力退化 |
| 4 | 保数据 | 已回滚,且需要保留现场 | 快照、隔离队列、证据哈希完成 | 恢复后无法回放与追责 |
| 5 | 验证恢复 | 数据已固化 | 至少 100 次验证运行且错误率回到预算内 | 带病恢复,二次事故 |
顺序的理由不是流程美学,而是每一步都在为下一步争取条件。
停新增最先做,因为它直接停止预算燃烧,而且可逆:入口恢复比模型回滚快得多。存量运行不一定要全部杀掉,可以让它们跑到检查点;第三章的 checkpoint 语义在这里派上用场。
降级第二,因为降级是局部、可逆、秒级生效的。它不需要等版本回滚完成,就能把用户可见损失压下来。前提是节点确实有替代产物:模板摘要、缓存答案、跳过可选步骤。没有替代产物时,降级动作应该被拒绝,而不是硬塞一个空结果。
回滚第三,因为回滚需要版本边界。模型版本、提示版本、编排版本三者必须有一个能指认的变更点;如果事故期间同时改了提示和路由,回滚就变成二次实验。降级先做,是为了在回滚准备期间仍有可接受的输出。
保数据第四,容易被误解为「最后再备份」。这里的保数据指固化事故现场:快照当前状态、隔离失败队列、保留 trace 与版本号、记录已产生的副作用。它放在回滚之后,是因为回滚会改变运行态;先把运行态稳定下来,再固化,才能保证快照对应的是同一个版本组合。
验证恢复最后做。至少 100 次验证运行,且观测错误率回到 1 - SLO 以内,才允许撤掉降级、恢复入口。少于这个样本量,98% 与 99% 在统计上分不开。
顺序要写成状态机,不是清单
把五步写成清单,值班的人会在压力下跳步:先回滚,再停入口;或者降级还没生效就宣布恢复。状态机的作用是让跳步变成一次明确的拒绝,而不是一次沉默的违规。
状态机的规则只有四条:
- 每个动作有唯一的前置状态。
degrade-node只能在intake-stopped之后执行;跳步返回out-of-order。 - 每个动作有证据门槛。降级要节点 id 与
hasFallback;回滚要当前版本与目标版本且二者不同;保数据要快照 id 与已固化行数;恢复要验证运行数与错误率。 - 重复动作幂等。已经停过入口,再点一次返回
already-done,不重复写日志,也不回退状态。 - 每次推进都写审计日志。动作、进入的状态、相对事故起点的偏移、关键证据,一条都不能少。
状态机不负责判断「要不要停入口」,那是值班负责人的决定;它负责保证决定之后动作不会乱序、不会缺证据、不会重复执行。把判断与执行分开,复盘时才能区分「判断错了」与「执行错了」。
复盘表:把「下次注意」变成可验收项
事故复盘最常见的失败,是写出一篇没有验收项的故事。复盘表要强制回答十二个问题,缺一项就不能结案:
| 字段 | 回答什么 | 示例 |
|---|---|---|
| 发现时间 | 谁在什么时候发现 | 10:12,SLO 告警 |
| 止血完成时间 | 第几步完成、耗时多少 | 10:26,降级生效 |
| 影响面 | 多少运行、多少链路、多少用户 | 1,240 次运行 / 3 条链路 |
| 预算消耗 | 烧掉多少倍错误预算 | 1.88 倍 |
| 触发节点 | 第一个偏离的节点 | content.summarize |
| 触发版本 | 模型、提示、编排哪一版 | summarize-v12 |
| 放大因素 | 什么让局部失败变成链路失败 | 重试把合同错误当成可重试错误,失败被重复计费 |
| 根因 | 可验证的机制,不是「疏忽」 | 输出合同新增必填字段,旧提示未同步 |
| 防线缺口 | 哪道门禁本该拦住 | 节点 SLO 未纳入发布门禁 |
| 行动项 | 可验收的改动 | 节点 SLO 与提示版本绑定进灰度 |
| 负责人 | 唯一负责人 | 编排平台组 |
| 截止时间 | 可检查的日期 | 2026-11-19 |
触发点、放大因素、根因、防线缺口是四件不同的事。触发点是「哪里先坏」,放大因素是「为什么没被局部化」,根因是「机制上为什么会坏」,防线缺口是「哪道检查本该拦住」。四栏写成同一句话,复盘就退化成追责。
实现
下面的模块把止血顺序写成状态机,把错误预算与复盘表写成可断言的函数。它不执行真实操作,只判定「这一步能不能做、证据够不够、做完进入什么状态」。
// file: src/orch/observability-incident.ts
// tier: L2
// verify: node --test test/orch/observability.test.ts
import { AiError } from "../core/errors.ts";
export type IncidentStage =
| "detected"
| "intake-stopped"
| "node-degraded"
| "version-rolled-back"
| "data-preserved"
| "stabilized";
export type IncidentAction =
| "stop-intake"
| "degrade-node"
| "rollback-version"
| "preserve-data"
| "stabilize";
/** 止血顺序是硬约束:先停新增,再降级,再回滚,最后固化数据。 */
export const CONTAINMENT_ORDER: readonly IncidentAction[] = [
"stop-intake",
"degrade-node",
"rollback-version",
"preserve-data",
"stabilize",
];
export const STAGE_AFTER: Record<IncidentAction, IncidentStage> = {
"stop-intake": "intake-stopped",
"degrade-node": "node-degraded",
"rollback-version": "version-rolled-back",
"preserve-data": "data-preserved",
"stabilize": "stabilized",
};
export interface IncidentEvidence {
nodeId?: string;
hasFallback?: boolean;
currentVersion?: string;
targetVersion?: string;
snapshotId?: string;
preservedRows?: number;
verifiedRuns?: number;
observedErrorRate?: number;
sloTarget?: number;
}
export interface IncidentLogEntry {
action: IncidentAction;
stage: IncidentStage;
atOffsetMs: number;
detail: string;
}
export interface IncidentState {
stage: IncidentStage;
log: IncidentLogEntry[];
}
export interface Transition {
ok: boolean;
state: IncidentState;
code: "applied" | "already-done" | "out-of-order" | "missing-evidence" | "invalid-evidence";
message: string;
}
export const INITIAL_INCIDENT: IncidentState = { stage: "detected", log: [] };
function fail(message: string): never {
throw new AiError("invalid-request", `incident: ${message}`);
}
function text(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}
function ratio(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
fail(`${field} 必须落在 [0, 1]`);
}
return value;
}
function integer(value: unknown, field: string, min = 0): number {
if (typeof value !== "number" || !Number.isInteger(value) || value < min) {
fail(`${field} 必须是 >= ${min} 的整数`);
}
return value;
}
function refuse(state: IncidentState, code: Transition["code"], message: string): Transition {
return { ok: false, state, code, message };
}
function evidenceCheck(action: IncidentAction, evidence: IncidentEvidence): string | null {
if (action === "degrade-node") {
if (!text(evidence.nodeId)) return "缺少要降级的节点";
if (evidence.hasFallback !== true) return "节点没有可用的降级产物";
}
if (action === "rollback-version") {
if (!text(evidence.currentVersion)) return "缺少当前版本";
if (!text(evidence.targetVersion)) return "缺少回滚目标版本";
if (evidence.currentVersion === evidence.targetVersion) return "回滚目标与当前版本相同";
}
if (action === "preserve-data") {
if (!text(evidence.snapshotId)) return "缺少数据快照标识";
if (!Number.isInteger(evidence.preservedRows) || (evidence.preservedRows ?? -1) < 0) {
return "缺少已固化行数";
}
}
if (action === "stabilize") {
if (!Number.isInteger(evidence.verifiedRuns) || (evidence.verifiedRuns ?? 0) < 100) {
return "至少需要 100 次验证运行";
}
const errorRate = ratio(evidence.observedErrorRate, "observedErrorRate");
const target = ratio(evidence.sloTarget, "sloTarget");
if (target === 1) return "SLO 目标为 100% 时无法判定恢复";
if (errorRate > 1 - target) {
return `错误率 ${errorRate} 仍高于预算 ${Number((1 - target).toFixed(4))}`;
}
}
return null;
}
/** 按固定顺序推进;重复动作幂等,跳步或证据不足一律拒绝。 */
export function applyIncidentAction(
state: IncidentState,
action: IncidentAction,
evidence: IncidentEvidence = {},
atOffsetMs = 0,
): Transition {
if (!Number.isFinite(atOffsetMs) || atOffsetMs < 0) fail("atOffsetMs 必须是非负数");
const index = CONTAINMENT_ORDER.indexOf(action);
if (index < 0) return refuse(state, "out-of-order", `未知动作 ${String(action)}`);
if (state.log.some((entry) => entry.action === action)) {
return { ok: true, state, code: "already-done", message: `${action} 已执行,保持原状态` };
}
const expectedStage = index === 0 ? "detected" : STAGE_AFTER[CONTAINMENT_ORDER[index - 1]];
if (state.stage !== expectedStage) {
return refuse(
state,
"out-of-order",
`${action} 需要先处于 ${expectedStage},当前是 ${state.stage}`,
);
}
const missing = evidenceCheck(action, evidence);
if (missing !== null) {
const code = missing.startsWith("缺少") ? "missing-evidence" : "invalid-evidence";
return refuse(state, code, missing);
}
const next: IncidentState = {
stage: STAGE_AFTER[action],
log: [
...state.log,
{
action,
stage: STAGE_AFTER[action],
atOffsetMs: Math.round(atOffsetMs),
detail: action === "degrade-node" ? String(evidence.nodeId) : action,
},
],
};
return { ok: true, state: next, code: "applied", message: `进入 ${next.stage}` };
}
/** 错误预算消耗倍数:成功率 98.12%、目标 99% 时,1% 的预算已经烧掉 1.88 倍。 */
export function errorBudgetBurn(successRate: number, sloTarget: number): number {
const success = ratio(successRate, "successRate");
const target = ratio(sloTarget, "sloTarget");
if (target === 1) fail("sloTarget 不能是 100%");
return Number(((1 - success) / (1 - target)).toFixed(4));
}
export interface ReviewField {
key: string;
label: string;
required: boolean;
}
export const REVIEW_FIELDS: readonly ReviewField[] = [
{ key: "detectedAt", label: "发现时间", required: true },
{ key: "mitigatedAt", label: "止血完成时间", required: true },
{ key: "affectedRuns", label: "影响面(工作流/运行数)", required: true },
{ key: "budgetBurn", label: "错误预算消耗倍数", required: true },
{ key: "triggerNode", label: "触发节点", required: true },
{ key: "triggerVersion", label: "触发版本(模型/提示/编排)", required: true },
{ key: "amplifier", label: "放大因素", required: true },
{ key: "rootCause", label: "根因", required: true },
{ key: "guardGap", label: "防线缺口", required: true },
{ key: "action", label: "行动项", required: true },
{ key: "owner", label: "负责人", required: true },
{ key: "dueAt", label: "截止时间", required: true },
];
export interface ReviewRow extends ReviewField {
value: string;
filled: boolean;
}
export interface ReviewTable {
rows: ReviewRow[];
missing: string[];
complete: boolean;
}
/** 复盘表先判定“能不能结案”,再谈文风:必填项为空就不允许关闭。 */
export function buildReviewTable(input: Record<string, string>): ReviewTable {
const rows = REVIEW_FIELDS.map((field) => {
const value = (input[field.key] ?? "").trim();
return { ...field, value, filled: value !== "" };
});
const missing = rows.filter((row) => row.required && !row.filled).map((row) => row.key);
return { rows, missing, complete: missing.length === 0 };
}
applyIncidentAction 先查动作是否已执行,再查前置状态,最后查证据。三个检查的顺序不能反:先查证据会把「已经做过」误报成「证据不足」,先查前置状态会让重复动作返回 out-of-order 而不是 already-done。
evidenceCheck 只做布尔判定,不修改状态。降级要求 hasFallback === true,不接受「大概有」;回滚要求当前版本与目标版本不同;恢复要求至少 100 次验证运行,且错误率不高于 1 - SLO。这些门槛都可以在测试里构造,不依赖线上环境。
buildReviewTable 把复盘表的十二个字段做成必填项。complete 为 false 时,事故不能结案;missing 直接告诉复盘人缺哪一栏,而不是让评审会逐条追问。
示例样本上的止血时间线
把 98.12% 的样本代入状态机,得到如下示例时间线。时间是演示值,状态迁移与判定来自确定性测试。
| 偏移 | 动作 | 证据 | 进入状态 |
|---|---|---|---|
| T+0 | 发现 | 成功率 98.12%,预算燃烧 1.88 倍 | detected |
| T+5s | 停新任务 | 入口拒绝新运行 | intake-stopped |
| T+90s | 降级节点 | content.summarize,有模板兜底 | node-degraded |
| T+10m | 回滚版本 | summarize-v12 → summarize-v11 | version-rolled-back |
| T+15m | 保数据 | 快照 snap-20261105-1012,1,240 行 | data-preserved |
| T+100m | 验证恢复 | 500 次运行,错误率 0.4% | stabilized |
恢复判定的错误率门槛是 1%,500 次运行观测到 0.4%,通过。若只跑 99 次,即使错误率为 0,状态机也拒绝恢复:样本量不足,无法区分 0.4% 与 1%。
止血之后,修复什么
止血只恢复服务,不修复系统。复盘行动项要按四层分配:触发层修合同与提示,放大层修重试与降级策略,根因层修版本绑定,防线层修发布门禁。只在触发层改一句提示,下一次同类变更仍会以另一种形式触发。
行动项还要有验收方式。示例中的「节点 SLO 与提示版本绑定进灰度」可以验收为:灰度期间,任一节点可用率低于自身目标 0.5 个百分点即自动停止放量。没有验收方式的行动项,不应该写进复盘表。
小结
编排事故的止血顺序是停新任务、降级节点、回滚版本、保数据、验证恢复。顺序的意义在于每一步都为下一步争取条件:停新增停止预算燃烧,降级争取回滚时间,回滚恢复版本,保数据固化现场,验证恢复防止带病上线。
把顺序写成状态机,跳步会返回 out-of-order,缺证据会返回 missing-evidence,重复动作返回 already-done。复盘表强制十二个字段,缺一项不能结案。98.12% 的样本烧掉 1.88 倍错误预算,这个数字比「成功率下降」更能说明事故的严重程度。
阅读导航:上一节:10.2 节点级 SLO 与整体 SLO · 下一节:11.1 声明式编排与代码编排 。
常见错误与边界
- 先查日志再停入口。查因需要时间,新任务会持续燃烧预算。
- 停入口时杀掉所有存量运行。能 checkpoint 的运行应该跑到检查点。
- 没有替代产物也执行降级。空结果不是降级,是另一种失败。
- 没定位版本边界就回滚。同时改提示与路由时,回滚会变成二次实验。
- 把保数据放到恢复之后。运行态已变,快照对不上事故版本。
- 验证样本少于 100 次就恢复。小样本分不清 98% 与 99%。
- 重复点击同一动作导致状态回退。状态机应返回
already-done。 - 复盘只写根因,不写放大因素与防线缺口。下次同类事故仍会绕过门禁。
- 行动项没有负责人或截止时间。没有验收项的复盘等于没复盘。
- 把示例时间线当成生产 SLA。时间线是演示值,不是承诺。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。