生成式 AI 质量工程(一):HTTP 200,但 AI 答案已经坏了

AI Engineering
#Generative AI#Observability#Quality Engineering#RAG#APM

生成式 AI 质量工程(一):HTTP 200,但 AI 答案已经坏了

副标题:生成式 AI 为什么需要独立于传统 APM 的质量可观测性

作者:James Xie

这是《生成式 AI 质量工程:发现、定位与证明》系列的第一篇。这个系列来自我在一家法律科技公司做 AI 问答产品的两个月实践,讲三件事:如何发现答案已经坏了,如何定位从哪一层开始坏,如何证明修改真的修好了。

第一篇先解决”发现”。


下午四点,客户成功的同事又发来一条工单截图。

用户问的是一个劳动合同法上的问题:公司单方调岗,员工不去,算不算旷工?AI 助手答得文从字顺、结构工整、语气专业,三段论都用上了——但核心引用是错的。它引了一条关于”协商一致变更劳动合同”的条款,去论证一个”单方变更”的场景,还附上了看起来煞有介事的条文号。对法律稍有涉猎的用户一眼就能看出不对;对真正遇到麻烦的提问者来说,这个答案可能直接误导他下一步的决定。

我打开监控大盘:QPS 正常,成功率 99.9%,P99 延迟在阈值内,错误率贴着零,CPU、内存、磁盘、网络全部绿色。下游依赖全部返回 200。

系统一切安好,答案已经坏了。

这是我入职这家公司两个月后,反复遇到的场景。我之前做的是传统企业应用架构,职业本能告诉我:看 QPS、看 fail rate、看延迟分位数、看依赖健康度。刚来的第一周,我看着满屏绿色的监控面板,一度以为这个系统的运行状况相当不错。

然后工单开始一张张进来。每张工单的背后,都是一次监控里”完美成功”的请求。

这篇文章讲清楚三件事:为什么传统 APM 看不见 AI 的质量问题;HTTP 200 之下到底藏着哪些故障;以及我们怎么设计一套质量可观测性,让”答案坏了”这件事第一次变得可发现、可统计、可追踪。


一、技术成功不等于质量成功

先把这个错位说透。

传统监控回答的问题是:系统活着吗? 请求有没有被正确处理、延迟是否正常、有没有抛异常、依赖是否可用。这套指标体系是为确定性系统设计的——同一个输入,要么正确处理,要么报错,对错边界清晰。

但 AI 问答产品的核心关注点是另一组问题:回答是否答非所问?内容的权威可信程度有多高?跟问题的相关程度如何?回答全不全面?流不流畅?综合起来,对提问者到底有没有用?

这两组问题之间,隔着一条鸿沟。一个请求可以在所有技术指标上满分,同时在所有质量指标上零分。

更让人难受的是 SSE 流式接口把这个错位焊死了。我们的问答流量几乎全部走 SSE:第一个 chunk 发出时,HTTP 状态行就已经是 200 了。之后模型生成什么——是严谨引用法条,还是一本正经地编造——都不可能再改变那个 200。状态码承诺的是”连接建立成功”,而用户以为它承诺的是”答案没问题”。 从头到尾,这个系统连”请求失败”这个最原始的信号都几乎不产生。

故障的表现形式也完全不同。传统系统的故障是响亮的:500、超时、连接拒绝,监控会替你先喊出来。生成式系统的故障是安静的:它不报错,它自信地说错话。法条引错、事实编造、答非所问——这些在日志里的形态,都是一次耗时正常、状态成功、字节数健康的请求。

在生成式系统里,最危险的故障不是报错,而是自信地说错话。

这就是为什么说 AI 产品需要一套独立于传统 APM 的质量可观测性。注意是”独立”,不是”替代”——APM 照常用,进程挂了、依赖断了还是要它报警。两者的分工是:

传统可观测性负责发现系统有没有失败,AI 质量可观测性负责发现系统是否完成了正确的过程。


二、日志、Trace、Metrics 分别能回答什么

在动手建新东西之前,我先盘了一遍手里的旧武器,弄清楚它们各自的边界在哪里。盲目上新系统的结果,往往是旧问题没解决,又多了一套没人看的看板。

Metrics(指标) 回答”整体健康度怎么样”。QPS、延迟、错误率、token 消耗。它对质量问题是结构性失明的:答案质量下降 30%,QPS 曲线不会有任何波澜。token 消耗涨了,你甚至分不清是答案变好了(引用更充分)还是变坏了(废话变多)。

Trace(链路追踪) 回答”这次请求经过了哪些环节,各环节花了多久”。它能告诉你检索用了 800ms、生成用了 3s,但它不告诉你检索回来的东西对不对、生成的内容靠不靠谱。Trace 看得见过程的存在,看不见过程的正确性。一次返回了三篇无关文档的检索,和一次精准命中五篇判例的检索,在 Trace 里长得一模一样。

