《互联网产品的 AI 架构·推理性能与成本工程》10.3 结果解读与回归风险

成本显著下降不等于可以发布:批处理每条只省 17.54 微元却把延迟推到 58 分钟,缓存的质量回退在 12 次比较后不再显著。本节用置信区间、效应量、质量回退形态与外推风险,逐组判断四组实验哪些能上线、哪些只能留作探索,并附 7 项判定测试。

显著只是第一道门

10.2 的四组实验都算出了「处理组更便宜」。但便宜是否可信、是否值得发布,是三个不同的问题:统计上是否显著、效应是否大到值得迁移、质量与延迟是否仍在护栏内。

先看显著性。批处理的成本差只有 17.54 微元,但 10,000 条配对样本把标准误压到 0.025,t 值达到 −695.013,p 小于 0.001。显著只说明「差值不太可能恰好来自抽样噪声」,不说明差值有业务价值。样本足够大时,任何微小差异都会显著;这时要看置信区间与相对变化,而不是只看星号。

置信区间比 p 值更有用

实验成本差(95% CI,微元)质量差(95% CI)p
提示压缩−178.00 [−195.80, −160.20]−0.1333 [−0.1646, −0.1021]<0.001
结果缓存−81.26 [−88.36, −74.16]−0.0117 [−0.0203, −0.0031]0.0078
小模型替代−217.50 [−238.69, −196.32]−0.0320 [−0.0364, −0.0276]<0.001
批处理−17.54 [−17.59, −17.49]0.0000 [0.0000, 0.0000]1.000

置信区间同时给出方向、幅度与不确定性。缓存的质量差上界是 −0.0031,说明「质量完全没变」不太可能;但下界只有 −0.0203,最坏情况接近 2 个百分点。评审要决定的是「最坏情况能否接受」,而不是「均值看起来还行」。

批处理的成本区间窄到几乎是一条线,因为每条条目的成本由固定公式决定,方差只来自 200 条在线与 9,800 条离线之间的分配。区间窄不代表结论强,只代表这个成本模型本身很确定;模型之外的延迟风险没有被区间覆盖。

多重比较:12 次检验里的一次显著

四组实验、三个指标,一共 12 次检验。即使所有处理都没有效果,按 α=0.05 也期望出现 0.6 次假阳性。把族错误率控制在 0.05,单次门槛应降到 0.05/12≈0.0042。

按这个门槛,缓存的质量回退 p=0.0078 不再显著。这不是说质量一定没退,而是说「在 12 次比较的家庭里,这次回退还不足以排除偶然」。结论应该写成「观察到 1.17 个百分点的质量下降,但未通过多重比较校正」,而不是「缓存显著损害质量」。压缩与小模型的质量 p 远小于校正门槛,结论不受影响。

质量回退的三种形态

第一种是均值整体下移。提示压缩的平均质量分从 1.0000 降到 0.8667,13.33 个百分点的差距在任何分段里都存在。这种回退最容易发现,也最容易被均值掩盖掉尾部:某个会话可能丢的是关键单号,而不是一条无关描述。

第二种是低概率、高代价的误命中。结果缓存 600 条请求里只有 7 次误命中,质量均值只降 1.17 个百分点;但每次误命中都是一次错误交付,代价 900 到 1,500 微元,合计 7,500 微元。均值把「7 次严重错误」摊成了「一点点质量损失」。

第三种是能力间不均衡。小模型总体保留率 96.41%,但 code.review 只有 94.15%,比 search.rerank 的 98.15% 低 4 个百分点。总体通过验收,不代表每个能力都能替换;如果生产流量里代码评审占比更高,总体保留率会继续下滑。

结论能不能外推

外推风险由两件事决定:实验样本的段构成与生产流量差多少,以及实验是否漏掉了处理组更差的段。示例检查给出:段构成差异 30.0%,受损段「批量任务」在生产中占 40%、在实验中只占 10%,综合判定为高风险。

四组实验的外推条件各不相同:

  • 提示压缩的 36 个会话是合成的,生产上下文更长、工具结果更多,压缩比与事实丢失率都会变。
  • 结果缓存只覆盖三个能力与精确命中,语义缓存、跨租户复用与失效传播都没有进入样本。
  • 小模型替代的 40 条样本按能力均分,生产流量不会均分;code.review 占比上升会拉低总体保留率。
  • 批处理的 2 小时窗口与 6 小时容忍度是设定值,生产的新鲜度要求更紧时,离线比例会下降。

