一份没人敢签字的基线报告
季度预算评审前,「回声」的工程团队交上来一页纸:延迟「可接受」、成本「在预算内」、质量「没有明显退步」、缓存「有效果」。财务问了一句:那下季度按多少预算?没有人能立刻回答。
问题不在数字难看,而在数字无法复算。平均延迟没有说样本量,成本没有说是否包含失败请求,质量没有说用的是哪套评测集,缓存没有说命中率的分母。四个结论各自成立,合在一起却不能支撑一个预算决定。
本节把第 1 章的延迟口径、第 8 章的 token 需求、第 9 章的成本结构、第 11 章的质量评测合成一份基线报告。基线不是一句总结,而是一组固定样本算出的指标、一张按维度切开的表,以及由指标触发的结论。文中所有数字都来自 ai-lab 示例样本的运行结果,不是生产实测。
指标:四个章节各交一项
一份成本基线至少要有四类指标。少任何一类,结论都会在某个方向上失真。
| 指标 | 来自 | 定义 | 缺失后的后果 |
|---|---|---|---|
| TTFT P95 / E2E P95 | 第 1 章 | 最近秩法取第 ceil(0.95×n) 个真实样本 | 平均值抹掉冷启动与长尾 |
| 平均输入 / 输出 token | 第 8 章 | 每条记录的 token 数取算术平均 | 容量模型失去输入,只能拍脑袋 |
| 总成本 / 平均成本 | 第 9 章 | 整数微元累加,失败请求也计入 | 失败成本消失,预算被低估 |
| 质量均值 | 第 11 章 | 评测分 0 到 1 的算术平均,失败记 0 | 降本无法与质量回退一起验收 |
成功率的口径需要单独说明:ok + cache-hit + degraded 都算交付了结果,只有 error 算失败。缓存命中仍然是用户拿到的答案;降级也仍然是可用体验。把降级混进错误,会让错误率虚高,团队会去修一个并不存在的可用性问题。
P95 用最近秩法而不是插值。样本量只有 240 条时,插值会造出一个不存在的延迟值;最近秩法取真实样本,虽然粗糙,但任何人都能按排序逐条复算。
样本:240 条确定性记录
样本由固定规则生成,不依赖随机数。四个能力共 240 条,结局按索引区间切分,成本、token 与质量分都由索引直接算出。
| 能力 | 样本 | 成功 / 缓存 / 降级 / 失败 | 平均成本(微元) | 质量均值 |
|---|---|---|---|---|
| chat.reply | 90 | 80 / 4 / 4 / 2 | 91.23 | 0.868 |
| search.rerank | 60 | 56 / 2 / 1 / 1 | 48.87 | 0.909 |
| doc.summarize | 50 | 46 / 2 / 1 / 1 | 123.36 | 0.852 |
| code.review | 40 | 37 / 1 / 2 / 0 | 192.93 | 0.833 |
| 合计 | 240 | 219 / 9 / 8 / 4 | 104.28 | 0.869 |
这张表本身就是一次分层检查。整体平均成本 104.28 微元,但 code.review 是 192.93,接近整体的两倍;search.rerank 只有 48.87。如果只看整体,最贵的切片会被便宜的能力平均掉。
样本量 240 条刚好越过 200 条的最低线,但这不是「足够」的同义词。它只够验证实现与口径,不足以为一个季度预算背书。基线报告必须把样本量写进结论,否则数字会被当成生产事实引用。
分层:整体会掩盖什么
分层不是多做一张表,而是回答「这个结论对谁成立」。
按能力切片
| 切片 | 成功率 | E2E P95 | 平均成本 | 缓存命中 | 质量均值 |
|---|---|---|---|---|---|
| 全部 | 98.33% | 834ms | 104.28 | 3.75% | 0.869 |
| chat.reply | 97.78% | 852ms | 91.23 | 4.44% | 0.868 |
| search.rerank | 98.33% | 526ms | 48.87 | 3.33% | 0.909 |
| doc.summarize | 98.00% | 854ms | 123.36 | 4.00% | 0.852 |
| code.review | 100.00% | 804ms | 192.93 | 2.50% | 0.833 |
chat.reply 的成功率低于整体,因为它有 2 条错误;code.review 成功率 100%,但质量均值最低。两种问题不能互相抵消:前者是可用性,后者是质量,修复动作完全不同。
按模型档位切片
economy 60 条、standard 140 条、frontier 40 条。frontier 的平均成本 192.93 微元,是 economy 的 3.95 倍,质量只高 0 个点还低——这正是 13.2 要处理的问题:贵的那一档是否真的买到了质量。
分层维度不是越多越好。能力和模型档位已经能回答「哪里贵、哪里慢、哪里质量低」;再加租户与地区,样本会被切得太碎,每一格都不足以支撑结论。
结论:conditional 不是失败
报告给出 conditional,唯一越线项是缓存命中率 3.75%,低于 15% 的下限。其余指标全部在阈值内。
| 检查项 | 观测 | 目标 | 结果 |
|---|---|---|---|
| 样本量 | 240 | ≥ 200 | 通过 |
| 错误率 | 1.67% | ≤ 2.00% | 通过 |
| 降级率 | 3.33% | ≤ 5.00% | 通过 |
| E2E P95 | 834ms | ≤ 900ms | 通过 |
| 平均成本 | 104.28 | ≤ 130 | 通过 |
| 质量均值 | 0.869 | ≥ 0.800 | 通过 |
| 缓存命中率 | 3.75% | ≥ 15.00% | 未通过 |
conditional 的含义是「可以继续用,但不能据此宣称缓存已经产生收益」。它把下一步动作限定在一个方向:先核对缓存键与提示版本,再决定是否投入缓存容量。如果报告写成 pass,这个动作就会被跳过;写成 fail,团队又会去修并不紧急的延迟。
阈值必须和样本一起版本化。半年后有人问「15% 是谁定的」,答案应该是一份带日期的策略文件,而不是某个人的记忆。
实现
下面的模块只做三件事:把记录切成整体、能力、模型档位三层;按固定阈值生成 findings;把 findings 汇总成 pass / conditional / fail。它不读日志、不访问账单,输入是一组已经结算好的记录。
// file: src/perf/baseline.ts
// tier: L1
// verify: node --test test/perf/baseline.test.ts
import { AiError } from "../core/errors.ts";
import { percentile } from "../observe/baseline.ts";
export type BaselineOutcome = "ok" | "cache-hit" | "degraded" | "error";
export type ModelTier = "frontier" | "standard" | "economy";
/** 一条结算记录,把第 1、8、9、11 章关心的字段放在同一行。 */
export interface BaselineRecord {
requestId: string;
capability: string;
tier: ModelTier;
outcome: BaselineOutcome;
ttftMs: number;
e2eMs: number;
inputTokens: number;
outputTokens: number;
costMicros: number;
/** 输入 token 中命中缓存的比例,0 到 1。 */
cachedInputShare: number;
/** 第 11 章的评测分,0 到 1;失败请求记 0。 */
qualityScore: number;
}
/** 阈值是基线报告的一部分,必须和样本一起版本化。 */
export interface BaselineTargets {
minSamples: number;
maxErrorRate: number;
maxDegradeRate: number;
minCacheHitRate: number;
maxP95E2eMs: number;
maxAvgCostMicros: number;
minQualityMean: number;
}
export const DEFAULT_TARGETS: BaselineTargets = {
minSamples: 200,
maxErrorRate: 0.02,
maxDegradeRate: 0.05,
minCacheHitRate: 0.15,
maxP95E2eMs: 900,
maxAvgCostMicros: 130,
minQualityMean: 0.8,
};
export interface BaselineSlice {
samples: number;
ok: number;
cacheHits: number;
degraded: number;
errors: number;
successRate: number;
errorRate: number;
degradedRate: number;
cacheHitRate: number;
ttftP95Ms: number;
e2eP95Ms: number;
avgCostMicros: number;
totalCostMicros: number;
avgInputTokens: number;
avgOutputTokens: number;
avgCachedInputShare: number;
qualityMean: number;
}
export type FindingSeverity = "blocker" | "warning";
export interface BaselineFinding {
id: string;
severity: FindingSeverity;
metric: string;
observed: string;
target: string;
action: string;
}
export interface BaselineReport {
overall: BaselineSlice;
byCapability: Record<string, BaselineSlice>;
byTier: Record<string, BaselineSlice>;
findings: BaselineFinding[];
verdict: "pass" | "conditional" | "fail";
targets: BaselineTargets;
}
const OUTCOMES = new Set<string>(["ok", "cache-hit", "degraded", "error"]);
const TIERS = new Set<string>(["frontier", "standard", "economy"]);
function fail(message: string): never {
throw new AiError("invalid-request", `perf-baseline: ${message}`);
}
function ratio(numerator: number, denominator: number): number {
return denominator === 0 ? 0 : numerator / denominator;
}
function mean(values: readonly number[]): number {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
}
function validate(record: BaselineRecord): void {
if (record.requestId.trim() === "" || record.capability.trim() === "") {
fail("requestId 与 capability 不能为空");
}
if (!OUTCOMES.has(record.outcome)) fail(`${record.requestId}: 未知 outcome ${String(record.outcome)}`);
if (!TIERS.has(record.tier)) fail(`${record.requestId}: 未知 tier ${String(record.tier)}`);
for (const field of ["ttftMs", "e2eMs", "inputTokens", "outputTokens", "costMicros"] as const) {
const value = record[field];
if (!Number.isFinite(value) || value < 0) fail(`${record.requestId}: ${field} 必须是非负有限数`);
}
if (!Number.isInteger(record.costMicros)) fail(`${record.requestId}: costMicros 必须是整数微元`);
if (record.e2eMs < record.ttftMs) fail(`${record.requestId}: e2eMs 不能小于 ttftMs`);
for (const field of ["cachedInputShare", "qualityScore"] as const) {
const value = record[field];
if (!Number.isFinite(value) || value < 0 || value > 1) fail(`${record.requestId}: ${field} 必须落在 [0, 1]`);
}
}
/** 一个切片就是同一口径下的一组样本;整体、能力、模型档位都复用它。 */
export function summarizeSlice(records: readonly BaselineRecord[]): BaselineSlice {
if (records.length === 0) fail("切片没有样本");
const count = (outcome: BaselineOutcome): number =>
records.filter((record) => record.outcome === outcome).length;
const ok = count("ok");
const cacheHits = count("cache-hit");
const degraded = count("degraded");
const errors = count("error");
const totalCostMicros = records.reduce((sum, record) => sum + record.costMicros, 0);
return {
samples: records.length,
ok,
cacheHits,
degraded,
errors,
successRate: ratio(ok + cacheHits + degraded, records.length),
errorRate: ratio(errors, records.length),
degradedRate: ratio(degraded, records.length),
cacheHitRate: ratio(cacheHits, records.length),
ttftP95Ms: percentile(records.map((record) => record.ttftMs), 0.95),
e2eP95Ms: percentile(records.map((record) => record.e2eMs), 0.95),
avgCostMicros: ratio(totalCostMicros, records.length),
totalCostMicros,
avgInputTokens: mean(records.map((record) => record.inputTokens)),
avgOutputTokens: mean(records.map((record) => record.outputTokens)),
avgCachedInputShare: mean(records.map((record) => record.cachedInputShare)),
qualityMean: mean(records.map((record) => record.qualityScore)),
};
}
function groupBy(
records: readonly BaselineRecord[],
key: (record: BaselineRecord) => string,
): Record<string, BaselineSlice> {
const groups = new Map<string, BaselineRecord[]>();
for (const record of records) {
const bucket = groups.get(key(record)) ?? [];
bucket.push(record);
groups.set(key(record), bucket);
}
const slices: Record<string, BaselineSlice> = {};
for (const [name, bucket] of groups) slices[name] = summarizeSlice(bucket);
return slices;
}
function percent(value: number): string {
return `${(value * 100).toFixed(2)}%`;
}
function finding(
id: string,
severity: FindingSeverity,
metric: string,
observed: string,
target: string,
action: string,
): BaselineFinding {
return { id, severity, metric, observed, target, action };
}
/**
* 基线报告把四项口径合成一张表:延迟看 P95,成本看均值,
* 质量看评测均值,样本量决定结论能不能成立。
*/
export function buildBaseline(
records: readonly BaselineRecord[],
targets: BaselineTargets = DEFAULT_TARGETS,
): BaselineReport {
if (records.length === 0) fail("没有样本,无法建立基线");
for (const record of records) validate(record);
const overall = summarizeSlice(records);
const byCapability = groupBy(records, (record) => record.capability);
const byTier = groupBy(records, (record) => record.tier);
const findings: BaselineFinding[] = [];
const add = (id: string, severity: FindingSeverity, metric: string,
observed: string, target: string, action: string): void => {
findings.push(finding(id, severity, metric, observed, target, action));
};
if (overall.samples < targets.minSamples) {
add("sample", "blocker", "sample-size", `${overall.samples} 条`,
`>= ${targets.minSamples} 条`, "扩大确定性样本后再引用结论");
}
if (overall.errorRate > targets.maxErrorRate) {
add("error", "blocker", "error-rate", percent(overall.errorRate),
`<= ${percent(targets.maxErrorRate)}`, "按错误类别拆分失败并先消不可重试错误");
}
if (overall.e2eP95Ms > targets.maxP95E2eMs) {
add("latency", "blocker", "e2e-p95", `${overall.e2eP95Ms}ms`,
`<= ${targets.maxP95E2eMs}ms`, "拆段定位排队、预填充与解码,再决定优化杠杆");
}
if (overall.qualityMean < targets.minQualityMean) {
add("quality", "blocker", "quality-mean", overall.qualityMean.toFixed(3),
`>= ${targets.minQualityMean}`, "回到评测集确认质量回退来自模型、提示还是压缩");
}
if (overall.degradedRate > targets.maxDegradeRate) {
add("degrade", "warning", "degrade-rate", percent(overall.degradedRate),
`<= ${percent(targets.maxDegradeRate)}`, "检查主模型健康度,确认降级没有变成默认体验");
}
if (overall.cacheHitRate < targets.minCacheHitRate) {
add("cache", "warning", "cache-hit-rate", percent(overall.cacheHitRate),
`>= ${percent(targets.minCacheHitRate)}`, "先核对缓存键与提示版本,再决定是否投入缓存容量");
}
if (overall.avgCostMicros > targets.maxAvgCostMicros) {
add("cost", "warning", "avg-cost", `${overall.avgCostMicros.toFixed(2)} 微元`,
`<= ${targets.maxAvgCostMicros} 微元`, "按能力与模型档位归因,优先处理单位成本最高的切片");
}
for (const [name, slice] of Object.entries(byCapability)) {
if (slice.e2eP95Ms > targets.maxP95E2eMs) {
add(`latency:${name}`, "warning", "capability-e2e-p95", `${slice.e2eP95Ms}ms`,
`<= ${targets.maxP95E2eMs}ms`, `单独拆 ${name} 的慢请求,能力级长尾会被整体 P95 掩盖`);
}
if (slice.qualityMean < targets.minQualityMean) {
add(`quality:${name}`, "warning", "capability-quality", slice.qualityMean.toFixed(3),
`>= ${targets.minQualityMean}`, `回到 ${name} 的评测集,确认回退来自模型档位还是提示变更`);
}
}
const order: Record<FindingSeverity, number> = { blocker: 0, warning: 1 };
findings.sort((left, right) => order[left.severity] - order[right.severity] || left.id.localeCompare(right.id));
const verdict = findings.some((item) => item.severity === "blocker")
? "fail"
: findings.length > 0 ? "conditional" : "pass";
return { overall, byCapability, byTier, findings, verdict, targets };
}
summarizeSlice 同时服务整体与分层,避免整体一套口径、切片另一套口径。buildBaseline 先算整体,再对每个能力切片补一条能力级告警;否则一个能力的 P95 越线会被整体 P95 掩盖。
verdict 只有三档:任一 blocker 为 fail,只有 warning 为 conditional,没有 findings 才是 pass。三档比布尔值多一层信息,评审时能区分「不能用」和「可以用但别宣称」。
示例样本上的运行结果
在实验工程根目录执行基线测试,输出如下:
node --test test/perf/baseline.test.ts
本机一次运行的真实摘要,耗时随机器变化:
ℹ tests 7
ℹ pass 7
ℹ fail 0
把 240 条记录输入 buildBaseline,整体结果是:样本 240,成功 219、缓存 9、降级 8、失败 4;成功率 98.33%,错误率 1.67%,降级率 3.33%,缓存命中率 3.75%;TTFT P95 296ms,E2E P95 834ms;总成本 25,028 微元,平均 104.28 微元;平均输入 1,522.58 token、输出 320.15 token;质量均值 0.869。结论 conditional,findings 只有一条 cache。
这些数字的意义不是「回声很健康」,而是每个数字都能被重新算出来。换一批样本,指标会变;换一组阈值,结论会变;换一个分层维度,最贵的切片会换一个名字。基线报告的可信度来自可复算,而不是来自参会者都点头。
基线落地的三个约束
报告要带样本量与时间窗口;阈值要集中管理并版本化;结论要绑定下一步动作。这份基线的动作很具体:核对缓存键与提示版本,用第 5 章的方法验证命中率能否提升;提升不了,就不投入缓存容量。
小结
推理成本基线把四个章节的口径合成一张可复算的表:延迟看 P95、容量看平均 token、成本看整数微元、质量看评测均值。240 条示例样本得到 98.33% 成功率、834ms E2E P95、104.28 微元平均成本与 0.869 质量均值;唯一越线项是 3.75% 的缓存命中率,因此结论是 conditional。
基线不是终点,而是优化方案的输入。下一节拿这份基线做三个候选方案的取舍,并把选择、回退与退出条件写成 ADR。
阅读导航:上一节:12.3 一次成本失控的复盘 · 下一节:13.2 优化方案与 ADR 。
常见错误与边界
- 只报平均值。没有 P95,冷启动与长尾会被一次「还不错」的均值抹平。
- 成功率只算
ok。缓存命中与降级仍在交付结果,算成失败会高估问题。 - 失败请求不计成本。已经消耗的 token 不会因为结果失败而退回。
- 只报整体,不做能力与模型档位切片。最贵的切片会被便宜能力平均掉。
- 把
conditional当成pass。带条件的通过必须绑定下一步动作。 - 把示例样本结果写成生产实测。数字必须有出处,来源不明的结论比没有结论更危险。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。