日志(Logs) 理论上回答”具体发生了什么”。但我们的日志是自由文本——给人看的那种。它能不能支撑质量分析,取决于写法,这一点后面专门展开。先说结论:绝大多数团队的日志写法,包括我们之前的,都撑不起。

三件套的共同点:它们围绕”请求的生死”设计,而 AI 的质量问题发生在”请求的内容”里。这不是工具不够好,是观测对象变了。


三、HTTP 200 之下的典型质量故障

两个月的工单调查做下来,我们把”监控全绿但答案是坏的”归了类。一个典型的法律 AI 问答链路,从用户提问到答案流出,中间至少有这样的环节:

法律 AI 问答链路的八个阶段

每个环节都可能安静地坏掉,而且每一种坏法,在 APM 里都是一次 HTTP 200:

  • 任务理解错误。用户问”竞业限制补偿金的标准”,系统理解成了”竞业限制协议的效力”。方向错了,后面每一步都在正确地执行一个错误的任务——检索很精准,生成很流畅,答案很自信,就是没有回答用户的问题。
  • 检索结果为空。向量库没召回任何相关内容,链路不报错,继续往下走。模型手里没有材料,但它不会说”我不知道”,它会凭借训练记忆硬答。这是幻觉最主要的生产线。
  • 只拿到部分数据。该召回五个相关判例,只回来一个。答案基于残缺的证据生成,天然片面——比如只引了支持用人单位的判例,漏掉了对员工有利的那几个。
  • fallback 被静默触发。主检索失败,降级到兜底的通用策略,答案质量降了一档。链路里没有任何告警,因为 fallback 的设计初衷就是”不让请求失败”——它成功了,以用户不知道的方式。
  • 关键上下文被过滤。上下文窗口装不下,截断逻辑按长度把排在后面的材料切掉了。被切掉的恰恰是最关键的那份文件。从工程上看,这是一次”成功的优化”;从质量上看,这是答案残缺的直接原因。
  • 模型忽略已有信息。该给的都给了,材料就在 context 里,模型没用,自顾自地”自由发挥”。这类故障最气人:你排查到检索层发现一切正常,差点就要下结论”是模型不行”。
  • 输出引用或后处理错误。答案正文没错,但引用拼装错了——正文说的是 A 判例,角标指向 B 判例。或者后处理的格式化把内容洗坏了。用户看到的是”这个 AI 连引用都是错的”,根子却在一个字符串拼接函数里。

注意到这些故障的共同特征了吗:它们每一个都有明确的工程含义,但没有一个会触发传统告警。 它们不产生 exception,不产生 5xx,不产生延迟尖峰。它们只产生一个结果——用户在工单里说”你们的 AI 在胡说八道”。

而团队拿到工单之后的处境是:没有网络问题,没有程序 error,资源一切如常。要回答”为什么”,只能手动复现那次问答,盯着体验结果做定性判断——“嗯,这确实是幻觉”,或者”感觉不够好,是不是该升级模型、调 prompt、加召回”。

靠体验和直觉定性,靠猜来定方案。同样的工单,三个工程师能给出三个不同的诊断:一个说是模型能力问题,一个说是 prompt 问题,一个说是召回问题。谁都说服不了谁,因为谁都没有数据。

这不是工程师该忍受的工作方式。


四、为什么自由文本日志撑不起质量分析

有人会说:这些环节不是没记录啊,日志里都打了。

问题是日志的写法。我们(以及绝大多数团队)的日志是给人类排障用的,长这样:

INFO  检索完成,耗时 812ms
INFO  召回文档 5 篇
WARN  上下文超长,已截断
INFO  开始生成回答

人能看懂,但分析系统没法用。你想统计”上周有多少请求的关键上下文被截断”,就得写正则去匹配自然语言文案,还得祈祷这半年里没人改过这行日志的措辞。有人把”已截断”改成”已裁剪”,你的统计就静默失效了——讽刺的是,用于观测质量的基础设施,自己也死得无声无息。

更深的缺陷是:自由文本日志只有事件,没有结果。“召回文档 5 篇”告诉你动作发生了,但不告诉你这 5 篇够不够用、是不是该回来的那几篇、有没有触发降级。质量分析要回答的恰恰是这些问题。

日志是给人看的,事件是给分析用的。 这两者从结构上就不是一个东西。给人看的东西追求可读性,措辞越自然越好;给分析用的东西追求可聚合性,字段越稳定越好。用前者做后者的事,就像拿会议纪要做数据统计——信息都在,就是用不了。