因此「实验通过」只能写成「在实验覆盖的段与窗口内通过」。发布前要补的是生产段构成、最差段的表现,以及窗口收紧后的成本曲线,而不是再跑一遍同样的样本。

四组复核结论

实验主指标质量护栏(≤1pp)综合判定
提示压缩成本 −19.72%,显著−13.33pp,突破不通过
结果缓存成本 −52.99%,显著−1.17pp,略超门槛边界,需重设护栏
小模型替代成本 −75.00%,显著−3.59pp,突破条件通过,需保护弱能力
批处理成本 −8.60%,显著0,通过仅延后消费可用

「边界」不是模糊词。缓存的质量差 1.17 个百分点,与 1 个百分点的护栏只差 0.17;要么把护栏改成 2 个百分点并说明依据,要么缩小缓存范围,把高风险能力排除。把边界写成「基本通过」,下一次评审就会把它当成通过。

实现

experiment-analysis.ts 提供配对分析、辛普森检测、护栏判定、发布决策与外推风险五组函数。它不读取实验数据,只接收 PairedSample 与分段汇总,因此同一套判定可以复用到下一章的质量-成本-延迟权衡。

// file: src/perf/experiment-analysis.ts
// tier: L2
// verify: node --test test/perf/experiment-analysis.test.ts

import { AiError } from "../core/errors.ts";
import { normalCdf, tCritical95 } from "./experiment.ts";
import type { MetricSpec, PairedSample } from "./experiment.ts";

export interface ArmSummary {
  n: number;
  mean: number;
  sd: number;
  se: number;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `experiment-analysis: ${message}`);
}

function mean(values: readonly number[]): number {
  return values.reduce((sum, value) => sum + value, 0) / values.length;
}

function sampleSd(values: readonly number[]): number {
  if (values.length < 2) return 0;
  const center = mean(values);
  const variance = values.reduce((sum, value) => sum + (value - center) ** 2, 0) / (values.length - 1);
  return Math.sqrt(variance);
}

export function summarizeArm(values: readonly number[]): ArmSummary {
  if (values.length === 0) fail("汇总至少需要 1 个值");
  const sd = sampleSd(values);
  return { n: values.length, mean: mean(values), sd, se: sd / Math.sqrt(values.length) };
}

export interface PairedAnalysis {
  metric: string;
  n: number;
  baseline: ArmSummary;
  treatment: ArmSummary;
  meanDiff: number;
  sdDiff: number;
  ci95: [number, number];
  tStat: number;
  pValue: number;
  relativeChange: number;
  cohensDz: number;
  /** 按指标方向归一后的改善量:正数表示处理组更好。 */
  improvement: number;
  significant: boolean;
}

/** 逐条配对后再做差,而不是拿两个组的均值直接相减。配对消掉样本难度差异。 */
export function analyzePaired(samples: readonly PairedSample[], spec: MetricSpec, alpha = 0.05): PairedAnalysis {
  if (samples.length < 2) fail("配对分析至少需要 2 条样本");
  for (const sample of samples) {
    if (!Number.isFinite(sample.baseline) || !Number.isFinite(sample.treatment)) fail(`样本 ${sample.id} 的数值必须是有限数`);
  }
  const baseline = summarizeArm(samples.map((sample) => sample.baseline));
  const treatment = summarizeArm(samples.map((sample) => sample.treatment));
  const diffs = samples.map((sample) => sample.treatment - sample.baseline);
  const meanDiff = mean(diffs);
  const sdDiff = sampleSd(diffs);
  const seDiff = sdDiff / Math.sqrt(samples.length);
  const crit = tCritical95(samples.length - 1);
  const ci95: [number, number] = seDiff === 0 ? [meanDiff, meanDiff] : [meanDiff - crit * seDiff, meanDiff + crit * seDiff];
  const tStat = seDiff === 0 ? (meanDiff === 0 ? 0 : Number.POSITIVE_INFINITY * Math.sign(meanDiff)) : meanDiff / seDiff;
  const pValue = seDiff === 0 ? (meanDiff === 0 ? 1 : 0) : 2 * (1 - normalCdf(Math.abs(tStat)));
  const improvement = spec.direction === "lower-is-better" ? -meanDiff : meanDiff;
  return {
    metric: spec.name, n: samples.length, baseline, treatment, meanDiff, sdDiff, ci95, tStat, pValue,
    relativeChange: baseline.mean === 0 ? 0 : meanDiff / Math.abs(baseline.mean),
    cohensDz: sdDiff === 0 ? 0 : meanDiff / sdDiff,
    improvement,
    significant: pValue < alpha && (ci95[0] > 0 || ci95[1] < 0),
  };
}

