《互联网产品的 AI 架构·推理性能与成本工程》13.1 推理成本基线

把第 1、8、9、11 章的延迟、容量、成本与质量口径合成一份可复算的推理成本基线:用 240 条确定性样本说明指标怎么定义、样本如何按能力与模型档位分层、结论如何从证据推导,并给出可逐条运行的 TypeScript 报告模块与示例样本结果。

一份没人敢签字的基线报告

季度预算评审前,「回声」的工程团队交上来一页纸:延迟「可接受」、成本「在预算内」、质量「没有明显退步」、缓存「有效果」。财务问了一句:那下季度按多少预算?没有人能立刻回答。

问题不在数字难看,而在数字无法复算。平均延迟没有说样本量,成本没有说是否包含失败请求,质量没有说用的是哪套评测集,缓存没有说命中率的分母。四个结论各自成立,合在一起却不能支撑一个预算决定。

本节把第 1 章的延迟口径、第 8 章的 token 需求、第 9 章的成本结构、第 11 章的质量评测合成一份基线报告。基线不是一句总结,而是一组固定样本算出的指标、一张按维度切开的表,以及由指标触发的结论。文中所有数字都来自 ai-lab 示例样本的运行结果,不是生产实测。

指标:四个章节各交一项

一份成本基线至少要有四类指标。少任何一类,结论都会在某个方向上失真。

指标来自定义缺失后的后果
TTFT P95 / E2E P95第 1 章最近秩法取第 ceil(0.95×n) 个真实样本平均值抹掉冷启动与长尾
平均输入 / 输出 token第 8 章每条记录的 token 数取算术平均容量模型失去输入,只能拍脑袋
总成本 / 平均成本第 9 章整数微元累加,失败请求也计入失败成本消失,预算被低估
质量均值第 11 章评测分 0 到 1 的算术平均,失败记 0降本无法与质量回退一起验收

成功率的口径需要单独说明:ok + cache-hit + degraded 都算交付了结果,只有 error 算失败。缓存命中仍然是用户拿到的答案;降级也仍然是可用体验。把降级混进错误,会让错误率虚高,团队会去修一个并不存在的可用性问题。

P95 用最近秩法而不是插值。样本量只有 240 条时,插值会造出一个不存在的延迟值;最近秩法取真实样本,虽然粗糙,但任何人都能按排序逐条复算。

样本:240 条确定性记录

样本由固定规则生成,不依赖随机数。四个能力共 240 条,结局按索引区间切分,成本、token 与质量分都由索引直接算出。

能力样本成功 / 缓存 / 降级 / 失败平均成本(微元)质量均值
chat.reply9080 / 4 / 4 / 291.230.868
search.rerank6056 / 2 / 1 / 148.870.909
doc.summarize5046 / 2 / 1 / 1123.360.852
code.review4037 / 1 / 2 / 0192.930.833
合计240219 / 9 / 8 / 4104.280.869

这张表本身就是一次分层检查。整体平均成本 104.28 微元,但 code.review 是 192.93,接近整体的两倍;search.rerank 只有 48.87。如果只看整体,最贵的切片会被便宜的能力平均掉。

样本量 240 条刚好越过 200 条的最低线,但这不是「足够」的同义词。它只够验证实现与口径,不足以为一个季度预算背书。基线报告必须把样本量写进结论,否则数字会被当成生产事实引用。

分层:整体会掩盖什么

分层不是多做一张表,而是回答「这个结论对谁成立」。

按能力切片

切片成功率E2E P95平均成本缓存命中质量均值
全部98.33%834ms104.283.75%0.869
chat.reply97.78%852ms91.234.44%0.868
search.rerank98.33%526ms48.873.33%0.909
doc.summarize98.00%854ms123.364.00%0.852
code.review100.00%804ms192.932.50%0.833

chat.reply 的成功率低于整体,因为它有 2 条错误;code.review 成功率 100%,但质量均值最低。两种问题不能互相抵消:前者是可用性,后者是质量,修复动作完全不同。

按模型档位切片

economy 60 条、standard 140 条、frontier 40 条。frontier 的平均成本 192.93 微元,是 economy 的 3.95 倍,质量只高 0 个点还低——这正是 13.2 要处理的问题:贵的那一档是否真的买到了质量。

分层维度不是越多越好。能力和模型档位已经能回答「哪里贵、哪里慢、哪里质量低」;再加租户与地区,样本会被切得太碎,每一格都不足以支撑结论。

结论:conditional 不是失败

报告给出 conditional,唯一越线项是缓存命中率 3.75%,低于 15% 的下限。其余指标全部在阈值内。

