《互联网产品的 AI 架构·模型接入与能力边界》2.2 选型矩阵:离线评测与在线 A/B

解释模型榜单为什么不能直接决定选型,并以「回声」的智能回复、内容审核与搜索重排为例,给出硬约束过滤、加权评分与并列处理的 TypeScript 实现,划分离线评测集与在线 A/B 各自负责的问题,附可运行测试与矩阵维护方法,说明选型结论何时会过期。

榜单为什么不能直接决定选型

每个季度都会出现新的模型榜单。榜单把一堆模型放在同一组题目上排名,看起来正好回答了“该选哪个”。但回声的选型会一再用到同一个反例:某个模型在通用问答上排得很靠前,放到私信智能回复里,候选句式的采纳率却不如一个排名更低的标准模型。

原因不神秘。榜单任务和产品任务至少有三处不同。第一,输入分布不同:榜单题目由评测方设计,产品输入来自真实用户,长度、口语化程度、错别字比例都不一样。第二,输出要求不同:榜单看答案对不对,产品还要求格式、长度、语气和安全边界。第三,错误代价不同:榜单上一道题答错只是少一分,产品里一条审核漏放可能是一次事故。

除此之外,榜单通常不告诉你延迟、价格、上下文有效性和可控性。一个质量高但延迟不稳定的模型,可能让智能回复的候选在输入框展开后才出现;一个便宜但上下文利用率低的模型,可能在长对话里突然失忆。这些问题不会出现在排名里,却会直接决定产品能不能上线。

所以选型需要一个矩阵:行是候选模型,列是五个维度,外加一列硬约束。矩阵不承诺给出“绝对正确”的答案,它只保证取舍过程可解释、可复现、可复审。

选型矩阵的输入

矩阵的输入分三层。

第一层是能力契约。它来自 1.3 的能力规格,说明这个产品能力要做什么、输出什么、错误代价多大、延迟预算是多少。没有能力契约,矩阵就没有验收标准,只能退化成“大家投个票”。

第二层是模型档案。它来自 2.1 的结构化档案,提供能力、档位、上下文、价格、延迟、区域与健康状态。档案必须经过校验,否则矩阵会把坏数据算成一个漂亮的分数。

第三层是产品约束。它把能力契约翻译成硬门槛:最低上下文、最大 P95 延迟、最高混合成本、目标区域、是否要求健康。硬门槛的作用是先排除“根本不能考虑”的模型,避免它们用高质量分掩盖致命缺陷。

三层输入准备好之后,矩阵的计算顺序固定为:硬约束过滤、加权评分、并列处理。顺序不能颠倒。如果先评分再过滤,一个延迟超标但质量极高的模型可能排在前面,评审时很难解释为什么它不能选;如果并列不显式处理,不同运行环境可能给出不同名次,评测报告就无法当作回归基线。

硬约束先于加权

硬约束是没有商量空间的条件。回声的智能回复要求:必须支持目标区域、上下文至少 32K、P95 不超过 3 秒、混合成本不超过每百万 token 20 000 微元、模型必须健康。任何一条不满足,模型直接出局,不进入打分。

这样做有两个好处。第一,评审讨论集中在“为什么这条门槛是这个值”,而不是“这个模型质量高能不能通融”。门槛值来自能力契约,改动门槛需要改契约,而不是改排序。第二,过滤原因被记录下来,下一次复用时能看到某个模型是因为区域、延迟还是成本被排除,而不是只看到一个空结果。

硬约束的顺序也有讲究。能力、健康、区域、上下文、延迟、成本,越靠前越是“结构性”问题。一个模型如果不支持目标区域,讨论它的延迟没有意义;如果不健康,讨论成本也没有意义。实现里每个候选只返回第一条不满足的原因,避免一次请求刷出一长串重复告警。

需要强调的是,硬约束不是永久判决。模型版本更新、区域扩展、价格调整之后,之前被排除的候选可能重新合格。矩阵应该保留被拒绝的原因和时间戳,定期重跑,而不是把拒绝结果写死在代码里。

加权评分与并列处理

