一次「三项一起改」的实验
「回声」团队做了一轮成本优化:把提示词裁短、给搜索结果加缓存、把分类请求换到小模型,三项同时上线。一周后账单下降 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 与反号段清单。
辛普森悖论不是统计花招,而是流量分配的必然结果。只要处理组的段构成与对照组不同,整体均值就会同时承载「段内效果」与「构成变化」两种信息。把两者拆开,才能回答「如果按生产流量比例分配,效果会是多少」。
设计评审的五个问题
在跑任何数据之前,评审只需要问五个问题:
- 基线的请求集合、成本口径与业务周期是否已经冻结?
- 处理组相对对照组,是否只有一个因子不同?
- 假设是否同时写了方向、最小幅度与质量护栏?
- 样本量是否由事前的最小效应推出,而不是由「能跑多少」决定?
- 结果是否会同时给出整体与分段,而不是只挑一个好看的均值?
五个问题里任意一个答不上来,实验可以继续跑,但结论只能用于探索,不能用于发布。把这条边界写进实验记录,比事后争论「这个数能不能信」更省时间。
实现
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% 的改善也能被宣布为成功。
- 假设没有质量护栏。成本下降可能只是把错误推给下游。
- 样本量按「能跑多少」决定。样本不足时「不显著」被误读为「没效果」。
- 统计窗口只覆盖一个时段。峰值、周末与促销日的结构差异会被当成处理效果。
- 只看整体均值。分段构成变化会制造辛普森悖论。
- 事后调整窗口或剔除样本。这会把实验变成对既定结论的拟合。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。