采纳率 68%,但用户改了一半
「回声」的内容团队给 AI 摘要做了一次季度汇报:采纳率 68%,比上季度提升 9 个百分点。两周后用户访谈推翻了结论——受访者说他们“基本都会改一下”,因为摘要经常漏掉关键信息。
复盘的第一个发现是口径。看板上的“采纳”统计的是“用户点击了插入按钮”,而点击之后是否编辑、编辑了多少,完全没有进入指标。第二个发现是分母:只统计了产生点击的会话,没有反馈的会话被排除在外。第三个发现是版本:离线评测用的是旧版指标定义,线上换了新定义,两个数字根本不可比。
这一节把线上质量信号收敛成四个:采纳、重试、转人工、投诉。它们共用一个分母,但回答不同问题;与离线评测对应时,还必须先确认指标版本与样本量。
文中出现的比例、样本量与耗时,除非标注为本机测试输出,都是案例假设值。
四个信号,一个分母
线上质量指标最常见的错误,是每个指标用各自的分母。采纳率除以“有反馈的建议”,重试率除以“总请求”,投诉率除以“活跃用户”。三个数字放在一张看板上,看起来都在变好,实际无法相互解释。
统一分母是“曝光过的建议数”:系统给出过建议,就算一次曝光。用户没点、没改、没反馈,仍然计入分母,只是计入“无反馈”。
把无反馈单独列出来,是这套口径的关键。无反馈既不是采纳也不是拒绝,它可能意味着用户没看到、不关心,或者界面没有提供反馈入口。把无反馈从分母里删掉,等于让沉默用户消失。
四个信号共享分母之后,可以相加成一个粗略的“结果分布”:原样采纳、改写采纳、拒绝、无反馈。分布比单个比例更能说明问题。
点击率不是采纳率
点击率统计“用户接受了建议”,包括原样采纳和改写后采纳。采纳率只统计原样采纳。两者之间的差额,就是用户实际付出的修改成本。
只看点击率,会把“每次都要大改”的产品算成成功。只看采纳率,又可能低估“改两个字就能用”的价值。
折中的做法是增加一个轻改口径:编辑距离不超过阈值的改写,算作“接近采纳”。阈值是案例假设值,本文取 0.2;团队应该按自己的编辑距离分布调整,并写进指标定义。
最终给出三个数:点击率、原样采纳率、轻改调整后的采纳率。三个数一起看,才能区分“建议很好”“建议方向对但细节差”“建议基本不能用”。
编辑距离本身也有边界。它衡量字符差异,不衡量语义差异;把“不”改成“是”可能距离很小,意思完全相反。它适合做粗粒度分层,不适合做唯一的质量判据。
重试与转人工:两种不同的失败
重试是用户在同一能力上再次发起请求。它说明上一次结果不可用,但用户还愿意给机会。重试率高,通常是输出不稳定、格式错误或等待太久。
转人工是用户放弃 AI,转而找人或走其他流程。它说明用户对能力本身失去信心,或者任务风险已经超过可接受范围。转人工率高,往往不是模型单点问题,而是能力边界没有划清。
两者不能合并成“失败率”。重试还有挽回空间,转人工意味着这次会话的 AI 价值已经归零。看板上应该分开,并允许按能力、模型、版本切片。
投诉是最稀疏也最昂贵的信号。一次投诉背后可能有几十次沉默的失望,但投诉本身又容易被少数极端用户放大。用“每千次曝光投诉数”而不是“投诉占比”,能让数字更易读,也便于跨能力比较。
线上与离线怎么对应
线上指标与离线评测不是同一个东西。离线评测有固定测试集、明确标注和可重复的评分;线上指标有真实用户、噪声反馈和不断变化的分母。两者只能对应,不能互相替代。
对应需要三个前提。
第一,指标版本一致。线上把“点击”算作采纳,离线把“原样采纳”算作采纳,两个数字放在一起比较就是错的。指标定义要像接口一样版本化。
第二,样本量足够。几十次曝光的采纳率波动极大,不能用来判断模型退步。样本不足时应该明确返回“不可比”,而不是给出一个看似精确的差值。
第三,比较的是同一个能力与模型。线上混了三个模型的流量,离线只评测了其中一个,差异可能全部来自流量结构。
满足前提后,再按指标逐项比较,并设置容差。采纳率下降超过 5 个百分点、重试率上升超过 3 个百分点、转人工率上升超过 2 个百分点,才值得触发排查。容差是案例假设值,不是行业标准。
方向也要写清楚:采纳率越高越好,重试率、转人工率越低越好。把方向写进代码,能避免“指标上升所以变好”的想当然。
实现
下面的实现包含事件校验、按建议去重、窗口与切片过滤、指标计算,以及与离线基线的对应检查。它不依赖任何分析平台,输出是可以直接写进时序库的普通对象。
// file: src/observe/quality.ts
// tier: L1
// verify: node --test test/observe/quality.test.ts
import { AiError } from "../core/errors.ts";
import type { CapabilityId } from "../core/types.ts";
/** 指标定义本身也要版本化,否则线上与离线只是两个同名的数字。 */
export const QUALITY_METRIC_VERSION = 1;
export type FeedbackKind =
| "accepted_unchanged"
| "accepted_edited"
| "rejected"
| "ignored";
export interface SuggestionEvent {
suggestionId: string;
capability: CapabilityId;
modelId: string;
surfacedAt: number;
/** null 表示用户没有做出任何反馈,不能当作拒绝。 */
feedback: FeedbackKind | null;
/** 编辑幅度 0 到 1,只有 accepted_edited 参与轻改判定。 */
editDistanceRatio?: number;
retried: boolean;
handoff: boolean;
complaint: boolean;
}
export type AddStatus = "accepted" | "duplicate" | "conflict";
const FEEDBACK_KINDS = new Set<string>([
"accepted_unchanged",
"accepted_edited",
"rejected",
"ignored",
]);
function fail(message: string): never {
throw new AiError("invalid-request", `quality: ${message}`);
}
function validateEvent(event: SuggestionEvent): void {
for (const field of ["suggestionId", "capability", "modelId"] as const) {
if (typeof event[field] !== "string" || event[field].trim() === "") fail(`${field} is required`);
}
if (!Number.isInteger(event.surfacedAt) || event.surfacedAt < 0) {
fail("surfacedAt must be a non-negative integer");
}
if (event.feedback !== null && !FEEDBACK_KINDS.has(event.feedback)) {
fail("feedback is not a known kind");
}
if (event.editDistanceRatio !== undefined) {
const ratio = event.editDistanceRatio;
if (typeof ratio !== "number" || !Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
fail("editDistanceRatio must be between 0 and 1");
}
}
for (const field of ["retried", "handoff", "complaint"] as const) {
if (typeof event[field] !== "boolean") fail(`${field} must be a boolean`);
}
}
const SAME_FIELDS: Array<keyof SuggestionEvent> = [
"capability", "modelId", "surfacedAt", "feedback", "editDistanceRatio", "retried", "handoff", "complaint",
];
function sameEvent(left: SuggestionEvent, right: SuggestionEvent): boolean {
return SAME_FIELDS.every((field) => left[field] === right[field]);
}
export interface QualityMetrics {
metricVersion: number;
surfaced: number;
withFeedback: number;
noFeedbackRate: number;
/** 点击口径:原样采纳 + 改编采纳,容易虚高。 */
clickThroughRate: number;
/** 真正口径:只有原样采纳才计入。 */
adoptionRate: number;
/** 改写幅度不超过阈值的那部分采纳。 */
lightEditRate: number;
/** 原样采纳 + 轻改,作为采纳率的上界参考。 */
adjustedAdoptionRate: number;
retryRate: number;
handoffRate: number;
complaintsPerThousand: number;
}
function rate(numerator: number, denominator: number): number {
return denominator === 0 ? 0 : numerator / denominator;
}
/** 分母是“曝光过的建议数”,不是“有反馈的建议数”,否则沉默用户会被抹掉。 */
export function computeQuality(
events: SuggestionEvent[],
options: { lightEditThreshold?: number } = {},
): QualityMetrics {
const threshold = options.lightEditThreshold ?? 0.2;
const surfaced = events.length;
let withFeedback = 0;
let unchanged = 0;
let edited = 0;
let light = 0;
let retried = 0;
let handoff = 0;
let complaints = 0;
for (const event of events) {
if (event.feedback !== null) withFeedback += 1;
if (event.feedback === "accepted_unchanged") unchanged += 1;
if (event.feedback === "accepted_edited") {
edited += 1;
if (event.editDistanceRatio !== undefined && event.editDistanceRatio <= threshold) light += 1;
}
if (event.retried) retried += 1;
if (event.handoff) handoff += 1;
if (event.complaint) complaints += 1;
}
return {
metricVersion: QUALITY_METRIC_VERSION,
surfaced,
withFeedback,
noFeedbackRate: rate(surfaced - withFeedback, surfaced),
clickThroughRate: rate(unchanged + edited, surfaced),
adoptionRate: rate(unchanged, surfaced),
lightEditRate: rate(light, surfaced),
adjustedAdoptionRate: rate(unchanged + light, surfaced),
retryRate: rate(retried, surfaced),
handoffRate: rate(handoff, surfaced),
complaintsPerThousand: rate(complaints, surfaced) * 1000,
};
}
/**
* 采集器按 suggestionId 去重。同一建议被上报两次是常态,
* 但两次内容不同说明上游状态错乱,应暴露为冲突而不是静默覆盖。
*/
export class QualityCollector {
private readonly events: Map<string, SuggestionEvent>;
constructor() {
this.events = new Map();
}
add(event: SuggestionEvent): { status: AddStatus; duplicateOf?: string } {
validateEvent(event);
const seen = this.events.get(event.suggestionId);
if (seen === undefined) {
this.events.set(event.suggestionId, event);
return { status: "accepted" };
}
return sameEvent(seen, event)
? { status: "duplicate", duplicateOf: seen.suggestionId }
: { status: "conflict", duplicateOf: seen.suggestionId };
}
get size(): number {
return this.events.size;
}
metrics(filter: { capability?: string; modelId?: string; fromMs?: number; toMs?: number } = {}, options: { lightEditThreshold?: number } = {}): QualityMetrics {
const selected: SuggestionEvent[] = [];
for (const event of this.events.values()) {
if (filter.capability !== undefined && event.capability !== filter.capability) continue;
if (filter.modelId !== undefined && event.modelId !== filter.modelId) continue;
if (filter.fromMs !== undefined && event.surfacedAt < filter.fromMs) continue;
if (filter.toMs !== undefined && event.surfacedAt >= filter.toMs) continue;
selected.push(event);
}
return computeQuality(selected, options);
}
}
export interface OfflineBaseline {
metricVersion: number;
capability: string;
modelId: string;
window: string;
sampleSize: number;
adoptionRate: number;
retryRate: number;
handoffRate: number;
}
export interface AlignmentOptions {
/** 低于该样本量时线上数字不足以与离线比较,默认 200。 */
minSample: number;
adoptionTolerance: number;
retryTolerance: number;
handoffTolerance: number;
}
export const DEFAULT_ALIGNMENT: AlignmentOptions = {
minSample: 200,
adoptionTolerance: 0.05,
retryTolerance: 0.03,
handoffTolerance: 0.02,
};
export type DivergenceMetric = "adoption" | "retry" | "handoff";
export interface Divergence {
metric: DivergenceMetric;
online: number;
offline: number;
/** 线上减离线。 */
delta: number;
direction: "online-better" | "online-worse";
}
export interface AlignmentResult {
comparable: boolean;
reason: "metric-version-mismatch" | "insufficient-sample" | null;
divergences: Divergence[];
}
function divergence(
metric: DivergenceMetric,
online: number,
offline: number,
tolerance: number,
higherIsBetter: boolean,
): Divergence | null {
const delta = online - offline;
if (Math.abs(delta) <= tolerance) return null;
const better = higherIsBetter ? delta > 0 : delta < 0;
return { metric, online, offline, delta, direction: better ? "online-better" : "online-worse" };
}
/**
* 线上与离线的对应关系有三个前提:指标版本一致、样本量足够、
* 比较的是同一个能力与模型。任何前提不满足都返回不可比。
*/
export function compareWithOffline(
online: QualityMetrics,
offline: OfflineBaseline,
options: AlignmentOptions = DEFAULT_ALIGNMENT,
): AlignmentResult {
if (online.metricVersion !== offline.metricVersion) {
return { comparable: false, reason: "metric-version-mismatch", divergences: [] };
}
if (online.surfaced < options.minSample) {
return { comparable: false, reason: "insufficient-sample", divergences: [] };
}
const checks = [
divergence("adoption", online.adoptionRate, offline.adoptionRate, options.adoptionTolerance, true),
divergence("retry", online.retryRate, offline.retryRate, options.retryTolerance, false),
divergence("handoff", online.handoffRate, offline.handoffRate, options.handoffTolerance, false),
];
return {
comparable: true,
reason: null,
divergences: checks.filter((item): item is Divergence => item !== null),
};
}
指标计算里没有四舍五入。比例在存储层再决定保留几位,计算层保留原始精度,否则多个比例相加时会出现对不上总体的现象。
compareWithOffline 在版本不一致或样本不足时返回 comparable: false,而不是抛异常。对应检查是分析工具,不是交易路径;它应该明确说“不能比”,而不是让调用方处理异常。
测试与真实输出
测试覆盖点击与采纳的差异、重复上报去重、非法反馈、窗口边界、能力与模型切片、三类负向信号、空样本、轻改阈值、样本不足、版本不一致、偏差方向与容差。执行:
node --test test/observe/quality.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 12
ℹ pass 12
ℹ fail 0
ℹ duration_ms 162.122208
空样本的测试会遍历所有指标字段并断言 Number.isFinite。比例计算最容易在分母为零时产生 NaN,而 NaN 一旦进入看板,通常会显示成空白或零,很难被发现。
看板要同时展示什么
只展示一个采纳率,几乎一定会误导。至少同时展示五个数:曝光量、无反馈率、点击率、原样采纳率、重试率与转人工率。曝光量说明样本是否足够,无反馈率说明反馈入口是否有效,后四个说明质量结构。
切片维度至少包括能力、模型和提示版本。没有提示版本,模型换了、提示也换了时,质量变化无法归因。
告警不要直接绑在单日比例上。低流量能力的比例天然抖动,应该用足够长的窗口,并同时要求最小样本量。否则告警会在深夜频繁触发,最后被静音。
线上信号的价值是发现“哪里变了”,不是证明“模型好不好”。后者仍然要靠离线评测与人工评审。
小结
线上质量信号要共用“曝光过的建议数”这个分母,把无反馈保留在分布里;点击率与采纳率必须分开,改写幅度决定建议的真实价值;重试、转人工与投诉分别对应不同层级的失败;与离线评测对应前,先检查指标版本、样本量、能力与模型是否一致。
下一章从单次请求与单条链路走向能力治理,先讨论能力清单与契约矩阵:一个产品到底承诺了哪些 AI 能力,边界又写在哪里。
阅读导航:上一节:12.2 计量、账单与配额对账 · 下一节:13.1 能力清单与契约矩阵 。
常见错误与边界
- 用“有反馈的建议”做分母。沉默用户被排除,指标会系统性偏高。
- 把点击当成采纳。用户点完再大改,成本被完全隐藏。
- 只报一个采纳率。没有点击率与无反馈率,无法判断问题出在哪。
- 把重试和转人工合并。两者对应不同的产品后果与处理人。
- 投诉率直接除以活跃用户。分母与场景不匹配,跨能力无法比较。
- 线上与离线用不同指标版本还直接比较。两个同名数字可能定义相反。
- 几十次曝光就宣布模型退步。样本不足时应该明确说不可比。
- 容差拍脑袋且不记录。阈值是案例假设值,要随产品事实复核。
- 只看单日比例做告警。低流量场景的抖动会把告警变成噪声。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。