通过硬约束的候选进入加权评分。五个维度的分数来自 2.1 的 scoreProfile,权重来自产品判断。权重必须在看结果之前确定,并且记录在评测报告里。否则很容易出现“先看谁分高,再调权重让想要的模型赢”的循环论证。

权重先归一化。归一化不是为了改变排序,而是为了让不同轮次的评分可以比较:这一轮质量权重占 40%,下一轮占 60%,评审能一眼看出取舍变化。负权重、全零权重、非有限权重都直接拒绝,因为这些输入会让排序失去意义。

排序规则固定为:加权分降序、混合成本升序、P95 升序、模型 id 字典序。后三项是并列处理。它们只有在某个维度权重为 0 时才可能生效,因为只要该维度参与评分,分数通常已经不同。显式写出并列规则,是为了保证同一份输入在任何机器、任何排序实现下都得到同样的名次。

并列处理还有一个产品含义:当两个模型在加权分上完全相同时,先选更便宜的,再选更快的,最后才用 id 保证确定性。这个顺序本身就是一条产品策略,应该被评审,而不是藏在排序函数里。

// file: src/core/selection-matrix.ts
// tier: L1
// verify: node --test test/core/selection-matrix.test.ts

import { AiError } from "./errors.ts";
import { blendedCostMicrosPerMTok, scoreProfile } from "./model-profile.ts";
import type { DimensionScores } from "./model-profile.ts";
import type { CapabilityId, ModelProfile } from "./types.ts";

/** 选型需求来自能力契约,不允许在评分函数里偷偷放宽。 */
export interface SelectionRequirement {
  capability: CapabilityId;
  region?: string;
  minContextWindow: number;
  maxP95LatencyMs: number;
  maxBlendedMicrosPerMTok: number;
  /** 输入 token 占比,默认 0.75(案例假设值)。 */
  inputShare?: number;
  /** 默认 true:不健康的模型不进入候选。 */
  requireHealthy?: boolean;
}

export interface CriterionWeights {
  quality: number;
  latency: number;
  cost: number;
  context: number;
  controllability: number;
}

export type RejectionCode =
  | "capability"
  | "region"
  | "healthy"
  | "context"
  | "latency"
  | "cost";

export interface Rejection {
  modelId: string;
  code: RejectionCode;
  detail: string;
}

export interface RankedCandidate {
  modelId: string;
  provider: string;
  /** 0..1 的加权分,只在本轮候选之间可比。 */
  score: number;
  breakdown: DimensionScores;
  /** 原始延迟,用于权重为 0 时的稳定并列处理。 */
  p95LatencyMs: number;
  rank: number;
}

/** 并列组必须显式记录,否则不同 sort 实现会给出不同名次。 */
export interface TieGroup {
  modelIds: string[];
  resolvedBy: "cost" | "latency" | "id";
  /** 组内第一名在结果中的名次,从 1 开始。 */
  rankStart: number;
}

export interface RankingResult {
  ranked: RankedCandidate[];
  rejected: Rejection[];
  weights: CriterionWeights;
  ties: TieGroup[];
}

const WEIGHT_KEYS: readonly (keyof CriterionWeights)[] = [
  "quality",
  "latency",
  "cost",
  "context",
  "controllability",
];

function assertRequirement(requirement: SelectionRequirement): void {
  if (requirement.capability.trim() === "") {
    throw new AiError("invalid-request", "capability 不能为空");
  }
  if (!Number.isFinite(requirement.minContextWindow) || requirement.minContextWindow <= 0) {
    throw new AiError("invalid-request", "minContextWindow 必须是正数");
  }
  if (!Number.isFinite(requirement.maxP95LatencyMs) || requirement.maxP95LatencyMs <= 0) {
    throw new AiError("invalid-request", "maxP95LatencyMs 必须是正数");
  }
  if (
    !Number.isFinite(requirement.maxBlendedMicrosPerMTok) ||
    requirement.maxBlendedMicrosPerMTok <= 0
  ) {
    throw new AiError("invalid-request", "maxBlendedMicrosPerMTok 必须是正数");
  }
}