检查项观测目标结果
样本量240≥ 200通过
错误率1.67%≤ 2.00%通过
降级率3.33%≤ 5.00%通过
E2E P95834ms≤ 900ms通过
平均成本104.28≤ 130通过
质量均值0.869≥ 0.800通过
缓存命中率3.75%≥ 15.00%未通过

conditional 的含义是「可以继续用,但不能据此宣称缓存已经产生收益」。它把下一步动作限定在一个方向:先核对缓存键与提示版本,再决定是否投入缓存容量。如果报告写成 pass,这个动作就会被跳过;写成 fail,团队又会去修并不紧急的延迟。

阈值必须和样本一起版本化。半年后有人问「15% 是谁定的」,答案应该是一份带日期的策略文件,而不是某个人的记忆。

实现

下面的模块只做三件事:把记录切成整体、能力、模型档位三层;按固定阈值生成 findings;把 findings 汇总成 pass / conditional / fail。它不读日志、不访问账单,输入是一组已经结算好的记录。

// file: src/perf/baseline.ts
// tier: L1
// verify: node --test test/perf/baseline.test.ts

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

export type BaselineOutcome = "ok" | "cache-hit" | "degraded" | "error";
export type ModelTier = "frontier" | "standard" | "economy";

/** 一条结算记录,把第 1、8、9、11 章关心的字段放在同一行。 */
export interface BaselineRecord {
  requestId: string;
  capability: string;
  tier: ModelTier;
  outcome: BaselineOutcome;
  ttftMs: number;
  e2eMs: number;
  inputTokens: number;
  outputTokens: number;
  costMicros: number;
  /** 输入 token 中命中缓存的比例,0 到 1。 */
  cachedInputShare: number;
  /** 第 11 章的评测分,0 到 1;失败请求记 0。 */
  qualityScore: number;
}

/** 阈值是基线报告的一部分,必须和样本一起版本化。 */
export interface BaselineTargets {
  minSamples: number;
  maxErrorRate: number;
  maxDegradeRate: number;
  minCacheHitRate: number;
  maxP95E2eMs: number;
  maxAvgCostMicros: number;
  minQualityMean: number;
}

export const DEFAULT_TARGETS: BaselineTargets = {
  minSamples: 200,
  maxErrorRate: 0.02,
  maxDegradeRate: 0.05,
  minCacheHitRate: 0.15,
  maxP95E2eMs: 900,
  maxAvgCostMicros: 130,
  minQualityMean: 0.8,
};

export interface BaselineSlice {
  samples: number;
  ok: number;
  cacheHits: number;
  degraded: number;
  errors: number;
  successRate: number;
  errorRate: number;
  degradedRate: number;
  cacheHitRate: number;
  ttftP95Ms: number;
  e2eP95Ms: number;
  avgCostMicros: number;
  totalCostMicros: number;
  avgInputTokens: number;
  avgOutputTokens: number;
  avgCachedInputShare: number;
  qualityMean: number;
}

export type FindingSeverity = "blocker" | "warning";

export interface BaselineFinding {
  id: string;
  severity: FindingSeverity;
  metric: string;
  observed: string;
  target: string;
  action: string;
}

export interface BaselineReport {
  overall: BaselineSlice;
  byCapability: Record<string, BaselineSlice>;
  byTier: Record<string, BaselineSlice>;
  findings: BaselineFinding[];
  verdict: "pass" | "conditional" | "fail";
  targets: BaselineTargets;
}

const OUTCOMES = new Set<string>(["ok", "cache-hit", "degraded", "error"]);
const TIERS = new Set<string>(["frontier", "standard", "economy"]);

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

function ratio(numerator: number, denominator: number): number {
  return denominator === 0 ? 0 : numerator / denominator;
}

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

function validate(record: BaselineRecord): void {
  if (record.requestId.trim() === "" || record.capability.trim() === "") {
    fail("requestId 与 capability 不能为空");
  }
  if (!OUTCOMES.has(record.outcome)) fail(`${record.requestId}: 未知 outcome ${String(record.outcome)}`);
  if (!TIERS.has(record.tier)) fail(`${record.requestId}: 未知 tier ${String(record.tier)}`);
  for (const field of ["ttftMs", "e2eMs", "inputTokens", "outputTokens", "costMicros"] as const) {
    const value = record[field];
    if (!Number.isFinite(value) || value < 0) fail(`${record.requestId}: ${field} 必须是非负有限数`);
  }
  if (!Number.isInteger(record.costMicros)) fail(`${record.requestId}: costMicros 必须是整数微元`);
  if (record.e2eMs < record.ttftMs) fail(`${record.requestId}: e2eMs 不能小于 ttftMs`);
  for (const field of ["cachedInputShare", "qualityScore"] as const) {
    const value = record[field];
    if (!Number.isFinite(value) || value < 0 || value > 1) fail(`${record.requestId}: ${field} 必须落在 [0, 1]`);
  }
}

