38 个单测全绿,功能一次都没触发过

这是整个项目里最值得讲的一个坑。我给一个 HITL(Human-in-the-Loop,人工介入决策)功能写了 38 个单元测试,全部通过,全量测试 977 passed。然后第一次跑真实链路——这个功能从头到尾一次都没触发过。修掉第一个 bug 立刻暴露第二个,修完第二个又暴露第三个,三个 bug 叠成了一条死链。

一、现象:功能「存在」但「从未发生」

这个 HITL 功能的设想很简单:当检索置信度不足、系统拿不准该给什么建议时,不要硬答,而是停下来让用户选补救方式(换年份、看相近专业、扩大检索范围)。

代码写完了,测试写了 38 个,全绿。链路接进了 SSE,前端卡片也做了。看起来一切正常。

但真实对话跑起来,它一次都没出现过。用户的每个问题都被系统直接回答,或者直接说「资料不足」,中间那个「请问您想怎么办」的环节,从来没有出现。

第一条判据:「没报错」不等于「跑通了」。一条从未被执行过的分支,里面所有的 bug 都是隐形的——测试覆盖率再高也照不出来,因为测试根本没走到那儿。

二、根因:三个 bug 叠成的死链

把三个 bug 分开看,每一个都不算复杂。它们致命的地方在于串成了一条链——前一个掩盖后一个,每修一个才露出下一个。

bug ① 哨兵字符串被当成了证据

工具层在查不到结果时,会返回一个特定字符串作为「哨兵」(sentinel),表示「这里没有数据」。而 confidence(置信度)层判断「是否有证据」时,只检查了「上下文非空」,没有识别哨兵。

结果:工具明明返回的是「没查到」,但置信度层认为「有证据」,于是判定「可以正常回答」,从来不问人。HITL 的第一道触发条件就永远不成立。

# 问题简化后的样子
contexts = tool_result          # "NO_RESULT_FOUND" 这种哨兵串
if contexts:                    # 非空 → 判为有证据 ❌
    confidence = "high"
else:
    trigger_hitl()              # 永远走不到这里

bug ② HITL 决策 ID 与 SSE 请求 ID 撞名

哨兵问题修好后,系统终于开始问人了——前端卡片弹出来了。但用户点了「看相近专业」,后端找不到对应的决策。

原因是 HITL 的决策 ID 和 SSE 流使用的请求 ID 用了同一个命名空间,发生覆盖。用户提交的选择携带的 ID,和系统等待的 ID 不是一个。

bug ③ 刷新页面恢复不出选项卡片

ID 修好后,能选、能提交了。但只要用户刷新页面,正在等待选择的卡片就消失了——前端拿不到「当前有一个中断正在等待」这个状态,于是恢复逻辑失效。

三、为什么 38 个单测全绿却没发现

这是最值得复盘的地方。回头看,原有测试的问题很清楚:

问题后果
测试全是手搓 state 直接喂给判据函数绕过了真实的 _retrieval_contexts 组装过程,测的是「判据逻辑」而不是「系统行为」
每个 bug 单独看都被 mock 掉了哨兵串在测试里被构造得「不像哨兵」,ID 在测试里天然一致,刷新恢复在测试里压根没测
只验证了函数返回值,没验证「是否被调用」参数存在不等于会被调用;返回值有了不等于用它的地方做了判断

四、修复与验证

三个 bug 依次修完,测试规模从 977 → 995 → 1007 passed。但光增加测试还不够——得证明新测试真的有杀伤力,否则只是「又多了一堆永远绿的断言」。

这里用了变异测试:把修好的代码故意改坏,注入典型退化,看测试会不会变红。四组变异分别有 2/1/1/1 条测试变红——说明测试确实打在行为入口上,不是空转。

变异注入变红的测试数
还原哨兵识别缺失2
还原 ID 命名冲突1
还原刷新恢复逻辑1
还原置信度判据1

顺带修掉的一个「说了但没做」

排查时还发现:HITL 提供「放宽检索条件再查一次」这个选项,点下去后端只回了一句「我放宽再查一次」,实际上并没有真的修改检索条件。用户以为系统重新查了,其实只是白跑一轮。

这类「说了但没做」的空转,比报错更隐蔽——它不报错、不崩溃,只是让功能看起来生效了。

五、三条可复用判据

  1. 「没报错」不等于「跑通了」。依赖状态流转的功能必须跑真实链路;从未执行的分支里所有 bug 都是隐形的。
  2. 单测要穿过真实链路。手搓 state 喂判据函数,测的是判据不是系统。要让测试从真实入口进、真实出口出。
  3. 变异测试是证明测试有杀伤力的唯一手段。断言要打在最终产物上(实际调用次数、最终报告行),不是函数签名或中间返回值。

HITL 功能 38 个单测全绿、真实链路上一次都没触发过,三个 bug 叠成一条死链。它教给我一条判据:「没报错」不等于「跑通了」。

—— 被问「这个项目最大的坑是什么」时的回答
← 返回笔记列表