生成式 AI 质量工程(二):不要从最终答案猜根因
副标题:用”最早异常边界”定位 RAG 系统质量回归
作者:James Xie
这是《生成式 AI 质量工程:发现、定位与证明》系列的第二篇。第一篇讲了如何发现”HTTP 200 但答案已经坏了”——用 Stage Event 和 Request Summary 把质量问题变成可观测的数据。
这一篇讲发现之后的事:质量确实在下降,但系统有九个阶段,到底是哪一层开始坏的?
一次例行发版后的第三天,投诉率抬头了。
周报会上,数据摆在那里:法律问答产品的点踩率从平时的 baseline 涨了近一倍,集中在”合同纠纷”类目。会议室里的讨论很热烈:
“肯定是检索的问题,上周不是刚更新了知识库吗?我抽查过几个 bad case,材料确实不对。”
“我看是 prompt,这周改过系统提示词。时间上完全对得上。”
“要我说就是模型不行,这个类目本来就难,回答一直很勉强,早该升级了。”
注意这三个人的发言方式。每个人都带着”证据”:一个抽查了几个 case,一个做了时间关联,一个凭长期印象。三种证据看起来都有点道理,但没有一种能把另外两种证伪。检索派抽查的 bad case,prompt 派也能解释;prompt 派的时间关联,知识库更新也对得上。
三个人,三个根因,三种修法。每种修法都要花掉一到两周。如果猜错了,两周后投诉率还在那里,只是会议室里换一批猜测。这种会我入职两个月开了不止一次,结局通常都一样:嗓门大的那个方案先做,或者资历深的那个猜测先信。
第一篇解决了”发现”——你现在能看到答案坏了。但看得见坏,不等于知道在哪修。一个九个阶段的 RAG 链路,最终答案只是整条链路的出口。从出口往里看,每一层都可能是凶手。
这篇讲一套定位方法:最早异常边界(Earliest Divergence)。核心主张一句话:不要从最终答案猜根因,要沿着链路找到”第一次变得不一样”的那个阶段。
一、最终评分只能发现症状
先认清手里指标的极限。
投诉率、点踩率、用户满意度、离线评测集总分——这些都是最终指标,挂在链路的出口上。它们的价值是报警:告诉你”变差了""变差了多少""从哪天开始变差的”。
但它们对”为什么变差”一无所知。点踩率翻倍,可能是理解阶段就偏了,可能是检索没给够材料,可能是排序把对的文档压到了下面,可能是上下文选择把关键材料截掉了,可能是模型自由发挥,也可能是后处理把引用洗坏了——九种病,一个症状。
最终指标还有一个更隐蔽的缺陷:平均数掩盖分布。评测集总分掉两分,可能是所有类目的答案都略差了一点(这往往指向模型或 prompt 这类全局变量),也可能是一个类目塌方、其他类目纹丝不动(这往往指向数据或检索这类局部变量)。两种情况的修法南辕北辙,而总分把它们揉成同一个数字。所以用最终指标报警时,至少要看分类目、分场景的细分曲线——但即便细分到类目,你得到的仍然只是”症状定位到了哪个病区”,不是”病灶在哪”。
拿最终指标当诊断依据,就像拿发烧当诊断结论。发烧告诉你去看医生,不告诉你吃什么药。
最终指标告诉我们哪里表现变差,但只有拆开链路,才能知道从哪里开始变差。
二、同一个指标,可以对应完全不同的根因
再往深走一步。即使把指标拆细一层,歧义也不会自动消失。
举一个真实的例子。我们追踪过一个指标:“引用支撑率”——答案里的关键论断有没有引用来源支撑。某周它掉了十几个点。
团队第一反应是”生成层坏了,模型不引用材料了”。但把 Request Summary 拉出来一看,引用支撑率下降的请求里,藏着三种完全不同的故事:
第一种:检索返回的材料本来就不相关,模型只能凭记忆答,自然没有可引用的来源。根因在检索。
第二种:检索给的材料是对的,也在 context 里,但模型没用。根因才在生成。
第三种:答案正文引用了正确的来源,但后处理拼装角标时映射错了文档 ID,引用在渲染层”被消失”了。根因在一个字符串处理函数里。
同一个指标,同一周的下跌,三个根因,三种修法。修检索的那周如果跑去调 prompt,指标不会有任何变化——这就是为什么那么多”优化”上线后石沉大海:不是优化没用,是优化打在了没病的地方。
更麻烦的是,三种故事在最终答案上长得几乎一样。用户看到的都是”答案没有可靠引用”,你抽查十个 bad case,如果不打开每个请求的 Request Summary 看过程数据,很可能十个都归到生成层——因为最终答案最像”凶手”的地方,永远是最后碰它的那个人。
教训:指标下降只是线索,不是结论。定位,必须回到每个请求的过程数据上去。
三、先建三层指标体系,再谈定位
定位方法依赖一套分层指标。我们把指标分成三层,各管各的事:

Component 层(组件级):每个模块自己的健康度。检索的召回数和空召回率、排序的 NDCG 和打分分布、上下文选择的截断率、各阶段的 fallback 率、prompt 和知识库的版本号。这一层回答”这个模块自己干得怎么样”。它的特点是先于用户感知变化——排序打分分布漂移的当天,投诉往往还没来,Component 层是你最早的哨兵。
Funnel 层(漏斗级):沿链路的转化和损耗。理解正确的请求里,多少检索到了足够材料?检索充分的请求里,多少关键上下文活到了模型输入?模型输入完整的请求里,多少产出了合格答案?每一站都有一个”留存率”,站与站之间的落差就是质量损耗。这一层回答”质量在哪一站开始漏”。
Product 层(产品级):投诉率、点踩率、用户停留、工单量、人工转接率。这一层回答”用户感受到了什么”。它最贴近业务,但离根因最远。
三层的用法是自上而下钻取。拿开头那个投诉潮走一遍完整路径:Product 层报警——点踩率翻倍,集中在合同纠纷类目;Funnel 层钻取——这个类目的请求里,“检索充分 → 关键上下文完整”这一站的留存率从八成掉到了五成,其他站没有明显变化;Component 层落点——排序模块的打分分布,在知识库更新那天发生了明显漂移。
大部分质量回归,钻到 Component 层就能看到异常。但注意,“看到异常”和”确认根因”之间还有距离。排序打分分布漂移了,和这次投诉潮是不是同一件事?时间相关不等于因果——要拿到因果级别的证据,需要后面四节的方法。
四、给每个阶段存一份可比较的 Snapshot
定位的前提是可比较。要比较,每个阶段的产出就必须保存成规范化的快照(canonical snapshot),而不是散落在日志里的自由文本。
第一篇的 Stage Event 记的是质量结果(outcome、计数、fallback),Snapshot 记的是产出本身的内容指纹:
- 理解阶段:解析出的问题类型、实体标签、意图置信度
- 规划阶段:拆解出的子任务序列
- 检索阶段:返回的文档 ID 有序列表
- 排序阶段:重排后的文档 ID 顺序和分数
- 上下文选择:最终入选的文档 ID 集合
- 模型输入:拼好的 prompt 哈希 + 各段的 token 计数
- 生成:答案文本哈希、引用的来源 ID
- 后处理:最终输出哈希
“canonical”是个有严格含义的词:同一份产出,任何时候、任何环境序列化出来都长一个样。三个具体要求——字段固定(不因为代码分支不同而多一个少一个)、顺序固定(列表按统一规则排序,不依赖字典遍历顺序)、空白固定(换行、缩进、分隔符全部规范化)。
为什么要这么较真?因为比较的前提是”相同就是相同,不同就是不同”。我们踩过坑:早期快照直接 JSON dump 一个 dict,结果 Python 版本升级后字典序列化顺序变了,同一阶段的快照全部”不同”,diff 出来满屏噪音,以为出了天大的回归,排查半天发现是序列化的锅。灰色地带一天不消除,比较一天不可信。
快照不用存全量内容,存 ID、哈希和计数就够了——和第一篇”只放指针不放内容”的原则一脉相承。文档 ID 列表的 diff 告诉你”材料变了”,哈希的 diff 告诉你”内容变了”,两者通常够用了。
五、把同一个 Query 的两次运行对齐
有了快照,还需要一对可比较的对象。
定位质量回归,本质上是比较”好的那个时候”和”坏的这个时候”。直觉的做法是拿昨天的流量和今天的流量比——这是糊涂账。query 不同、用户不同、流量结构不同:周一早上来的是查法条的律师,周五晚上来的是被裁员情绪激动的当事人,两者的答案质量分布天然不同。差异漫天都是,归因无从下手。
我们的做法是配对(Control/Test):选取一批固定的 query,用回归前的版本组合(Control)和回归后的版本组合(Test)各跑一遍,逐 query 对齐。Version Provenance 保证两边的版本四元组是明确、固定的;Replay Pointer 保证两边拿到的输入是等价的。
这批 query 怎么选,直接决定定位的效率。我们的配对集由三部分组成:历史投诉过的 bad case query(回归最可能在这里复发)、按类目分层采样的常规 query(保证覆盖面)、以及一小撮”健康标杆”query(答案一直很好,用来检测全局性破坏)。几十到一百条,比重跑全量评测集便宜得多,针对性也强得多。
对齐之后,每个 query 都有两份逐阶段的快照链:一份 Control,一份 Test。比较才有意义——同一个问题,同一批知识库材料,唯一的变量是版本组合。差异出现在哪,账就记在哪。
六、最早异常边界:定位的核心原则
现在可以讲方法本身了。原则只有一句话:
沿链路逐阶段比较 Control 和 Test 的快照,找到”前一阶段相同,当前阶段首次不同”的那个阶段。它就是最早异常边界,根因就在这一层或它的输入侧。