/** 一个切片就是同一口径下的一组样本;整体、能力、模型档位都复用它。 */
export function summarizeSlice(records: readonly BaselineRecord[]): BaselineSlice {
  if (records.length === 0) fail("切片没有样本");
  const count = (outcome: BaselineOutcome): number =>
    records.filter((record) => record.outcome === outcome).length;
  const ok = count("ok");
  const cacheHits = count("cache-hit");
  const degraded = count("degraded");
  const errors = count("error");
  const totalCostMicros = records.reduce((sum, record) => sum + record.costMicros, 0);

  return {
    samples: records.length,
    ok,
    cacheHits,
    degraded,
    errors,
    successRate: ratio(ok + cacheHits + degraded, records.length),
    errorRate: ratio(errors, records.length),
    degradedRate: ratio(degraded, records.length),
    cacheHitRate: ratio(cacheHits, records.length),
    ttftP95Ms: percentile(records.map((record) => record.ttftMs), 0.95),
    e2eP95Ms: percentile(records.map((record) => record.e2eMs), 0.95),
    avgCostMicros: ratio(totalCostMicros, records.length),
    totalCostMicros,
    avgInputTokens: mean(records.map((record) => record.inputTokens)),
    avgOutputTokens: mean(records.map((record) => record.outputTokens)),
    avgCachedInputShare: mean(records.map((record) => record.cachedInputShare)),
    qualityMean: mean(records.map((record) => record.qualityScore)),
  };
}

function groupBy(
  records: readonly BaselineRecord[],
  key: (record: BaselineRecord) => string,
): Record<string, BaselineSlice> {
  const groups = new Map<string, BaselineRecord[]>();
  for (const record of records) {
    const bucket = groups.get(key(record)) ?? [];
    bucket.push(record);
    groups.set(key(record), bucket);
  }
  const slices: Record<string, BaselineSlice> = {};
  for (const [name, bucket] of groups) slices[name] = summarizeSlice(bucket);
  return slices;
}

function percent(value: number): string {
  return `${(value * 100).toFixed(2)}%`;
}

function finding(
  id: string,
  severity: FindingSeverity,
  metric: string,
  observed: string,
  target: string,
  action: string,
): BaselineFinding {
  return { id, severity, metric, observed, target, action };
}

/**
 * 基线报告把四项口径合成一张表:延迟看 P95,成本看均值,
 * 质量看评测均值,样本量决定结论能不能成立。
 */
export function buildBaseline(
  records: readonly BaselineRecord[],
  targets: BaselineTargets = DEFAULT_TARGETS,
): BaselineReport {
  if (records.length === 0) fail("没有样本,无法建立基线");
  for (const record of records) validate(record);
  const overall = summarizeSlice(records);
  const byCapability = groupBy(records, (record) => record.capability);
  const byTier = groupBy(records, (record) => record.tier);
  const findings: BaselineFinding[] = [];
  const add = (id: string, severity: FindingSeverity, metric: string,
    observed: string, target: string, action: string): void => {
    findings.push(finding(id, severity, metric, observed, target, action));
  };

  if (overall.samples < targets.minSamples) {
    add("sample", "blocker", "sample-size", `${overall.samples} 条`,
      `>= ${targets.minSamples} 条`, "扩大确定性样本后再引用结论");
  }
  if (overall.errorRate > targets.maxErrorRate) {
    add("error", "blocker", "error-rate", percent(overall.errorRate),
      `<= ${percent(targets.maxErrorRate)}`, "按错误类别拆分失败并先消不可重试错误");
  }
  if (overall.e2eP95Ms > targets.maxP95E2eMs) {
    add("latency", "blocker", "e2e-p95", `${overall.e2eP95Ms}ms`,
      `<= ${targets.maxP95E2eMs}ms`, "拆段定位排队、预填充与解码,再决定优化杠杆");
  }
  if (overall.qualityMean < targets.minQualityMean) {
    add("quality", "blocker", "quality-mean", overall.qualityMean.toFixed(3),
      `>= ${targets.minQualityMean}`, "回到评测集确认质量回退来自模型、提示还是压缩");
  }
  if (overall.degradedRate > targets.maxDegradeRate) {
    add("degrade", "warning", "degrade-rate", percent(overall.degradedRate),
      `<= ${percent(targets.maxDegradeRate)}`, "检查主模型健康度,确认降级没有变成默认体验");
  }
  if (overall.cacheHitRate < targets.minCacheHitRate) {
    add("cache", "warning", "cache-hit-rate", percent(overall.cacheHitRate),
      `>= ${percent(targets.minCacheHitRate)}`, "先核对缓存键与提示版本,再决定是否投入缓存容量");
  }
  if (overall.avgCostMicros > targets.maxAvgCostMicros) {
    add("cost", "warning", "avg-cost", `${overall.avgCostMicros.toFixed(2)} 微元`,
      `<= ${targets.maxAvgCostMicros} 微元`, "按能力与模型档位归因,优先处理单位成本最高的切片");
  }
  for (const [name, slice] of Object.entries(byCapability)) {
    if (slice.e2eP95Ms > targets.maxP95E2eMs) {
      add(`latency:${name}`, "warning", "capability-e2e-p95", `${slice.e2eP95Ms}ms`,
        `<= ${targets.maxP95E2eMs}ms`, `单独拆 ${name} 的慢请求,能力级长尾会被整体 P95 掩盖`);
    }
    if (slice.qualityMean < targets.minQualityMean) {
      add(`quality:${name}`, "warning", "capability-quality", slice.qualityMean.toFixed(3),
        `>= ${targets.minQualityMean}`, `回到 ${name} 的评测集,确认回退来自模型档位还是提示变更`);
    }
  }

  const order: Record<FindingSeverity, number> = { blocker: 0, warning: 1 };
  findings.sort((left, right) => order[left.severity] - order[right.severity] || left.id.localeCompare(right.id));
  const verdict = findings.some((item) => item.severity === "blocker")
    ? "fail"
    : findings.length > 0 ? "conditional" : "pass";

  return { overall, byCapability, byTier, findings, verdict, targets };
}