export interface SegmentObservation {
  segment: string;
  baselineN: number;
  baselineMean: number;
  treatmentN: number;
  treatmentMean: number;
}

export interface SegmentDelta {
  segment: string;
  baselineN: number;
  treatmentN: number;
  delta: number;
  weight: number;
}

export interface SimpsonReport {
  aggregateBaseline: number;
  aggregateTreatment: number;
  aggregateDelta: number;
  withinSegmentDelta: number;
  signFlip: boolean;
  flippedSegments: string[];
  segments: SegmentDelta[];
}

function weightedMean(values: readonly number[], weights: readonly number[]): number {
  const total = weights.reduce((sum, weight) => sum + weight, 0);
  if (total === 0) return 0;
  return values.reduce((sum, value, index) => sum + value * weights[index], 0) / total;
}

/** 整体差值用两组各自样本量加权,段内差值用每段总样本量加权;反号即构成悖论。 */
export function detectSimpson(segments: readonly SegmentObservation[]): SimpsonReport {
  if (segments.length === 0) fail("辛普森检查至少需要 1 个分段");
  for (const item of segments) {
    if (item.baselineN < 0 || item.treatmentN < 0) fail(`${item.segment}: 样本量不能为负`);
    if (!Number.isFinite(item.baselineMean) || !Number.isFinite(item.treatmentMean)) fail(`${item.segment}: 均值必须是有限数`);
  }
  const aggregateBaseline = weightedMean(segments.map((item) => item.baselineMean), segments.map((item) => item.baselineN));
  const aggregateTreatment = weightedMean(segments.map((item) => item.treatmentMean), segments.map((item) => item.treatmentN));
  const totalN = segments.reduce((sum, item) => sum + item.baselineN + item.treatmentN, 0);
  const details: SegmentDelta[] = segments.map((item) => ({
    segment: item.segment, baselineN: item.baselineN, treatmentN: item.treatmentN,
    delta: item.treatmentMean - item.baselineMean,
    weight: totalN === 0 ? 0 : (item.baselineN + item.treatmentN) / totalN,
  }));
  const withinSegmentDelta = details.reduce((sum, item) => sum + item.delta * item.weight, 0);
  const aggregateDelta = aggregateTreatment - aggregateBaseline;
  const signFlip = aggregateDelta * withinSegmentDelta < 0 && Math.abs(aggregateDelta) > 1e-9 && Math.abs(withinSegmentDelta) > 1e-9;
  return {
    aggregateBaseline, aggregateTreatment, aggregateDelta, withinSegmentDelta, signFlip,
    flippedSegments: signFlip
      ? details.filter((item) => item.delta * aggregateDelta < 0 && item.weight >= 0.05).map((item) => item.segment)
      : [],
    segments: details,
  };
}

export interface GuardrailResult {
  metric: string;
  /** 处理组相对对照组的原始差值。 */
  delta: number;
  allowed: number;
  passed: boolean;
}

/** 护栏按“回退量”判断:成本看是否涨,质量看是否跌,方向不能共用一套符号。 */
export function checkGuardrail(analysis: PairedAnalysis, spec: MetricSpec): GuardrailResult {
  const regression = spec.direction === "higher-is-better" ? -analysis.meanDiff : analysis.meanDiff;
  return { metric: spec.name, delta: analysis.meanDiff, allowed: spec.nonInferiorityMargin, passed: regression <= spec.nonInferiorityMargin };
}

export interface DecisionPolicy {
  alpha: number;
  /** 相对改善低于该比例时,不值得为迁移与回滚风险买单。 */
  minRelativeEffect: number;
}

export interface ExperimentDecision {
  ship: boolean;
  reasons: string[];
}

export function decideExperiment(primary: PairedAnalysis, guardrails: readonly GuardrailResult[], policy: DecisionPolicy): ExperimentDecision {
  const reasons: string[] = [];
  if (!primary.significant) reasons.push("主指标未达到统计显著");
  if (primary.improvement <= 0) reasons.push("主指标没有改善");
  if (Math.abs(primary.relativeChange) < policy.minRelativeEffect) {
    reasons.push(`相对变化 ${(primary.relativeChange * 100).toFixed(2)}% 低于门槛 ${(policy.minRelativeEffect * 100).toFixed(2)}%`);
  }
  for (const guardrail of guardrails) {
    if (!guardrail.passed) reasons.push(`护栏 ${guardrail.metric} 回退 ${guardrail.delta.toFixed(4)} 超过允许 ${guardrail.allowed}`);
  }
  return { ship: reasons.length === 0, reasons };
}

