《互联网产品的 AI 架构·多能力编排与任务闭环》10.3 编排事故的止血与复盘

摘要节点的单步失败率升到 12%,整条链路成功率掉到 98.12%,错误预算已经烧掉 1.88 倍。本节给出编排事故的止血顺序:停新任务、降级节点、回滚版本、保数据,再验证恢复;把顺序写成带证据门槛的状态机,并给出一张缺项就不能结案的复盘表。附可运行的 TypeScript 模块与 21 项测试。

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% 在统计上分不开。

顺序要写成状态机,不是清单

把五步写成清单,值班的人会在压力下跳步:先回滚,再停入口;或者降级还没生效就宣布恢复。状态机的作用是让跳步变成一次明确的拒绝,而不是一次沉默的违规。

状态机的规则只有四条:

  1. 每个动作有唯一的前置状态。degrade-node 只能在 intake-stopped 之后执行;跳步返回 out-of-order。
  2. 每个动作有证据门槛。降级要节点 id 与 hasFallback;回滚要当前版本与目标版本且二者不同;保数据要快照 id 与已固化行数;恢复要验证运行数与错误率。
  3. 重复动作幂等。已经停过入口,再点一次返回 already-done,不重复写日志,也不回退状态。
  4. 每次推进都写审计日志。动作、进入的状态、相对事故起点的偏移、关键证据,一条都不能少。

状态机不负责判断「要不要停入口」,那是值班负责人的决定;它负责保证决定之后动作不会乱序、不会缺证据、不会重复执行。把判断与执行分开,复盘时才能区分「判断错了」与「执行错了」。

复盘表:把「下次注意」变成可验收项

事故复盘最常见的失败,是写出一篇没有验收项的故事。复盘表要强制回答十二个问题,缺一项就不能结案:

字段回答什么示例
发现时间谁在什么时候发现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-v11version-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。时间线是演示值,不是承诺。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查