公开排行榜能帮助你缩小候选范围,却不能替你决定哪个模型适合客服、代码审查、知识库问答或内容归纳。同一个模型可能在通用知识题上领先,却因为 JSON 不稳定、首字等待过长、引用不可核验,无法满足你的生产约束。
业务选型真正要回答的是:在同一批真实任务、同一套 Prompt 和同一计分规则下,候选模型能否守住质量底线,并以可接受的成本和延迟持续运行? 本文给出一套 Node.js 最小框架,帮助你生成证据,而不是给出一张脱离业务的模型名次表。
如果你正在评估 AI 内容系统,可先阅读AI 总结质量回归测试;准备把候选模型接入生产时,再参考Node.js 多模型轮询与故障切换。
一、先写决策规则,再调用模型
评测最常见的错误,是先跑一批模型,再从结果里挑对自己有利的指标。更可靠的方式是预先登记评测协议:
- 业务任务、用户群体与不可接受的失败;
- 测试集版本、样本来源、纳入和排除规则;
- 固定的系统提示词、用户模板、工具和结构化输出约束;
- 模型版本、采样参数、服务区域与运行日期;
- 自动检查、人工量表、模型裁判及其权重;
- 质量硬门槛、统计比较方法、成本与延迟预算;
- 超时、拒答、空响应、限流和重试怎样计分。
OpenAI Evals 的官方文档也把评测拆为测试数据与测试逻辑,并提醒开发者先确认是否已有适用模板。无论使用哪个框架,这两个部分都需要版本化;只保存最终平均分无法复现一次决策。
二、建立贴近生产分布的测试集
测试集应来自脱敏后的真实任务,并保留困难样本,不能只选模型容易回答的问题。一个最小夹具可以这样描述:
{"id":"support-001","segment":"refund","input":"用户请求退款……","mustInclude":["退款时限"],"forbiddenClaims":["保证当天到账"],"expectedSchema":{"required":["answer","citations"]},"risk":"high"}
每条样本至少应包含稳定 ID、输入、任务分层、判分依据和风险等级。涉及检索时,还要冻结知识库快照或记录索引版本,否则候选模型读到的证据不同,结果便不可比。
样本分层比单纯增加数量更重要。例如客服评测可以按退款、物流、账户、安全和未知问题划分;代码任务可以按语言、缺陷类型和仓库规模划分。汇总时同时报告总体结果与各分层结果,避免大量简单样本掩盖高风险场景退化。
测试集不要长期不变。线上出现的新失败应在脱敏、人工确认后加入回归集,但最终验收集要限制访问,防止团队反复针对固定答案调 Prompt,得到无法泛化的高分。
三、把质量拆成可检查的信号
“回答不错”不能作为发布门禁。业务质量通常至少包含以下三层:
| 层级 | 典型指标 | 判定方式 |
|---|---|---|
| 程序契约 | 非空、JSON 可解析、字段完整、类型正确 | 自动断言 |
| 任务正确性 | 关键点覆盖、答案匹配、代码测试通过、引用可定位 | 确定性规则或人工核验 |
| 风险与体验 | 无依据陈述、安全边界、语气、可操作性 | 量表评分与人工复核 |
硬性错误不要被平均分抵消。例如高风险样本出现一条无依据的退款承诺,即使其他样本语言流畅,也应阻断发布。模型裁判可以降低初筛成本,但裁判的 Prompt、模型版本和分数映射同样需要记录,并用人工标注样本验证;不能把裁判给出的 0.9 直接称为准确率。
四、同时记录成本与延迟
模型调用应至少记录:
- 端到端延迟:从客户端发出请求到收到完整响应;
- 首 Token 延迟(TTFT):流式响应中从请求发出到首个内容片段;
- 输出速度:首个内容片段后,每秒生成的 Token;
- 输入、输出和缓存 Token 数;
- 超时、限流、服务错误与重试次数;
- 按运行时价格快照计算的单请求成本。
非流式接口无法测得真实 TTFT,只能得到完整响应耗时。不要用总耗时除以 Token 数反推 TTFT,也不要比较运行在不同地区、不同并发和不同网络条件下的数据。
若供应商按每百万 Token 报价,单次请求的估算成本可以写成:
cost = inputTokens / 1,000,000 × inputPrice
+ cachedInputTokens / 1,000,000 × cachedInputPrice
+ outputTokens / 1,000,000 × outputPrice
价格必须带供应商、币种、生效日期和计费口径。模型深度推理、工具调用、批处理或缓存可能采用不同规则,应以运行时实际账单与供应商官方价格页校准,不能把文章里的历史报价写死为结论。
除“平均每请求成本”外,还应计算每个成功任务的成本:总成本除以通过业务门禁的样本数。低价模型若频繁失败、重试或转人工,实际完成成本可能更高。
五、一个无第三方依赖的 Node.js 评测骨架
下面的示例只依赖 Node.js 内置模块。invokeModel 是适配器:实际项目中由它调用候选模型,并统一返回文本、usage 和可选的 TTFT。示例默认不发起任何外部请求,因此不会生成虚假的实测数字。
// evaluate.mjs
import assert from 'node:assert/strict';
import { performance } from 'node:perf_hooks';
function estimateCost(usage, price) {
const input = usage.inputTokens ?? 0;
const cached = usage.cachedInputTokens ?? 0;
const output = usage.outputTokens ?? 0;
return (
input * price.inputPerMillion
+ cached * price.cachedInputPerMillion
+ output * price.outputPerMillion
) / 1_000_000;
}
function percentile(values, p) {
assert.ok(values.length > 0, 'percentile 需要至少一个样本');
const sorted = [...values].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
function grade(fixture, responseText) {
const text = responseText.trim();
const missing = fixture.mustInclude.filter(term => !text.includes(term));
const forbidden = fixture.forbiddenClaims.filter(term => text.includes(term));
return {
passed: text.length > 0 && missing.length === 0 && forbidden.length === 0,
missing,
forbidden
};
}
export async function evaluate({ fixtures, candidate, invokeModel }) {
const rows = [];
for (const fixture of fixtures) {
const started = performance.now();
try {
const result = await invokeModel({ candidate, fixture });
const latencyMs = performance.now() - started;
const quality = grade(fixture, result.text);
rows.push({
fixtureId: fixture.id,
segment: fixture.segment,
risk: fixture.risk,
ok: quality.passed,
quality,
latencyMs,
ttftMs: result.ttftMs ?? null,
usage: result.usage,
estimatedCost: estimateCost(result.usage, candidate.price),
error: null
});
} catch (error) {
rows.push({
fixtureId: fixture.id,
segment: fixture.segment,
risk: fixture.risk,
ok: false,
latencyMs: performance.now() - started,
estimatedCost: 0,
error: error instanceof Error ? error.message : String(error)
});
}
}
const latencies = rows.map(row => row.latencyMs);
const passed = rows.filter(row => row.ok).length;
const totalCost = rows.reduce((sum, row) => sum + row.estimatedCost, 0);
return {
protocol: candidate.protocol,
candidate: candidate.id,
sampleCount: rows.length,
passRate: passed / rows.length,
failureCount: rows.length - passed,
p50LatencyMs: percentile(latencies, 50),
p95LatencyMs: percentile(latencies, 95),
totalCost,
costPerPassedTask: passed > 0 ? totalCost / passed : null,
rows
};
}
这里的字符串包含检查只是演示。事实问答可用人工参考答案和证据核验,代码任务应运行隔离测试,结构化输出应使用 JSON Schema。不同任务不要硬套同一个评分器。
六、候选模型配置必须是不可变快照
候选配置不能只保存一个容易变化的营销名称。建议至少固定以下字段:
const candidate = {
id: 'provider:model-version:prompt-v4',
provider: 'replace-me',
model: 'replace-with-pinned-version',
protocol: 'business-eval-2026-08-v1',
parameters: { temperature: 0, topP: 1, maxOutputTokens: 800 },
promptHash: 'sha256:replace-me',
endpointRegion: 'replace-me',
price: {
currency: 'replace-me',
effectiveAt: 'YYYY-MM-DD',
inputPerMillion: 0,
cachedInputPerMillion: 0,
outputPerMillion: 0
}
};
这里的零价格是明显的占位值,运行前必须替换并校验,不能进入正式报告。生产实现可以增加断言,拒绝币种、生效日期或价格缺失的配置。API Key 只从密钥管理系统或环境变量读取,绝不能写入评测记录。
对于无法固定底层版本的模型别名,也要记录每次响应返回的模型标识和运行时间。候选模型、Prompt、工具定义、检索索引和价格快照共同构成一个可发布单元。
七、用基线对比,而不是孤立看候选分数
模型升级时,应让生产基线与候选版本在同一批夹具、相近时间和相同并发条件下运行。输出按 fixtureId 配对后,重点查看:
- 候选新增了哪些失败,而不只是总通过率变化;
- 高风险分层是否退化;
- P50 与 P95 延迟是否变化;
- 每个成功任务成本是否变化;
- 失败是否集中在某类输入、语言或长度;
- 多次重复运行时,结论是否稳定。
平均值会隐藏长尾。交互式产品通常更关心 P95 延迟;批处理任务可能更关心吞吐和总成本。样本较少时,一个案例就可能显著改变比例,因此报告必须同时显示分子、分母和逐样本差异,必要时给出置信区间,而不是只发布两位小数的“胜率”。
采样输出具有随机性。即使设置相同参数,服务端实现更新也可能改变结果。对关键样本重复运行,并报告每个候选的运行次数与波动,比只选择最好的一次更可信。
八、设置质量优先的回归门禁
门禁应由业务风险决定。下面只展示结构,不提供可直接照抄的阈值:
function checkGate({ baseline, candidate, limits }) {
const blockers = [];
if (candidate.failureCount > limits.maxFailures) {
blockers.push('失败样本超过上限');
}
if (candidate.passRate < baseline.passRate - limits.maxPassRateDrop) {
blockers.push('通过率相对基线退化');
}
if (candidate.p95LatencyMs > limits.maxP95LatencyMs) {
blockers.push('P95 延迟超过预算');
}
if (
candidate.costPerPassedTask == null
|| candidate.costPerPassedTask > limits.maxCostPerPassedTask
) {
blockers.push('成功任务成本超过预算或无法计算');
}
const highRiskFailure = candidate.rows.some(row => row.risk === 'high' && !row.ok);
if (highRiskFailure) blockers.push('存在高风险样本失败');
return { passed: blockers.length === 0, blockers };
}
limits 必须在看候选结果前,由产品、工程、安全和财务共同确认。高风险任务可以采用“一条关键失败即阻断”;低风险文案任务则可允许少量变化进入人工抽查。切勿为了让某个候选通过而事后放宽门槛。
门禁通过也不等于自动全量上线。先小流量灰度,监控真实错误、人工转接、用户反馈、延迟和账单,再逐步扩大流量。Prompt 和配置的灰度、回滚方法可继续阅读生产环境 Prompt 版本管理。
九、评测报告应能回答“为什么选它”
一份可审核的选型报告至少包含:
- 决策日期、负责人和评测协议版本;
- 候选模型、Prompt、参数、工具和知识库版本;
- 测试集规模、分层、数据时间范围与脱敏方式;
- 总体及分层质量结果、失败案例和人工复核结论;
- P50/P95 延迟、超时率、重试率和测试并发;
- Token 用量、价格快照、总成本与成功任务成本;
- 与生产基线的逐样本退化和改善;
- 已知限制、灰度方案、监控指标和回退条件。
对外发布时应说明数据来自哪个版本和什么条件。不要把开发环境单次运行包装成生产 SLA,也不要将内部业务集结果外推为“全行业最强”。
十、推荐实施顺序
- 选择一个价值明确、边界清楚的业务任务;
- 从真实流量中脱敏抽样,并补充高风险与边界案例;
- 固定基线模型、Prompt、参数和检索快照;
- 先实现格式、字段、禁用声明等确定性检查;
- 为主观质量建立人工量表和双人校准样本;
- 统一采集 usage、错误、端到端延迟和流式 TTFT;
- 在同一环境运行基线与候选,保留逐样本输出;
- 按预先登记的门禁决定拒绝、复核或灰度;
- 上线后把新失败沉淀为回归夹具,并定期复跑。
FAQ
公开榜单第一名可以直接进入生产吗?
不可以。公开榜单衡量的是特定数据集、Prompt、评分器和运行条件下的能力。你的业务可能更看重结构化输出、私有知识、长尾语言、延迟或失败成本,必须用真实任务复测。
质量、价格和速度应该怎样加权?
先设置不可妥协的质量与安全门槛,再在通过门槛的候选中比较延迟和成功任务成本。把三项直接加成一个总分,可能让低价抵消关键事实错误,通常不适合高风险任务。
为什么看每个成功任务成本,而不是每百万 Token 单价?
Token 单价只描述计费单位。模型失败、超时、重试、生成过长或需要人工修订,都会增加完成一个业务任务的真实成本。成功任务成本能把质量损失和调用费用放进同一决策视角。
测试集需要多少样本?
没有适用于所有业务的固定数字。样本要先覆盖关键分层与高风险失败,再根据结果波动和决策风险扩充。报告样本数、分层和不确定性,比引用一个脱离业务的最低数量更可靠。
可以只用模型裁判完成自动评测吗?
不建议。模型裁判适合扩大初筛范围,但需要用人工标注数据验证其一致性与偏差。高风险判断、裁判分歧和新型失败仍应进入人工复核。
总结
业务模型评测不是寻找一张永远有效的排行榜,而是建立一个可以重复回答决策问题的工程系统:冻结真实样本和配置,用清晰规则衡量质量,统一记录成本与延迟,以生产基线识别退化,再通过预先登记的门禁和灰度发布控制风险。
先用少量、覆盖关键风险的夹具跑通整条证据链,再持续吸收线上失败。这样得出的模型选择未必能被概括成一句“谁最强”,却能清楚说明:在什么任务、什么版本和什么约束下,为什么选择它,以及出现问题时如何回退。