summarizeSlice 同时服务整体与分层,避免整体一套口径、切片另一套口径。buildBaseline 先算整体,再对每个能力切片补一条能力级告警;否则一个能力的 P95 越线会被整体 P95 掩盖。

verdict 只有三档:任一 blocker 为 fail,只有 warning 为 conditional,没有 findings 才是 pass。三档比布尔值多一层信息,评审时能区分「不能用」和「可以用但别宣称」。

示例样本上的运行结果

在实验工程根目录执行基线测试,输出如下:

node --test test/perf/baseline.test.ts

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

ℹ tests 7
ℹ pass 7
ℹ fail 0

把 240 条记录输入 buildBaseline,整体结果是:样本 240,成功 219、缓存 9、降级 8、失败 4;成功率 98.33%,错误率 1.67%,降级率 3.33%,缓存命中率 3.75%;TTFT P95 296ms,E2E P95 834ms;总成本 25,028 微元,平均 104.28 微元;平均输入 1,522.58 token、输出 320.15 token;质量均值 0.869。结论 conditional,findings 只有一条 cache。

这些数字的意义不是「回声很健康」,而是每个数字都能被重新算出来。换一批样本,指标会变;换一组阈值,结论会变;换一个分层维度,最贵的切片会换一个名字。基线报告的可信度来自可复算,而不是来自参会者都点头。

基线落地的三个约束

报告要带样本量与时间窗口;阈值要集中管理并版本化;结论要绑定下一步动作。这份基线的动作很具体:核对缓存键与提示版本,用第 5 章的方法验证命中率能否提升;提升不了,就不投入缓存容量。

小结

推理成本基线把四个章节的口径合成一张可复算的表:延迟看 P95、容量看平均 token、成本看整数微元、质量看评测均值。240 条示例样本得到 98.33% 成功率、834ms E2E P95、104.28 微元平均成本与 0.869 质量均值;唯一越线项是 3.75% 的缓存命中率,因此结论是 conditional。

基线不是终点,而是优化方案的输入。下一节拿这份基线做三个候选方案的取舍,并把选择、回退与退出条件写成 ADR。

阅读导航:上一节:12.3 一次成本失控的复盘 · 下一节:13.2 优化方案与 ADR 。

常见错误与边界

  • 只报平均值。没有 P95,冷启动与长尾会被一次「还不错」的均值抹平。
  • 成功率只算 ok。缓存命中与降级仍在交付结果,算成失败会高估问题。
  • 失败请求不计成本。已经消耗的 token 不会因为结果失败而退回。
  • 只报整体,不做能力与模型档位切片。最贵的切片会被便宜能力平均掉。
  • 把 conditional 当成 pass。带条件的通过必须绑定下一步动作。
  • 把示例样本结果写成生产实测。数字必须有出处,来源不明的结论比没有结论更危险。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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