《互联网产品的 AI 架构·推理性能与成本工程》10.2 四组对照实验

四组对照跑在同一批固定样本上:提示压缩省 19.72% 成本却丢掉 13.33% 的事实探针;结果缓存省 52.99% 现金成本但误命中 7 次;小模型省 75% 成本而质量降 3.59%;批处理省 8.60% 却把单条延迟推到 58 分钟。

四组实验的共同口径

四组实验使用同一套设计:固定样本、配对到同一条请求、成本用整数微元、延迟用毫秒、质量分落在 0 到 1。每组只改变一个因子,并同时报告成本、延迟与质量三列。数据全部来自 ai-lab 的示例样本运行结果,不是生产实测。

四组实验分别复用前几章的模块:压缩调用第 3 章的 compressContext 与事实探针审计,缓存调用第 5 章的 evaluateCacheEconomics,小模型调用第 6 章的 evaluateDistillation,批处理调用第 7 章的 classifyTask 与 compareCost。这些模块没有被修改,实验层只负责生成样本、汇总与配对。

实验一:提示压缩

样本是 36 个会话上下文,每个包含 5 条检索片段、2 到 3 条历史与 1 条工具结果。基线保留全部片段,处理组应用去重、历史摘要、相关性阈值与全局预算。质量用 5 条事实探针衡量:每个检索片段里的单号与金额必须在保留文本中逐字存活。

指标基线(全量)处理组(压缩)变化
每会话成本902.67 微元724.67 微元−19.72%
每会话延迟230.33 ms212.56 ms−7.72%
质量分1.00000.8667−13.33pp
样本数3636配对

成本与延迟都显著下降,但质量分也显著下降:24 个会话各有一条低分段事实被相关性阈值丢掉。压缩策略在成本上通过,在质量护栏上失败。这正是只看 token 降幅会漏掉的结果。

实验二:结果缓存

样本是 600 条请求,覆盖 chat.reply、search.summary 与 ticket.classify 三个能力。基线每次生成,处理组命中时直接返回。命中率 57.50%,误命中 7 次;误命中代价不计入推理账单,但计入质量分。

指标基线(无缓存)处理组(缓存)变化
每次请求成本153.33 微元72.08 微元−52.99%
每次请求延迟1033.33 ms445.42 ms−56.90%
质量分1.00000.9883−1.17pp
样本数600600配对

现金成本从 92,000 微元降到 43,245 微元。若把 7,500 微元误命中返工计入经济成本,净收益是 41,255 微元,对应经济成本下降 44.84%。两个口径都对,但回答的问题不同:账单问现金,业务问净收益。

实验三:小模型替代

样本是 4 个能力各 10 条,共 40 条。基线是教师模型,处理组是保留率 95% 到 99% 的学生模型,单价系数 0.25、延迟系数 0.42。质量按同一条样本的教师分与学生分配对。

指标基线(教师)处理组(学生)变化
每条成本290.00 微元72.50 微元−75.00%
每条延迟515.00 ms216.50 ms−57.96%
质量分0.88930.8573−3.59%
样本数4040配对

总体保留率 96.41%,单条最大质量差 0.0580,关键样本失败 0 条,按第 6 章的验收策略通过。但 code.review 的保留率只有 94.15%,低于 search.rerank 的 98.15%;总体通过不代表每个能力都能替换。

实验四:批处理

样本是 10,000 条商品刷新条目,其中 200 条必须在窗口前产出。基线全部走在线;处理组 200 条在线、9,800 条进入 2 小时离线窗口。质量两边都是 1,因为批处理不改变内容,只改变处理位置。

指标基线(全部在线)处理组(混合)变化
每条成本204.00 微元186.46 微元−8.60%
每条延迟850 ms3,528,017 ms不可直接比较
质量分1.00001.00000
样本数10,00010,000配对

成本从 2,040,000 微元降到 1,864,600 微元,节省 175,400 微元。延迟那一行不是「变慢 414,960%」,而是「从同步等待变成窗口内产出」:两类延迟量的语义不同。批处理的收益只对可延后消费的条目成立。

实现

experiments-four.ts 复用已有模块,只新增样本生成、指标汇总与配对样本输出。summarizeRun 同时汇总基线与处理组,避免只算处理组;allocate 把离线作业的固定开销逐微元摊到条目上,保证汇总值与总量一致。

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

