满意度 4.7 分,投诉却翻了一倍
「回声」平台的质量周报连续三周显示「平均满意度 4.7/5」,直到客服团队把投诉工单拉出来:涉及 chat.reply 的投诉从每周 11 起涨到 23 起。两边都没说谎,问题出在指标口径。满意度是主动评价,愿意打分的人只占调用量的 3.7%;投诉是被动记录,覆盖全部会话。一个只看愿意说话的人,一个只看最不满意的人,两个数放在同一页上,谁也解释不了谁。
线上质量运营的第一件事不是加看板,而是定义信号与分母。离线评测回答「这套能力在固定样本上是否达标」,线上抽检回答「今天真实流量里发生了什么」。两者不能互相替代:离线评测集会被污染、会过期,线上流量会漂移、会出现评测集没覆盖的意图。卷三 7.1 讲的是端到端评测怎么搭;这一节讲的是上线之后每天看什么、抽多少、怎么切。
本节所有数字都来自示例样本上的运行结果,不是生产统计。
五类信号,三种成本
线上能自动采集的质量信号并不多。把它们限定为五类,是为了让每个信号都有明确的产生动作、可归属的责任方和可比较的分母。
| 信号 | 方向 | 权重 | 产生动作 | 主要偏差 |
|---|---|---|---|---|
| 点赞 | 正 | 1 | 用户主动点击 | 顺手点击,噪声大 |
| 采纳 | 正 | 2 | 复制、引用、发送 | 只能看到被采纳的样本 |
| 转人工 | 负 | 2 | 用户要求人工或系统转接 | 受人工入口位置影响 |
| 重复提问 | 负 | 2 | 同一会话再次问同一意图 | 依赖意图识别准确率 |
| 投诉 | 负 | 4 | 用户显式提交工单 | 数量少,但最可信 |
权重不是精确刻度,只用于排序与预警。把投诉设成 4,是因为它同时包含用户损失与处理成本;把点赞设成 1,是因为它的点击成本最低。权重一旦写进代码,就不能在季度中途改;否则同一能力的周环比会失去意义。
五类信号必须统一除以请求数,得到「每千次请求的净信号」:
netPerThousand = (点赞×1 + 采纳×2 - 转人工×2 - 重复提问×2 - 投诉×4) / 请求数 × 1000
绝对计数不能跨能力、跨租户比较。chat.reply 每天 20 万次调用,投诉 40 起;content.summarize 每天 2 万次调用,投诉 8 起。前者每千次 0.2 起,后者每千次 0.4 起,后者的相对质量更差。看板上如果只显示「投诉 40 对 8」,结论会完全反过来。
抽检不是抽查几个案例
信号是全网自动采集的,抽检是人工或模型辅助的深检查。抽检要回答一个统计问题:在给定的置信水平与误差下,需要看多少条样本,才能判断当期合格率是否低于基线。
比例抽检的样本量分两步。先按无限总体算:
n0 = z² × p × (1 - p) / e²
其中 z 由置信水平决定(90% 为 1.6449、95% 为 1.96、99% 为 2.5758),p 是预期合格率,e 是允许的绝对误差。再按有限总体校正:
n = n0 / (1 + (n0 - 1) / N)
N 是当期可抽检总量。校正后的样本量不会超过总体;当总体很大时,校正系数接近 1。
示例样本取 N = 10,000、p = 0.95、e = 0.02、95% 置信水平:n0 = 456.19,校正后 436.33,向上取整得到 437 条。如果换成 N = 10 亿,样本量是 457 条,只多 20 条。这说明抽检成本主要由误差与置信水平决定,而不是由流量规模决定。
抽检结果不能只报一个点估计。380/400 的合格率是 95.0%,但 95% 的 Wilson 区间是 [92.40%, 96.74%]。如果基线是 96%,这个区间已经跨过基线,不能宣称「没有退化」,也不能宣称「显著退化」。区间是判断「有没有证据」的工具,不是给老板看的装饰。
按能力与租户切分
总合格率会把不同难度、不同租户混在一起。内容摘要和私信回复的合格标准不同,大租户和小租户的流量结构也不同。抽检至少要按「能力 × 租户」切分,并为每个分层保留最小样本。
分层分配采用按总体比例分配,先给每个分层一个最小配额,再用最大余数法补齐。示例样本有三个分层:chat.reply/content 6,000 条、content.summarize/content 3,000 条、search.rerank/trade 1,000 条,总样本 500 条,每层最小 20 条。分配结果是 285、152、63,合计正好 500。
最小配额的作用不是统计精度,而是防止长尾租户永远不出现在报告里。一个租户只有 1,000 条流量,按纯比例只能分到 50 条;如果它同时是投诉最多的租户,50 条可能一条问题都抽不到。最小配额让每个分层至少有 20 条,代价是大分层少分一点。
切分之后还要设发布门槛。单层样本少于 30 条时,只报区间,不报排名;少于 10 条时,只登记案例,不进入周报。小样本的极端值会把排名搅乱,最后变成「谁流量小谁质量差」。
实现:信号汇总、样本量与分层
quality-ops.ts 只做三件事:把五类信号按分母归一、计算抽检样本量与 Wilson 区间、按分层分配样本。它不读数据库,也不调用模型,所有输入都是显式参数。
// file: src/gov/quality-ops.ts
// tier: L2
// verify: node --test test/gov/quality-ops.test.ts
/** 线上质量信号分五类:两类正信号、三类负信号,权重只用于排序与预警。 */
export type QualitySignalKind = "like" | "adopt" | "handoff" | "repeat-question" | "complaint";
export interface QualitySignalDefinition {
kind: QualitySignalKind;
direction: "positive" | "negative";
weight: number;
note: string;
}
export const QUALITY_SIGNALS: readonly QualitySignalDefinition[] = [
{ kind: "like", direction: "positive", weight: 1, note: "主动点赞;成本最低、噪声最大" },
{ kind: "adopt", direction: "positive", weight: 2, note: "复制、引用或发送;比点赞更接近真实采纳" },
{ kind: "handoff", direction: "negative", weight: 2, note: "转人工;说明模型没有闭环" },
{ kind: "repeat-question", direction: "negative", weight: 2, note: "同一会话重复提问;说明意图未命中" },
{ kind: "complaint", direction: "negative", weight: 4, note: "显式投诉;最贵也最可信的负信号" },
];
export interface SignalSnapshot {
capability: string;
tenant: string;
requests: number;
counts: Readonly<Record<QualitySignalKind, number>>;
}
export interface SignalSummary {
capability: string;
tenant: string;
requests: number;
positiveWeighted: number;
negativeWeighted: number;
netPerThousand: number;
}
/** 信号必须先除以请求数;绝对值不能跨能力、跨租户比较。 */
export function summarizeSignals(snapshot: SignalSnapshot): SignalSummary {
if (!Number.isInteger(snapshot.requests) || snapshot.requests <= 0) {
throw new Error("quality-ops: requests must be a positive integer");
}
let positive = 0;
let negative = 0;
for (const definition of QUALITY_SIGNALS) {
const count = snapshot.counts[definition.kind] ?? 0;
if (!Number.isInteger(count) || count < 0) {
throw new Error(`quality-ops: invalid count for ${definition.kind}`);
}
if (definition.direction === "positive") positive += count * definition.weight;
else negative += count * definition.weight;
}
return {
capability: snapshot.capability,
tenant: snapshot.tenant,
requests: snapshot.requests,
positiveWeighted: positive,
negativeWeighted: negative,
netPerThousand: round4(((positive - negative) / snapshot.requests) * 1000),
};
}
export type ConfidenceLevel = 0.9 | 0.95 | 0.99;
const Z_SCORE: Readonly<Record<ConfidenceLevel, number>> = { 0.9: 1.6449, 0.95: 1.96, 0.99: 2.5758 };
export interface SampleSizeInput {
/** 当期可抽检的总体量,必须为正整数。 */
population: number;
/** 预期合格率,取 0 与 1 之间;保守取 0.5 可得到最大样本量。 */
expectedRate: number;
/** 允许的绝对误差,例如 0.02 表示 ±2 个百分点。 */
margin: number;
confidence: ConfidenceLevel;
}
export interface SampleSizePlan {
z: number;
/** 无限总体公式得到的样本量。 */
uncorrected: number;
/** 有限总体校正后的样本量,未取整。 */
corrected: number;
/** 实际抽检量,向上取整且不超过总体。 */
sample: number;
/** 校正系数;等于 1 表示总体足够大、不需要校正。 */
finitePopulationCorrection: number;
}
/** 比例抽检的样本量:先算无限总体,再做有限总体校正。 */
export function planSampleSize(input: SampleSizeInput): SampleSizePlan {
const { population, expectedRate, margin, confidence } = input;
if (!Number.isInteger(population) || population <= 0) {
throw new Error("quality-ops: population must be a positive integer");
}
if (!(expectedRate > 0 && expectedRate < 1)) {
throw new Error("quality-ops: expectedRate must be between 0 and 1");
}
if (!(margin > 0 && margin < 1)) {
throw new Error("quality-ops: margin must be between 0 and 1");
}
const z = Z_SCORE[confidence];
if (z === undefined) throw new Error(`quality-ops: unsupported confidence ${confidence}`);
const uncorrected = (z * z * expectedRate * (1 - expectedRate)) / (margin * margin);
const corrected = uncorrected / (1 + (uncorrected - 1) / population);
return {
z,
uncorrected: round4(uncorrected),
corrected: round4(corrected),
sample: Math.min(population, Math.ceil(corrected)),
finitePopulationCorrection: round4(corrected / uncorrected),
};
}
export interface WilsonInterval {
point: number;
low: number;
high: number;
confidence: ConfidenceLevel;
}
/** Wilson 区间:小样本与接近 0/1 的比例比正态近似更稳。 */
export function wilsonInterval(
passed: number,
total: number,
confidence: ConfidenceLevel = 0.95,
): WilsonInterval {
if (!Number.isInteger(passed) || !Number.isInteger(total) || total <= 0 || passed < 0 || passed > total) {
throw new Error("quality-ops: passed/total must satisfy 0 <= passed <= total");
}
const z = Z_SCORE[confidence];
if (z === undefined) throw new Error(`quality-ops: unsupported confidence ${confidence}`);
const point = passed / total;
const denominator = 1 + (z * z) / total;
const center = (point + (z * z) / (2 * total)) / denominator;
const half = (z * Math.sqrt((point * (1 - point)) / total + (z * z) / (4 * total * total))) / denominator;
return {
point: round6(point),
low: round6(Math.max(0, center - half)),
high: round6(Math.min(1, center + half)),
confidence,
};
}
export interface SampleStratum {
capability: string;
tenant: string;
population: number;
}
export interface StratumAllocation extends SampleStratum {
sample: number;
}
export interface AllocationOptions {
/** 每个分层至少抽多少;用于保证长尾租户不至于完全缺席。 */
minimumPerStratum?: number;
}
/** 按 population 比例分配,最小配额优先,余数用最大余数法,合计精确等于样本量。 */
export function allocateSample(
strata: readonly SampleStratum[],
sampleSize: number,
options: AllocationOptions = {},
): StratumAllocation[] {
if (strata.length === 0) throw new Error("quality-ops: strata must not be empty");
if (!Number.isInteger(sampleSize) || sampleSize <= 0) {
throw new Error("quality-ops: sampleSize must be a positive integer");
}
const minimum = options.minimumPerStratum ?? 1;
if (!Number.isInteger(minimum) || minimum < 0) {
throw new Error("quality-ops: minimumPerStratum must be a non-negative integer");
}
for (const stratum of strata) {
if (!Number.isInteger(stratum.population) || stratum.population <= 0) {
throw new Error(`quality-ops: invalid population for ${stratum.capability}/${stratum.tenant}`);
}
}
const totalPopulation = strata.reduce((sum, stratum) => sum + stratum.population, 0);
const target = Math.min(sampleSize, totalPopulation);
if (target < strata.length * minimum) {
throw new Error("quality-ops: sampleSize is smaller than the minimum allocation");
}
const base = strata.map((stratum) => Math.min(minimum, stratum.population));
const remaining = target - base.reduce((sum, value) => sum + value, 0);
const remainingPopulation = strata.reduce((sum, stratum, index) => sum + (stratum.population - base[index]), 0);
const quotas = strata.map((stratum, index) =>
remainingPopulation === 0 ? 0 : (remaining * (stratum.population - base[index])) / remainingPopulation,
);
const extra = quotas.map((quota) => Math.floor(quota));
let assigned = extra.reduce((sum, value) => sum + value, 0);
const order = quotas
.map((quota, index) => ({ index, fraction: quota - Math.floor(quota) }))
.sort((left, right) => right.fraction - left.fraction || left.index - right.index);
let cursor = 0;
let guard = 0;
while (assigned < remaining && guard < remaining * order.length + order.length) {
const { index } = order[cursor % order.length];
if (base[index] + extra[index] < strata[index].population) {
extra[index] += 1;
assigned += 1;
}
cursor += 1;
guard += 1;
}
return strata.map((stratum, index) => ({
capability: stratum.capability,
tenant: stratum.tenant,
population: stratum.population,
sample: base[index] + extra[index],
}));
}
function round4(value: number): number {
return Math.round(value * 1e4) / 1e4;
}
function round6(value: number): number {
return Math.round(value * 1e6) / 1e6;
}
代码里有三个刻意的选择。第一,summarizeSignals 在请求数为零时直接抛错,而不是返回 0;零分母的「零投诉」是最危险的假质量。第二,样本量先做有限总体校正再取整,取整放在最后,避免中间步骤的舍入误差被放大。第三,分层分配用最大余数法而不是逐层四舍五入,保证各层样本之和精确等于总样本;否则报告里的分层数相加会对不上总数。
这三个选择都不复杂,但它们决定了报告能不能被复核。复核者拿到同样的输入,应该得到同样的 437 条与 285/152/63。
示例样本上的运行结果
| 项目 | 输入 | 结果 |
|---|---|---|
| 信号汇总 | 1,000 次请求;点赞 40、采纳 10、转人工 20、重复提问 15、投诉 2 | 正权重 60、负权重 78、净信号 -18/千次 |
| 样本量(有限总体) | N=10,000;p=0.95;e=0.02;95% | n0=456.19;校正 436.33;抽 437 |
| 样本量(大总体) | N=10 亿;同上 | 抽 457 |
| Wilson 区间 | 380/400;95% | 点估计 95.00%;区间 [92.40%, 96.74%] |
| 分层分配 | 6,000 / 3,000 / 1,000;总样本 500;最小 20 | 285 / 152 / 63 |
这些是示例样本上的运行结果。真实流量里,信号计数会随季节、活动和租户结构变化,但分母口径、区间方法与分层规则不变。
运营节奏:日、周、月
日看信号。每天只看两个数:净信号/千次、投诉绝对数。前者看趋势,后者看异常。任何一个能力单日净信号跌破过去 28 天的下四分位,就进入次日抽检名单。
周做抽检。按上周流量分配样本,覆盖全部能力与活跃租户;单层样本不足 30 条时只报区间。周报不排名租户,只标注「低于基线且区间不重叠」的格子。
月做校准。把人工抽检结果与自动信号做一致性检查:如果某能力的投诉率很低但人工抽检不合格率很高,说明信号采集有盲区;如果信号很差但人工抽检很好,说明权重或意图识别有偏差。校准结果反过来调整信号权重,但调整必须记录版本,季度中途不生效。
这三个节奏分别对应三种问题:日看「今天有没有坏事」、周看「这周有没有退化」、月看「我们的尺子还准不准」。把三者合成一个月度大报表,通常会导致日报没人看、月报来不及。
与离线评测的边界
离线评测回答「在固定样本上,这个版本是否比上一个版本好」。线上抽检回答「在真实流量上,今天有多少条交付不合格」。两者的样本来源、判定标准与责任人都不同。
离线评测集可以覆盖罕见意图,但会被模型见过;线上抽检反映真实分布,但抽不到罕见意图。正确做法是让两者互相供料:线上抽检发现的新失败模式进入评测集,评测集的回归结果决定新版本能否上线。
不要把线上抽检做成「再跑一遍离线评测」。线上样本没有标准答案,判定依赖人工或强模型;它的价值是发现分布漂移与长尾问题,而不是复现离线分数。
小结
线上质量信号限定为点赞、采纳、转人工、重复提问、投诉五类,按请求数归一成每千次净信号。示例样本的 1,000 次请求得到正权重 60、负权重 78、净信号 -18/千次。
抽检样本量先算无限总体、再做有限总体校正:N=10,000、p=0.95、e=0.02 时抽 437 条;N=10 亿时也只抽 457 条。380/400 的 95% Wilson 区间是 [92.40%, 96.74%],跨过 96% 基线时不能下「退化」或「没退化」的结论。分层按比例分配并保留最小配额,示例的 500 条分成 285/152/63。
阅读导航:上一节:5.3 人工评审与仲裁 · 下一节:6.2 质量事故的分级与响应 。
常见错误与边界
- 用满意度均分代替质量。主动评价只覆盖愿意打分的人,覆盖率和分母都没有写。
- 比较不同能力的投诉绝对数。调用量差十倍时,绝对数会把结论完全颠倒。
- 抽检只报点估计。没有区间,就无法判断变化是不是抽样噪声。
- 总体很大时仍套无限总体公式。流量上亿时,有限总体校正可以忽略,但小总体不能。
- 分层只按比例分配。长尾租户会永远抽不到,问题也永远不会出现在报告里。
- 小样本直接排名。单层少于 30 条时,排名反映的是抽样波动,不是质量。
- 把线上抽检做成离线评测。线上没有标准答案,判据与责任人都不一样。
- 季度中途改信号权重。同一能力的周环比会失去可比性,历史报告也无法复盘。
- 只看负信号。没有正信号,团队会把「少做事」当成质量提升。
- 把信号当处罚依据。信号用于发现与排序,处罚会让一线隐藏真实反馈。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。