《互联网产品的 AI 架构·模型接入与能力边界》13.3 基线度量与风险清单

用 60 条确定性样本跑完基线,得到成功率、P95 延迟、单次成本、缓存命中率与降级率,再按影响乘概率生成风险清单,并给出进入卷二的七项条件。文中数字来自 ai-lab 的示例样本运行结果,不是生产实测;同时说明样本量、阈值与风险分档为什么必须一起写清楚。

一份说不清好坏的上线报告

「回声」的 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。

这批样本生成的风险清单是:

风险影响概率分数证据
请求失败率超出可接受范围5525失败 5/60,错误率 8.33%
降级路径被频繁触发4416降级 5/60,降级率 8.33%
样本量不足以支撑结论2510样本 60 条,最低 200 条
缓存命中率低于基线326命中率 16.67%,下限 20.00%

排序后,可用性风险排第一,降级风险第二。延迟与成本没有进入清单,因为 P95 55ms 远低于 800ms、平均成本 6 微元远低于 200 微元,且没有一条样本越线。

风险清单只收录有样本证据的项。没有证据的担心可以写进评审记录,但不应该混进按分数排序的清单,否则高风险项会被主观猜测淹没。

进入卷二的条件

卷二讨论推理性能与成本工程。它建立在卷一的网关基线上:如果一次普通调用都无法稳定交付,任何优化都只是在放大的噪声上做实验。

因此进入卷二需要同时满足七项条件:

条件要求这批样本
样本量≥ 200 条60,未满足
成功率≥ 99%91.67%,未满足
P95 延迟≤ 800ms55ms,满足
平均成本≤ 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% 成功率不等于稳定。
  • 把示例样本结果写成生产实测。数字必须有出处,来源不明的结论比没有结论更危险。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查