/** 权重先校验再归一化:负权重与全零权重都会让排序失去意义。 */
export function normalizeWeights(weights: CriterionWeights): CriterionWeights {
  let sum = 0;
  for (const key of WEIGHT_KEYS) {
    const value = weights[key];
    if (!Number.isFinite(value) || value < 0) {
      throw new AiError("invalid-request", `权重 ${key} 必须是非负有限数`);
    }
    sum += value;
  }
  if (sum === 0) {
    throw new AiError("invalid-request", "权重之和不能为 0");
  }
  const normalized: CriterionWeights = {
    quality: 0,
    latency: 0,
    cost: 0,
    context: 0,
    controllability: 0,
  };
  for (const key of WEIGHT_KEYS) {
    normalized[key] = weights[key] / sum;
  }
  return normalized;
}

/** 硬约束按固定顺序检查,返回第一条不满足的原因,避免一次请求刷出多条噪声。 */
export function rejectionFor(
  profile: ModelProfile,
  requirement: SelectionRequirement,
): Rejection | null {
  if (!profile.capabilities.includes(requirement.capability)) {
    return { modelId: profile.id, code: "capability", detail: `未声明能力 ${requirement.capability}` };
  }
  if ((requirement.requireHealthy ?? true) && !profile.healthy) {
    return { modelId: profile.id, code: "healthy", detail: "模型当前不健康" };
  }
  if (requirement.region && !profile.regions.includes(requirement.region)) {
    return { modelId: profile.id, code: "region", detail: `不支持区域 ${requirement.region}` };
  }
  if (profile.contextWindow < requirement.minContextWindow) {
    return {
      modelId: profile.id,
      code: "context",
      detail: `上下文 ${profile.contextWindow} < ${requirement.minContextWindow}`,
    };
  }
  if (profile.p95LatencyMs > requirement.maxP95LatencyMs) {
    return {
      modelId: profile.id,
      code: "latency",
      detail: `P95 ${profile.p95LatencyMs}ms > ${requirement.maxP95LatencyMs}ms`,
    };
  }
  const blended = blendedCostMicrosPerMTok(profile, requirement.inputShare ?? 0.75);
  if (blended > requirement.maxBlendedMicrosPerMTok) {
    return {
      modelId: profile.id,
      code: "cost",
      detail: `混合成本 ${blended} > ${requirement.maxBlendedMicrosPerMTok}`,
    };
  }
  return null;
}

function weightedScore(scores: DimensionScores, weights: CriterionWeights): number {
  return (
    scores.quality * weights.quality +
    scores.latency * weights.latency +
    scores.cost * weights.cost +
    scores.context * weights.context +
    scores.controllability * weights.controllability
  );
}

/**
 * 排序规则固定为:分数降序 -> 混合成本升序 -> P95 升序 -> id 字典序。
 * 最后两项只在某个维度被权重排除(权重为 0)时才可能生效,用于让结果可复现。
 */
function compareCandidates(a: RankedCandidate, b: RankedCandidate): number {
  if (a.score !== b.score) return b.score - a.score;
  if (a.breakdown.blendedMicrosPerMTok !== b.breakdown.blendedMicrosPerMTok) {
    return a.breakdown.blendedMicrosPerMTok - b.breakdown.blendedMicrosPerMTok;
  }
  if (a.p95LatencyMs !== b.p95LatencyMs) {
    return a.p95LatencyMs - b.p95LatencyMs;
  }
  return a.modelId < b.modelId ? -1 : a.modelId > b.modelId ? 1 : 0;
}

function tieResolvedBy(
  group: RankedCandidate[],
  profilesById: Map<string, ModelProfile>,
): TieGroup["resolvedBy"] {
  const cost = group[0].breakdown.blendedMicrosPerMTok;
  if (group.some((item) => item.breakdown.blendedMicrosPerMTok !== cost)) return "cost";
  const latency = profilesById.get(group[0].modelId)?.p95LatencyMs;
  if (group.some((item) => profilesById.get(item.modelId)?.p95LatencyMs !== latency)) {
    return "latency";
  }
  return "id";
}

/**
 * 一次完整的离线选型:硬约束过滤,加权评分,显式并列处理。
 * 同一输入必须得到逐字节相同的结果,便于把评测报告当作回归基线。
 */