质量分析需要的是结构化事件:每个环节在完成时,以固定 schema 上报一次自己的结果——不是”我跑了多久”,而是”我产出了什么,产出是否完整”。没有这层结构,所有质量调查都只能回到”复现 + 肉眼判断”的原始状态。


五、设计结构化 Stage Event

我们的做法:给链路里每个阶段定义一个 Stage Event,阶段结束时强制上报。核心字段不多,但每个都有明确用途:

{
  "request_id": "req_8f3a2c",
  "stage": "retrieval",
  "status": "success",
  "outcome": "partial",
  "counts": { "requested": 5, "returned": 1, "filtered": 0 },
  "doc_refs": ["doc_771", "doc_204"],
  "fallback_triggered": false,
  "version": { "policy": "legal-retrieval-v14", "index": "kb-2026-08-15" },
  "duration_ms": 812
}

设计上有几个关键决策,都是踩过坑之后的结论。

第一,status 和 outcome 必须分开。 status 是技术结果——这个阶段执行成功还是抛错。outcome 是质量结果——full(完整产出)、partial(部分产出)、empty(空产出)、degraded(降级产出)。检索没报错但只回来一篇文档,就是 status: success, outcome: partial。这两个字段的分离,就是”技术成功不等于质量成功”在数据模型上的落地。这是整套设计里最核心的一个决定,后面所有分析能力都建立在它上面。

第二,计数比描述重要。 “召回文档 5 篇”是描述,returned: 1 是数据。期望几个、实际几个、被过滤掉几个——数字可以直接聚合、告警、画趋势,描述不行。有了计数,“检索空召回率”才第一次变成一个可以画在图上的指标,而不是一种模糊的感觉。

第三,fallback 必须显式声明。 静默降级是质量回归最大的藏匿点。每个阶段只要走了兜底路径,就必须在事件里标记,不允许”悄悄地用了次优方案”。我们专门给 fallback_triggered: true 设了聚合告警——它单独出现一次不说明问题,但占比从 2% 涨到 8%,一定有什么东西坏了。

第四,事件里只放指针,不放内容。 检索回来的文档 ID 列表、上下文选择后留下的片段 ID——记 ID,不记原文。原因到第八节讲 Replay 的时候细说,这里先记住这个约束贯穿了整个 schema。

落地成本没有想象中大。我们没有推翻重写日志系统,而是在链路的关键节点加了事件上报,旧的自由文本日志原样保留——排障时人还是要看它们的。两件事各干各的,互不干扰。

这套事件的产物,我们叫 Stage Outcomes:一次请求在每个阶段的质量结果集合。它是后面所有分析的原子单位。


六、每个请求唯一的一份质量摘要

Stage Event 是流水,还需要一个汇聚点。我们为每个请求生成一份 Request Summary——整个请求生命周期结束时的质量快照:

  • 这次请求经历了哪些阶段,各阶段的 outcome 是什么
  • 有没有触发 fallback,在哪个阶段触发的
  • 有没有空召回、部分召回、上下文截断
  • 最终答案是否产出,引用了哪些来源 ID
  • 关联的用户反馈(如果有的话)

它的作用类似传统 APM 里的 Request Trace 概要,但回答的问题不同。Trace 概要回答”这次请求慢在哪、错在哪”,Request Summary 回答”这次回答的质量链路完整吗,哪一环打了折扣”。

Stage Event 汇聚为 Request Summary 的质量观测闭环

有了这个摘要,工单调查的姿势完全变了。

以前拿到投诉,第一步是复现:找到用户的问题,自己问一遍,盯着回答看,然后开会讨论”我觉得是模型的问题”。一次调查半天起步,结论还不一定对。

现在第一步是调出这个 request_id 的 Summary,先看质量链路本身有没有缺口。文章开头那个调岗的工单,Summary 打开一看:检索阶段 outcome: partialrequested: 5, returned: 1,回来的那篇还是关于”协商一致变更”的。结论当场就出来了——不是模型胡说,是检索层就没把”单方变更”的材料给够。根本不用复现。

我印象最深的一次,是一位同事坚持认为某类投诉是”模型能力不行,得换模型”。Summary 拉出来,那个请求类型的检索 outcome: empty 占了六成。幻觉不是模型的玄学,是一个空的检索结果被静默传递到了生成环节。 换再强的模型,手里没材料,照样得编。


七、记录版本来源:答案是谁生的

AI 系统的答案不是代码一个因素决定的。同一段代码,模型版本变了、prompt 模板变了、检索策略(Policy)变了、知识库快照变了,答案就会变。

所以每个 Request Summary 必须带 Version Provenance:这次回答由哪个模型版本、哪版 prompt、哪套检索策略、哪个知识库快照共同产生。

