一份说不清好坏的上线报告
「回声」的 AI 网关准备进入下一阶段时,团队写了一份上线报告:
- 平均延迟「大约几十毫秒」。
- 成功率「绝大多数请求都成功」。
- 成本「比预算低」。
- 缓存「有效果」。
- 降级「偶尔发生」。
五个结论都像对的,但没有一个能被复核。产品经理问「绝大多数是多少」,算法同学说「大概九成多」;财务问「低多少」,工程说「月底看账单」;安全问「降级时用户看到什么」,没有人能立刻回答。
问题不在于团队不认真,而在于没有一份可复现的基线。基线不是一句总结,而是一组从固定样本算出来的数字,加上一组由这些数字触发的风险判断。
本节用 60 条确定性样本跑一遍 13.2 的基线,得到五项指标与一张风险清单。所有数字都标注为「示例样本上的运行结果」,由 ai-lab 的 mock provider 产生,不是任何真实产品的生产实测。
60 条样本从哪里来
样本不是随手编的。它们由 13.2 的 runBaseline 逐条跑出来,每条记录请求号、能力、结局、延迟、成本、是否缓存命中与是否降级。
这批示例样本的构成是:
| 类型 | 数量 | 构造方式 | 典型延迟 |
|---|---|---|---|
| 正常成功 | 40 | 不同内容的私信回复请求 | 53ms |
| 缓存命中 | 10 | 重复前十条内容,换请求号 | 2ms |
| 降级 | 5 | 注入 timeout,走模板兜底 | 55ms |
| 阻断 | 5 | 审核能力在无可用模型时按策略阻断 | 5ms |
| 合计 | 60 | 全部由 mock provider 确定性产生 | — |
样本的「延迟」是六个阶段声明耗时之和,不是墙上时钟。「成本」是预算账本的实际扣减,不是按单价反推。这样同一份样本在任何机器上都会得到同一组指标。
五个指标怎么算
指标定义必须先写清楚,否则同名数字会在不同团队之间打架。
| 指标 | 定义 | 容易犯的错 |
|---|---|---|
| 成功率 | (ok + cache-hit + degraded) / 总数 | 把降级算失败,成功率被低估 |
| P95 延迟 | 最近秩法,第 ceil(0.95 × n) 个值 | 用平均值代替,长尾被抹平 |
| 单次成本 | 总微元 / 样本数 | 只算成功请求,失败成本消失 |
| 缓存命中率 | cache-hit / 总数 | 把语义命中重复计数 |
| 降级率 | degraded / 总数 | 把降级混进错误率 |
成功率的定义最容易被争论。这里把降级与缓存命中都算作「交付了结果」,只有完全没有结果的阻断才算失败。理由很产品化:用户看到模板建议,仍然是可用体验;用户看到空白,才是失败。
P95 使用最近秩法而不是插值。样本量小的时候,插值会造出一个不存在的延迟值;最近秩法取真实样本,虽然粗糙,但可以逐条复算。
示例样本上的运行结果
在实验工程根目录执行度量脚本后,这 60 条样本得到:
| 指标 | 结果 |
|---|---|
| 样本数 | 60 |
| 成功 / 缓存 / 降级 / 失败 | 40 / 10 / 5 / 5 |
| 成功率 | 91.67% |
| 错误率 | 8.33% |
| 降级率 | 8.33% |
| 缓存命中率 | 16.67% |
| 平均延迟 | 40.67ms |
| P95 延迟 | 55ms |
| 总成本 | 360 微元 |
| 平均单次成本 | 6.00 微元 |
这组数字说明三件事。第一,延迟与成本都远低于默认阈值,当前瓶颈不在性能。第二,失败与降级各占 8.33%,高于 1% 与 5% 的容忍线。第三,缓存命中率 16.67%,低于 20% 的下限。
分能力看,55 条私信回复样本的成功率是 100%,但降级率 9.09%、缓存命中率 18.18%;5 条审核样本全部阻断,成功率 0。整体成功率被审核样本拉低,这正是分能力切片的价值:如果不拆开,团队会以为私信回复也不稳定。
风险清单:影响乘概率
风险不是一句「可能有风险」,而是两个可比较的维度:影响有多大、发生概率有多高。
影响由风险类型决定:可用性 5,延迟、成本、降级各 4,缓存 3,证据不足 2。概率不靠打分,而靠样本里的观测比例映射到 1 到 5 档:超过容忍线两倍记 5,超过记 4,达到一半记 3,有观测但很低记 2。
这批样本生成的风险清单是:
| 风险 | 影响 | 概率 | 分数 | 证据 |
|---|---|---|---|---|
| 请求失败率超出可接受范围 | 5 | 5 | 25 | 失败 5/60,错误率 8.33% |
| 降级路径被频繁触发 | 4 | 4 | 16 | 降级 5/60,降级率 8.33% |
| 样本量不足以支撑结论 | 2 | 5 | 10 | 样本 60 条,最低 200 条 |
| 缓存命中率低于基线 | 3 | 2 | 6 | 命中率 16.67%,下限 20.00% |
排序后,可用性风险排第一,降级风险第二。延迟与成本没有进入清单,因为 P95 55ms 远低于 800ms、平均成本 6 微元远低于 200 微元,且没有一条样本越线。
风险清单只收录有样本证据的项。没有证据的担心可以写进评审记录,但不应该混进按分数排序的清单,否则高风险项会被主观猜测淹没。
进入卷二的条件
卷二讨论推理性能与成本工程。它建立在卷一的网关基线上:如果一次普通调用都无法稳定交付,任何优化都只是在放大的噪声上做实验。
因此进入卷二需要同时满足七项条件:
| 条件 | 要求 | 这批样本 |
|---|---|---|
| 样本量 | ≥ 200 条 | 60,未满足 |
| 成功率 | ≥ 99% | 91.67%,未满足 |
| P95 延迟 | ≤ 800ms | 55ms,满足 |
| 平均成本 | ≤ 200 微元 | 6.00 微元,满足 |
| 缓存命中率 | ≥ 20% | 16.67%,未满足 |
| 降级率 | ≤ 5% | 8.33%,未满足 |
| 高分风险 | 无 ≥ 20 分项 | 可用性 25 分,未满足 |
最终拦截项是 sample、success、cache、degrade、risk。这不是「基线很差」,而是「这批示例样本只够验证实现,不够支撑上线结论」。把样本扩大到 200 条、消除审核阻断与超时降级之后,同样的代码才可能给出绿色结论。
条件不是永久门槛。团队可以调整阈值,但必须同时写下调整理由与新的证据;只改阈值不改样本,等于把门禁变成装饰。
实现
下面的模块只做三件事:从样本计算五项指标、按影响乘概率生成风险清单、判断是否满足进入下一卷的条件。它不读取日志,也不访问网络;输入是一组已经结算好的样本。
// file: src/observe/baseline.ts
// tier: L1
// verify: node --test test/observe/baseline.test.ts
import { AiError } from "../core/errors.ts";
export type SampleOutcome = "ok" | "cache-hit" | "degraded" | "error";
/** 一条样本就是一次请求的结算行,不要在统计阶段再回查原始日志。 */
export interface BaselineSample {
requestId: string; capability: string; outcome: SampleOutcome;
latencyMs: number; costMicros: number; cacheHit: boolean; degraded: boolean;
}
export interface BaselineThresholds {
latencyBudgetMs: number; costCeilingMicros: number; minCacheHitRate: number;
maxErrorRate: number; maxDegradeRate: number; minSampleSize: number;
}
/** 阈值是基线的一部分,写死在代码里等于让数字失去出处。 */
export const DEFAULT_THRESHOLDS: BaselineThresholds = {
latencyBudgetMs: 800, costCeilingMicros: 200, minCacheHitRate: 0.2,
maxErrorRate: 0.01, maxDegradeRate: 0.05, minSampleSize: 200,
};
export interface CapabilityBaseline {
samples: number; successRate: number; p95LatencyMs: number;
avgCostMicros: number; cacheHitRate: number; degradedRate: number;
}
export interface BaselineMetrics {
samples: number; ok: number; cacheHits: number; degraded: number; errors: number;
successRate: number; errorRate: number; degradedRate: number; cacheHitRate: number;
avgLatencyMs: number; p95LatencyMs: number;
totalCostMicros: number; avgCostMicros: number; overLatency: number; overCost: number;
byCapability: Record<string, CapabilityBaseline>;
}
const OUTCOMES = new Set<string>(["ok", "cache-hit", "degraded", "error"]);
function fail(message: string): never {
throw new AiError("invalid-request", `baseline: ${message}`);
}
function validateSample(sample: BaselineSample): void {
if (sample.requestId.trim() === "" || sample.capability.trim() === "") fail("requestId 与 capability 不能为空");
if (!OUTCOMES.has(sample.outcome)) fail(`未知 outcome ${String(sample.outcome)}`);
if (!Number.isFinite(sample.latencyMs) || sample.latencyMs < 0) fail(`${sample.requestId}: latencyMs 必须是非负有限数`);
if (!Number.isInteger(sample.costMicros) || sample.costMicros < 0) fail(`${sample.requestId}: costMicros 必须是非负整数`);
}
/** 最近秩法:第 ceil(p*n) 个值,结果可被逐条样本复算。 */
export function percentile(values: readonly number[], ratio: number): number {
if (!(ratio > 0 && ratio <= 1)) fail("ratio 必须落在 (0, 1]");
if (values.length === 0) return 0;
const sorted = [...values].sort((left, right) => left - right);
const rank = Math.ceil(ratio * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, rank))];
}
function rate(numerator: number, denominator: number): number {
return denominator === 0 ? 0 : numerator / denominator;
}
function summarize(samples: readonly BaselineSample[], thresholds: BaselineThresholds): Omit<BaselineMetrics, "byCapability"> {
const latencies = samples.map((sample) => sample.latencyMs);
const count = (outcome: SampleOutcome): number => samples.filter((sample) => sample.outcome === outcome).length;
const ok = count("ok");
const cacheHits = count("cache-hit");
const degraded = count("degraded");
const errors = count("error");
const totalCostMicros = samples.reduce((sum, sample) => sum + sample.costMicros, 0);
return {
samples: samples.length, ok, cacheHits, degraded, errors,
successRate: rate(ok + cacheHits + degraded, samples.length),
errorRate: rate(errors, samples.length),
degradedRate: rate(degraded, samples.length),
cacheHitRate: rate(cacheHits, samples.length),
avgLatencyMs: rate(latencies.reduce((sum, value) => sum + value, 0), samples.length),
p95LatencyMs: percentile(latencies, 0.95),
totalCostMicros,
avgCostMicros: rate(totalCostMicros, samples.length),
overLatency: samples.filter((sample) => sample.latencyMs > thresholds.latencyBudgetMs).length,
overCost: samples.filter((sample) => sample.costMicros > thresholds.costCeilingMicros).length,
};
}
/** 成功率把降级与缓存命中都算作“交付了结果”,错误才算失败。 */
export function computeBaseline(
samples: readonly BaselineSample[],
thresholds: BaselineThresholds = DEFAULT_THRESHOLDS,
): BaselineMetrics {
if (samples.length === 0) fail("没有样本,无法建立基线");
for (const sample of samples) validateSample(sample);
const overall = summarize(samples, thresholds);
const groups = new Map<string, BaselineSample[]>();
for (const sample of samples) {
const bucket = groups.get(sample.capability) ?? [];
bucket.push(sample);
groups.set(sample.capability, bucket);
}
const byCapability: Record<string, CapabilityBaseline> = {};
for (const [capability, bucket] of groups) {
const data = summarize(bucket, thresholds);
byCapability[capability] = {
samples: data.samples, successRate: data.successRate, p95LatencyMs: data.p95LatencyMs,
avgCostMicros: data.avgCostMicros, cacheHitRate: data.cacheHitRate, degradedRate: data.degradedRate,
};
}
return { ...overall, byCapability };
}
export type RiskCategory = "availability" | "latency" | "cost" | "degrade" | "cache" | "evidence";
export interface BaselineRisk {
id: string; category: RiskCategory; title: string;
impact: number; probability: number; score: number; evidence: string; action: string;
}
function percent(value: number): string {
return `${(value * 100).toFixed(2)}%`;
}
/** 把观测比例映射到 1..5 的概率档,阈值来自基线而不是主观打分。 */
function band(value: number, tolerance: number): number {
if (value <= 0) return 1;
const ratio = value / Math.max(tolerance, 1e-9);
if (ratio >= 2) return 5;
if (ratio >= 1) return 4;
if (ratio >= 0.5) return 3;
return 2;
}
function risk(
id: string, category: RiskCategory, title: string,
impact: number, probability: number, evidence: string, action: string,
): BaselineRisk {
return { id, category, title, impact, probability, score: impact * probability, evidence, action };
}
/** 风险清单只收录有样本证据的项,按 影响×概率 从高到低排序。 */
export function buildRiskList(
metrics: BaselineMetrics,
thresholds: BaselineThresholds = DEFAULT_THRESHOLDS,
): BaselineRisk[] {
const risks: BaselineRisk[] = [];
const total = metrics.samples;
if (metrics.errors > 0) {
risks.push(risk("availability", "availability", "请求失败率超出可接受范围", 5,
band(metrics.errorRate, thresholds.maxErrorRate),
`失败 ${metrics.errors}/${total},错误率 ${percent(metrics.errorRate)}`,
"按错误类别拆分失败,先消除不可重试错误"));
}
if (metrics.degraded > 0) {
risks.push(risk("degrade", "degrade", "降级路径被频繁触发", 4,
band(metrics.degradedRate, thresholds.maxDegradeRate),
`降级 ${metrics.degraded}/${total},降级率 ${percent(metrics.degradedRate)}`,
"检查主模型健康度与降级链,确认降级没有变成默认体验"));
}
if (metrics.overLatency > 0 || metrics.p95LatencyMs > thresholds.latencyBudgetMs) {
risks.push(risk("latency", "latency", "P95 延迟超出交互预算", 4,
band(rate(metrics.overLatency, total), 0.05),
`P95 ${metrics.p95LatencyMs}ms,超预算 ${metrics.overLatency}/${total}`,
"拆段定位慢在排队、调用还是后处理,再决定换模型或加缓存"));
}
if (metrics.overCost > 0 || metrics.avgCostMicros > thresholds.costCeilingMicros) {
risks.push(risk("cost", "cost", "单次成本超出预算上限", 4,
band(rate(metrics.overCost, total), 0.05),
`平均 ${metrics.avgCostMicros.toFixed(2)} 微元/次,超上限 ${metrics.overCost}/${total}`,
"检查输入长度与重试次数,必要时下调模型档位"));
}
if (metrics.cacheHitRate < thresholds.minCacheHitRate) {
const shortfall = thresholds.minCacheHitRate - metrics.cacheHitRate;
risks.push(risk("cache", "cache", "缓存命中率低于基线", 3,
band(shortfall, thresholds.minCacheHitRate * 0.5),
`命中率 ${percent(metrics.cacheHitRate)},下限 ${percent(thresholds.minCacheHitRate)}`,
"核对缓存键是否包含易变字段,并确认提示版本没有频繁变更"));
}
if (total < thresholds.minSampleSize) {
risks.push(risk("evidence", "evidence", "样本量不足以支撑结论", 2, 5,
`样本 ${total} 条,最低 ${thresholds.minSampleSize} 条`,
"扩大确定性样本集后再决定是否进入下一卷"));
}
return risks.sort((left, right) =>
right.score - left.score || right.impact - left.impact || left.id.localeCompare(right.id));
}
export interface ReadinessCondition { id: string; met: boolean; detail: string }
export interface VolumeReadiness { ready: boolean; conditions: ReadinessCondition[]; blockers: string[] }
/** 进入卷二前,基线必须同时满足样本量、成功率、延迟、成本、缓存、降级与风险七项。 */
export function assessVolumeReadiness(
metrics: BaselineMetrics,
risks: readonly BaselineRisk[],
thresholds: BaselineThresholds = DEFAULT_THRESHOLDS,
): VolumeReadiness {
const critical = risks.filter((item) => item.score >= 20);
const conditions: ReadinessCondition[] = [
{ id: "sample", met: metrics.samples >= thresholds.minSampleSize, detail: `${metrics.samples}/${thresholds.minSampleSize} 条样本` },
{ id: "success", met: metrics.successRate >= 1 - thresholds.maxErrorRate, detail: `成功率 ${percent(metrics.successRate)}` },
{ id: "latency", met: metrics.p95LatencyMs <= thresholds.latencyBudgetMs, detail: `P95 ${metrics.p95LatencyMs}ms / ${thresholds.latencyBudgetMs}ms` },
{ id: "cost", met: metrics.avgCostMicros <= thresholds.costCeilingMicros, detail: `平均 ${metrics.avgCostMicros.toFixed(2)} / ${thresholds.costCeilingMicros} 微元` },
{ id: "cache", met: metrics.cacheHitRate >= thresholds.minCacheHitRate, detail: `命中率 ${percent(metrics.cacheHitRate)}` },
{ id: "degrade", met: metrics.degradedRate <= thresholds.maxDegradeRate, detail: `降级率 ${percent(metrics.degradedRate)}` },
{ id: "risk", met: critical.length === 0, detail: critical.length === 0 ? "无高分风险" : `高分风险 ${critical.map((item) => item.id).join(",")}` },
];
const blockers = conditions.filter((condition) => !condition.met).map((condition) => condition.id);
return { ready: blockers.length === 0, conditions, blockers };
}
summarize 同时服务整体与分能力视图,避免两套口径。percentile 单独导出,是为了让 P95 可以被测试逐条核对。buildRiskList 只收录触发条件的风险,assessVolumeReadiness 则把指标与风险合并成七项条件。
阈值集中在 DEFAULT_THRESHOLDS,不是散落在各个函数里。阈值一旦成为常量,就必须跟着样本一起版本化;否则半年后没人说得清「20% 命中率」是谁定的。
测试与运行结果
测试覆盖指标计算、P95 最近秩、空样本与非法样本、风险排序、健康样本通过、混合样本未通过、阈值替换七种情况。执行:
node --test test/observe/baseline.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 7
ℹ pass 7
ℹ fail 0
「健康样本」用 200 条样本、25% 缓存命中与 100ms P95 构造,指标全绿、风险清单为空、七项条件全部满足;「混合样本」则触发四项拦截。两条测试一起证明:门禁不是永远说 yes,也不是永远说 no,而是跟着样本变化。
小结
基线度量把「大概不错」换成一组可复算的数字:成功率、P95 延迟、单次成本、缓存命中率与降级率。风险清单把这些数字映射成影响与概率,按乘积排序。进入卷二的条件则把这些判断固化成门禁。
60 条示例样本的结论是「实现可用、证据不足」:延迟与成本健康,失败、降级与缓存三项未达标,样本量也远低于 200。这个结论比一句「整体良好」有用得多,因为它直接告诉团队下一步该补什么。
卷一到此收口。下一卷从知识链路开始:当模型需要引用外部文档时,检索、切分、权限与新鲜度会接管这一卷留下的预算与失败边界。
阅读导航:上一节:13.2 网关的组合:路由、缓存、降级 · 下一节:目录与学习路径 。
常见错误与边界
- 用平均值代替 P95。长尾被抹平,高峰期的用户体验完全看不见。
- 成功率只算
ok。降级仍在交付结果,算成失败会让团队误判可用性。 - 失败请求不计成本。已经消耗的 token 不会因为失败而退回。
- 只报整体指标,不做分能力切片。一个能力的阻断会把另一个能力的稳定掩盖掉。
- 缓存命中率把语义命中重复计数。分子大于实际复用次数,指标失真。
- 风险概率凭主观打分。没有样本证据的分数无法复现,也无法比较。
- 把没有证据的担心塞进风险清单。高风险项会被主观猜测淹没。
- 阈值散落在代码各处。调整一个数字要改多个文件,最后没人说得清口径。
- 只看风险分数,不看样本量。60 条样本上的 100% 成功率不等于稳定。
- 把示例样本结果写成生产实测。数字必须有出处,来源不明的结论比没有结论更危险。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。