export interface ShareObservation {
  segment: string;
  /** 实验样本中该段占比。 */
  experimentShare: number;
  /** 生产流量中该段占比。 */
  productionShare: number;
  /** 处理组减对照组;负数表示处理组更差。 */
  delta: number;
}

export interface ExtrapolationReport {
  /** 两组占比的总变差距离,0 表示构成完全一致。 */
  shareGap: number;
  risk: "low" | "medium" | "high";
  underRepresentedHarmful: string[];
  reasons: string[];
}

/** 外推风险 = 段构成差多少 + 是否漏掉受损段;只报“代表性良好”无法指导发布。 */
export function extrapolationRisk(items: readonly ShareObservation[]): ExtrapolationReport {
  if (items.length === 0) fail("外推检查至少需要 1 个分段");
  for (const item of items) {
    if (item.experimentShare < 0 || item.productionShare < 0) fail(`${item.segment}: 占比不能为负`);
  }
  const shareGap = 0.5 * items.reduce((sum, item) => sum + Math.abs(item.experimentShare - item.productionShare), 0);
  const underRepresentedHarmful = items
    .filter((item) => item.delta < 0 && item.productionShare - item.experimentShare > 0.05)
    .map((item) => item.segment);
  const harmfulProduction = items.filter((item) => item.delta < 0).reduce((sum, item) => sum + item.productionShare, 0);
  const reasons: string[] = [];
  if (shareGap > 0.15) reasons.push(`段构成差异 ${(shareGap * 100).toFixed(1)}% 超过 15%`);
  if (underRepresentedHarmful.length > 0) reasons.push(`受损段被低采样:${underRepresentedHarmful.join("、")}`);
  if (harmfulProduction > 0.2) reasons.push(`生产流量中受损段占 ${(harmfulProduction * 100).toFixed(1)}%`);
  return { shareGap, risk: reasons.length >= 2 ? "high" : reasons.length === 1 ? "medium" : "low", underRepresentedHarmful, reasons };
}

analyzePaired 同时返回原始差值、相对变化、Cohen’s dz 与置信区间,是为了让评审能区分「统计显著」与「效应够大」。decideExperiment 把显著性、最小相对效应与全部护栏放在一次判断里,任何一项不满足都会返回具体原因,而不是一个布尔值。

extrapolationRisk 不判断实验本身好不好,只回答「这份样本能否代表生产」。当受损段被低采样时,它直接点名该段;这比一个「代表性良好」的形容词更能阻止错误发布。

测试与运行结果

测试覆盖配对区间、护栏方向、发布决策、不显著情形、辛普森悖论、外推风险与非法输入。执行:

node --test test/perf/experiment-analysis.test.ts

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 7
ℹ pass 7
ℹ fail 0
ℹ duration_ms 154.132792

示例中辛普森检测返回整体差值 +46、段内差值 −10、signFlip=true;外推检查返回段构成差异 30.0%、风险 high、受损段 ["批量任务"]。这些结果与 10.1 的设计门禁、10.2 的配对样本使用同一套口径,可以逐项复算。

小结

结果解读要同时回答四个问题:差值是否超出抽样噪声、幅度是否值得迁移、质量与延迟是否仍在护栏内、样本能否代表生产。四组实验里,只有批处理在质量上无回退,但它的延迟语义已经改变;其余三组都需要重新谈护栏或缩小范围。显著性只是入场券,不是发布许可。

下一章把成本、延迟与质量放到同一个权衡面上,讨论如何为不同能力选择不同的最优点。

阅读导航:上一节:10.2 四组对照实验 · 下一节:11.1 质量-成本-延迟的权衡面 。

常见错误与边界

  • 把 p<0.05 当成「可以发布」。它只排除抽样噪声,不回答幅度与护栏。
  • 样本足够大时追逐微小效应。批处理每条省 17.54 微元,但延迟代价没有被算进去。
  • 只报均值不报区间。缓存质量最坏可能降 2 个百分点,均值只显示 1.17。
  • 忽略多重比较。12 次检验里的一次 p=0.0078,校正后不再显著。
  • 用总体保留率掩盖弱能力。code.review 的 94.15% 会被其他能力平均掉。
  • 把误命中当成「少量误差」。7 次错误交付的代价不会因为占比低而消失。
  • 把实验结论直接外推到生产。段构成、窗口与流量比例都可能不同。
  • 护栏被突破后改成「基本通过」。下一次评审会把边界当成既成事实。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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