import { estimateTokens } from "../prompt/context-budget.ts";
import { compressContext, auditCompression } from "./compression.ts";
import type { CompressionPolicy, FactProbe, SourceSegment } from "./compression.ts";
import { evaluateCacheEconomics } from "./cache-econ.ts";
import type { CapabilityCacheEcon } from "./cache-econ.ts";
import { evaluateDistillation } from "./distill.ts";
import type { DistillSample, StudentProfile } from "./distill.ts";
import { breakEvenItems, classifyTask, compareCost, offlineItemMicros, onlineItemMicros } from "./batch.ts";
import type { CostShape } from "./batch.ts";
import type { PairedSample } from "./experiment.ts";

export interface ArmResult {
  name: string;
  units: number;
  totalCostMicros: number;
  meanCostMicros: number;
  meanLatencyMs: number;
  quality: number;
}

export interface ExperimentRun {
  id: string;
  baseline: ArmResult;
  treatment: ArmResult;
  costSamples: PairedSample[];
  latencySamples: PairedSample[];
  qualitySamples: PairedSample[];
  notes: string[];
}

export interface FourExperimentReport {
  experiments: ExperimentRun[];
  baselineTotalMicros: number;
  treatmentTotalMicros: number;
  savingMicros: number;
}

/** 三个指标的配对样本长度必须一致;基线/处理组分别汇总,避免只算处理组。 */
function summarizeRun(
  baselineName: string, treatmentName: string,
  costs: readonly PairedSample[], latencies: readonly PairedSample[], qualities: readonly PairedSample[],
): { baseline: ArmResult; treatment: ArmResult } {
  if (costs.length === 0 || costs.length !== latencies.length || costs.length !== qualities.length) {
    throw new Error("experiment: 三个指标的样本长度不一致");
  }
  const average = (samples: readonly PairedSample[], side: "baseline" | "treatment"): number =>
    samples.reduce((sum, item) => sum + item[side], 0) / samples.length;
  const arm = (name: string, side: "baseline" | "treatment"): ArmResult => {
    const total = costs.reduce((sum, item) => sum + item[side], 0);
    return {
      name, units: costs.length, totalCostMicros: total, meanCostMicros: total / costs.length,
      meanLatencyMs: average(latencies, side), quality: average(qualities, side),
    };
  };
  return { baseline: arm(baselineName, "baseline"), treatment: arm(treatmentName, "treatment") };
}

/** 把整数总量按条目均分,余数落在前几项;保证求和与总量逐微元一致。 */
function allocate(total: number, count: number): number[] {
  const base = Math.floor(total / count);
  const remainder = total - base * count;
  return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
}

const COMPRESSION_POLICY: CompressionPolicy = {
  maxTokens: 300, minScore: 0.4, duplicateThreshold: 0.65, recentHistory: 1, summaryChars: 50,
};
const COMPRESSION_CAPS = ["doc.summarize", "chat.reply", "search.summary", "ticket.classify"] as const;

function contextSegments(capIndex: number, context: number): SourceSegment[] {
  const cap = COMPRESSION_CAPS[capIndex];
  const segments: SourceSegment[] = [];
  for (let index = 0; index < 5; index += 1) {
    const amount = 1200 + ((capIndex * 37 + context * 11 + index * 53) % 8800);
    segments.push({
      id: `${cap}-${context}-r${index}`, kind: "retrieval",
      text: `检索片段${index}:单号 A${capIndex}${context}${index},金额 ${amount} 元,客户等级 ${(context % 4) + 1},状态已确认,需在 ${(index % 6) + 2} 小时内处理。`,
      score: index === 0 ? 0.95 : index === 1 ? 0.8 : index === 2 ? 0.66 : index === 3 ? 0.52 : context % 3 !== 0 ? 0.28 : 0.55,
      recency: 10 - index, mustKeep: index === 0,
    });
  }
  for (let index = 0; index < 2 + (context % 2); index += 1) {
    segments.push({
      id: `${cap}-${context}-h${index}`, kind: "history",
      text: index === 2
        ? "历史记录:客户上次选择加急处理,已确认运费差额,本次续问同一订单的发票抬头。"
        : "历史记录:客户此前询问过同类问题,已按标准话术答复,未留下未结事项。",
      score: 0.7, recency: index,
    });
  }
  segments.push({
    id: `${cap}-${context}-t0`, kind: "tool",
    text: `工具返回:可用库存 ${(context + 1) * 7} 件,预计 ${(context % 5) + 1} 天到货,供应商编号 S${capIndex}${context}。`,
    score: 0.6, recency: 20,
  });
  return segments;
}

