基线之后,三个方案同时出现
13.1 的基线给出一个明确信号:成本与延迟都在阈值内,唯一越线项是缓存命中率 3.75%。评审会上因此出现三个方案:做前缀缓存、把摘要能力切到经济档、给输出加硬上限。三个方案都自称能降本,也都有人支持。
如果按「谁降得多」投票,经济档会赢:它承诺降本 35%,是三者中最大的。但它同时承诺质量下降 2.8 个百分点,而且只有 120 条证据。前缀缓存降本 22%,质量不动;输出上限降本 12%,质量只降 0.4 个百分点。三个数字放在一起,已经不是一个投票问题,而是一个取舍问题。
取舍需要留下记录。架构决策记录(ADR)的作用不是把决定写得漂亮,而是让半年后的人能看懂:当时有哪些选项、为什么选这个、什么情况下应该撤。
文中收益、质量与回本期都来自 13.1 的同一组示例样本,不是生产实测;月请求量 300 万是用于计算的假设参数。
ADR 要回答的五个问题
一份能用的 ADR 至少要有五栏。缺任何一栏,决策都会退化成会议纪要。
| 栏目 | 必须回答 | 常见反例 |
|---|---|---|
| 背景 | 基线数字、约束与时间窗口 | 「最近成本有点高」 |
| 候选 | 至少两个可执行选项 | 只写最终方案 |
| 决策 | 选哪个、预计收益、回本期 | 「先试试看」 |
| 后果 | 质量、延迟、运维与回退代价 | 只写好处 |
| 状态 | 提议、已采纳、已否决或已废弃 | 没有状态,旧文档永远有效 |
「只写最终方案」是最常见的错误。没有候选,就没有比较;没有比较,回退时也不知道该退回哪里。ADR 的价值恰恰在于保留被否决的方案,以及它们被否决的理由。
三个候选方案
三个方案都从同一份基线出发,杠杆不同,代价也不同。
| 方案 | 杠杆 | 成本变化 | P95 变化 | 质量变化 | 证据 | 风险 | 实施成本 |
|---|---|---|---|---|---|---|---|
| 稳定前缀 + 前缀缓存 | 上下文 | -22% | -8% | 0 | 240 条 | 低 | 8,000,000 微元 |
| doc.summarize 切经济档 | 模型档位 | -35% | -25% | -2.8 点 | 120 条 | 中 | 12,000,000 微元 |
| 输出上限 + 停止序列 | 输出长度 | -12% | -10% | -0.4 点 | 240 条 | 低 | 3,000,000 微元 |
前缀缓存排除了提示改版带来的命中破坏:稳定前缀放在最前,可变内容追加在后。经济档直接换模型,降本最快,但质量代价最大。输出上限最便宜,收益也最小,适合作为叠加项而不是主方案。
判据:先护栏,再收益
决策不能把「降本」和「质量」放在一个加权公式里相加。权重一旦可调,结论就可以被调出来。更稳的做法是两道关:先过护栏,再比收益。
第一道:硬护栏
| 护栏 | 阈值 | 作用 |
|---|---|---|
| 质量回退 | 不超过 1.5 个百分点 | 防止用质量换短期账单 |
| P95 变化 | 不超过 +10% | 防止降本换来长尾恶化 |
| 证据量 | 至少 200 条 | 防止小样本上的偶然结论 |
| 可逆性 | 必须有回退路径 | 防止一步到位的单向门 |
| 高风险 | 默认不允许 | 高风险必须单独评审 |
经济档在两道护栏上同时失败:质量回退 2.8 点超过 1.5 点,证据 120 条低于 200 条。它降本最多,但不能进入候选排序。这不是说经济档永远不能用,而是说这批证据不足以支撑它。
第二道:收益与回本期
过护栏的方案按两个数字排序:月节省与回本期。
月节省 = 平均成本 × 月请求量 × 降本比例
回本期 = 实施成本 ÷ (月节省 ÷ 30)
按基线 104.28 微元、月请求 300 万计算,前缀缓存每月省 68,640,000 微元,回本期 4 天;输出上限每月省 37,440,000 微元,回本期 3 天。前缀缓存节省更多,输出上限回本更快。排序规则必须提前写死,否则两个方案各自都能找到支持自己的指标。
这里选择「先看月节省、再看风险等级、再看 P95」的字典序。理由是成本优化的主目标是长期账单,回本期只作为可行性检查;如果主目标是现金流,排序规则就应该反过来,但必须在 ADR 里写明。
回退条件与退出条件
这两个词经常被混用,实际含义相反。
| 条件 | 回答的问题 | 例子 |
|---|---|---|
| 回退条件 | 出什么问题就撤 | 缓存命中率连续 3 天低于 10% |
| 退出条件 | 什么时候停止投入、转为常态 | 月成本连续两个月下降 ≥15% |
回退条件是保护机制,触发后回到基线;退出条件是收尾机制,达成后把实验转成日常配置。只写回退,方案会永远停留在灰度;只写退出,出问题时没有人知道该撤。
每个候选方案都必须同时具备两类条件。经济档的回退条件是质量跌破 0.80、长文档摘要出现结构性缺失;退出条件是质量回退稳定在 1 个点以内、月成本下降至少 25%。这两组条件不成立,方案就不该进入候选表。
实现
下面的模块把护栏判断、收益排序与 ADR 生成放在一起。输入是 13.1 的基线摘要和候选方案,输出是每个候选的可行性、节省、回本期,以及一份可直接归档的 ADR。
// file: src/perf/adr.ts
// tier: L1
// verify: node --test test/perf/adr.test.ts
import { AiError } from "../core/errors.ts";
export type RiskLevel = "low" | "medium" | "high";
/** ADR 的输入必须是 13.1 报告里的同一组数字,不能用另一套口径。 */
export interface AdrBaseline {
avgCostMicros: number;
e2eP95Ms: number;
qualityMean: number;
monthlyRequests: number;
}
export interface AdrCandidate {
id: string;
title: string;
levers: string[];
/** 负数表示降本;正数表示会涨价。 */
costDeltaPct: number;
p95DeltaPct: number;
/** 质量分变化,单位是百分点;-1.5 表示从 0.882 降到 0.867。 */
qualityDeltaPts: number;
implementationCostMicros: number;
evidenceSamples: number;
risk: RiskLevel;
reversible: boolean;
rollback: string[];
exit: string[];
}
export interface AdrPolicy {
maxQualityDropPts: number;
maxP95IncreasePct: number;
minEvidenceSamples: number;
allowHighRisk: boolean;
}
export const DEFAULT_ADR_POLICY: AdrPolicy = {
maxQualityDropPts: 1.5,
maxP95IncreasePct: 10,
minEvidenceSamples: 200,
allowHighRisk: false,
};
export interface CandidateAssessment {
id: string;
title: string;
eligible: boolean;
reasons: string[];
savingsMicrosPerMonth: number;
qualityAfter: number;
p95AfterMs: number;
paybackDays: number;
reversibility: "reversible" | "one-way";
risk: RiskLevel;
}
export interface AdrAlternative {
id: string;
verdict: "accepted" | "rejected";
reason: string;
}
export interface AdrRecord {
id: string;
title: string;
status: "accepted" | "rejected";
context: string;
decision: string;
consequences: string[];
alternatives: AdrAlternative[];
}
export interface AdrDecision {
status: "accepted" | "rejected";
chosen: string | null;
assessments: CandidateAssessment[];
adr: AdrRecord;
}
const RISK_ORDER: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2 };
function fail(message: string): never {
throw new AiError("invalid-request", `adr: ${message}`);
}
function integer(value: number, field: string, min = 0): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
function validateCandidate(candidate: AdrCandidate): void {
for (const field of ["id", "title"] as const) {
if (candidate[field].trim() === "") fail(`${field} is required`);
}
if (candidate.levers.length === 0) fail(`${candidate.id}: levers must not be empty`);
integer(candidate.evidenceSamples, `${candidate.id}.evidenceSamples`, 1);
integer(candidate.implementationCostMicros, `${candidate.id}.implementationCostMicros`);
if (!Number.isFinite(candidate.costDeltaPct)) fail(`${candidate.id}: costDeltaPct must be finite`);
if (candidate.rollback.length === 0) fail(`${candidate.id}: rollback conditions are required`);
if (candidate.exit.length === 0) fail(`${candidate.id}: exit conditions are required`);
}
function assess(
baseline: AdrBaseline,
candidate: AdrCandidate,
policy: AdrPolicy,
): CandidateAssessment {
validateCandidate(candidate);
const savingsMicrosPerMonth = Math.round(
baseline.avgCostMicros * baseline.monthlyRequests * (-candidate.costDeltaPct / 100),
);
const qualityAfter = Number((baseline.qualityMean + candidate.qualityDeltaPts / 100).toFixed(3));
const p95AfterMs = Math.round(baseline.e2eP95Ms * (1 + candidate.p95DeltaPct / 100));
const dailySavings = savingsMicrosPerMonth / 30;
const paybackDays = savingsMicrosPerMonth <= 0
? Number.POSITIVE_INFINITY
: Math.ceil(candidate.implementationCostMicros / dailySavings);
const reasons: string[] = [];
if (candidate.evidenceSamples < policy.minEvidenceSamples) {
reasons.push(`evidence ${candidate.evidenceSamples} < ${policy.minEvidenceSamples}`);
}
if (-candidate.qualityDeltaPts > policy.maxQualityDropPts) {
reasons.push(`quality drop ${(-candidate.qualityDeltaPts).toFixed(2)} > ${policy.maxQualityDropPts}`);
}
if (candidate.p95DeltaPct > policy.maxP95IncreasePct) {
reasons.push(`p95 increase ${candidate.p95DeltaPct}% > ${policy.maxP95IncreasePct}%`);
}
if (candidate.risk === "high" && !policy.allowHighRisk) {
reasons.push("high risk is not allowed by policy");
}
if (!candidate.reversible) reasons.push("candidate is one-way; no rollback path");
if (savingsMicrosPerMonth <= 0) reasons.push("no positive monthly saving");
return {
id: candidate.id,
title: candidate.title,
eligible: reasons.length === 0,
reasons,
savingsMicrosPerMonth,
qualityAfter,
p95AfterMs,
paybackDays,
reversibility: candidate.reversible ? "reversible" : "one-way",
risk: candidate.risk,
};
}
function pick(assessments: readonly CandidateAssessment[]): CandidateAssessment | null {
const eligible = assessments.filter((item) => item.eligible);
if (eligible.length === 0) return null;
return [...eligible].sort((left, right) =>
right.savingsMicrosPerMonth - left.savingsMicrosPerMonth ||
RISK_ORDER[left.risk] - RISK_ORDER[right.risk] ||
left.p95AfterMs - right.p95AfterMs ||
left.id.localeCompare(right.id))[0];
}
/**
* 决策函数只做三件事:按护栏淘汰、按收益排序、把结果写成 ADR。
* 回退条件与退出条件必须分开:前者是失败时怎么撤,后者是什么时候停止投入。
*/
export function decideAdr(
baseline: AdrBaseline,
candidates: readonly AdrCandidate[],
policy: AdrPolicy = DEFAULT_ADR_POLICY,
): AdrDecision {
if (candidates.length === 0) fail("至少需要一个候选方案");
integer(baseline.monthlyRequests, "baseline.monthlyRequests", 1);
if (!Number.isFinite(baseline.avgCostMicros) || baseline.avgCostMicros < 0) fail("baseline.avgCostMicros 非法");
if (!Number.isFinite(baseline.e2eP95Ms) || baseline.e2eP95Ms < 0) fail("baseline.e2eP95Ms 非法");
if (!Number.isFinite(baseline.qualityMean) || baseline.qualityMean < 0 || baseline.qualityMean > 1) {
fail("baseline.qualityMean 必须落在 [0, 1]");
}
const assessments = candidates.map((candidate) => assess(baseline, candidate, policy));
const chosen = pick(assessments);
const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
const alternatives: AdrAlternative[] = assessments.map((item) => ({
id: item.id,
verdict: item.id === chosen?.id ? "accepted" : "rejected",
reason: item.id === chosen?.id
? `savings ${item.savingsMicrosPerMonth} micros/month, payback ${item.paybackDays} days`
: item.reasons.length > 0 ? item.reasons.join("; ") : "lower saving than the accepted option",
}));
const context =
`baseline: ${baseline.avgCostMicros} micros/request, p95 ${baseline.e2eP95Ms}ms, ` +
`quality ${baseline.qualityMean.toFixed(3)}, ${baseline.monthlyRequests} requests/month; ` +
`policy: quality drop <= ${policy.maxQualityDropPts}pts, p95 increase <= ${policy.maxP95IncreasePct}%, ` +
`evidence >= ${policy.minEvidenceSamples}.`;
const consequences = chosen === null
? ["没有候选方案同时满足收益与护栏;保持基线,补充证据后再决策"]
: [
...byId.get(chosen.id)!.exit.map((condition) => `退出条件:${condition}`),
...byId.get(chosen.id)!.rollback.map((condition) => `回退条件:${condition}`),
`预期质量 ${chosen.qualityAfter.toFixed(3)},P95 ${chosen.p95AfterMs}ms,回本期 ${chosen.paybackDays} 天`,
];
return {
status: chosen === null ? "rejected" : "accepted",
chosen: chosen?.id ?? null,
assessments,
adr: {
id: "ADR-013",
title: chosen === null ? "本轮不采纳成本优化方案" : `采纳「${chosen.title}」`,
status: chosen === null ? "rejected" : "accepted",
context,
decision: chosen === null
? "所有候选方案至少违反一项护栏,暂不改变基线。"
: `采纳「${chosen.title}」,预计每月节省 ${chosen.savingsMicrosPerMonth} 微元,` +
`回本期 ${chosen.paybackDays} 天。`,
consequences,
alternatives,
},
};
}
decideAdr 先对每个候选跑同一组护栏,再在可行集里按「月节省、风险、P95」排序。被拒绝的方案不会被删除,而是进入 adr.alternatives,保留原因。这样半年后回看,能区分「当时没选」和「当时没考虑」。
paybackDays 用 Math.ceil,因为回本期按完整天数表达;savingsMicrosPerMonth 用 Math.round,保持整数微元口径。两个舍入方向不同,是为了让回本期不被低估、节省不被高估。
示例样本上的运行结果
把 13.1 的基线摘要与三个候选输入决策函数,得到:
| 方案 | 可行 | 月节省(微元) | 回本期 | 质量后 | P95 后 |
|---|---|---|---|---|---|
| 稳定前缀 + 前缀缓存 | 是 | 68,640,000 | 4 天 | 0.869 | 767ms |
| doc.summarize 切经济档 | 否 | 109,200,000 | 4 天 | 0.841 | 626ms |
| 输出上限 + 停止序列 | 是 | 37,440,000 | 3 天 | 0.865 | 751ms |
最终采纳前缀缓存:预计每月节省 68,640,000 微元,回本期 4 天,质量不变,P95 从 834ms 降到 767ms。ADR 的 alternatives 里,经济档被标记为「证据 120 < 200;质量回退 2.80 > 1.5」,输出上限被标记为「节省低于被采纳方案」。
这个结果值得多看一眼:节省最大的方案被否决,节省更小的方案被采纳。这不是自相矛盾,而是护栏在起作用。如果只看节省,经济档会赢;但它的质量代价没有被证据支撑,赢了也不可持续。
决策之后:把 ADR 变成门禁
ADR 写完不等于执行。三个数字应该进入发布流程:月节省进入预算基线,回本期进入项目排期,回退条件进入监控规则。
| ADR 字段 | 落地位置 | 验证方式 |
|---|---|---|
| 月节省 68,640,000 | 下季度预算 | 账单连续两个月对比 |
| 回本期 4 天 | 项目计划 | 实施成本与节省对齐 |
| 缓存命中率 <10% 回退 | 监控告警 | 注入低命中样本,告警必须触发 |
| 质量回退 ≤0.3 点 | 回归门禁 | 评测集跑批,超线自动阻断 |
门禁要能失败。只把条件写进文档,下一次发布仍然会绕过;只有一条会变红的测试,才能让回退条件真正生效。
小结
这一节把基线变成了一个可执行的决策:三个候选方案先过质量、延迟、证据与可逆性护栏,再按月节省与回本期排序。经济档降本最多,却因质量回退 2.8 点、证据只有 120 条被否决;前缀缓存以降本 22%、质量不变、回本期 4 天被采纳;输出上限作为低风险叠加项保留。
ADR 的价值不在结论本身,而在它保留了被否决的方案与理由。下一节走出单能力优化,讨论多能力编排会带来哪些新的成本。
阅读导航:上一节:13.1 推理成本基线 · 下一节:13.3 进入卷三 。
常见错误与边界
- 只写最终方案。没有候选与否决理由,回退时不知道退回哪里。
- 把降本和质量放进一个加权分。权重可调,结论就可以被调出来。
- 用节省最大的方案直接上线。收益越大,越要先看护栏。
- 证据不足就下结论。120 条样本上的 35% 降本只是方向,不是承诺。
- 把回退条件写成「效果不好就撤」。没有可观测阈值,等于没有条件。
- 只有回退、没有退出。方案会永远停在灰度,无法转成常态。
- 回本期用四舍五入。少算一天,排期就会过于乐观。
- ADR 写完不进门禁。文档里的条件不会自动生效。
- 把示例收益写成生产收益。基线、请求量与价格一变,数字全部要重算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。