从一次智能回复的争论说起
「回声」的私信页要上线智能回复:用户收到一条消息,输入框上方给出三条候选回复,点一下就能发送。第一次评审会上,产品经理说要用最强的模型,理由是“回复质量直接代表产品形象”;工程负责人说用最便宜的模型,理由是“这就是个短文本生成,没必要花那个钱”。
两边都没有说错,但两边都没有把问题说完整。产品经理说的是质量维度,工程负责人说的是成本维度,而真正决定用户体验的还有延迟:候选回复必须在用户点开输入框之前出现,晚一秒就没人看。除此之外,对话历史有多长、模型能不能被约束在安全范围内,也都是选型的输入。
这类争论在每个接入 AI 的产品里都会出现。解决办法不是让职位更高的人拍板,而是先把“模型能力”拆成可以分别讨论、分别测量的维度,再让每个维度回到具体任务上。本章给出五个维度:质量、延迟、成本、上下文、可控性。它们不是排行榜上的五个栏目,而是产品做取舍时必须回答的五个问题。
文中出现的延迟、价格、比例,除非明确标注为本机运行输出,都是案例假设值,用来演示判断过程,不代表任何真实厂商或模型的实测数据。
五个维度不是一张榜单
榜单通常只回答一个问题:在某一组公开题目上,谁的平均分更高。它把质量压缩成一个数字,把其余四个维度留给了读者自己想象。产品选型面对的却是另一组问题:
- 这个任务答错一次的代价有多大,质量差多少才会被用户感知?
- 用户愿意等多久,P50 和 P95 的差距会不会让一部分人先流失?
- 每次请求的混合成本是多少,毛利能不能覆盖?
- 真实输入有多长,上下文窗口是够用还是看起来够用?
- 我们能不能限制输出长度、限定区域、在异常时降级?
五个维度必须一起看,因为它们在产品里会互相换算。质量不够,可以用人工复核补;延迟不够,可以改成异步;成本太高,可以缩小上下文;上下文不够,可以拆任务;可控性不够,则可能根本不能上线。把五个维度分开,是为了让换算关系显式,而不是让某一个维度偷偷替所有维度做决定。
质量:分任务而不是总分
质量不是一个标量。同一个模型在“短回复是否自然”和“长文事实是否准确”上的相对位置可能完全不同。回声把质量拆成任务级指标:智能回复看采纳率与人工改写率,审核分类看召回率与误杀率,搜索重排看前三条结果的相关性。每个指标都有自己的验收线,而不是共用一个“综合能力分”。
这也解释了一个常见误读:榜单第一不等于任务第一。公开题目往往偏通用知识,而产品任务有固定的输入分布、固定的输出格式和固定的错误代价。一个在通用题上略弱的模型,如果在你的任务分布上更稳,反而可能是更好的选择。判断依据只能来自你自己的评测集,这一点会在 2.2 展开。
延迟:用户感知的是端到端
模型档案里的 P95 延迟只是一个片段。用户感知的是从点击到看到结果的整条链路:排队、鉴权、检索、模型推理、流式传输、前端渲染。模型再快,如果前面有一次同步检索,端到端仍然会慢。
因此延迟维度要区分两件事:模型的 P95 是预算分配的依据,不是对用户的承诺。回声给智能回复设定的端到端预算是 800 毫秒(案例假设值),其中模型最多分到 500 毫秒,剩余留给网关、检索和渲染。选型时先看模型 P95 是否落在预算内,再看超时后的降级路径是否存在。
还要注意延迟分布的形状。两个模型平均延迟相同,一个稳定在 400 毫秒,另一个一半请求 100 毫秒、一半请求 700 毫秒,用户体验完全不同。档案里只存 P95 是一个折中:它比平均值更接近尾部体验,但仍然不能替代线上分位数监控。
成本:按混合成本算,不只看单价
模型价格通常分输入和输出两档,输出往往更贵。只比较输入单价会得出错误结论,因为生成任务的输出 token 占比可能很高。回声用“混合成本”做统一口径:按真实请求的输入输出比例,把两档价格折算成每百万 token 的加权微元。
假设某次请求平均输入 1 800 token、输出 300 token,那么输入占比约 86%。如果直接用 50/50 估算,会把输出成本摊薄,低估实际支出。混合成本必须从线上采样,而不是拍一个固定比例。档案里保留两档原始价格,派生函数负责按比例折算,这样口径变化时不需要改档案。
成本维度还有一个容易忽略的部分:失败请求也要计量。超时、重试、被内容策略拦截的请求都可能已经消耗了 token。选型时如果只算成功请求的成本,会把便宜但不稳定的模型算得过好。
上下文:窗口不是可用长度
上下文窗口是模型一次能接收的最大 token 数,但它不等于业务可用的长度。输入、输出、系统提示、工具定义、检索片段共享同一个窗口。一个标称 128K 的模型,在留出 8K 输出、2K 系统提示和工具描述之后,真正能放对话历史的空间可能只剩下一百多 K。
更关键的是“有效上下文”与“窗口”不是一回事。模型在超长输入中段的信息利用率可能下降,这是需要在自己的任务上评测的性质,不能只凭窗口数字推断。回声的做法是给每个能力定义一个“最小可用上下文”,再由评测确认在这个长度下质量是否达标。窗口只负责过滤明显不够的候选,不负责证明候选足够好。
可控性:能不能被约束
可控性是最容易被忽略的维度。它问的不是“模型听不听话”这种笼统问题,而是:我们能不能限制输出长度、能不能把请求固定在某几个区域、能不能在异常时切到模板兜底、能不能保证同样的输入不会产生越界内容。
在档案层面,可控性可以由几个可检查的事实派生:输出上限占上下文的比例是否足够小、是否覆盖多个区域、当前是否健康。这些事实不能证明模型一定服从指令,但能证明契约有约束点。真正的指令遵循能力仍然要靠评测,尤其是格式遵循、拒答边界和提示注入抵抗。
可控性低不一定意味着不能用,而意味着必须加外层约束:结构化输出校验、内容过滤、人工复核、灰度发布。把这些成本算进去之后,某些“能力强但难约束”的模型未必划算。
为什么需要模型档案
五个维度如果只存在于评审会的幻灯片里,下一次换人就会丢失。工程上需要一个结构化档案,把可量化的事实固定下来,让选型、路由、审计都引用同一份数据。档案至少应包含:
- 身份与提供方,用于故障隔离和成本归属。
- 能力列表,说明这个模型能接哪些产品能力。
- 质量档位,作为相对定位,不是绝对分数。
- 上下文窗口与输出上限,用于硬约束过滤。
- 输入输出价格,用整数微元表示,避免浮点误差。
- P95 延迟与可用区域。
- 健康状态,由探测任务维护。
这份档案放在 src/core/types.ts 里,选型与网关共用。本章新增的 model-profile.ts 不重复定义结构,只负责校验和派生指标。这样职责清楚:类型是契约,函数是操作。
把维度写进可校验的档案
档案最容易出的问题不是“值不好”,而是“值不合法”:能力列表为空、输出上限大于上下文、价格写成小数、区域重复。这些问题如果等到路由时才暴露,会变成线上故障。下面的实现把校验集中在入口,一次返回全部问题。
// file: src/core/model-profile.ts
// tier: L1
// verify: node --test test/core/model-profile.test.ts
import { AiError } from "./errors.ts";
import type { ModelProfile, QualityTier } from "./types.ts";
/** 档案校验只回答“这份档案能不能进入选型”,不评价模型本身好坏。 */
export interface ProfileIssue {
field: string;
code: "missing" | "range" | "duplicate" | "type";
message: string;
}
export interface ProfileValidation {
ok: boolean;
issues: ProfileIssue[];
}
const TIERS: readonly QualityTier[] = ["frontier", "standard", "economy"];
/**
* 档案是选型的唯一事实来源,因此先校验再打分。
* 一次返回全部问题,评审时能一次改完,而不是修一个报一个。
*/
export function validateProfile(profile: ModelProfile): ProfileValidation {
const issues: ProfileIssue[] = [];
const push = (field: string, code: ProfileIssue["code"], message: string): void => {
issues.push({ field, code, message });
};
if (profile.id.trim() === "") {
push("id", "missing", "模型 id 不能为空");
}
if (profile.provider.trim() === "") {
push("provider", "missing", "provider 不能为空");
}
if (profile.capabilities.length === 0) {
push("capabilities", "missing", "至少声明一个能力,否则档案无法被路由");
}
const seenCapabilities = new Set<string>();
for (const capability of profile.capabilities) {
if (capability.trim() === "") {
push("capabilities", "missing", "能力标识不能为空");
} else if (seenCapabilities.has(capability)) {
push("capabilities", "duplicate", `能力重复:${capability}`);
}
seenCapabilities.add(capability);
}
if (!Number.isSafeInteger(profile.contextWindow) || profile.contextWindow <= 0) {
push("contextWindow", "range", "上下文窗口必须是正整数");
}
if (!Number.isSafeInteger(profile.maxOutputTokens) || profile.maxOutputTokens <= 0) {
push("maxOutputTokens", "range", "输出上限必须是正整数");
} else if (profile.maxOutputTokens > profile.contextWindow) {
push("maxOutputTokens", "range", "输出上限不能超过上下文窗口");
}
if (!Number.isSafeInteger(profile.inputMicrosPerMTok) || profile.inputMicrosPerMTok < 0) {
push("inputMicrosPerMTok", "type", "输入价格必须是非负整数微元");
}
if (!Number.isSafeInteger(profile.outputMicrosPerMTok) || profile.outputMicrosPerMTok < 0) {
push("outputMicrosPerMTok", "type", "输出价格必须是非负整数微元");
}
if (!Number.isFinite(profile.p95LatencyMs) || profile.p95LatencyMs <= 0) {
push("p95LatencyMs", "range", "P95 延迟必须是正数");
}
if (profile.regions.length === 0) {
push("regions", "missing", "至少声明一个可用区域");
}
const seenRegions = new Set<string>();
for (const region of profile.regions) {
if (region.trim() === "") {
push("regions", "missing", "区域标识不能为空");
} else if (seenRegions.has(region)) {
push("regions", "duplicate", `区域重复:${region}`);
}
seenRegions.add(region);
}
if (!TIERS.includes(profile.qualityTier)) {
push("qualityTier", "type", `未知质量档位:${String(profile.qualityTier)}`);
}
if (typeof profile.healthy !== "boolean") {
push("healthy", "type", "healthy 必须是布尔值");
}
return { ok: issues.length === 0, issues };
}
/** 档案不合格时抛出统一的 invalid-request,避免坏数据流进评分。 */
export function assertValidProfile(profile: ModelProfile): void {
const result = validateProfile(profile);
if (!result.ok) {
const fields = result.issues.map((issue) => issue.field).join(", ");
throw new AiError("invalid-request", `invalid model profile ${profile.id}: ${fields}`);
}
}
/**
* 混合成本:按输入/输出占比折算成“每百万 token 的加权微元”。
* 业务流量里输入通常远多于输出,默认 0.75/0.25 只是案例假设值,必须按真实请求重算。
*/
export function blendedCostMicrosPerMTok(profile: ModelProfile, inputShare = 0.75): number {
if (!Number.isFinite(inputShare) || inputShare < 0 || inputShare > 1) {
throw new AiError("invalid-request", "inputShare 必须在 0 到 1 之间");
}
const value =
profile.inputMicrosPerMTok * inputShare +
profile.outputMicrosPerMTok * (1 - inputShare);
return Math.round(value);
}
/**
* 质量档位到分数的映射是产品约定,不是客观测量。
* 它只用于同一份预算表里的相对比较,不能拿去和外部榜单对齐。
*/
export const TIER_QUALITY_SCORE: Record<QualityTier, number> = {
frontier: 1,
standard: 0.7,
economy: 0.4,
};
export type Dimension = "quality" | "latency" | "cost" | "context" | "controllability";
/** 打分预算来自产品约束,五个维度都用同一份预算归一化。 */
export interface DimensionBudget {
maxBlendedMicrosPerMTok: number;
maxP95LatencyMs: number;
minContextWindow: number;
inputShare?: number;
/** 输出上限占上下文的比例上限,默认 0.25。 */
maxOutputRatio?: number;
}
export interface DimensionScores {
quality: number;
latency: number;
cost: number;
context: number;
controllability: number;
/** 派生值,便于报告里直接展示成本,而不是只给一个分数。 */
blendedMicrosPerMTok: number;
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));
}
function assertBudget(budget: DimensionBudget): void {
if (!Number.isFinite(budget.maxBlendedMicrosPerMTok) || budget.maxBlendedMicrosPerMTok <= 0) {
throw new AiError("invalid-request", "maxBlendedMicrosPerMTok 必须是正数");
}
if (!Number.isFinite(budget.maxP95LatencyMs) || budget.maxP95LatencyMs <= 0) {
throw new AiError("invalid-request", "maxP95LatencyMs 必须是正数");
}
if (!Number.isFinite(budget.minContextWindow) || budget.minContextWindow <= 0) {
throw new AiError("invalid-request", "minContextWindow 必须是正数");
}
const ratio = budget.maxOutputRatio ?? 0.25;
if (!Number.isFinite(ratio) || ratio <= 0 || ratio > 1) {
throw new AiError("invalid-request", "maxOutputRatio 必须在 0 到 1 之间");
}
}
/**
* 五个维度统一折算到 0..1,越大越适合当前预算。
* 延迟与成本在预算值处得 0.5 分,低于预算一半才拿满分,避免“刚好达标”被当成优秀。
*/
export function scoreProfile(profile: ModelProfile, budget: DimensionBudget): DimensionScores {
assertBudget(budget);
const blended = blendedCostMicrosPerMTok(profile, budget.inputShare ?? 0.75);
const ratio = budget.maxOutputRatio ?? 0.25;
const latency = clamp01(1 - profile.p95LatencyMs / budget.maxP95LatencyMs / 2);
const cost = clamp01(1 - blended / budget.maxBlendedMicrosPerMTok / 2);
const context = clamp01(profile.contextWindow / budget.minContextWindow / 2);
let controllability = 0.4;
if (profile.maxOutputTokens / profile.contextWindow <= ratio) {
controllability += 0.2;
}
if (profile.regions.length >= 2) {
controllability += 0.2;
}
if (profile.healthy) {
controllability += 0.2;
}
return {
quality: TIER_QUALITY_SCORE[profile.qualityTier],
latency,
cost,
context,
controllability: clamp01(controllability),
blendedMicrosPerMTok: blended,
};
}
校验函数只回答“能不能进入选型”,不评价模型好坏。这个边界很重要:如果把业务偏好写进校验,档案就会变成另一张排行榜。价格用整数微元、延迟用正数、能力不允许重复,这些都是结构性约束,和“哪个模型更好”无关。
assertValidProfile 把不合格档案转成统一的 invalid-request 错误。这样业务方看到的错误类别是一致的,不会因为某个提供方的私有字段缺失而得到奇怪的异常。
派生指标要能解释
有了合法档案,下一步是把五个维度折算成可以比较的分数。这里必须小心:分数是内部决策工具,不是对外宣传的质量证明。scoreProfile 做了三件事。
第一,把成本折算成混合成本。默认输入占比 0.75 只是案例假设值,真实值必须从请求日志重算。函数接受 inputShare 参数,并在越界时直接抛错,避免悄悄截断后得到一个看似合理的错误答案。
第二,把延迟、成本、上下文归一化到 0 到 1。延迟与成本在“刚好等于预算”时得 0.5 分,只有低于预算一半才接近满分。这样做的原因是预算本身是上限,不是目标;刚好达标的方案不应该在评分上显得优秀。上下文则相反,达到最小要求得 0.5 分,达到两倍才满分。
第三,把可控性拆成可检查的加分项:输出上限占比不超过阈值、覆盖至少两个区域、当前健康。基础分 0.4,三个条件各加 0.2。这个分数衡量的是“契约有多少约束点”,不是“模型有多听话”。文章里必须把这句话说清楚,否则读者会把它当成质量指标。
质量维度直接取档位映射:frontier 1.0、standard 0.7、economy 0.4。这三个数字是产品约定,不是测量结果。它们只用于同一份预算表内的相对比较。如果业务对“标准档”的定义变了,改映射即可,不需要改档案。
回声的三次选型讨论
回到开头那场争论。用五个维度重新看智能回复,结论会变得具体。
智能回复的质量指标是采纳率,错误代价中等:一条不合适的候选回复会被用户忽略,不会自动发送。延迟预算很紧,因为候选必须在输入框展开前出现。成本按每条私信一次请求计算,量级可控。上下文只需要最近若干轮对话,窗口压力不大。可控性要求包括长度限制和敏感内容过滤。
在这些约束下,前沿模型和质量档更高的标准模型都能进入候选,经济模型则在质量维度上风险偏大。最终选择不是“最强”或“最便宜”,而是“在延迟和成本预算内,采纳率达标且可控性足够”的那一个。这个结论可能随流量结构变化,所以选型要定期重跑,而不是一次定终身。
内容审核是另一组取舍。审核分类的输出是有限的几个标签,错误代价不对称:漏放的风险远高于误杀。质量维度看召回率,延迟要求高,成本按每条内容计算且量很大。上下文很短,可控性要求极高,因为审核结果会直接影响用户。这里的结论通常是:用便宜模型做初筛,把不确定样本交给更强模型或人工。便宜不是目的,分层才是。
搜索重排则必须单独评测。重排的输入是查询和候选文档,输出是顺序,质量指标是相关性而不是文本流畅度。通用榜单几乎不能预测重排效果,因为任务结构完全不同。回声为搜索重排单独建了一套离线评测集,并且要求线上 A/B 验证点击与转化,而不是只看离线分数。这一点会在下一节展开。
五个维度之间会互相换算
五个维度不是并列的复选框,它们在预算约束下互相换算。常见的换算有四条。
质量换成本:用更强模型减少人工复核,或者用更弱模型增加复核。哪边便宜取决于错误率和人工成本,不能凭感觉。
延迟换质量:同步生成改成异步生成,可以放宽模型延迟预算,但会改变交互形态。智能回复可以异步预生成,搜索重排通常不能。
成本换上下文:把长文档拆成多段分别处理,能降低单次请求的上下文压力,但会增加调用次数和拼接逻辑,总成本未必下降。
可控性换一切:如果模型无法被约束在业务边界内,前面三个维度的优势都可能作废。可控性不达标时,正确的动作是换模型或加外层约束,而不是在评分表里给它加权。
把这些换算写进评审记录,比写进代码更重要。代码只能固化当前结论,评审记录才能解释结论为什么成立、什么时候会失效。
小结
模型能力可以拆成质量、延迟、成本、上下文、可控性五个维度。质量要分任务看,延迟要看端到端预算,成本要按混合口径算,上下文要区分窗口与可用长度,可控性要落到可检查的约束点。档案把这些事实固定下来,校验保证坏数据不进入选型,派生函数把维度折算成可比较的分数。
分数只服务于内部决策。它不能替代离线评测,也不能替代线上实验。下一节会把五个维度放进选型矩阵,说明硬约束、加权评分与并列处理如何配合,以及离线评测集与在线 A/B 各自负责哪一半问题。
阅读导航:上一节:1.3 ai-lab:从需求到能力契约 · 下一节:2.2 选型矩阵:离线评测与在线 A/B 。
常见错误与边界
- 把榜单总分当成任务质量。公开题目与产品任务的输入分布、输出格式、错误代价都不同,榜单只能用来缩小候选范围。
- 只比较输入单价。生成任务的输出 token 往往更贵,必须用真实请求的输入输出比例计算混合成本。
- 把上下文窗口当成可用长度。系统提示、输出预留、工具定义都占窗口,有效上下文还要单独评测。
- 用平均延迟做决策。平均延迟会掩盖尾部体验,至少要同时看 P95 和超时率。
- 把可控性等同于“模型听话”。档案只能证明存在约束点,指令遵循与注入抵抗必须靠评测。
- 用一套权重打所有任务。智能回复、内容审核、搜索重排的维度权重不同,必须分别配置。
- 选型一次定终身。流量结构、价格、模型版本都会变,矩阵需要定期重跑并保留历史基线。
- 忽略失败请求的成本。超时、重试与被拦截的请求也可能消耗 token,只算成功请求会低估支出。
- 把内部评分对外宣传。档位映射与归一化都是产品约定,不是客观测量,不能当作质量背书。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。