function compressionRun(): ExperimentRun {
  const costs: PairedSample[] = [];
  const latencies: PairedSample[] = [];
  const qualities: PairedSample[] = [];
  let beforeTokens = 0;
  let afterTokens = 0;
  COMPRESSION_CAPS.forEach((cap, capIndex) => {
    for (let context = 0; context < 9; context += 1) {
      const segments = contextSegments(capIndex, context);
      const compressed = compressContext(segments, COMPRESSION_POLICY);
      const probes: FactProbe[] = segments.filter((segment) => segment.kind === "retrieval").map((segment) => {
        const parts = segment.text.split(",");
        return { id: segment.id, text: `${parts[0]},${parts[1]}` };
      });
      const audit = auditCompression(compressed, probes);
      const baselineTokens = segments.reduce((sum, segment) => sum + estimateTokens(segment.text), 0);
      const treatedTokens = compressed.afterTokens;
      const id = `${cap}-ctx${context}`;
      costs.push({ id, baseline: baselineTokens * 3, treatment: treatedTokens * 3 });
      latencies.push({ id, baseline: Math.round(140 + baselineTokens * 0.3), treatment: Math.round(140 + treatedTokens * 0.3) });
      qualities.push({ id, baseline: 1, treatment: audit.probes.filter((probe) => probe.found).length / probes.length });
      beforeTokens += baselineTokens;
      afterTokens += treatedTokens;
    }
  });
  return {
    id: "prompt-compression",
    ...summarizeRun("全量上下文", "压缩上下文", costs, latencies, qualities),
    costSamples: costs, latencySamples: latencies, qualitySamples: qualities,
    notes: [
      `压缩前 ${beforeTokens} token,压缩后 ${afterTokens} token,节省 ${beforeTokens - afterTokens} token`,
      `事实探针平均存活率 ${(qualities.reduce((sum, item) => sum + item.treatment, 0) / qualities.length * 100).toFixed(2)}%`,
      "成本按每 token 3 微元计算;延迟按 140ms 固定开销加 0.3ms/token 估算",
    ],
  };
}

const CACHE_CAPS: (CapabilityCacheEcon & { hitLatencyMs: number; missLatencyMs: number; falseHits: number })[] = [
  { capability: "chat.reply", requests: 300, hits: 240, generationMicros: 120, hitServeMicros: 8, lookupMicros: 2,
    entries: 50, storageMicrosPerEntry: 4, invalidationEvents: 2, invalidationMicrosPerEvent: 50,
    falseHitRate: 0.0125, falseHitCostMicros: 900, hitLatencyMs: 40, missLatencyMs: 900, falseHits: 3 },
  { capability: "search.summary", requests: 200, hits: 100, generationMicros: 260, hitServeMicros: 12, lookupMicros: 6,
    entries: 40, storageMicrosPerEntry: 4, invalidationEvents: 1, invalidationMicrosPerEvent: 40,
    falseHitRate: 0.03, falseHitCostMicros: 1500, hitLatencyMs: 60, missLatencyMs: 1500, falseHits: 3 },
  { capability: "ticket.classify", requests: 100, hits: 5, generationMicros: 40, hitServeMicros: 5, lookupMicros: 6,
    entries: 45, storageMicrosPerEntry: 4, invalidationEvents: 1, invalidationMicrosPerEvent: 20,
    falseHitRate: 0.2, falseHitCostMicros: 300, hitLatencyMs: 30, missLatencyMs: 500, falseHits: 1 },
];

