《互联网产品的 AI 架构·推理性能与成本工程》10.1 基线、假设与实验设计

三项优化一起上线,账单降了三成,却无法回答每一项贡献多少。本节把基线、可判定假设、单变量对照、样本量与统计窗口写成可执行的设计门禁,用 63 条样本量的推导说明何时该说样本不够,并演示辛普森悖论:每个分段都更便宜,整体反而更贵。附 6 项设计门禁测试。

一次「三项一起改」的实验

「回声」团队做了一轮成本优化:把提示词裁短、给搜索结果加缓存、把分类请求换到小模型,三项同时上线。一周后账单下降 31%,负责人准备把方案推广到所有能力。

评审时有人问:这 31% 里,提示压缩贡献多少?缓存贡献多少?小模型又贡献多少?如果下周缓存命中率下降,成本会反弹到哪?团队答不上来。三项一起改,账单会给出一个漂亮的总数,却不会告诉你总数来自哪里。

本节讨论的不是某个优化技巧,而是让优化结论可被复核的实验设计:基线怎么定、假设怎么写、为什么一次只改一个因子、样本量与统计窗口怎么算,以及整体结论为什么可能被分段数据推翻。文中所有数字来自 ai-lab 的示例样本运行结果,不是任何真实产品的生产实测。

基线不是上个月的平均值

基线要满足三个条件:同一批请求、同一套口径、同一段业务周期。拿上个月账单对比这个月,至少混入了流量结构、模型价格、请求长度和季节性的变化。把这些变化全部记到「优化」头上,是成本实验最常见的伪结论。

示例做法是把基线固定为同一天、同一批 600 条请求、同一套整数微元口径。处理组只改变一个因子,其余全部冻结。基线一旦固定,就不能在处理组效果不好时回头修改;否则实验会退化成「找到一个能证明方案有效的对照组」。

设计项必须固定常见偷换
请求集合同一批请求 ID 与配对关系本月全量对比上月全量
成本口径整数微元,含重试与失败只算 token,不算重试
业务周期覆盖完整工作日与周末只取促销日或上午峰值
模型价格同一版本价目表中途调价后混算
质量样本同一批标注集与判定规则处理组换一套更简单的题

这张表的作用不是追求「绝对公平」,而是让所有可能变化的量都有归属。任何一项没有固定,差值就只能解释为「优化加上某个未知因素」,而不能解释为优化本身。

假设必须可判定

「缓存能省钱」不是假设,因为它无法被证伪。可判定的假设长这样:在 600 条重复请求上,启用精确缓存后,单次请求的平均推理现金成本下降至少 20%,同时质量分下降不超过 1 个百分点。

三个要素缺一不可:方向(下降)、幅度(20%)、护栏(质量不超过 1 个百分点)。没有幅度的假设会在 0.3% 的改善上宣布成功;没有护栏的假设会用质量换成本,却把质量损失留给下一环节。

单变量与对照组

单变量不是「只改一个参数」,而是「处理组与对照组之间只有一个因子不同」。如果提示压缩改变了输入长度,同时又换了模型,成本变化就无法归因。

对照组也不是「没有优化」,而是「保持现状且同样被观测」。示例中的四组实验,每组都有独立的基线样本与处理样本,并配对到同一条请求:同一条请求既有一条基线成本,也有一条处理成本。配对让样本自身的难度被消掉,差值才是因子的效果。把两组均值直接相减,会把「处理组恰好抽到更简单的请求」也算成优化收益。

样本量与统计窗口

样本量由三件事决定:希望检测到的最小效应 Δ、指标的标准差 σ、以及犯错的容忍度 α 与检验力。两组等分、双侧检验的近似公式是:

n = 2 × (z(1−α/2) + z(power))² × σ² / Δ²

在 α=0.05、power=0.8、σ=20、Δ=10 的示例里,每组需要 63 条样本。如果实际只有 20 条,能稳定检出的最小效应约为 17.7,10 的改善会被噪声吞掉。此时「不显著」不代表「没效果」,只代表「样本不够」。

统计窗口则要覆盖至少一个完整业务周期。只统计周一上午,会把周末积压带来的长请求排除在外;只统计促销日,会把峰值成本当成常态。窗口与样本量必须一起写进设计,事后调整窗口等同于换一个实验。

辛普森悖论:整体与分段

示例:老用户 100 条基线、900 条处理,均值从 90 降到 80;新用户 900 条基线、100 条处理,均值从 20 降到 10。两段内部都降了 10,但整体均值从 27 升到 73,涨幅 46。原因是处理组里 90% 是老用户,而老用户的基数均值本来就高。

整体结论说「处理组更贵」,分段结论说「每段都更便宜」,两者都算对了,但回答的是不同问题。detectSimpson 同时计算两种加权:整体按两组各自样本量加权,段内按每段总样本量加权;反号时返回 signFlip 与反号段清单。