这个字段救过我们一次。有段时间某类问题的投诉突然增多,Summary 显示质量链路完整——检索 full、上下文 full、没有 fallback。排查陷入僵局,最后是把投诉请求的 Version Provenance 和正常时段一对比,发现知识库快照在一周前更新过,新增的一批法规解读文档挤掉了原来排在前列的判例。检索”完整”,但完整的是另一批材料。

四元组缺一个,事后就没法回答”质量变化到底是哪个变量引起的”。没有版本记录的系统,每次质量回归都是一次考古发掘。

这件事看起来琐碎,却是整个系列第三篇的伏笔:没有版本来源,就不存在可比的对照实验,“修改有没有效果”永远是一笔糊涂账。


八、不保存原始用户内容,也要能 Replay

法律场景的用户问题涉及隐私和合规——有人直接把整个劳动争议的过程贴进来,姓名、公司、金额一应俱全。原始提问内容不能想存就存。但质量调查又需要复现能力。矛盾怎么解?

我们的答案是 Replay Pointer。Summary 里不存用户原文,存一组复现所需的”指针”:

  • 脱敏后的输入特征:问题类型、实体标签(劳动法/竞业限制/调岗)、长度区间
  • 命中的文档 ID 列表
  • 版本四元组
  • 随机种子(如果推理端支持)

调查时拿着这组指针,可以在隔离环境里重放一条语义等价、但不包含原始用户内容的请求。上下游的材料和版本都对得上,只是问题本身换成了一个”同类的脱敏问题”。

够用吗?对定位”哪一层坏了”够用。对逐字还原用户看到的东西不够——但那是合规必须付出的代价。实践中我们发现,九成以上的质量问题靠指针级复现就能定性,因为大部分质量故障的根源在链路的中间环节,而不是用户措辞的细枝末节。


九、把用户反馈钉到具体运行上

最后一块拼图:工单和用户反馈。

以前反馈是飘着的——“今天上午有人说答案不对”。上午有三千个请求,你说的是哪个?反馈和生产数据之间没有桥,两边各自存在,永远对不上。

现在每个会话的每次回答都带 request_id,反馈入口直接把它带上,工单系统录入时也强制关联。一条抱怨从”一种情绪”变成”一个可检索的样本”。

这一步做完,闭环就形成了:反馈进来 → 定位到 request_id → 打开 Request Summary → 看 Stage Outcomes → 找到第一个打折扣的阶段。反馈数据第一次可以聚合了:这个月被投诉的请求里,多少比例检索层就有缺口?多少比例链路完整、纯粹是生成质量的问题?前者修检索,后者才轮到讨论模型和 prompt。资源投在哪,第一次有了依据。

注意到最后这句话了吗——“第一个打折扣的阶段”。这就是第二篇的主题。现实里的请求往往不止一个阶段有瑕疵:检索 partial、上下文又截断、生成还自由发挥。当多个阶段都打了折扣,到底哪个是根因?答案不是”从最像的那个猜”,而是一套叫”最早异常边界”的定位方法。第一篇的 Stage Outcomes 在 schema 设计时就必须保证阶段之间可比较——同样的 outcome 枚举、同样的计数口径——否则第二篇的方法无从谈起。这也是为什么我们在设计字段时对”四个决策”那么较真。


结语:把”胡说八道”变成一行数据

总结一下这篇的核心产物,五件东西:

  • Request Summary:每个请求唯一的质量摘要
  • Stage Outcomes:各阶段的结构化质量结果
  • Version Provenance:模型、Prompt、策略、知识库的版本四元组
  • Quality Signals:从事件中提取的可聚合质量信号
  • Replay Pointer:不碰用户原文的复现能力

它们共同回答了一个问题:当客户说”你们的 AI 在胡说八道”时,你能不能在两分钟内打开一条记录,说出这次回答经历了什么、哪一环打了折扣、由哪些版本共同产生。

这套东西没有任何高深的算法,全是朴素的工程决定:把 status 和 outcome 分开,把描述变成计数,把降级显式化,把版本钉在每次回答上。难的不是技术,是接受一个事实——你熟悉的那个”系统健康”的定义,在生成式时代已经不够用了。

传统监控告诉你请求活着。质量可观测性告诉你这次回答值不值得信任。

HTTP 200 的意思是话送到了。它从来不负责话说得对不对。


下一篇:《生成式 AI 质量工程(二):不要从最终答案猜根因——用”最早异常边界”定位 RAG 系统质量回归》。发现了问题只是开始,答案坏了和知道哪一层开始坏之间,还隔着一整套定位方法论。

🖼️ [封面图]: 极简数据主义风格,一块巨大的绿色监控仪表盘,所有指示灯全绿,但仪表盘下方流出的文字流是破碎、扭曲的,深邃黑背景配荧光青数据流,冷峻高智力感,8k 分辨率