function cacheRun(): ExperimentRun {
  const costs: PairedSample[] = [];
  const latencies: PairedSample[] = [];
  const qualities: PairedSample[] = [];
  for (const cap of CACHE_CAPS) {
    const overhead = allocate(
      cap.entries * cap.storageMicrosPerEntry + cap.invalidationEvents * cap.invalidationMicrosPerEvent, cap.requests,
    );
    let falseSeen = 0;
    for (let index = 0; index < cap.requests; index += 1) {
      const remainder = (index * cap.hits) % cap.requests;
      const isHit = remainder < cap.hits;
      const isFalse = isHit && remainder === 0 && falseSeen < cap.falseHits;
      if (isFalse) falseSeen += 1;
      const id = `${cap.capability}-${index}`;
      costs.push({ id, baseline: cap.generationMicros, treatment: (isHit ? cap.hitServeMicros : cap.generationMicros) + cap.lookupMicros + overhead[index] });
      latencies.push({ id, baseline: cap.missLatencyMs, treatment: isHit ? cap.hitLatencyMs : cap.missLatencyMs });
      qualities.push({ id, baseline: 1, treatment: isFalse ? 0 : 1 });
    }
  }
  const economics = evaluateCacheEconomics(CACHE_CAPS);
  return {
    id: "result-cache",
    ...summarizeRun("无缓存", "启用缓存", costs, latencies, qualities),
    costSamples: costs, latencySamples: latencies, qualitySamples: qualities,
    notes: [
      `命中率 ${(economics.hitRate * 100).toFixed(2)}%,经济净收益 ${economics.netBenefitMicros} 微元`,
      `现金成本 ${economics.noCacheMicros} → ${costs.reduce((sum, item) => sum + item.treatment, 0)} 微元`,
      `误命中 ${CACHE_CAPS.reduce((sum, cap) => sum + cap.falseHits, 0)} 次,误命中代价 ${economics.falseHitCostMicros} 微元不计入推理账单`,
    ],
  };
}

const DISTILL_CAPS = ["search.rerank", "chat.reply", "doc.summarize", "code.review"] as const;
const TEACHER_COST: Record<string, number> = { "search.rerank": 180, "chat.reply": 240, "doc.summarize": 320, "code.review": 420 };
const TEACHER_LATENCY: Record<string, number> = { "search.rerank": 380, "chat.reply": 460, "doc.summarize": 540, "code.review": 680 };
const STUDENT: StudentProfile = {
  id: "student-economy-v1",
  retention: { "search.rerank": 0.99, "chat.reply": 0.98, "doc.summarize": 0.97, "code.review": 0.95 },
  difficultyPenalty: 0.015, latencyRatio: 0.42, costRatio: 0.25, contextWindow: 8192,
};

function distillRun(): ExperimentRun {
  const samples: DistillSample[] = [];
  DISTILL_CAPS.forEach((capability, capIndex) => {
    for (let index = 0; index < 10; index += 1) {
      samples.push({
        id: `${capability}-${index}`, capability, difficulty: Number((index / 9).toFixed(2)),
        teacherScore: Number((0.84 + ((capIndex * 4 + index * 3) % 11) / 100).toFixed(2)),
        critical: capability === "code.review" && index === 9,
      });
    }
  });
  const evaluation = evaluateDistillation(samples, STUDENT);
  const costs: PairedSample[] = evaluation.samples.map((result) => ({
    id: result.id, baseline: TEACHER_COST[result.capability], treatment: Math.round(TEACHER_COST[result.capability] * STUDENT.costRatio),
  }));
  const latencies: PairedSample[] = evaluation.samples.map((result) => ({
    id: result.id, baseline: TEACHER_LATENCY[result.capability], treatment: Math.round(TEACHER_LATENCY[result.capability] * STUDENT.latencyRatio),
  }));
  const qualities: PairedSample[] = evaluation.samples.map((result) => ({
    id: result.id, baseline: result.teacherScore, treatment: result.studentScore,
  }));
  return {
    id: "small-model",
    ...summarizeRun("教师模型", "学生模型", costs, latencies, qualities),
    costSamples: costs, latencySamples: latencies, qualitySamples: qualities,
    notes: [
      `总体质量保留率 ${(evaluation.retention * 100).toFixed(2)}%,单条最大质量差 ${evaluation.maxDrop.toFixed(4)}`,
      `关键样本失败 ${evaluation.criticalFailures} 条,验收结论 ${evaluation.decision.accepted ? "通过" : "不通过"}`,
      `回退能力:${evaluation.fallback.disabledCapabilities.join("、") || "无"}`,
    ],
  };
}

const BATCH_TASK = {
  id: "catalog-refresh", items: 10_000, itemToleranceMs: 6 * 3_600_000, freshnessMaxAgeMs: 12 * 3_600_000,
  windowMs: 2 * 3_600_000, partialSuccessAllowed: true, consumption: "deferred" as const, urgentItems: 200,
};
const BATCH_SHAPE: CostShape = {
  tokenMicrosPerItem: 180, onlineCallFixedMicros: 20, offlineJobFixedMicros: 50_000, onlineRetryRate: 0.02, offlineRetryRate: 0.005,
};