辛普森悖论不是统计花招,而是流量分配的必然结果。只要处理组的段构成与对照组不同,整体均值就会同时承载「段内效果」与「构成变化」两种信息。把两者拆开,才能回答「如果按生产流量比例分配,效果会是多少」。

设计评审的五个问题

在跑任何数据之前,评审只需要问五个问题:

  1. 基线的请求集合、成本口径与业务周期是否已经冻结?
  2. 处理组相对对照组,是否只有一个因子不同?
  3. 假设是否同时写了方向、最小幅度与质量护栏?
  4. 样本量是否由事前的最小效应推出,而不是由「能跑多少」决定?
  5. 结果是否会同时给出整体与分段,而不是只挑一个好看的均值?

五个问题里任意一个答不上来,实验可以继续跑,但结论只能用于探索,不能用于发布。把这条边界写进实验记录,比事后争论「这个数能不能信」更省时间。

实现

experiment.ts 只做设计门禁与统计基础:它不生成实验数据,也不访问模型。checkDesign 拦结构性问题,requiredSampleSize 与 minimumDetectableEffect 回答样本够不够,normalQuantile 与 tCritical95 供后续区间估计使用。配对分析与辛普森检测放在 experiment-analysis.ts,在 10.3 展开。

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

import { AiError } from "../core/errors.ts";

export type MetricKind = "costMicros" | "latencyMs" | "qualityScore";
export type MetricDirection = "lower-is-better" | "higher-is-better";
export type AssignmentUnit = "request" | "item" | "sample";

/** 指标口径固定三件事:单位、方向、非劣性边界。缺一项就会在评审时各说各话。 */
export interface MetricSpec {
  name: string;
  kind: MetricKind;
  direction: MetricDirection;
  /** 允许处理组相对对照组回退的上限;主指标通常为 0。 */
  nonInferiorityMargin: number;
}

export interface ExperimentDesign {
  id: string;
  hypothesis: string;
  unit: AssignmentUnit;
  baselineArm: string;
  treatmentArm: string;
  /** 处理组相对对照组只允许改变一个因子。 */
  changedFactors: string[];
  primary: MetricSpec;
  guardrails: MetricSpec[];
  /** 每组目标样本量;等分分配时即每组样本数。 */
  sampleSize: number;
  /** 统计窗口,必须覆盖至少一个完整业务周期。 */
  windowMinutes: number;
  allocationRatio: number;
  assignment: "hash" | "alternate" | "none";
}

export interface DesignIssue {
  code: string;
  message: string;
}

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

/** 设计门禁只拦结构性问题;多因子、无分配、缺质量护栏都会在这里暴露。 */
export function checkDesign(design: ExperimentDesign): DesignIssue[] {
  const issues: DesignIssue[] = [];
  if (design.changedFactors.length !== 1) {
    issues.push({ code: "factor-count", message: `单变量实验必须恰好改变 1 个因子,当前 ${design.changedFactors.length} 个` });
  }
  if (design.changedFactors.some((factor) => factor.trim() === "")) {
    issues.push({ code: "empty-factor", message: "因子名不能为空" });
  }
  if (design.assignment === "none") {
    issues.push({ code: "no-assignment", message: "没有随机或交替分配,无法排除顺序、时段与选择偏差" });
  }
  if (!Number.isInteger(design.sampleSize) || design.sampleSize <= 0) {
    issues.push({ code: "sample-size", message: "每组样本量必须是正整数" });
  }
  if (!Number.isFinite(design.windowMinutes) || design.windowMinutes <= 0) {
    issues.push({ code: "window", message: "统计窗口必须是正数分钟" });
  }
  if (!(design.allocationRatio > 0 && design.allocationRatio < 1)) {
    issues.push({ code: "allocation", message: "流量比例必须落在 (0, 1)" });
  } else if (Math.abs(design.allocationRatio - 0.5) > 0.1) {
    issues.push({ code: "unbalanced", message: `处理组流量 ${design.allocationRatio},两组样本量相差超过 20%,方差估计会失真` });
  }
  if (!design.guardrails.some((spec) => spec.kind === "qualityScore")) {
    issues.push({ code: "no-quality-guardrail", message: "缺少质量护栏;只看成本与延迟会把质量回退放行" });
  }
  if (design.primary.nonInferiorityMargin < 0) {
    issues.push({ code: "negative-margin", message: "非劣性边界不能为负" });
  }
  return issues;
}

