一个总分最高的方案,为什么上线后被换掉
「回声」的模型选型会上,团队把八个候选配置做成一张表,用加权总分排序:质量占 50%,成本占 30%,延迟占 20%。分数最高的是配置 G,大家准备按它上线。
两周后,配置 G 被换成了配置 C。原因不是 G 的质量不好,而是 G 在三条轴上都输给了 C:质量相同,C 更便宜,延迟也更低。加权总分却因为把三个不同量纲的数字先归一化再相加,让 G 的某一项被放大,最终排到了第一。
这类错误不是算错了小数,而是方法本身丢掉了一个关键问题:三个目标之间,哪些差异是“真的不能同时拿到”,哪些只是“没有被更好的点覆盖”。前者需要取舍,后者只需要换一个候选。
本节把候选放回三维坐标,用支配关系求 Pareto 前沿,再按能力护栏与边际成本选点。文中所有数字都来自 ai-lab 的示例样本运行结果,不是生产实测。
三条轴与一个证据门槛
三个目标必须先有统一口径,否则“质量高”只是形容词。
| 轴 | 单位 | 口径 | 常见错误 |
|---|---|---|---|
| 质量 | 通过率 0–1 | 固定评测集上的通过比例 | 用主观评分代替可复算的用例 |
| 成本 | 微元/千次 | 输入、输出与重试的整数微元之和 | 只算成功请求,失败成本消失 |
| 延迟 | 毫秒 | P95,最近秩法 | 用平均值抹平长尾 |
样本量不是第四条轴,而是准入门槛。一个只有 40 条样本的 100% 通过率,不能和 600 条样本的 84% 放在同一张前沿图上比较。样本不足的坐标先被护栏拒绝,不参与支配关系。
成本用整数微元、延迟用毫秒,是为了让比较可复算。质量用通过率而不是“1–5 分”,是因为通过与否可以由评测脚本逐条重放;主观分一旦进入坐标,前沿就会随评审人变化。
支配:三条都不差,才叫更好
在三维空间里,点 A 支配点 B,当且仅当 A 在三条轴上都不比 B 差,且至少有一条严格更好。
A 支配 B ⇔ qA ≥ qB 且 cA ≤ cB 且 lA ≤ lB
且 (qA > qB 或 cA < cB 或 lA < lB)
被支配的点可以安全淘汰:一定存在另一个点,质量不低、成本不高、延迟不慢,而且至少一项更好。淘汰它不是“放弃一种取舍”,而是去掉一个永远不该被选的选项。
注意支配是偏序,不是全序。两个点可能互不支配:一个质量高但贵,一个便宜但慢。它们都可能留在前沿上,最终选哪个取决于业务护栏与偏好。
前沿与“不可兼得”的两种含义
Pareto 前沿是所有不被任何其他点支配的候选集合。它把平面分成两部分:前沿上的点,和前沿内部被支配的点。
这里有两个经常被混为一谈的“不可兼得”:
- 被支配区域:其实可以兼得。存在一个点在三方面都不差,只是团队没有发现。这里的正确动作是换点,不是取舍。
- 前沿上的移动:真的不能兼得。要提高质量,必须多付成本或接受更高延迟。这里的正确动作是定价取舍,不是继续找“完美方案”。
示例样本里,F、G、H 都落在被支配区域:F 被 B 支配,G 被 C 支配,H 被 D 支配。它们不是“另一种取舍”,而是更差的坐标。真正需要讨论的是前沿上的 A、B、E、C、D 五个点。
按能力设护栏,而不是全站一套阈值
同一个模型,对私信回复可能够用,对审核结论可能不够。护栏必须按能力设置:
| 能力 | 最低质量 | 成本上限(微元/千次) | P95 上限 | 最小样本 |
|---|---|---|---|---|
| chat.reply | 0.78 | 650 | 650ms | 500 |
| moderation.decide | 0.92 | 900 | 400ms | 800 |
示例样本只展示 chat.reply。在它的护栏下,A 因质量 0.72 被拒,E 因 P95 700ms 被拒,D 因成本 700 被拒,F 因样本 400 被拒。可行集只剩 B 与 C。
护栏的作用不是把前沿压成一个点,而是把“业务不能接受”的区域先切掉,再在剩余部分谈性价比。阈值本身要版本化:质量下限提高 0.02,可行集可能完全变样。
边际成本:沿质量阶梯给取舍定价
前沿按成本升序排列后,不是每个点都在“用成本换质量”。有些点只是用更高的成本换更低的延迟,例如 E:它比 B 贵 40 微元,质量高 0.03,但延迟从 460ms 升到 700ms。把 E 也算进质量阶梯,会把 C 的边际成本从 4000 抬到 12000。
因此只对“质量新高”的点计算边际成本:
marginal = (cost[i] - cost[prev]) / (quality[i] - quality[prev])
示例样本的完整前沿上,B 相对 A 是 1000 微元/质量点,E 相对 B 是 1333.33,C 相对 E 是 12000,D 相对 C 是 14000。这个阶梯说明:从 A 到 B 很便宜,从 E 到 C 很贵。
但 E 被延迟护栏淘汰后,可行阶梯只剩 B 与 C,C 的边际成本重算为 4000。这个差异不是实现细节,而是决策事实:被业务拒绝的中间点,不应该继续影响“下一档质量值多少钱”的判断。
三种选点方式
前沿本身不回答“选哪个”。至少需要三种显式策略:
- max-quality:在可行集里选质量最高,适合审核、医疗、金融等错误代价高的能力。
- min-cost:在可行集里选成本最低,适合草稿、摘要、内部工具等可容忍返工的能力。
- knee:给定“每提升一个质量点最多愿意多付多少微元”,沿质量阶梯走到预算边界。
示例样本的护栏下,max-quality 选 C(质量 0.84,成本 420),min-cost 选 B(质量 0.80,成本 260)。knee 阈值取 5000 微元/质量点时选 C,因为 B→C 的边际成本是 4000;阈值降到 3000 时选 B。
三种策略没有哪个更“正确”。它们的区别是业务愿意为什么付费,必须写进决策记录,而不是藏进加权系数。
实现
下面的模块做四件事:校验坐标、求支配关系与前沿、在可行集上重算质量阶梯、按显式目标选点。它不访问模型,也不读取线上日志;输入是一组已经结算好的候选。
// file: src/perf/quality.ts
// tier: L1
// verify: node --test test/perf/quality.test.ts
import { AiError } from "../core/errors.ts";
export type SelectionObjective = "max-quality" | "min-cost" | "knee";
/** 一个候选点就是“某能力 + 某配置”在三条轴上的实测坐标。 */
export interface PerfCandidate {
id: string;
capability: string;
quality: number;
/** 每千次请求的整数微元成本。 */
costMicrosPer1k: number;
latencyP95Ms: number;
/** 支撑该坐标的评测样本数;样本不足的坐标不能参与选点。 */
sampleSize: number;
}
export interface CapabilityGuardrail {
capability: string;
minQuality: number;
maxCostMicrosPer1k: number;
maxLatencyP95Ms: number;
minSampleSize: number;
}
export interface SelectionPolicy {
objective: SelectionObjective;
/** knee 模式:每提升 1 个质量点,最多愿意多付多少微元/千次。 */
maxMarginalCostMicrosPerQualityPoint?: number;
}
export interface Rejection { id: string; reasons: string[] }
export interface FrontierPoint extends PerfCandidate {
/** 相对上一个“质量新高”点的边际成本;不是新高时为 null。 */
marginalCostMicrosPerQualityPoint: number | null;
qualityLeader: boolean;
}
export interface FrontierSelection {
chosen: string | null;
feasible: string[];
rejected: Rejection[];
points: FrontierPoint[];
rationale: string;
}
function fail(message: string): never {
throw new AiError("invalid-request", `quality-frontier: ${message}`);
}
function validate(candidate: PerfCandidate): void {
for (const field of ["id", "capability"] as const) {
if (typeof candidate[field] !== "string" || candidate[field].trim() === "") fail(`${field} is required`);
}
if (!Number.isFinite(candidate.quality) || candidate.quality < 0 || candidate.quality > 1) {
fail(`quality of "${candidate.id}" must be between 0 and 1`);
}
for (const field of ["costMicrosPer1k", "latencyP95Ms"] as const) {
if (!Number.isInteger(candidate[field]) || candidate[field] < 0) fail(`${field} must be an integer >= 0`);
}
if (!Number.isInteger(candidate.sampleSize) || candidate.sampleSize < 1) {
fail(`sampleSize of "${candidate.id}" must be an integer >= 1`);
}
}
/** 三条轴都不差,且至少一条严格更好;只有被支配的点才可安全淘汰。 */
export function dominates(left: PerfCandidate, right: PerfCandidate): boolean {
const noWorse =
left.quality >= right.quality &&
left.costMicrosPer1k <= right.costMicrosPer1k &&
left.latencyP95Ms <= right.latencyP95Ms;
const strict =
left.quality > right.quality ||
left.costMicrosPer1k < right.costMicrosPer1k ||
left.latencyP95Ms < right.latencyP95Ms;
return noWorse && strict;
}
function sameObjectives(left: PerfCandidate, right: PerfCandidate): boolean {
return left.quality === right.quality &&
left.costMicrosPer1k === right.costMicrosPer1k &&
left.latencyP95Ms === right.latencyP95Ms;
}
/** Pareto 前沿只保留非支配点,并按成本升序排列。 */
export function paretoFrontier(candidates: readonly PerfCandidate[]): PerfCandidate[] {
if (candidates.length === 0) fail("at least one candidate is required");
const capability = candidates[0].capability;
const seen = new Set<string>();
for (const candidate of candidates) {
validate(candidate);
if (candidate.capability !== capability) fail(`mixed capabilities: ${capability} / ${candidate.capability}`);
if (seen.has(candidate.id)) fail(`duplicate candidate "${candidate.id}"`);
seen.add(candidate.id);
}
const nonDominated = candidates.filter(
(candidate) => !candidates.some((other) => other.id !== candidate.id && dominates(other, candidate)),
);
const unique: PerfCandidate[] = [];
for (const candidate of [...nonDominated].sort((left, right) => left.id.localeCompare(right.id))) {
if (!unique.some((kept) => sameObjectives(kept, candidate))) unique.push(candidate);
}
return unique.sort((left, right) =>
left.costMicrosPer1k - right.costMicrosPer1k ||
left.latencyP95Ms - right.latencyP95Ms ||
right.quality - left.quality ||
left.id.localeCompare(right.id));
}
/**
* 三维前沿投影到“成本-质量”平面后,只有不断刷新质量高点的点才构成质量阶梯。
* 其余点用成本换延迟,不能用来计算“每提升一个质量点多少钱”。
*/
export function annotateFrontier(frontier: readonly PerfCandidate[]): FrontierPoint[] {
const sorted = [...frontier].sort((left, right) =>
left.costMicrosPer1k - right.costMicrosPer1k ||
left.latencyP95Ms - right.latencyP95Ms ||
right.quality - left.quality ||
left.id.localeCompare(right.id));
const points: FrontierPoint[] = [];
let bestQuality = -1;
let previous: PerfCandidate | null = null;
for (const candidate of sorted) {
const leader = candidate.quality > bestQuality;
let marginal: number | null = null;
if (leader) {
if (previous !== null) {
marginal = (candidate.costMicrosPer1k - previous.costMicrosPer1k) / (candidate.quality - previous.quality);
}
bestQuality = candidate.quality;
previous = candidate;
}
points.push({ ...candidate, qualityLeader: leader, marginalCostMicrosPerQualityPoint: marginal });
}
return points;
}
function guardrailReasons(candidate: PerfCandidate, guardrail: CapabilityGuardrail): string[] {
const reasons: string[] = [];
if (candidate.capability !== guardrail.capability) reasons.push(`capability ${candidate.capability} != ${guardrail.capability}`);
if (candidate.quality < guardrail.minQuality) reasons.push(`quality ${candidate.quality.toFixed(3)} < ${guardrail.minQuality.toFixed(3)}`);
if (candidate.costMicrosPer1k > guardrail.maxCostMicrosPer1k) reasons.push(`cost ${candidate.costMicrosPer1k} > ${guardrail.maxCostMicrosPer1k}`);
if (candidate.latencyP95Ms > guardrail.maxLatencyP95Ms) reasons.push(`latency ${candidate.latencyP95Ms} > ${guardrail.maxLatencyP95Ms}`);
if (candidate.sampleSize < guardrail.minSampleSize) reasons.push(`sample ${candidate.sampleSize} < ${guardrail.minSampleSize}`);
return reasons;
}
function pick(feasible: readonly FrontierPoint[], policy: SelectionPolicy): FrontierPoint | null {
if (feasible.length === 0) return null;
if (policy.objective === "max-quality") {
return [...feasible].sort((left, right) =>
right.quality - left.quality ||
left.costMicrosPer1k - right.costMicrosPer1k ||
left.latencyP95Ms - right.latencyP95Ms ||
left.id.localeCompare(right.id))[0];
}
if (policy.objective === "min-cost") {
return [...feasible].sort((left, right) =>
left.costMicrosPer1k - right.costMicrosPer1k ||
left.latencyP95Ms - right.latencyP95Ms ||
right.quality - left.quality ||
left.id.localeCompare(right.id))[0];
}
const threshold = policy.maxMarginalCostMicrosPerQualityPoint;
if (threshold === undefined || !Number.isFinite(threshold) || threshold <= 0) {
fail("knee objective requires a positive maxMarginalCostMicrosPerQualityPoint");
}
const leaders = feasible.filter((point) => point.qualityLeader);
const affordable = leaders.filter((point) =>
point.marginalCostMicrosPerQualityPoint !== null &&
point.marginalCostMicrosPerQualityPoint <= threshold);
return affordable.length > 0 ? affordable[affordable.length - 1] : leaders[0] ?? null;
}
/**
* 先按能力护栏过滤,再在前沿上按显式目标选点。
* 被支配且可行与被护栏拒绝的原因分开记录,避免把“更差”说成“不可行”。
*/
export function selectOnFrontier(
candidates: readonly PerfCandidate[],
guardrail: CapabilityGuardrail,
policy: SelectionPolicy,
): FrontierSelection {
const frontier = paretoFrontier(candidates);
const points = annotateFrontier(frontier);
const feasible = points.filter((point) => guardrailReasons(point, guardrail).length === 0);
// 边际成本必须在“过滤后的可行集”上重算:被护栏淘汰的中间点不该抬高阶梯价格。
const ladder = annotateFrontier(feasible);
const feasibleIds = new Set(feasible.map((point) => point.id));
const frontierIds = new Set(frontier.map((point) => point.id));
const rejected: Rejection[] = [];
for (const candidate of candidates) {
if (feasibleIds.has(candidate.id)) continue;
const reasons = guardrailReasons(candidate, guardrail);
if (reasons.length > 0) {
rejected.push({ id: candidate.id, reasons });
continue;
}
if (!frontierIds.has(candidate.id)) {
const dominator = candidates.find((other) => other.id !== candidate.id && dominates(other, candidate));
rejected.push({
id: candidate.id,
reasons: [dominator ? `dominated by "${dominator.id}"` : "equivalent to another frontier point"],
});
}
}
const chosen = pick(ladder, policy);
const rationale = chosen
? `objective=${policy.objective}; chosen "${chosen.id}" (quality ${chosen.quality.toFixed(3)}, cost ${chosen.costMicrosPer1k}, latency ${chosen.latencyP95Ms}); feasible=${feasible.length}, rejected=${rejected.length}`
: `objective=${policy.objective}; no feasible candidate; rejected=${rejected.length}`;
return { chosen: chosen?.id ?? null, feasible: feasible.map((point) => point.id), rejected, points, rationale };
}
paretoFrontier 先校验“同一能力、唯一 id、坐标合法”,再淘汰被支配点。相等坐标只保留字典序最小的 id,避免同一组数字在前沿上出现两次。
annotateFrontier 用 qualityLeader 标记质量新高点,只有这些点参与边际成本计算。这个标记让“用成本换延迟”的点不会被误算成“用成本换质量”。
selectOnFrontier 先按护栏过滤,再对可行集重新标注一次。示例中 C 在完整前沿上的边际成本是 12000,在可行集上是 4000;如果不重算,knee 会错误地停在 B。
示例样本上的运行结果
在实验工程根目录执行:
node examples/perf/quality-frontier.ts
本机一次运行的真实输出如下:
frontier=a,b,e,c,d
a quality=0.72 cost=180 latency=420 marginal=null
b quality=0.80 cost=260 latency=460 marginal=1000.00
e quality=0.83 cost=300 latency=700 marginal=1333.33
c quality=0.84 cost=420 latency=520 marginal=12000.00
d quality=0.86 cost=700 latency=610 marginal=14000.00
objective=max-quality chosen=c feasible=b,c rejected=6
objective=min-cost chosen=b feasible=b,c rejected=6
objective=knee chosen=c feasible=b,c rejected=6
feasible-ladder b marginal=null
feasible-ladder c marginal=4000.00
这组结果给出三个判断。第一,前沿只有五个点,八个候选里有三个是被支配的,选型会不需要讨论它们。第二,最贵的 D 质量最高但成本 700,超过护栏,贵不等于可上线。第三,C 在完整前沿上的边际成本 12000,在可行集上只有 4000;同一份数据,过滤前后必须重算,否则 knee 会给出相反结论。
落地顺序
第一步先固定评测集与结算口径。质量通过率、成本微元与 P95 延迟必须来自同一批请求,否则三条轴不在同一时空。
第二步按能力写护栏。先写“绝对不能接受什么”,再写“愿意为质量付多少”。顺序反了,团队会先陷入性价比争论,却说不清底线。
第三步才是求前沿与选点。前沿的价值是缩小讨论范围:把“八个方案哪个好”变成“两个可行方案愿意为 0.04 的质量差付 160 微元吗”。
最后把选点结果与阈值写进 ADR。半年后模型价格、评测集与业务护栏都会变,没有记录的选点无法复核。
小结
质量、成本与延迟构成三维权衡面。支配关系淘汰“三条都不如别人”的点;Pareto 前沿保留真正需要取舍的点;能力护栏切掉业务不能接受的区域;边际成本给“再提升一点质量”定价。示例样本的前沿是 A、B、E、C、D,护栏后只剩 B 与 C,max-quality 与 knee 选 C,min-cost 选 B。
下一节把“质量”从一次选型变成持续门禁:评测集怎样构建、怎样分层抽样、阈值怎样带置信度,以及新模型为什么会在整体指标持平的情况下被拦下。
阅读导航:上一节:10.3 结果解读与回归风险 · 下一节:11.2 评测集与回归门禁 。
常见错误与边界
- 用加权总分选模型。归一化与权重把取舍藏起来,被支配点也可能排第一。
- 把被支配点当成一种取舍。它只是三条都不如别人,正确动作是换点。
- 只画成本-质量二维图。延迟可能让一个“更便宜”的点根本不可用。
- 全站共用一套护栏。审核与草稿对质量、延迟的要求不同。
- 忽略样本量。40 条样本的 100% 通过率不能和 600 条样本的 84% 同图比较。
- 对前沿上所有点计算边际成本。用成本换延迟的点会污染质量阶梯。
- 在完整前沿上算完边际成本后直接过滤。可行集变了,阶梯必须重算。
- 把最贵等同于最好。超过成本或延迟护栏的点不可上线。
- 把示例样本结果写成生产实测。数字必须标注来源与样本量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。