《互联网产品的 AI 架构·模型接入与能力边界》12.3 质量信号的线上采集

从「回声」一次采纳率 68% 的汇报被用户访谈推翻出发,说明线上质量信号为什么要以曝光为分母、点击率与采纳率差在哪里、重试与转人工如何补足,以及线上指标与离线评测对应的三个前提,附可运行的 TypeScript 采集实现、版本化指标定义与真实测试输出。

采纳率 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 能力清单与契约矩阵 。

常见错误与边界

  • 用“有反馈的建议”做分母。沉默用户被排除,指标会系统性偏高。
  • 把点击当成采纳。用户点完再大改,成本被完全隐藏。
  • 只报一个采纳率。没有点击率与无反馈率,无法判断问题出在哪。
  • 把重试和转人工合并。两者对应不同的产品后果与处理人。
  • 投诉率直接除以活跃用户。分母与场景不匹配,跨能力无法比较。
  • 线上与离线用不同指标版本还直接比较。两个同名数字可能定义相反。
  • 几十次曝光就宣布模型退步。样本不足时应该明确说不可比。
  • 容差拍脑袋且不记录。阈值是案例假设值,要随产品事实复核。
  • 只看单日比例做告警。低流量场景的抖动会把告警变成噪声。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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