/** Acklam 有理逼近;误差远小于实验本身的抽样误差。 */
export function normalQuantile(p: number): number {
  if (!(p > 0 && p < 1)) fail("概率必须落在 (0, 1)");
  const a = [-39.69683028665376, 220.9460984245205, -275.9285104469687, 138.357751867269, -30.66479806614716, 2.506628277459239];
  const b = [-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572];
  const c = [-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783];
  const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416];
  const low = 0.02425;
  if (p < low) {
    const q = Math.sqrt(-2 * Math.log(p));
    return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
  }
  if (p > 1 - low) {
    const q = Math.sqrt(-2 * Math.log(1 - p));
    return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
  }
  const q = p - 0.5;
  const r = q * q;
  return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
}

function erf(x: number): number {
  const sign = x < 0 ? -1 : 1;
  const ax = Math.abs(x);
  const t = 1 / (1 + 0.3275911 * ax);
  const poly = ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t;
  return sign * (1 - poly * Math.exp(-ax * ax));
}

export function normalCdf(z: number): number {
  return 0.5 * (1 + erf(z / Math.SQRT2));
}

const T95 = [12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, 2.201, 2.179, 2.160, 2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086, 2.080, 2.074, 2.069, 2.064, 2.060, 2.056, 2.052, 2.048, 2.045, 2.042];

export function tCritical95(df: number): number {
  if (!Number.isInteger(df) || df < 1) fail("自由度必须是正整数");
  return df <= 30 ? T95[df - 1] : 1.96;
}

/**
 * 两组等分、双侧检验所需样本量:n = 2 × (z(1−α/2) + z(power))² × σ² / Δ²。
 * Δ 是期望检测到的最小差值,不是事后观测到的差值。
 */
export function requiredSampleSize(effect: number, sd: number, alpha = 0.05, power = 0.8): number {
  if (!(effect > 0)) fail("effect 必须是正数");
  if (!(sd > 0)) fail("sd 必须是正数");
  if (!(alpha > 0 && alpha < 1)) fail("alpha 必须落在 (0, 1)");
  if (!(power > 0 && power < 1)) fail("power 必须落在 (0, 1)");
  const z = normalQuantile(1 - alpha / 2) + normalQuantile(power);
  return Math.ceil((2 * z * z * sd * sd) / (effect * effect));
}

/** 给定样本量能稳定检出的最小效应,用来区分“没效果”和“没样本”。 */
export function minimumDetectableEffect(sd: number, sampleSize: number, alpha = 0.05, power = 0.8): number {
  if (!(sd > 0)) fail("sd 必须是正数");
  if (!Number.isInteger(sampleSize) || sampleSize < 2) fail("sampleSize 必须是 >= 2 的整数");
  const z = normalQuantile(1 - alpha / 2) + normalQuantile(power);
  return z * sd * Math.sqrt(2 / sampleSize);
}

export interface PairedSample {
  id: string;
  baseline: number;
  treatment: number;
}

checkDesign 把「多因子」「无分配」「缺质量护栏」「流量失衡」「窗口非法」分成独立错误码,而不是返回一个布尔值。评审时可以逐条讨论:是补质量护栏,还是承认这是一次探索性实验、不用于发布决策。一个看起来「只是提醒」的结构问题,往往决定实验结论能不能进入发布流程。

requiredSampleSize 的 Δ 必须事前给定。用观测到的差值反推样本量,等于先看答案再出题,得到的检验力没有意义。

测试与运行结果

测试覆盖合法设计、多因子拦截、分配与护栏、样本量、分位数与非法参数。执行:

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

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

ℹ tests 6
ℹ pass 6
ℹ fail 0
ℹ duration_ms 113.920083

示例数字可复算:requiredSampleSize(10, 20) 返回 63,minimumDetectableEffect(20, 63) 返回 9.9834,normalQuantile(0.975) 返回 1.959964,tCritical95(9) 返回 2.262。这些数字不随机器变化,只随输入变化。

小结

实验设计的产出不是「一个结论」,而是一组可复核的约束:基线固定、假设可判定、一次只改一个因子、样本量与窗口事前确定、整体与分段同时报告。满足这些约束,31% 才能被拆成三项各自的贡献;不满足,31% 只是一个无法归因的总数。

下一节在同一套设计下运行四组对照实验,逐组给出成本、延迟与质量三列结果。

阅读导航:上一节:9.3 账单对账与差异分析 · 下一节:10.2 四组对照实验 。

常见错误与边界

  • 用上个月账单当基线。流量结构、价格与请求长度都变了,差值无法归因。
  • 三项优化一起上线。账单下降是事实,但每个因子的贡献无法分离。
  • 假设没有幅度。0.3% 的改善也能被宣布为成功。
  • 假设没有质量护栏。成本下降可能只是把错误推给下游。
  • 样本量按「能跑多少」决定。样本不足时「不显著」被误读为「没效果」。
  • 统计窗口只覆盖一个时段。峰值、周末与促销日的结构差异会被当成处理效果。
  • 只看整体均值。分段构成变化会制造辛普森悖论。
  • 事后调整窗口或剔除样本。这会把实验变成对既定结论的拟合。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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