同一件事,三个人给了三个级别
周五下午,chat.reply 把一批退款咨询回答成了「已为您提交退款」。客服主管说这是 P0,因为用户会真的等退款;平台值班说这是 P2,因为只影响了一个租户的 60 条会话;算法同学说先看看是不是提示版本的问题,暂时不定级。
三个人都不算错,他们看的是不同侧面:客服看用户损失,值班看影响范围,算法看技术原因。事故分级要解决的不是「谁对」,而是「在信息不完整的前五分钟,用一组固定事实算出级别,让所有人按同一套时限行动」。
这一节给出这套判定。所有数字来自示例样本上的运行结果,不是生产事故统计。
分级只看两组输入
事故分级最容易犯的错,是把「原因」当成「级别」。模型版本回滚失败、提示写错、上游超时,这些是原因;用户是否丢数据、是否被越权访问、是否被错误扣款,这些是危害。原因决定谁来修,危害决定多紧急。
本章把输入限定为两组。
危害事实,四个布尔值:是否发生隐私或数据越权暴露、是否发生数据丢失、是否造成资金或订单动作错误、是否有安全违规内容到达用户。这四个事实只要有一个为真,就不能按低级别处理。
影响范围,四个计数:受影响租户数、总租户数、失败交付数、总交付数。它们算出两个比例:失败率与租户覆盖率。
分级规则按优先级从高到低执行,先命中先定级:
| 级别 | 触发条件 | 典型场景 |
|---|---|---|
| P0 | 隐私暴露、数据丢失、资金或订单错误;或安全违规覆盖半数以上租户;或失败率 ≥50% 且影响 ≥3 个租户 | 越权读到他人会话、错误退款、批量内容违规 |
| P1 | 安全违规;或失败率 ≥20%;或受影响租户 ≥50% | 单租户安全违规、大面积回复失败 |
| P2 | 失败率 ≥5%;或影响 ≥2 个租户 | 某能力在多个租户上退化 |
| P3 | 其余:单一租户且失败率 <5% | 单租户的少量格式错误 |
这张表的顺序不能交换。先判断 P0 的危害项,再看 P1 的比例项,最后才落到 P2/P3。如果先按失败率算,一个失败率只有 2% 但发生了越权的事故会被判成 P3;如果先按租户数算,一个只影响一个租户但错误扣款的事故也会被压低。
「安全违规」与「隐私暴露」要分开。安全违规是内容层面的,例如违规内容没有被拦住;隐私暴露是数据层面的,例如 A 租户读到了 B 租户的会话。前者可能只影响体验与合规,后者直接触发通知与删除义务,所以隐私暴露单独进入 P0。
响应时限不是 SLA,是行动顺序
级别确定后,响应时限回答四个问题:多久内确认、多久内止血、多久更新一次、多久交复盘。示例采用的口径如下。
| 级别 | 确认 | 止血 | 更新间隔 | 复盘时限 | 指挥官 |
|---|---|---|---|---|---|
| P0 | 5 分钟 | 30 分钟 | 15 分钟 | 24 小时 | 事件指挥官 |
| P1 | 15 分钟 | 60 分钟 | 30 分钟 | 72 小时 | 平台值班负责人 |
| P2 | 60 分钟 | 240 分钟 | 120 分钟 | 7 天 | 能力提供方值班 |
| P3 | 240 分钟 | 24 小时 | 24 小时 | 14 天 | 能力提供方负责人 |
这些时限从「发现时刻」起算,不从「有人在群里说我看看」起算。发现时刻可以是告警触发、抽检命中或用户投诉进入工单系统的时刻;它必须被记录,否则复盘时无法判断响应是否及时。
确认与止血是两件事。确认是「有人接手并声明级别」,止血是「用户不再继续受损」。P0 要求 5 分钟内确认,不是要求 5 分钟内修好;30 分钟内止血,也不是要求 30 分钟内找到根因。把确认、止血、修复混成一个「解决时间」,会让团队为了数字好看而延迟声明。
更新间隔的作用是压住信息真空。事故期间最大的风险不是技术问题本身,而是受影响方不知道发生了什么,于是各自猜测、各自采取动作。P0 每 15 分钟一次更新,哪怕内容是「仍在止血,暂无新结论」,也比沉默好。
通知对象与止血动作
通知对象由级别决定,不由「谁跟谁熟」决定。P0 通知值班、平台负责人、安全、合规、受影响租户负责人与业务高管;P1 通知值班、平台负责人、能力提供方与受影响租户负责人;P2 通知值班、能力提供方与受影响租户负责人;P3 只通知能力提供方与质量运营。
通知对象里有两个容易漏掉的角色。第一是合规:隐私暴露与数据丢失不仅是技术事故,还触发对外通知与删除义务,必须在止血的同时启动。第二是受影响租户负责人:租户需要知道自己的用户是否受影响、是否需要自行通知,这个判断不能由平台单方面代替。
止血动作也要按级别预置,不能临场发明。P0 的默认动作是关闭受影响能力的入口或路由、切回上一个稳定能力版本、冻结相关模型与提示变更、保留请求与回放包。P1 是把受影响租户切到降级档或人工队列、回滚最近一次能力版本或路由变更、保留失败请求与抽检样本。P2 限制问题能力的流量比例、提高转人工阈值、记录受影响请求范围。P3 登记样本、纳入下次抽检、按常规迭代修复。
止血动作有一个共同点:都可逆、都可快速执行、都不依赖根因。关路由、切版本、冻结变更,这些动作在不知道原因时也能做。反过来说,「先查清楚再动手」在 P0/P1 里是错误的优先级;查根因可以并行,但不能阻塞止血。
超时升级
分级不是一次性动作。事故在确认时限内没有被接手,应该自动升一级,而不是等下一班人上班。
示例规则是:超过确认时限仍未确认,级别升一级;P0 已经最高,仍返回 P0,表示继续扩大通知范围。升级由系统自动执行,不依赖某个人记得。升级后,新的确认时限从升级时刻重新计算,避免「已经超时一次,后面全部作废」。
升级只解决「没人接手」,不解决「判断错误」。如果确认后发现危害事实比最初更多,例如从单租户扩展到多租户、从内容违规发现伴随数据越权,应该重新定级,而不是在原来级别上继续处理。重新定级要记录原因与时间,复盘时才能还原判断过程。
实现:分级、时间线与升级
quality-incidents.ts 把上面的规则写成三个纯函数:gradeIncident 输入事实、输出级别与策略;responseDeadlines 输入发现时刻、输出四条截止时间;escalationTarget 输入级别、经过时间与是否确认、输出升级目标。
// file: src/gov/quality-incidents.ts
// tier: L2
// verify: node --test test/gov/quality-ops.test.ts
/** P0 最严重,P3 最轻;分级只看事实,不看谁在群里先说话。 */
export type IncidentSeverity = "P0" | "P1" | "P2" | "P3";
export interface IncidentFacts {
affectedTenants: number;
totalTenants: number;
failedDeliveries: number;
totalDeliveries: number;
privacyExposure: boolean;
dataLoss: boolean;
financialImpact: boolean;
safetyViolation: boolean;
}
export interface ResponsePolicy {
severity: IncidentSeverity;
acknowledgeMinutes: number;
mitigateMinutes: number;
updateEveryMinutes: number;
postmortemHours: number;
commander: string;
notify: readonly string[];
stopBleeding: readonly string[];
}
export const RESPONSE_POLICIES: Readonly<Record<IncidentSeverity, ResponsePolicy>> = {
P0: {
severity: "P0",
acknowledgeMinutes: 5,
mitigateMinutes: 30,
updateEveryMinutes: 15,
postmortemHours: 24,
commander: "事件指挥官(平台负责人授权)",
notify: ["值班", "平台负责人", "安全", "合规", "受影响租户负责人", "业务高管"],
stopBleeding: [
"关闭受影响能力的入口或路由",
"切回上一个稳定能力版本",
"冻结相关模型与提示变更",
"保留请求、回放包与审计日志",
],
},
P1: {
severity: "P1",
acknowledgeMinutes: 15,
mitigateMinutes: 60,
updateEveryMinutes: 30,
postmortemHours: 72,
commander: "平台值班负责人",
notify: ["值班", "平台负责人", "能力提供方", "受影响租户负责人"],
stopBleeding: [
"把受影响租户切到降级档或人工队列",
"回滚最近一次能力版本或路由变更",
"保留失败请求与抽检样本",
],
},
P2: {
severity: "P2",
acknowledgeMinutes: 60,
mitigateMinutes: 240,
updateEveryMinutes: 120,
postmortemHours: 168,
commander: "能力提供方值班",
notify: ["值班", "能力提供方", "受影响租户负责人"],
stopBleeding: ["限制问题能力的流量比例", "提高转人工阈值", "记录受影响请求范围"],
},
P3: {
severity: "P3",
acknowledgeMinutes: 240,
mitigateMinutes: 1440,
updateEveryMinutes: 1440,
postmortemHours: 336,
commander: "能力提供方负责人",
notify: ["能力提供方", "质量运营"],
stopBleeding: ["登记样本并纳入下次抽检", "按常规迭代修复,不打断当前发布"],
},
};
export interface IncidentGrade {
severity: IncidentSeverity;
failureRate: number;
affectedRate: number;
reasons: readonly string[];
policy: ResponsePolicy;
}
/** 危害类事实优先于比例:只要越权、丢数据或动钱,就不能按 P3 处理。 */
export function gradeIncident(facts: IncidentFacts): IncidentGrade {
if (
!Number.isInteger(facts.totalTenants) ||
facts.totalTenants <= 0 ||
!Number.isInteger(facts.affectedTenants) ||
facts.affectedTenants < 0 ||
facts.affectedTenants > facts.totalTenants
) {
throw new Error("quality-incidents: invalid tenant counts");
}
if (
!Number.isInteger(facts.totalDeliveries) ||
facts.totalDeliveries <= 0 ||
!Number.isInteger(facts.failedDeliveries) ||
facts.failedDeliveries < 0 ||
facts.failedDeliveries > facts.totalDeliveries
) {
throw new Error("quality-incidents: invalid delivery counts");
}
const failureRate = facts.failedDeliveries / facts.totalDeliveries;
const affectedRate = facts.affectedTenants / facts.totalTenants;
const reasons: string[] = [];
if (facts.privacyExposure) reasons.push("隐私或数据越权暴露");
if (facts.dataLoss) reasons.push("数据丢失");
if (facts.financialImpact) reasons.push("资金或订单动作错误");
if (facts.safetyViolation) reasons.push("安全违规内容到达用户");
let severity: IncidentSeverity;
if (facts.privacyExposure || facts.dataLoss || facts.financialImpact) {
severity = "P0";
} else if ((facts.safetyViolation && affectedRate >= 0.5) || (failureRate >= 0.5 && facts.affectedTenants >= 3)) {
severity = "P0";
if (facts.safetyViolation) reasons.push("安全违规覆盖半数以上租户");
else reasons.push("失败率不低于 50% 且影响至少 3 个租户");
} else if (facts.safetyViolation || failureRate >= 0.2 || affectedRate >= 0.5) {
severity = "P1";
if (failureRate >= 0.2) reasons.push("交付失败率不低于 20%");
if (affectedRate >= 0.5) reasons.push("受影响租户不少于一半");
} else if (failureRate >= 0.05 || facts.affectedTenants >= 2) {
severity = "P2";
if (failureRate >= 0.05) reasons.push("交付失败率不低于 5%");
if (facts.affectedTenants >= 2) reasons.push("影响多个租户");
} else {
severity = "P3";
reasons.push("影响限于单一租户且失败率低于 5%");
}
return { severity, failureRate, affectedRate, reasons, policy: RESPONSE_POLICIES[severity] };
}
export interface IncidentTimeline {
detectedAt: number;
acknowledgeBy: number;
mitigateBy: number;
firstUpdateBy: number;
postmortemBy: number;
}
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
/** 时间线从发现时刻起算,不从「有人开始在群里讨论」起算。 */
export function responseDeadlines(detectedAt: number, policy: ResponsePolicy): IncidentTimeline {
if (!Number.isFinite(detectedAt)) throw new Error("quality-incidents: detectedAt must be finite");
return {
detectedAt,
acknowledgeBy: detectedAt + policy.acknowledgeMinutes * MINUTE_MS,
mitigateBy: detectedAt + policy.mitigateMinutes * MINUTE_MS,
firstUpdateBy: detectedAt + policy.updateEveryMinutes * MINUTE_MS,
postmortemBy: detectedAt + policy.postmortemHours * HOUR_MS,
};
}
const SEVERITY_ORDER: readonly IncidentSeverity[] = ["P0", "P1", "P2", "P3"];
/** 超时未确认自动升一级;P0 已是最高的,仍返回 P0 表示继续升级通知范围。 */
export function escalationTarget(
grade: IncidentGrade,
elapsedMinutes: number,
acknowledged: boolean,
): IncidentSeverity | null {
if (acknowledged || elapsedMinutes <= grade.policy.acknowledgeMinutes) return null;
const index = SEVERITY_ORDER.indexOf(grade.severity);
return SEVERITY_ORDER[Math.max(0, index - 1)];
}
代码里有三个刻意的选择。第一,危害事实优先于比例,任何越权、丢数据、动钱都直接进入 P0,不参与后续比例判断。第二,时间线从发现时刻起算,detectedAt 是必填参数,没有默认值,避免「忘了记录」变成「没有超时」。第三,超时升级对 P0 仍返回 P0,而不是 null;这样调用方可以统一执行「扩大通知」,不需要为最高级别写特例。
示例样本上的运行结果
| 输入 | 结果 |
|---|---|
| 隐私暴露为真,其余正常 | P0;确认 5 分钟、止血 30 分钟 |
| 失败 300/1,000,影响 1 个租户 | P1;失败率 30% |
| 失败 60/1,000,影响 1 个租户 | P2;失败率 6% |
| 失败 10/1,000,影响 1 个租户 | P3;失败率 1% |
| 安全违规,影响 6/10 租户 | P0;覆盖率 60% |
| 安全违规,影响 1/10 租户 | P1;单租户安全违规 |
| P2 事故,61 分钟未确认 | 升级为 P1 |
| P2 事故,59 分钟未确认 | 不升级 |
这些是示例样本上的运行结果。真实事故的计数会更复杂,但判定顺序、时限口径与升级规则不变。
一次完整的事故流程
把规则串起来,一次事故至少经过六个阶段。
发现:告警、抽检或投诉触发。记录发现时刻、首个证据与影响面初判。这个阶段不要求级别准确,但要求时间准确。
声明:按事实算出级别,指定指挥官,发出第一条通知。声明可以升级,不能降级后静默处理;降级要记录原因。
止血:执行该级别的默认动作,目标是让用户不再继续受损。止血与根因调查并行,不互相阻塞。
沟通:按更新间隔发布进展,内容包括已确认事实、已采取动作、下一步与下次更新时间。没有新结论也要发。
恢复:确认指标回到基线、受影响租户得到安置、临时措施有退出计划。恢复不等于关闭,临时路由与降级档要明确何时撤。
复盘:在时限内提交,包含时间线、根因、影响范围、止血有效性、行动项与负责人。复盘不是追责会,但必须能回答「下次同样的事,能不能更早发现、更快止血」。
这六个阶段里,最容易被跳过的是「沟通」与「恢复」。前者被当成浪费时间,后者被当成自然结束。结果是事故关了,租户还在等一个解释,临时降级档留了三个月没人撤。
与质量信号的关系
6.1 的信号负责发现,事故分级负责定级。信号阈值可以触发调查,但不能直接决定级别。某能力单日投诉从 2 起涨到 6 起,可能只是流量涨了三倍;也可能是一次 P1 事故的开始。调查要用影响范围与危害事实重新算,而不是把信号涨幅直接映射成级别。
反过来,事故复盘应该回写信号规则。如果一次 P0 在发生前没有任何信号预警,说明信号覆盖有盲区;如果一次 P3 被信号连续误报,说明阈值或权重需要校准。回写必须走版本,不能在事故当天随手改。
小结
事故分级只看两组输入:四个危害事实与两个比例。危害事实优先,越权、丢数据、动钱直接 P0;比例决定 P1 到 P3。示例中失败 300/1,000 是 P1,60/1,000 是 P2,10/1,000 是 P3;安全违规影响 6/10 租户是 P0,影响 1/10 租户是 P1。
响应时限从发现时刻起算:P0 确认 5 分钟、止血 30 分钟、每 15 分钟更新、24 小时内复盘。超时未确认自动升一级,P0 保持 P0 并扩大通知。止血动作预置、可逆、不依赖根因,先让用户不再受损,再查为什么。
阅读导航:上一节:6.1 线上质量信号与抽检 · 下一节:6.3 质量报告与季度复盘 。
常见错误与边界
- 先定原因再定级别。原因决定谁修,危害决定多急,两者不能混。
- 用失败率单独定级。失败率 2% 的越权事故仍然是 P0。
- 把确认当修复。5 分钟确认不是 5 分钟修好,混淆会让团队延迟声明。
- 止血前先查根因。P0/P1 的默认顺序是先让用户不再受损。
- 通知对象按关系决定。合规与受影响租户负责人经常被漏掉,事后补通知成本更高。
- 事故期间不更新。信息真空会让受影响方自行猜测并采取错误动作。
- 超时升级靠人记得。没有自动升级,夜班与周末的事故会一直停在同一级别。
- 重新定级不记录。级别变了但过程没留痕,复盘无法还原判断依据。
- 恢复后不撤临时措施。降级档与临时路由留三个月,会变成新的技术债。
- 把复盘开成追责会。追责会让下一次事故被隐瞒,而不是被更早发现。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。