每个能力都变好了,总体却变差了
季度复盘会上,质量运营放出一页:总体合格率从 94.4% 降到 93.9%,下降 0.5 个百分点。内容团队立刻说:「我们的摘要合格率从 90% 升到 91%。」客服团队说:「我们的回复从 98% 升到 99%。」交易团队说:「检索从 96% 升到 97%。」三个团队都没说谎,但总体的确下降了。
这不是数据错误,而是结构变化。摘要流量的份额从 40% 升到 60%,而摘要本来就是三个能力里合格率最低的。每个能力的合格率都上升,流量却更多流向低合格率能力,加权平均就会被拉下来。这个现象在统计里叫辛普森悖论,在质量运营里叫「份额效应」。
季度复盘如果不能把份额效应与真实退化分开,就会变成一场互相指责。这一节给出复盘的结构、归因方法与指标准入规则。所有数字来自示例样本上的运行结果,不是生产统计。
复盘的六段结构
一份可复查的季度质量报告至少包含六段:基线、变化、归因、行动、负责人、复查。缺任何一段,报告都会退化成「本季度我们做了很多事」。
| 段落 | 必须回答的问题 | 可复核的证据 |
|---|---|---|
| 基线 | 跟什么比 | 冻结标签、分母、版本、抽检方法 |
| 变化 | 变了多少 | 点估计、置信区间、按能力与租户切分 |
| 归因 | 为什么变 | 份额效应、分段率效应、交叉项 |
| 行动 | 改什么 | 具体改动,不是「加强管理」 |
| 负责人 | 谁在什么时候完成 | 姓名或团队、截止日期 |
| 复查 | 怎么知道有效 | 指标、目标、复查日期 |
基线必须冻结。冻结标签至少包含四项:统计区间、分母口径、能力版本三元组、抽检方法。季度中途改了其中任何一项,后面的差异就不再是质量变化,而是口径变化。示例里的基线标签是 2026Q3-freeze-v3,当前标签是 2026Q4-live;两个标签不同,报告必须解释差异来自版本还是来自流量。
变化要带区间。6.1 的 Wilson 区间在这里同样适用:总体从 94.4% 降到 93.9%,如果 95% 区间是 [93.5%, 94.3%],那 0.5 个百分点的下降可能仍在抽样误差内。没有区间,归因就会把噪声当成信号。
三因子分解
设第 i 个分段的基线份额为 s0、当前份额为 s1,基线合格率为 r0、当前合格率为 r1,总体变化可以拆成:
mix = Σ (s1 - s0) × r0
rate = Σ s0 × (r1 - r0)
inter = Σ (s1 - s0) × (r1 - r0)
total = mix + rate + inter
三项分别是:流量结构变化带来的影响、各分段合格率变化带来的影响、两者的交叉项。单位是百分点。三项相加必须精确等于总变化;如果对不上,说明分段没有覆盖全部流量,或者分母口径不一致。
示例样本的三个分段如下。
| 分段 | 基线通过/总量 | 当前通过/总量 | 基线率 | 当前率 | 变化 |
|---|---|---|---|---|---|
chat.reply | 3,920/4,000 | 2,475/2,500 | 98.0% | 99.0% | +1.0pp |
content.summarize | 3,600/4,000 | 5,460/6,000 | 90.0% | 91.0% | +1.0pp |
search.rerank | 1,920/2,000 | 1,455/1,500 | 96.0% | 97.0% | +1.0pp |
总体从 9,440/10,000 = 94.4% 变成 9,390/10,000 = 93.9%,下降 0.5 个百分点。分解结果:份额效应 -1.5pp,分段率效应 +1.0pp,交叉项 0.0pp。三项相加是 -0.5pp。
这个结果把会议从「谁退化了」拉到「流量为什么变了」。三个分段都在变好,总体下降完全来自份额:摘要从 40% 涨到 60%,回复从 40% 降到 25%,检索从 20% 降到 15%。下一步不是让三个团队回去写检查,而是问「摘要流量为什么涨、这个涨势是否可持续、低合格率能力承担更多流量是否需要额外资源」。
归因也有边界。三因子分解只能解释「已切分的维度」。如果分段粒度太粗,例如只按能力不按租户,租户结构变化会被混进能力份额里。分段选择要在报告里写清楚,并且季度之间保持一致;换分段就要重跑基线,不能拿新旧两套分段直接比。
哪些指标不该进报告
质量报告最常见的污染不是算错,而是放进了无法解释的指标。以下五类应该被拦在报告之外。
第一类,没有分母的绝对计数。「本季度投诉 37 起」不能说明质量好坏,除非同时给出请求数与每千次投诉率。37 起可能来自 10 万次调用,也可能来自 1,000 万次调用。
第二类,序数量表的算术平均。「平均满意度 4.7/5」把「满意」和「非常满意」当成等距刻度,求出来的均值没有明确含义。正确做法是报分布:满意及以上占比多少、不满意占比多少、拒答率多少。
第三类,口径不明的「准确率」。模型准确率必须说明任务、评测集版本、判定标准与分母。没有这四项的准确率,无法判断是模型变好还是评测集变简单。
第四类,把使用量当质量。「AI 使用率提升 30%」说明采用度,不说明交付质量。使用量可以是背景指标,但不能替代合格率。
第五类,只挑案例的 Top-N 故事。「本月最佳案例」可以作为补充,但不能作为质量结论。案例没有抽样框,无法回答「这种情况占多少」。
代码里的 auditMetrics 只做机械检查:比率有没有分子分母、计数有没有被当成质量结论、序数量表有没有被求平均、指标有没有写范围。它不判断业务语义,但能拦住大多数「看起来很有道理」的指标。
行动项要能被复查
归因之后,每个负向贡献都要落到行动项。行动项至少包含五个字段:对应的归因、具体改动、负责人、截止日期、复查指标与目标。
示例行动项:针对「摘要份额上升」,把低价值会话改走经济档,负责人是客服团队,截止 2026-11-15,复查指标是 chat.reply 每千次净信号不低于 -15。
不合格的行动项通常长这样:「加强质量意识」「持续优化提示」「跟进一下」。它们没有改动、没有负责人、没有复查指标,季度结束后无法判断是否完成,也无法判断是否有效。
validateReview 会拦住六类问题:基线没冻结、没有复盘日期、没有行动项、行动项缺负责人/原因/改动/复查、截止日期越界、行动项 id 重复。它不评价行动是否正确,只保证每条行动都可追踪。
截止日期允许落到下一个季度末,因为有些改动需要跨季度验证。再远就不属于本次复盘的跟踪范围,应该拆成阶段性里程碑。复查指标必须能在现有数据里算出来,不能写「用户感受明显改善」这种无法判定的目标。
实现:归因、复盘校验与指标准入
quality-review.ts 包含三个部分:decomposeQuality 做三因子分解;validateReview 检查复盘结构;auditMetrics 做指标准入。三者都是纯函数,输入显式、输出可断言。
// file: src/gov/quality-review.ts
// tier: L2
// verify: node --test test/gov/quality-ops.test.ts
export interface SegmentQuality {
segment: string;
baselinePassed: number;
baselineTotal: number;
currentPassed: number;
currentTotal: number;
}
export interface SegmentAttribution {
segment: string;
baselineShare: number;
currentShare: number;
baselineRate: number;
currentRate: number;
deltaPp: number;
mixPp: number;
ratePp: number;
interactionPp: number;
}
export interface QualityAttribution {
baselineRate: number;
currentRate: number;
deltaPp: number;
mixEffectPp: number;
rateEffectPp: number;
interactionPp: number;
segments: readonly SegmentAttribution[];
}
/**
* 季度差异拆成三块:份额变化(mix)、分段合格率变化(rate)与交叉项。
* 三项相加精确等于总差异;数值单位是百分点。
*/
export function decomposeQuality(segments: readonly SegmentQuality[]): QualityAttribution {
if (segments.length === 0) throw new Error("quality-review: segments must not be empty");
const seen = new Set<string>();
for (const segment of segments) {
if (segment.segment.trim() === "" || seen.has(segment.segment)) {
throw new Error(`quality-review: invalid or duplicate segment ${segment.segment}`);
}
seen.add(segment.segment);
for (const [passed, total] of [
[segment.baselinePassed, segment.baselineTotal],
[segment.currentPassed, segment.currentTotal],
]) {
if (!Number.isInteger(total) || total <= 0 || !Number.isInteger(passed) || passed < 0 || passed > total) {
throw new Error(`quality-review: invalid counts for ${segment.segment}`);
}
}
}
const baselineTotal = segments.reduce((sum, segment) => sum + segment.baselineTotal, 0);
const currentTotal = segments.reduce((sum, segment) => sum + segment.currentTotal, 0);
const baselinePassed = segments.reduce((sum, segment) => sum + segment.baselinePassed, 0);
const currentPassed = segments.reduce((sum, segment) => sum + segment.currentPassed, 0);
const baselineRate = baselinePassed / baselineTotal;
const currentRate = currentPassed / currentTotal;
let mix = 0;
let rate = 0;
const rows = segments.map((segment): SegmentAttribution => {
const baselineShare = segment.baselineTotal / baselineTotal;
const currentShare = segment.currentTotal / currentTotal;
const baseRate = segment.baselinePassed / segment.baselineTotal;
const currRate = segment.currentPassed / segment.currentTotal;
const mixPp = (currentShare - baselineShare) * baseRate * 100;
const ratePp = baselineShare * (currRate - baseRate) * 100;
mix += mixPp;
rate += ratePp;
return {
segment: segment.segment,
baselineShare: round4(baselineShare),
currentShare: round4(currentShare),
baselineRate: round4(baseRate),
currentRate: round4(currRate),
deltaPp: round4((currRate - baseRate) * 100),
mixPp: round4(mixPp),
ratePp: round4(ratePp),
interactionPp: round4((currentShare - baselineShare) * (currRate - baseRate) * 100),
};
});
const deltaPp = round4((currentRate - baselineRate) * 100);
const mixEffectPp = round4(mix);
const rateEffectPp = round4(rate);
return {
baselineRate: round4(baselineRate),
currentRate: round4(currentRate),
deltaPp,
mixEffectPp,
rateEffectPp,
interactionPp: round4(deltaPp - mixEffectPp - rateEffectPp),
segments: rows,
};
}
export interface ReviewAction {
id: string;
cause: string;
change: string;
owner: string;
dueDate: string;
verification: { metric: string; target: string };
status: "proposed" | "in-progress" | "done";
}
export interface QuarterReview {
quarter: string;
baselineLabel: string;
currentLabel: string;
reviewDate: string;
attribution: QualityAttribution;
actions: readonly ReviewAction[];
}
export type ReviewIssueCode =
| "invalid-quarter" | "unfrozen-baseline" | "missing-review-date" | "no-actions"
| "missing-owner" | "missing-cause" | "missing-change" | "missing-verification"
| "due-outside-window" | "duplicate-action-id";
export interface ReviewIssue {
code: ReviewIssueCode;
actionId?: string;
detail: string;
}
const QUARTER_PATTERN = /^(\d{4})Q([1-4])$/;
/** 行动项最晚可以落到下一个季度末;再远就不是本季度复盘能跟踪的范围。 */
export function reviewWindowEnd(quarter: string): string {
const match = QUARTER_PATTERN.exec(quarter);
if (match === null) throw new Error(`quality-review: invalid quarter ${quarter}`);
const index = Number(match[2]);
const year = index === 4 ? Number(match[1]) + 1 : Number(match[1]);
const endMonth = (index === 4 ? 1 : index + 1) * 3;
return `${year}-${pad(endMonth)}-${lastDay(year, endMonth)}`;
}
export function validateReview(review: QuarterReview): ReviewIssue[] {
const issues: ReviewIssue[] = [];
if (!QUARTER_PATTERN.test(review.quarter)) {
return [{ code: "invalid-quarter", detail: `季度 ${review.quarter} 不是 YYYYQn 格式` }];
}
const windowEnd = reviewWindowEnd(review.quarter);
if (review.baselineLabel.trim() === "") {
issues.push({ code: "unfrozen-baseline", detail: "基线没有冻结标签,季度中改口径会让差异无法解释" });
}
if (review.reviewDate.trim() === "") {
issues.push({ code: "missing-review-date", detail: "缺少复盘日期" });
}
if (review.actions.length === 0) {
issues.push({ code: "no-actions", detail: "复盘没有行动项,只是一次汇报" });
}
const ids = new Set<string>();
for (const action of review.actions) {
if (ids.has(action.id)) {
issues.push({ code: "duplicate-action-id", actionId: action.id, detail: "行动项 id 重复" });
}
ids.add(action.id);
if (action.owner.trim() === "") {
issues.push({ code: "missing-owner", actionId: action.id, detail: "行动项没有负责人" });
}
if (action.cause.trim() === "") {
issues.push({ code: "missing-cause", actionId: action.id, detail: "行动项没有对应的归因" });
}
if (action.change.trim() === "") {
issues.push({ code: "missing-change", actionId: action.id, detail: "行动项没有具体改动" });
}
if (action.verification.metric.trim() === "" || action.verification.target.trim() === "") {
issues.push({ code: "missing-verification", actionId: action.id, detail: "行动项没有复查指标或目标" });
}
if (action.dueDate.trim() === "" || action.dueDate < review.reviewDate || action.dueDate > windowEnd) {
issues.push({
code: "due-outside-window",
actionId: action.id,
detail: `截止日期必须落在 ${review.reviewDate} 到 ${windowEnd} 之间`,
});
}
}
return issues;
}
export type ReportMetricKind = "rate" | "count" | "event-count" | "duration" | "ordinal-mean" | "score";
export interface ReportMetric {
name: string;
kind: ReportMetricKind;
numerator?: number;
denominator?: number;
scope?: string;
}
export type MetricIssueCode = "missing-denominator" | "count-as-quality" | "ordinal-mean" | "missing-scope";
export interface MetricIssue {
name: string;
code: MetricIssueCode;
detail: string;
}
/**
* 报告指标的准入检查。它不判断业务好坏,只拦三类无法解释的口径:
* 没有分母的比率、没有分母的质量计数、以及把序数量表当连续量求平均。
*/
export function auditMetrics(metrics: readonly ReportMetric[]): MetricIssue[] {
const issues: MetricIssue[] = [];
for (const metric of metrics) {
if (metric.scope === undefined || metric.scope.trim() === "") {
issues.push({ name: metric.name, code: "missing-scope", detail: "指标没有说明能力、租户或链路范围" });
}
if (metric.kind === "rate") {
if (
metric.numerator === undefined || metric.denominator === undefined ||
!Number.isFinite(metric.numerator) || !Number.isFinite(metric.denominator) ||
metric.denominator <= 0 || metric.numerator < 0 || metric.numerator > metric.denominator
) {
issues.push({ name: metric.name, code: "missing-denominator", detail: "比率必须给出有效的分子与分母" });
}
} else if (metric.kind === "count") {
issues.push({ name: metric.name, code: "count-as-quality", detail: "质量结论不能用绝对计数,必须换算成带分母的比率" });
} else if (metric.kind === "ordinal-mean") {
issues.push({ name: metric.name, code: "ordinal-mean", detail: "满意度是序数量表,应报分布而不是直接求平均" });
}
}
return issues;
}
function round4(value: number): number {
return Math.round(value * 1e4) / 1e4;
}
function pad(value: number): string {
return value < 10 ? `0${value}` : `${value}`;
}
function lastDay(year: number, month: number): number {
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
代码里有三个刻意的选择。第一,交叉项用 总差异 - 份额效应 - 分段率效应 反算,保证三项相加精确等于总差异;分开四舍五入会让报告出现 0.1pp 的对账差。第二,基线标签为空直接报 unfrozen-baseline,因为「我们一直用这个口径」不是冻结证据。第三,指标检查把 count 与 event-count 分开:事故数可以作为事件计数出现,投诉数不能作为质量结论出现。
示例样本上的运行结果
| 项目 | 结果 |
|---|---|
| 总体基线 | 9,440/10,000 = 94.40% |
| 总体当前 | 9,390/10,000 = 93.90% |
| 总变化 | -0.50pp |
| 份额效应 | -1.50pp |
| 分段率效应 | +1.00pp |
| 交叉项 | 0.00pp |
| 完整复盘 | 0 个结构问题 |
| 缺负责人/原因/改动/复查、截止越界、id 重复 | 8 类问题全部命中 |
| 指标准入 | 无分母比率、绝对计数、序数平均、缺范围各命中 1 条 |
这些是示例样本上的运行结果。真实季度的分段会更多,但分解公式、校验规则与指标口径不变。
报告之外的三件事
第一,报告要能被复算。附上分段明细、分母、区间与脚本版本,读者才能验证 94.4% 与 93.9% 不是手工填的。
第二,报告要区分「本季度变化」与「长期趋势」。一个季度下降 0.5pp 可能是噪声,连续四个季度下降才是趋势。趋势判断需要把历史区间画在一起,而不是只看两个点。
第三,报告要写明没有回答的问题。例如「摘要份额上升的原因尚未归因」「某租户的样本量不足,未纳入排名」。把未知写出来,比用模糊结论填满页面更可信。
小结
季度复盘按六段组织:基线、变化、归因、行动、负责人、复查。基线必须冻结,变化要带区间,归因用份额、分段率、交叉项三因子分解,行动项必须有负责人、截止日期与复查指标。
示例中三个能力的合格率都上升 1.0pp,总体却从 94.4% 降到 93.9%:份额效应 -1.5pp、分段率效应 +1.0pp、交叉项 0。报告不应出现无分母计数、序数平均、口径不明的准确率、使用量与 Top-N 案例;这些东西无法回答「质量有没有变」。
阅读导航:上一节:6.2 质量事故的分级与响应 · 下一节:7.1 预算、账单与归因的分工 。
常见错误与边界
- 把总体变化直接归给团队。份额变化会制造辛普森悖论,先分解再问责。
- 季度中途改分母口径。口径变了,差异就不再是质量变化。
- 归因只写「某能力下降」。没有份额与分段率拆解,行动项只能靠猜。
- 三项相加对不上总变化。通常是分段漏了流量或分母不一致。
- 报平均满意度。序数量表求平均没有明确含义,应报分布。
- 报绝对投诉数。没有请求数,绝对数无法跨能力、跨租户比较。
- 准确率不写评测集与判定标准。数字越漂亮,越可能来自更简单的样本。
- 行动项写「加强」「优化」「跟进」。没有改动与复查指标,季度后无法验收。
- 基线标签写成「一直如此」。没有版本与日期,就不是冻结基线。
- 报告只写结论不写未知。未归因的问题被藏起来,下季度会以更大规模出现。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。