export function rankCandidates(
  profiles: readonly ModelProfile[],
  requirement: SelectionRequirement,
  weights: CriterionWeights,
): RankingResult {
  assertRequirement(requirement);
  const normalized = normalizeWeights(weights);
  const inputShare = requirement.inputShare ?? 0.75;

  const rejected: Rejection[] = [];
  const scored: RankedCandidate[] = [];

  for (const profile of profiles) {
    const rejection = rejectionFor(profile, requirement);
    if (rejection) {
      rejected.push(rejection);
      continue;
    }
    const breakdown = scoreProfile(profile, {
      maxBlendedMicrosPerMTok: requirement.maxBlendedMicrosPerMTok,
      maxP95LatencyMs: requirement.maxP95LatencyMs,
      minContextWindow: requirement.minContextWindow,
      inputShare,
    });
    scored.push({
      modelId: profile.id,
      provider: profile.provider,
      score: weightedScore(breakdown, normalized),
      breakdown,
      p95LatencyMs: profile.p95LatencyMs,
      rank: 0,
    });
  }

  scored.sort(compareCandidates);
  scored.forEach((candidate, index) => {
    candidate.rank = index + 1;
  });

  const profilesById = new Map(profiles.map((profile) => [profile.id, profile]));
  const ties: TieGroup[] = [];
  let start = 0;
  while (start < scored.length) {
    let end = start + 1;
    while (end < scored.length && scored[end].score === scored[start].score) {
      end += 1;
    }
    if (end - start > 1) {
      const group = scored.slice(start, end);
      ties.push({
        modelIds: group.map((item) => item.modelId),
        resolvedBy: tieResolvedBy(group, profilesById),
        rankStart: start + 1,
      });
    }
    start = end;
  }

  return { ranked: scored, rejected, weights: normalized, ties };
}

实现里有两个细节值得说明。第一,rejectionFor 是独立函数,测试可以直接验证每条硬约束,不需要构造完整排序。第二,rankCandidates 返回 ties,记录每个并列组以及打破并列的依据。报告里如果不写清楚“这两个模型同分,按成本选了便宜的那个”,下一次评审就会重新争论一遍。

离线评测集的分工

离线评测集负责回答“候选有没有资格进入线上”。它有几个特点:可重复、可切片、可在发布前跑、不依赖真实流量。

回声为每个能力维护独立的评测集。智能回复的样本来自脱敏后的历史私信与人工撰写的候选,标注包括“可接受”“需改写”“不可接受”。审核分类的样本强调边界案例与对抗样本,因为漏放的代价高。搜索重排的样本是查询、候选文档与相关性标注,和文本生成完全不是同一套数据。

离线评测要覆盖三类指标。第一类是任务质量:采纳率代理、召回率、相关性分数。第二类是约束满足:格式合法率、长度超限率、拒答正确率。第三类是尾部表现:按输入长度、语言、用户群切片后的最差分组,而不是只看总体平均。

离线评测也有明确的边界。它无法告诉你用户会不会用、用了之后产品指标会不会变、模型延迟在真实网络下是什么样。它只能证明候选没有明显的质量缺陷,不能证明候选会带来业务收益。把离线分数当成上线依据,是选型里最常见的过度推断。

在线 A/B 的分工

在线 A/B 负责回答“上线之后产品是不是真的更好”。它看的是因果效应,不是模型分数。回声的智能回复实验会同时观察采纳率、改写率、发送率、举报率和端到端延迟;审核实验会观察漏放率、误杀率、申诉率和人工复核量;搜索重排实验会观察点击率、转化率和无结果率。

在线实验必须设置护栏指标。质量提升不能以延迟暴涨、成本失控或举报率上升为代价。护栏指标一旦越线,实验应该停止,而不是等主指标显著。这一点在 AI 功能上尤其重要,因为模型输出会进入用户可见的内容,负面影响可能比传统功能更快扩散。

离线与在线的分工可以概括为:离线负责排除,线上负责确认。离线评测决定谁能进实验,在线实验决定谁能全量。两者都不能单独完成选型。只做离线,会把“评测集上好看”误当成“产品上有效”;只做在线,则会让明显不合格的候选消耗真实用户,实验成本和风险都太高。

