一次没有记录的取舍
「回声」的智能回复在三个月里换过三次模型。第一次为了演示效果,选了质量最高的前沿模型;月底账单出来,这个功能的成本超过预估四倍,于是换成标准模型;两周后用户反馈“回复变得模板化”,又换成两个模型按场景分流。
每次讨论都开了会,每个人都记得自己当时的理由,但没人能拿出完整的决策记录。第三次会上,有人问“上次为什么不用经济模型”,会议室安静了十几秒——不是没有原因,而是原因散在三个人的聊天记录里,拼不回来。更麻烦的是,三次切换都没有定义“什么情况下应该重新评估”。于是争论由客诉触发、由账单触发、由某个人的直觉触发,唯独不由可观测的条件触发。
这一节要解决的不是“选哪个模型”,而是“如何把选择过程固定下来,使它可复述、可复核、可被后来的人推翻”。
文中出现的指标、阈值与成本,除非特别标注为本机测试输出,都是案例假设值,用来演示决策结构,不是任何真实产品的线上数据。
取舍不是打分
很多团队会做一个加权评分:质量占 50%,成本占 30%,延迟占 20%,算出一个总分,选最高的。这个方法看起来客观,实际上把最重要的信息藏进了权重里。
| 方案 | 质量 | 成本(微元/千次) | 延迟 P95 |
|---|---|---|---|
| frontier | 0.94 | 6000 | 2400 ms |
| standard | 0.91 | 1800 | 1600 ms |
| economy | 0.82 | 600 | 900 ms |
权重稍微一动,排序就会翻转。把成本权重从 0.2 提到 0.4,原本排第二的方案可能升到第一;再提到 0.6,结论又变一次。三个结果都能算出来,但没有任何一份记录说明“为什么这次成本权重是 0.6”。权重成了事后调节结论的旋钮。
本书主张两个替代做法:第一,先用护栏排除不可行方案。质量低于 0.85、每千次成本高于 3000 微元、P95 高于 2000 毫秒的方案直接出局。护栏是硬约束,不参与排序,也不应该被“总分更高”抵消。
第二,剩下的方案按显式优先级排序。优先级写成 quality > latency > cost 或 cost > latency > quality,排序规则本身就是决策的一部分。质量优先还是成本优先,必须能被一句话复述;复述不出来的取舍,等于没有做。
用护栏先排除不可行
下面的实现把“护栏 + 优先级”固定成函数。它不产生一个神秘的总分,而是返回可行集合、被拒方案及原因,以及最终选择。
// file: src/cost/tradeoff.ts
// tier: L1
// verify: node --test test/cost/tradeoff.test.ts
import { AiError } from "../core/errors.ts";
export type DecisionAxis = "quality" | "cost" | "latency";
export const DECISION_AXES: DecisionAxis[] = ["quality", "cost", "latency"];
export interface Candidate {
id: string;
/** 评测集通过率,0 到 1。 */
quality: number;
/** 每千次请求的整数微元成本。 */
costMicrosPer1k: number;
/** P95 延迟,单位毫秒。 */
latencyP95Ms: number;
}
export interface Guardrails {
minQuality: number;
maxCostMicrosPer1k: number;
maxLatencyP95Ms: number;
}
export interface Rejection {
id: string;
reasons: string[];
}
export interface TradeoffDecision {
chosen: string | null;
feasible: string[];
rejected: Rejection[];
rationale: string;
}
function fail(message: string): never {
throw new AiError("invalid-request", `tradeoff: ${message}`);
}
function requireInteger(value: number, field: string, min: number): void {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
}
function requireQuality(value: number, field: string): void {
if (!Number.isFinite(value) || value < 0 || value > 1) fail(`${field} must be between 0 and 1`);
}
function requireText(value: string, field: string): void {
if (value.trim() === "") fail(`${field} is required`);
}
function validatePriority(priority: DecisionAxis[]): void {
if (
priority.length !== DECISION_AXES.length ||
new Set(priority).size !== priority.length ||
DECISION_AXES.some((axis) => !priority.includes(axis))
) {
fail("priority must contain quality, cost and latency exactly once");
}
}
/**
* 先按护栏淘汰不可行方案,再按显式优先级排序。优先级本身就是决策记录的一部分,
* 不用加权总分把取舍藏起来:质量优先还是成本优先,必须能被复述。
*/
export function evaluateTradeoff(
candidates: Candidate[],
guardrails: Guardrails,
priority: DecisionAxis[],
): TradeoffDecision {
if (candidates.length === 0) fail("at least one candidate is required");
validatePriority(priority);
requireQuality(guardrails.minQuality, "minQuality");
requireInteger(guardrails.maxCostMicrosPer1k, "maxCostMicrosPer1k", 0);
requireInteger(guardrails.maxLatencyP95Ms, "maxLatencyP95Ms", 0);
const seen = new Set<string>();
const rejected: Rejection[] = [];
const feasible: Candidate[] = [];
for (const candidate of candidates) {
requireText(candidate.id, "candidate id");
if (seen.has(candidate.id)) fail(`duplicate candidate "${candidate.id}"`);
seen.add(candidate.id);
requireQuality(candidate.quality, `quality of "${candidate.id}"`);
requireInteger(candidate.costMicrosPer1k, `cost of "${candidate.id}"`, 0);
requireInteger(candidate.latencyP95Ms, `latency of "${candidate.id}"`, 0);
const reasons: string[] = [];
if (candidate.quality < guardrails.minQuality) {
reasons.push(`quality ${candidate.quality.toFixed(3)} < ${guardrails.minQuality.toFixed(3)}`);
}
if (candidate.costMicrosPer1k > guardrails.maxCostMicrosPer1k) {
reasons.push(`cost ${candidate.costMicrosPer1k} > ${guardrails.maxCostMicrosPer1k}`);
}
if (candidate.latencyP95Ms > guardrails.maxLatencyP95Ms) {
reasons.push(`latency ${candidate.latencyP95Ms} > ${guardrails.maxLatencyP95Ms}`);
}
if (reasons.length > 0) rejected.push({ id: candidate.id, reasons });
else feasible.push(candidate);
}
feasible.sort((left, right) => {
for (const axis of priority) {
if (axis === "quality" && left.quality !== right.quality) return right.quality - left.quality;
if (axis === "cost" && left.costMicrosPer1k !== right.costMicrosPer1k) {
return left.costMicrosPer1k - right.costMicrosPer1k;
}
if (axis === "latency" && left.latencyP95Ms !== right.latencyP95Ms) {
return left.latencyP95Ms - right.latencyP95Ms;
}
}
return left.id.localeCompare(right.id);
});
const chosen = feasible[0] ?? null;
const rationale = chosen
? `priority=${priority.join(">")}; chosen "${chosen.id}" (quality ${chosen.quality.toFixed(3)}, cost ${chosen.costMicrosPer1k}, latency ${chosen.latencyP95Ms}); feasible=${feasible.length}, rejected=${rejected.length}`
: `priority=${priority.join(">")}; no feasible candidate; rejected=${rejected.length}`;
return { chosen: chosen?.id ?? null, feasible: feasible.map((item) => item.id), rejected, rationale };
}
export interface AdrBaseline extends Candidate {
volumePerDay: number;
modelId: string;
}
export interface AdrRecord {
id: string;
title: string;
status: "proposed" | "accepted" | "superseded";
date: string;
context: string;
decision: string;
consequences: string;
guardrails: Guardrails;
priority: DecisionAxis[];
baseline: AdrBaseline;
}
export function renderAdr(record: AdrRecord): string {
if (!/^ADR-\d{3,}$/.test(record.id)) fail(`invalid adr id "${record.id}"`);
requireText(record.title, "title");
requireText(record.context, "context");
requireText(record.decision, "decision");
requireText(record.consequences, "consequences");
if (!/^\d{4}-\d{2}-\d{2}$/.test(record.date)) fail(`invalid date "${record.date}"`);
validatePriority(record.priority);
requireInteger(record.baseline.volumePerDay, "baseline.volumePerDay", 1);
requireText(record.baseline.modelId, "baseline.modelId");
return [
`# ${record.id}: ${record.title}`,
"",
`- 状态:${record.status}`,
`- 日期:${record.date}`,
`- 优先级:${record.priority.join(" > ")}`,
`- 基线模型:${record.baseline.modelId}`,
"",
"## 背景",
"",
record.context,
"",
"## 决策",
"",
record.decision,
"",
"## 后果",
"",
record.consequences,
"",
"## 基线指标",
"",
`| 指标 | 值 | 护栏 |`,
`| --- | --- | --- |`,
`| 质量 | ${record.baseline.quality.toFixed(3)} | >= ${record.guardrails.minQuality.toFixed(3)} |`,
`| 成本(微元/千次) | ${record.baseline.costMicrosPer1k} | <= ${record.guardrails.maxCostMicrosPer1k} |`,
`| 延迟 P95(毫秒) | ${record.baseline.latencyP95Ms} | <= ${record.guardrails.maxLatencyP95Ms} |`,
`| 日请求量 | ${record.baseline.volumePerDay} | - |`,
].join("\n");
}
export interface ReviewThresholds {
qualityDropPoints: number;
costIncreaseRatio: number;
latencyIncreaseRatio: number;
volumeGrowthRatio: number;
maxAgeDays: number;
}
/** 案例假设值:任何一项越界都触发复核,而不是等季度评审。 */
export const DEFAULT_REVIEW_THRESHOLDS: ReviewThresholds = {
qualityDropPoints: 0.03,
costIncreaseRatio: 0.2,
latencyIncreaseRatio: 0.25,
volumeGrowthRatio: 1,
maxAgeDays: 90,
};
export interface ReviewSnapshot {
quality: number;
costMicrosPer1k: number;
latencyP95Ms: number;
volumePerDay: number;
modelId: string;
daysSinceReview: number;
}
export interface ReviewCheck {
due: boolean;
reasons: string[];
}
/** 触发条件按固定顺序检查,输出顺序稳定,可直接写进工单。 */
export function checkReview(
record: AdrRecord,
snapshot: ReviewSnapshot,
thresholds: ReviewThresholds = DEFAULT_REVIEW_THRESHOLDS,
): ReviewCheck {
requireQuality(snapshot.quality, "snapshot.quality");
requireInteger(snapshot.costMicrosPer1k, "snapshot.costMicrosPer1k", 0);
requireInteger(snapshot.latencyP95Ms, "snapshot.latencyP95Ms", 0);
requireInteger(snapshot.volumePerDay, "snapshot.volumePerDay", 0);
requireInteger(snapshot.daysSinceReview, "snapshot.daysSinceReview", 0);
const baseline = record.baseline;
const reasons: string[] = [];
const qualityDrop = baseline.quality - snapshot.quality;
if (qualityDrop >= thresholds.qualityDropPoints) {
reasons.push(`quality dropped ${qualityDrop.toFixed(3)} >= ${thresholds.qualityDropPoints}`);
}
const costLimit = Math.ceil(baseline.costMicrosPer1k * (1 + thresholds.costIncreaseRatio));
if (snapshot.costMicrosPer1k >= costLimit) {
reasons.push(`cost ${snapshot.costMicrosPer1k} >= ${costLimit}`);
}
const latencyLimit = Math.ceil(baseline.latencyP95Ms * (1 + thresholds.latencyIncreaseRatio));
if (snapshot.latencyP95Ms >= latencyLimit) {
reasons.push(`latency ${snapshot.latencyP95Ms} >= ${latencyLimit}`);
}
const volumeLimit = Math.ceil(baseline.volumePerDay * (1 + thresholds.volumeGrowthRatio));
if (snapshot.volumePerDay >= volumeLimit) {
reasons.push(`volume ${snapshot.volumePerDay} >= ${volumeLimit}`);
}
if (snapshot.modelId !== baseline.modelId) {
reasons.push(`model changed ${baseline.modelId} -> ${snapshot.modelId}`);
}
if (snapshot.daysSinceReview >= thresholds.maxAgeDays) {
reasons.push(`age ${snapshot.daysSinceReview} >= ${thresholds.maxAgeDays} days`);
}
return { due: reasons.length > 0, reasons };
}
evaluateTradeoff 有三个值得说明的设计。
第一,拒绝原因是结构化的字符串,逐条列出越界的指标和具体数值,让 ADR 的“考虑过哪些方案”有据可查。第二,排序使用字典序,不是加权和:priority 的第一个维度决定主要顺序,只有前一个维度完全相同时才看下一个,指标相同时用 id 兜底。第三,没有可行方案时返回 chosen: null,不抛错也不强行选一个;正确的动作是回到需求,判断是护栏太严,还是当前模型组合真的无法满足承诺。renderAdr 把决策渲染成固定章节,checkReview 负责事后复核。三件事放在同一个模块,是因为它们共享同一组指标和阈值;分散在三个仓库里,迟早会出现“ADR 里写 0.85、告警里写 0.8”的漂移。
ADR 模板
| 字段 | 回答什么 | 反例 |
|---|---|---|
| 编号与标题 | 这是哪次决策 | “模型选型讨论” |
| 状态 | 提议、接受还是被取代 | 只写“已完成” |
| 日期 | 什么时候定的 | 只写月份 |
| 优先级 | 冲突时先保什么 | “综合考虑” |
| 背景 | 当时面对什么约束 | 只写“为了优化” |
| 决策 | 具体选了什么、怎么路由 | 只写“用标准模型” |
| 后果 | 放弃了什么、欠下什么 | 只写“效果更好” |
ADR 是 append-only 的:决策变了,不修改旧记录,而是新增一条并把旧记录标为 superseded。这样后来的人能看到完整的演化路径,而不是一份被反复擦写的“当前最佳实践”。renderAdr 输出的“基线指标”表是复核的锚点。没有基线,三个月后就无法判断质量是“下降了 2 个点”还是“一直在 0.9 附近波动”。
触发复核的六个条件
复核不应该等季度评审,而应该由条件触发。下面六个阈值是案例假设值,团队要按自己的业务调整,但必须写进 ADR。
| 条件 | 阈值 | 为什么值得复核 |
|---|---|---|
| 质量下降 | 基线减当前 >= 0.03 | 三个点是评测集能感知的最小变化 |
| 成本上升 | 当前 >= 基线 × 1.2 | 两成足以改变单位经济 |
| 延迟上升 | 当前 >= 基线 × 1.25 | 四分之一通常意味着跨过了体验档位 |
| 流量增长 | 当前 >= 基线 × 2 | 容量结构可能不再适用 |
| 模型变更 | 模型 id 不同 | 价格、能力、区域都可能变 |
| 时间流逝 | 距上次复核 >= 90 天 | 防止决策在无人注意时过期 |
checkReview 按固定顺序检查并返回原因列表。顺序固定不是为了好看,而是为了让工单和告警去重:同一份快照每次生成的文本必须一样。边界值算触发。质量刚好下降 0.03、成本刚好到 1.2 倍、时间刚好 90 天,都应该复核。用 > 还是 >= 本身不重要,重要的是全系统统一,并且写进测试。
测试与真实输出
测试覆盖八个场景:护栏淘汰、优先级切换、并列排序、无解、非法输入、无触发、单条件触发、多条件顺序与渲染。执行:
node --test test/cost/tradeoff.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 护栏先淘汰不可行方案,再按优先级选出唯一结果 (1.01475ms)
✔ 优先级改变时选择跟着改变,取舍不被加权总分掩盖 (0.114125ms)
✔ 指标完全相同时按 id 稳定排序,没有可行方案时返回 null (7.827167ms)
✔ 重复 id 与不完整的优先级被拒绝 (0.272459ms)
✔ 指标未越界时不需要复核 (0.118958ms)
✔ 六类触发条件各自独立生效,边界值算触发 (0.104ms)
✔ 多个条件同时命中时按固定顺序输出 (0.069333ms)
✔ ADR 渲染出固定章节与指标表 (0.20775ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 204.553208
第一个测试里,frontier 被成本和延迟两条护栏同时拦下,economy 被质量拦下,只有 standard 可行。拒绝原因是两条独立的字符串,而不是“综合评分不足”——评审时能直接看到差在哪。第二个测试说明优先级不是装饰:同一组方案,cost > latency > quality 选 economy,quality > cost > latency 选 frontier。两种选择都合理,区别在于产品当下更怕什么。ADR 要把这个“更怕什么”写下来。
复核之后做什么
复核触发后只有三种结局:维持原决策并更新基线,调整参数并记录,或用新 ADR 推翻旧决策并把旧记录标为 superseded。最不应该出现的是第四种:口头改配置、记录不动——下一次复核会拿过期基线比较,所有阈值都失效。复核也不是越频繁越好,六个条件的意义是“该看的时候一定看”,而不是“天天看”。
小结
质量、成本与延迟的取舍不能靠加权总分,因为权重会把“为什么这样选”藏起来。可复述的做法是:先用护栏排除不可行方案,再按显式优先级排序,把拒绝原因和基线指标一起写进 ADR。ADR 是 append-only 的决策记录,状态、日期、优先级、背景、决策、后果和基线缺一不可;复核由六个可计算条件触发:质量下降、成本上升、延迟上升、流量翻倍、模型变更、时间到期。下一章进入缓存与复用:哪些结果可以精确命中,哪些只能语义近似,缓存命中率为什么会骗人,以及如何在成本和新鲜度之间划线。
阅读导航:上一节:8.2 Token 成本模型与配额 · 下一节:9.1 精确缓存、语义缓存与结果复用 。
常见错误与边界
- 用加权总分代替取舍。权重可以事后调整,结论就失去了可复述性。
- 护栏被总分抵消。质量低于底线但总分最高,仍然不该入选。
- ADR 只写结论不写背景。后来的人无法判断当时的约束是否还成立。
- 修改旧 ADR 而不是新增。决策演化路径被擦掉,复核失去基线。
- 阈值散落在代码和文档里。ADR 写 0.85、告警写 0.8,两边都以为自己是权威。
- 把案例阈值当行业标准。三个点、两成、四分之一都要按自己的评测集和成本结构调整。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。