为什么强调”最早”?因为链路上的故障会传导。检索漏了文档,排序自然排不出它,上下文自然没有它,模型输入自然缺材料,生成自然只能硬编。从最终答案看,症状像”模型在幻觉”——但幻觉是下游被上游传染的结果。从症状倒着猜,你很可能把板子打在受害者身上。生成层是整条链路里最大的”背锅侠”,因为它是最后一个碰答案的人,而人们的归因本能是就近原则。
逐阶段比较把这个传导链切开。回到开头那个会议室。合同纠纷类目的投诉潮,配对比较跑完,每个 query 的两条快照链摆在一起做 diff:
理解阶段:实体标签、意图置信度——相同。规划阶段:子任务序列——相同。检索阶段:文档 ID 列表——相同,doc_552(一个关键判例)两边都回来了。排序阶段:不同了。Control 里 doc_552 排在第 2 位,Test 里掉到第 11 位。再往后,上下文选择阶段 Control 保留了它,Test 把它截掉了——但这只是排序差异的传导,不是独立故障。
最早异常边界落在排序层。顺着 Component 指标查:知识库更新收录了一批”合同范本”类文档,排序模型对这个新文档类型打出了系统性偏高的分,把判例类文档集体压了下去。理解没变,检索没变,是排序的分数分布变了。
根因收敛到排序模型对新文档类型的打分偏差。不是 prompt 的锅,更不是模型能力的锅。会议室里嗓门最大的检索派和资历最深的模型派,都猜错了——他们猜错的姿势还不一样:一个被时间相关骗了,一个被最终答案的表面特征骗了。
三周才能吵出结果的会,数据半小时给答案。
七、归因分四级,别假装每次都有铁证
诚实的定位方法必须承认:不是每次都能拿到”文档 ID 列表不同”这种铁证。我们把归因结论分成四级,调查报告里必须标注是哪一级——标注归因等级,是防止团队把猜测当结论的制度设计。
直接归因:最早边界处的快照差异是机械可验证的——ID 列表不同、计数不同、哈希不同。上面那个排序案例就是直接归因:doc_552 从第 2 位掉到第 11 位,白纸黑字。改哪一层,一目了然。
强推断:边界明确,但差异是语义级的。比如理解阶段的实体标签相同,但意图置信度从 0.9 掉到 0.6;或者模型输入的 token 结构变了,但字面内容 diff 不出来。差异存在且方向清楚,但需要人工确认差异的性质和影响。这类结论可以用,但要带着保留意见用。
弱推断:多个阶段同时出现差异,最早边界不唯一。典型场景是一次发版同时改了三个模块——每个模块都有自己的快照差异,分不清谁是因谁是果,或者互为因果。这时只能给出候选根因排序,然后配合单独实验逐个排除:每次只回滚一个模块,看边界是否消失。这也是”一次发版只改一个变量”这条工程纪律如此重要的原因——不是因为洁癖,是因为多变量并发会让归因降级。
不可归因:Control 和 Test 全链路快照一致,但质量评分仍然不同。这本身就是重要信息——它说明变量不在你观测的链路里:可能是模型提供方静默升了级(托管模型的快照哈希一样,行为不一样),可能是评测端出了问题,也可能是流量结构变了(Test 期间来的就不是同一类问题,配对集没覆盖到)。
第四级尤其值得单独告警。它防的是一种团队自欺欺人:找不到根因就硬指定一个,然后围绕错误的假设动工。两周工程资源砸进去,指标纹丝不动,团队士气受挫,下一次真回归来了反而没人愿意查了。承认”这次定位不了”,比假装”这次定位到了”值钱得多。
八、两个反直觉的诊断推论
这套比较框架用熟了,会得到几个一眼看不出、但逻辑上很硬的推论。分享两个最常用的,它们的价值在于直接用快照组合排除大片嫌疑区。
推论一:Context 相同但答案不同,凶手在生成层之后。
如果 Test 和 Control 的模型输入快照一致——同一份 prompt 结构、同一批材料、同样的 token 计数——但答案哈希不同,那么理解、规划、检索、排序、上下文选择,五个阶段全部无罪释放。问题只剩三个嫌疑人:模型本身(版本变了,或托管方静默升级了)、解码参数(温度、种子)、生成之后的后处理。
这个推论的实战价值极高,因为三个嫌疑人都很好验证:解码参数查配置 diff,一分钟;模型版本查 Version Provenance,一分钟;剩下的就是后处理。排查范围从九个阶段缩到三个,而三个里有两个一分钟排除。
推论二:最终输出相同但评分不同,凶手在评测端。
如果两次运行的最终输出完全一致——哈希相同——但质量评分掉了,那么整条生产链路无罪。变的是评测:LLM Judge 的 prompt 改了、Judge 模型换了、评分标准的执行漂移了、评测集的标注口径变了。
这个推论在实践中的价值超出想象。我们就遇到过一次”质量回归”:离线评测分连续两天走低,全组排查生产链路,版本 diff、快照 diff、流量分析做了个遍,一无所获。最后有人把同一批历史答案用新旧两套评测各跑一遍——旧答案在新评测下分数也掉了。生产系统什么都没变,是尺子变了:评测集更新时引入了一批标注口径不一致的样本。两天排查,结论是一句话。
度量系统本身也是系统,它也会坏,而且坏起来没有任何告警。 推论二的存在提醒我们:每次”质量回归”的假设清单里,都应该有一条是”评测端坏了”,而且应该第一个排除——因为它的验证成本最低。
九、收敛到一个待修改模块
定位的终点不是一份漂亮的分析报告,而是一个可执行的结论:根因收敛到一个待修改模块,以及一个可证伪的假设。
我们给这个产物定了个格式,叫 Root-cause Hypothesis,四句话。用排序案例填一个实例:
- 最早异常边界在排序阶段(附 Control/Test 快照 diff:
doc_552从第 2 位降至第 11 位,判例类文档整体排名下移) - 差异的具体内容是排序模型对新增”合同范本”类文档打分系统性偏高(归因等级:直接归因)
- 它解释了约六成的回归样本(剩余样本中,约两成边界在检索层、两成不可归因,另行处理)
- 建议在排序特征中加入文档类型权重,或将范本类文档排除出判例检索池,预期最早边界消失
第四句最关键。“预期最早边界消失”是一个可证伪的预测:修改上线后,重跑配对比较,如果 Test 在排序阶段的快照回到与 Control 一致,假设被证实;如果没有,假设被推翻,回到分析。定位不再是会议室里的资历竞赛,而是一个可以被数据枪毙的假设。
注意第三句里的”六成”,它承认了质量回归经常是多根因的。一次投诉潮里,六成样本的边界在排序,两成在检索,剩下两成不可归因——这是常态,不是分析不到位。修法是先修占比最大的那个,再看残余。指望一个修改解决所有问题,是另一种形式的猜。
还有一点容易被忽略:Root-cause Hypothesis 写完,工作才做完一半。“预期最早边界消失”这个预测谁来验证、怎么验证、验证到什么程度算修好——这已经超出了定位的范畴,进入了”证明”的范畴。那是第三篇的事。
结语:定位是一门关于”第一次”的学问
这篇的核心产物,四件东西:
- Stage Snapshots:各阶段可比较的规范化快照
- Boundary Comparison:同一 query 的 Control/Test 逐阶段对齐
- Earliest Divergence:第一次变得不一样的阶段
- Root-cause Hypothesis:可证伪的根因假设,收敛到一个待修改模块
回过头看,整套方法其实在对抗一种人性:故障发生后,人人都想立刻给出解释,而解释最容易从最终答案的表面特征出发——“答案在胡说,所以模型不行”。表面特征是最诚实的骗子,它说的都是真的,只是都不是原因。
“最早”两个字是这套方法的灵魂。链路上有一百个地方在撒谎,只有第一次不一样的地方说的是真话。
最终指标告诉我们哪里表现变差,最早异常边界告诉我们从哪里开始变差。
发现靠观测,定位靠比较。下一篇也是最后一篇,讲定位之后的事:根因找到了,模块改了——你怎么证明真的修好了? “跑了一下感觉好了”和”证明修好了”之间,隔着 Freeze、Replay 和配对评测的一整套受控实验方法。
下一篇:《生成式 AI 质量工程(三):明明只换了一个模型,为什么实验仍然无法解释——用 Freeze、Replay 和配对评测证明一次 AI 改动真的有效》。
🖼️ [封面图]: 极简数据主义风格,一条由九个发光节点组成的横向链路,前两个节点是稳定的青色,第三个节点开始变成破碎的红色,后续节点逐渐崩解,深邃黑背景,冷峻高智力感,8k 分辨率