账单在凌晨三点变了颜色
「回声」的月度预算告警在凌晨三点响起:过去一小时的成本是平时的四倍多。值班同学先看可用性,所有请求都成功,错误率没有上升;再看延迟,P95 只比平时高了两百毫秒;最后看模型分布,大部分请求已经切到了备用供应商和更大模型。
这不是一次可用性事故。用户没有看到错误,也没有看到空白;产品仍然在正常回答。它是一次成本事故:系统用更高的单价和更多的尝试,把一次外部的 429 故障扛了过去,代价是 25 分钟花掉了相当于 2 小时的钱。
事故结束后,团队用三样东西还原了全过程:一张按分钟的成本曲线、一份网关的重试日志、一份降级链的配置变更记录。三样东西对不上时,复盘就会退化成“大概是重试太多了”;对得上时,才能把 4.85 倍拆成两个具体的乘数。
文中请求量、成本与时间线,全部来自示例样本上的运行结果,用于演示复盘方法,不是任何真实产品的生产数据。
时间线:从 429 到回滚
样本以每分钟 1200 条请求为基线,基线每次请求平均 1.2 次尝试、每次尝试 40 微元,折合每分钟 57,600 微元。25 分钟里发生了七个阶段。
| 分钟 | 阶段 | 尝试/请求 | 微元/尝试 | 成本/分钟 | 累计成本 |
|---|---|---|---|---|---|
| 0 | 正常 | 1.2 | 40 | 57,600 | 57,600 |
| 4 | 主供应商 429 | 1.8 | 45 | 97,200 | 154,800 |
| 7 | 切到更大模型 | 2.4 | 120 | 345,600 | 500,400 |
| 11 | 跨供应商重试叠加 | 3.8 | 130 | 592,800 | 1,093,200 |
| 15 | 预算告警 | 4.2 | 140 | 705,600 | 1,798,800 |
| 19 | 回滚策略 | 1.4 | 60 | 100,800 | 1,899,600 |
| 25 | 恢复 | 1.2 | 40 | 57,600 | 1,957,200 |
预算设为 1,200,000 微元。第 11 分钟累计 1,093,200,已经用掉 91.1%,但还没有越线;第 15 分钟累计 1,798,800,越线 598,800。告警在同分钟触发,值班同学四分钟后开始回滚,第 25 分钟成本回到基线。
时间线里有两个值得记住的间隔。第一个是“429 出现”到“预算告警”的 11 分钟:故障在第 4 分钟就可见,成本告警却到第 15 分钟才响。第二个是“预算越线”到“开始回滚”的 4 分钟:告警响了,但回滚动作没有和告警绑定。
把 4.85 倍拆成两个乘数
总成本 1,957,200,基线 403,200,倍数是 4.854。这个数字单独看没有解释力,拆开才有。
| 因子 | 计算 | 结果 |
|---|---|---|
| 尝试放大 | 19,200 次尝试 ÷ 10,080 次基线尝试 | 1.905 |
| 单价放大 | 101.94 微元/次 ÷ 40 微元/次 | 2.548 |
| 总倍数 | 1.905 × 2.548 | 4.854 |
两个因子分别对应两个独立的机制。尝试放大来自重试:正常时每条请求 1.2 次尝试,故障高峰到 4.2 次。单价放大来自模型切换:正常时每次 40 微元,切到更大模型后到 140 微元。任何一个单独发生,成本都只是 1.9 倍或 2.5 倍;两个同时发生,才是 4.85 倍。
这也是为什么“根因是模型太贵”不成立。模型单价确实涨了 3.5 倍,但如果没有重试放大,成本不会到 4.85 倍;反过来,如果只重试不换贵模型,成本也只有 1.9 倍。复盘要同时写出两个机制,以及它们为什么会同时被触发。
峰值分钟的成本是 705,600,相当于基线每分钟的 12.25 倍。这个数字比总倍数更值得进告警,因为总倍数会被故障前后的正常分钟稀释,峰值分钟不会。
根因不是“模型太贵”
复盘里最容易写错的一栏是根因。把“备用模型太贵”写成根因,修复动作就会变成“换一个便宜的备用模型”;下一次 429 来时,重试仍然会把流量放大,只是单价低一点。
这次事故的触发、根因与贡献因素要分开写:
- 触发:主供应商在区域高峰返回 429,这是外部事件,不可控。
- 根因:网关把 429 当成普通可重试错误,跨供应商重试没有全局预算;降级链允许成本倒挂,把更大模型接在了主模型后面。
- 贡献因素:本地配额没有覆盖供应商侧的真实限额;预算告警按小时聚合;值班同学先扩容备用供应商,没有先摘掉重试。
根因落在系统设计上,而不是某一个人的操作上。值班同学扩容备用供应商,是在当时的界面和告警下最自然的反应;如果系统没有提供“一键摘掉跨供应商重试”,不能把责任推给个人判断。
三道修复闸门
修复不是“以后注意”,而是三道可以在发布前验证的闸门。
第一道是重试预算,来自 12.1。单请求仍有 maxAttempts,但整批重试不得超过原始请求数的 10%。在 600 条示例请求上,这把尝试从 1023 次压到 660 次,放大倍数从 1.705 压到 1.100。故障日的尝试曲线不再随故障时长线性上涨。
第二道是成本单调,来自 12.2。降级链在计划阶段拒绝比前一步更贵的步骤。样本里 360 微元的更大模型会被标记为 cost-inversion,不会进入链;规则结果仍然能拿到,成本从 790 微元降到 430 微元。
第三道是分钟级预算告警。把告警从“按小时聚合”改成“按分钟累计、越过 60% 即通知”,这次事故会在第 7 分钟到第 11 分钟之间触发,而不是第 15 分钟。样本里第 7 分钟累计 500,400,第 11 分钟 1,093,200;越早看见,能截住的成本越多。
三道闸门分别对应三个不同的失败面:流量放大、单价倒挂、发现太晚。只修其中一道,另外两道仍然会把事故放大。
复盘模板
复盘不是自由作文。下面这张表把必须回答的问题固定下来,缺任何一项都不算完成。
| 栏目 | 必须回答 | 反例 |
|---|---|---|
| 影响 | 多少请求、多少钱、相对基线多少倍 | “花了不少钱” |
| 时间线 | 每个阶段的时间、动作与证据 | 只有开始和结束 |
| 根因 | 系统设计里可修复的那一处 | “模型太贵” |
| 贡献因素 | 让根因更容易发生的条件 | 只写一条 |
| 发现缺口 | 为什么没有更早发现 | “告警没响” |
| 响应缺口 | 为什么没有更早止损 | “处理不及时” |
| 动作 | 谁、何时、属于预防/发现/止损 | 没有 owner 和日期 |
“动作”至少要有三条,并且至少一条是预防。只有“加强监控”不算复盘,因为监控只能发现下一次,不能阻止下一次。
下面的模块把时间线分析、成本倍数拆解与复盘校验放在一起。它不读取监控系统,也不访问账单;输入是一组已经按分钟聚合好的阶段数据,输出是可直接放进复盘文档的结构化结果。
// file: src/perf/incident.ts
// tier: L1
// verify: node --test test/perf/incident.test.ts
import { AiError } from "../core/errors.ts";
export interface IncidentPhase {
minute: number; label: string; requests: number;
attemptsPerRequest: number;
/** 每次尝试的平均成本,整数微元。 */
microsPerAttempt: number;
}
export interface IncidentBaseline { attemptsPerRequest: number; microsPerAttempt: number; }
export interface IncidentPoint {
minute: number; label: string; requests: number; attempts: number;
costMicros: number; cumulativeMicros: number; budgetUtilization: number; overBudget: boolean;
}
export interface IncidentAnalysis {
points: IncidentPoint[];
totalRequests: number; totalAttempts: number; totalMicros: number; baselineMicros: number;
/** 总成本相对基线的倍数,拆成尝试放大与单价放大两个因子。 */
multiplier: number; attemptsMultiplier: number; priceMultiplier: number;
peakMinute: number; peakMicros: number;
budgetBreachMinute: number | null; budget: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `incident: ${message}`);
}
function finite(value: number, field: string, min = 0): number {
if (!Number.isFinite(value) || value < min) fail(`${field} must be a finite number >= ${min}`);
return value;
}
function integer(value: number, field: string, min = 0): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
/**
* 把事故时间线折算成成本曲线。成本倍数拆成“尝试放大”和“单价放大”,
* 是为了回答复盘中最常见的问题:到底是重试多了,还是模型贵了。
*/
export function analyzeIncident(
phases: readonly IncidentPhase[], baseline: IncidentBaseline, budgetMicros: number,
): IncidentAnalysis {
if (phases.length === 0) fail("phases must not be empty");
finite(budgetMicros, "budgetMicros");
finite(baseline.attemptsPerRequest, "baseline.attemptsPerRequest");
finite(baseline.microsPerAttempt, "baseline.microsPerAttempt");
if (baseline.attemptsPerRequest <= 0 || baseline.microsPerAttempt <= 0) fail("baseline values must be > 0");
const ordered = [...phases].sort((left, right) => left.minute - right.minute);
const points: IncidentPoint[] = [];
let cumulative = 0; let totalRequests = 0; let totalAttempts = 0; let totalMicros = 0;
let peakMinute = ordered[0].minute; let peakMicros = -1; let budgetBreachMinute: number | null = null;
for (const phase of ordered) {
integer(phase.minute, "phase.minute");
integer(phase.requests, "phase.requests");
finite(phase.attemptsPerRequest, "phase.attemptsPerRequest");
integer(phase.microsPerAttempt, "phase.microsPerAttempt");
const attempts = Math.round(phase.requests * phase.attemptsPerRequest);
const costMicros = attempts * phase.microsPerAttempt;
cumulative += costMicros;
totalRequests += phase.requests;
totalAttempts += attempts;
totalMicros += costMicros;
if (costMicros > peakMicros) { peakMicros = costMicros; peakMinute = phase.minute; }
if (budgetBreachMinute === null && cumulative > budgetMicros) budgetBreachMinute = phase.minute;
points.push({
minute: phase.minute, label: phase.label, requests: phase.requests, attempts, costMicros,
cumulativeMicros: cumulative, budgetUtilization: cumulative / budgetMicros,
overBudget: cumulative > budgetMicros,
});
}
const baselineAttempts = totalRequests * baseline.attemptsPerRequest;
const baselineMicros = baselineAttempts * baseline.microsPerAttempt;
const averagePrice = totalAttempts === 0 ? 0 : totalMicros / totalAttempts;
return {
points, totalRequests, totalAttempts, totalMicros, baselineMicros,
multiplier: totalMicros / baselineMicros,
attemptsMultiplier: totalAttempts / baselineAttempts,
priceMultiplier: averagePrice / baseline.microsPerAttempt,
peakMinute, peakMicros, budgetBreachMinute, budget: budgetMicros,
};
}
export interface PostmortemAction {
action: string; owner: string; due: string; type: "prevent" | "detect" | "mitigate";
}
export interface IncidentPostmortem {
title: string; severity: "sev1" | "sev2" | "sev3";
detectedAt: string; resolvedAt: string;
impact: { requests: number; costMicros: number; costMultiplier: number; userVisible: string; };
timeline: { at: string; event: string; evidence: string; }[];
rootCause: string; contributing: string[]; detectionGap: string; responseGap: string;
actions: PostmortemAction[];
}
/** 复盘不是自由作文:缺时间线、缺根因、缺防复发动作,都不算完成。 */
export function validatePostmortem(postmortem: IncidentPostmortem): string[] {
const issues: string[] = [];
if (postmortem.title.trim() === "") issues.push("title is empty");
if (postmortem.rootCause.trim() === "") issues.push("rootCause is empty");
if (postmortem.detectionGap.trim() === "") issues.push("detectionGap is empty");
if (postmortem.responseGap.trim() === "") issues.push("responseGap is empty");
if (postmortem.contributing.length === 0) issues.push("contributing is empty");
if (postmortem.timeline.length < 3) issues.push("timeline needs at least 3 entries");
if (postmortem.actions.length < 3) issues.push("actions need at least 3 entries");
if (!postmortem.actions.some((action) => action.type === "prevent")) {
issues.push("at least one prevent action is required");
}
if (postmortem.impact.costMultiplier < 1) issues.push("costMultiplier must be >= 1");
if (postmortem.impact.userVisible.trim() === "") issues.push("impact.userVisible is empty");
if (postmortem.detectedAt > postmortem.resolvedAt) issues.push("detectedAt is after resolvedAt");
postmortem.timeline.forEach((entry, index) => {
if (entry.event.trim() === "" || entry.evidence.trim() === "") {
issues.push(`timeline[${index}] needs event and evidence`);
}
if (index > 0 && postmortem.timeline[index - 1].at > entry.at) {
issues.push(`timeline[${index}] is out of order`);
}
});
postmortem.actions.forEach((action, index) => {
if (action.action.trim() === "" || action.owner.trim() === "" || action.due.trim() === "") {
issues.push(`actions[${index}] needs action, owner and due`);
}
});
return issues;
}
export function buildPostmortem(postmortem: IncidentPostmortem): IncidentPostmortem {
const issues = validatePostmortem(postmortem);
if (issues.length > 0) throw new AiError("invalid-request", `postmortem: ${issues.join("; ")}`);
return postmortem;
}
analyzeIncident 把总倍数拆成 attemptsMultiplier 与 priceMultiplier,这两个字段是复盘时最容易被漏掉的解释。validatePostmortem 则把“必须有时间线、必须有根因、必须有预防动作”变成可执行的检查,而不是评审会上靠人提醒。
示例样本上的运行结果
把上面的七个阶段输入分析函数,得到的结果是:
| 指标 | 结果 |
|---|---|
| 总请求 | 8,400 |
| 总尝试 | 19,200 |
| 总成本 | 1,957,200 微元 |
| 基线成本 | 403,200 微元 |
| 总倍数 | 4.854 |
| 尝试放大 | 1.905 |
| 单价放大 | 2.548 |
| 峰值分钟 | 第 15 分钟,705,600 微元 |
| 预算越线 | 第 15 分钟 |
| 第 11 分钟累计 | 1,093,200 微元(91.1%) |
复盘对象本身也通过了模板校验:时间线 6 条、动作 4 条、其中 2 条为预防。测试还固定了三条拒绝规则:根因为空、时间线少于 3 条、没有任何预防动作,validatePostmortem 都会返回问题,buildPostmortem 会直接抛错。
这些数字的意义不在于“这次花了多少”,而在于它们可以被逐条复算。换一份时间线,倍数会变;换一个预算,越线分钟会变;换一套重试策略,尝试放大因子会变。复盘的可信度来自“每个数字都能被重新算出来”,而不是来自“大家都同意这个结论”。
防复发:把结论变成门禁
复盘动作如果不进入发布流程,下一次故障时仍然靠人记。四个动作应该分别落到不同的门禁上。
| 动作 | 门禁 | 验证方式 |
|---|---|---|
| 跨供应商重试全局预算 10% | 网关配置校验 | 样本尝试数 ≤ 原始请求 × 1.1 |
| 降级链禁止成本倒挂 | 链计划阶段 | 更贵步骤被标记 cost-inversion |
| 预算告警分钟级、60% 通知 | 监控配置 | 注入样本时间线,告警在第 7~11 分钟触发 |
429 冷却尊重 Retry-After | 网关单元测试 | 冷却期内准入返回 cooldown |
门禁要能失败。只写“已配置”不算门禁,必须有一个确定性的样本,在配置回退时让测试变红。这次事故的三道闸门都对应可断言的样本:600 条请求、790 微元的成本倒挂链、七段事故时间线。样本不变,结论就不会随人而变。
小结
这次事故的完整链条是:外部 429 触发重试,重试把流量放大 1.905 倍;降级链切到更贵的模型,把单价放大 2.548 倍;两个乘数相乘,得到 4.854 倍成本。预算告警晚了 11 分钟,回滚又晚了 4 分钟,最终 25 分钟花掉 1,957,200 微元。
修复不是“换便宜模型”,而是三道闸门:重试预算限制流量放大,成本单调限制单价倒挂,分钟级告警限制发现延迟。复盘模板则把影响、时间线、根因、贡献因素、发现缺口、响应缺口和动作固定下来,并用代码校验,避免复盘退化成叙事。
下一节进入卷二收口:把前面所有成本优化与稳定性约束收敛成一份可复算的推理成本基线。
阅读导航:上一节:12.2 降级链的成本约束 · 下一节:13.1 推理成本基线 。
常见错误与边界
- 把成本事故写成可用性事故。用户没有报错,不代表系统没有付出代价。
- 根因写“模型太贵”。单价只是两个乘数之一,重试放大同样重要。
- 只报总倍数,不报峰值分钟。总倍数会被正常分钟稀释,峰值才对应真实的失控速度。
- 时间线只有开始和结束。没有中间阶段,就无法判断是哪一步放大了成本。
- 复盘没有证据列。每个阶段都要有可复查的数字或日志,而不是“当时大概这样”。
- 动作没有 owner 和日期。没有责任人和截止时间的动作,等于没有动作。
- 动作全是“加强监控”。监控只能发现下一次,不能阻止下一次。
- 把告警和回滚分开。告警响了但没人知道该按哪个开关,4 分钟就这样过去。
- 修复只改模型价格。重试预算不改,下一次 429 仍然会把流量放大。
- 样本不可复算。复盘结论必须能由固定样本重新跑出来,否则只是叙事。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。