function batchRun(): ExperimentRun {
  const policy = { minItems: breakEvenItems(BATCH_SHAPE), capacityPerWindow: 12_000, rerunReserveRatio: 0.1 };
  const decision = classifyTask(BATCH_TASK, policy);
  const comparison = compareCost(decision, BATCH_SHAPE);
  const onlineItem = onlineItemMicros(BATCH_SHAPE);
  const offlineItem = offlineItemMicros(BATCH_SHAPE);
  const overhead = allocate(BATCH_SHAPE.offlineJobFixedMicros, decision.offlineItems);
  const costs: PairedSample[] = [];
  const latencies: PairedSample[] = [];
  const qualities: PairedSample[] = [];
  for (let index = 0; index < BATCH_TASK.items; index += 1) {
    const isOnline = index < decision.onlineItems;
    const offlineIndex = index - decision.onlineItems;
    const id = `item-${index}`;
    costs.push({ id, baseline: onlineItem, treatment: isOnline ? onlineItem : offlineItem + overhead[offlineIndex] });
    latencies.push({ id, baseline: 850, treatment: isOnline ? 850 : Math.round(((offlineIndex + 0.5) * BATCH_TASK.windowMs) / decision.offlineItems) });
    qualities.push({ id, baseline: 1, treatment: 1 });
  }
  return {
    id: "offline-batch",
    ...summarizeRun("全部在线", "紧急在线 + 离线批处理", costs, latencies, qualities),
    costSamples: costs, latencySamples: latencies, qualitySamples: qualities,
    notes: [
      `盈亏平衡条数 ${policy.minItems},作业 ${BATCH_TASK.items} 条,判断 ${decision.placement}`,
      `在线 ${decision.onlineItems} 条,离线 ${decision.offlineItems} 条,节省 ${comparison.savingMicros} 微元(${(comparison.savingShare * 100).toFixed(2)}%)`,
      "离线延迟按条目在 2 小时窗口内均匀产出估算,不能与在线逐条延迟直接比较",
    ],
  };
}

export function runFourExperiments(): FourExperimentReport {
  const experiments = [compressionRun(), cacheRun(), distillRun(), batchRun()];
  const baselineTotalMicros = experiments.reduce((sum, item) => sum + item.baseline.totalCostMicros, 0);
  const treatmentTotalMicros = experiments.reduce((sum, item) => sum + item.treatment.totalCostMicros, 0);
  return { experiments, baselineTotalMicros, treatmentTotalMicros, savingMicros: baselineTotalMicros - treatmentTotalMicros };
}

四个实验都返回 costSamples、latencySamples 与 qualitySamples,它们不是三个互不相干的表,而是同一批 id 上的三列观测。10.3 的配对分析、置信区间与护栏判定直接消费这三个数组,不需要重新采样。

合计与运行结果

实验样本基线总成本处理总成本节省降幅
提示压缩3632,49626,0886,40819.72%
结果缓存60092,00043,24548,75552.99%
小模型替代4011,6002,9008,70075.00%
批处理10,0002,040,0001,864,600175,4008.60%
合计10,6762,176,0961,936,833239,26311.00%

合计只降 11.00%,因为批处理占总成本的 93.75%,把前三项的高降幅稀释了。这也是「整体数字」最常见的骗局:它由样本量最大的那一组决定,而不是由效果最好的那一组决定。

node --test test/perf/experiments-four.test.ts

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

ℹ tests 6
ℹ pass 6
ℹ fail 0
ℹ duration_ms 236.353125

小结

四组实验给出四种不同的取舍:压缩降本但丢事实,缓存降本但有误命中,小模型大幅降本但能力间保留率不均,批处理降本但延迟语义改变。没有一组是「全面更优」;能被推广的只有「在指定护栏内更优」的那一组。

下一节把三列结果放进显著性、置信区间与质量回退的框架,判断哪些结论能发布、哪些只能用于探索。

阅读导航:上一节:10.1 基线、假设与实验设计 · 下一节:10.3 结果解读与回归风险 。

常见错误与边界

  • 把三列结果合成一个「综合分」。权重是主观的,合成后无法判断护栏是否被突破。
  • 只看总额不看单条。样本量最大的实验会主导合计降幅。
  • 把离线延迟与在线延迟放在同一列比较。两者语义不同,差值没有业务含义。
  • 把缓存现金成本与经济净收益混用。误命中返工不显示在推理账单上,但真实发生。
  • 用总体保留率代替分能力保留率。某个能力塌陷会被其他能力平均掉。
  • 假设四组实验的样本可以合并。它们的单位、分布与成本结构都不同。
  • 把示例数字当成生产预期,或忘记记录模块版本。样本固定、流量不固定,阈值一变结论就不可复算。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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