还有一个常见误区:用在线 A/B 替代离线回归。线上实验通常只覆盖当前候选,无法保证新版本没有破坏旧场景。每次模型版本升级,仍然要跑离线回归集,尤其是历史失败样本与边界样本。

回声的三个矩阵

智能回复的矩阵以质量权重为主,但不能压过延迟硬约束。前沿模型和标准模型都能进入候选,经济模型在采纳率代理指标上不达标。最终选择往往取决于成本与延迟的余量:如果标准模型在预算内能达到验收线,就没有必要为边际质量支付前沿价格。

审核分类的矩阵把召回率放在第一位,成本权重次之,延迟要求很严。由于错误代价不对称,矩阵里不追求“总体准确率最高”,而是要求在高风险类别上不漏放。常见方案是两层:便宜模型做初筛,不确定样本升级到更强模型或人工。矩阵评估的是整个分层方案的成本与召回,而不是单个模型的平均分。

搜索重排的矩阵必须单独建。它的输入是多文档,输出是顺序,质量指标是相关性,延迟预算通常比生成任务更紧。通用榜单和智能回复的评测集都不能直接复用。回声为它单独维护标注集,并且要求离线相关性提升与线上点击提升同时成立,才进入全量。

三个矩阵的共同点是:权重不同、硬约束不同、评测集不同,但计算流程相同。流程相同才能复用工具与报告格式,输入不同才能避免用一个标准衡量所有任务。

矩阵会过期

选型矩阵不是一次性文档。模型版本会更新,价格会调整,区域会变化,用户输入分布也会漂移。一个在三个月前成立的结论,可能因为一次版本升级或一次涨价而失效。

回声的做法是给矩阵加两个时间字段:评测集版本与档案快照时间。每次重跑都保留历史报告,比较名次变化与分数变化。如果某个模型的名次大幅上升,先检查是不是档案变了;如果评测集变了,先确认新旧样本是否可比。没有这两个字段,历史报告之间无法比较,矩阵就会变成一堆无法解释的截图。

矩阵还需要定期复审硬约束。业务增长之后,成本上限可能要下调;新区域上线之后,区域要求可能要扩展;监管要求变化之后,可控性门槛可能要收紧。硬约束的变化应该走变更评审,而不是在代码里悄悄改一个常量。

小结

选型矩阵把五个维度放进一个可复现的流程:硬约束先排除不能用的候选,加权评分在合格候选之间排序,并列处理保证结果确定。离线评测集负责排除明显不合格的候选,在线 A/B 负责确认产品收益,两者分工明确、不能互相替代。榜单可以帮你发现候选,但不能替你完成选型。

矩阵的结论有时效。档案、价格、区域、用户分布变化之后,矩阵要重跑,历史报告要保留。下一节讨论一个更现实的问题:无论怎么选,产品最终往往会同时使用多个模型。多模型并存不是架构失控,而是能力、成本、区域与供应共同作用的常态。

阅读导航:上一节:2.1 模型能力的五个维度 · 下一节:2.3 多模型并存的现实 。

常见错误与边界

  • 用榜单排名直接做选型。榜单任务与产品任务不同,排名只能缩小候选范围,不能替代自有评测集。
  • 先评分再过滤。硬约束必须前置,否则高分会掩盖区域、延迟或成本上的致命缺陷。
  • 看完结果再调权重。权重应在评测前确定并记录,否则排序会变成对偏好的事后合理化。
  • 不记录并列处理。同分模型按什么规则排序必须写进报告,否则不同环境可能给出不同名次。
  • 把离线分数当成上线依据。离线只能证明候选没有明显缺陷,不能证明用户会用、产品指标会涨。
  • 用在线实验替代离线回归。线上实验覆盖不了历史失败样本,版本升级前仍要跑回归集。
  • 三个能力共用一套评测集。智能回复、审核分类、搜索重排的输入输出结构不同,评测集必须分开。
  • 矩阵一次定终身。模型版本、价格、区域和用户分布都会变,矩阵需要定期重跑并保留快照。
  • 只优化主指标,不看护栏。延迟、成本、举报率越线时,质量提升也不应继续放量。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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