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 提供「放宽检索条件再查一次」这个选项,点下去后端只回了一句「我放宽再查一次」,实际上并没有真的修改检索条件。用户以为系统重新查了,其实只是白跑一轮。
这类「说了但没做」的空转,比报错更隐蔽——它不报错、不崩溃,只是让功能看起来生效了。
五、三条可复用判据
- 「没报错」不等于「跑通了」。依赖状态流转的功能必须跑真实链路;从未执行的分支里所有 bug 都是隐形的。
- 单测要穿过真实链路。手搓 state 喂判据函数,测的是判据不是系统。要让测试从真实入口进、真实出口出。
- 变异测试是证明测试有杀伤力的唯一手段。断言要打在最终产物上(实际调用次数、最终报告行),不是函数签名或中间返回值。
HITL 功能 38 个单测全绿、真实链路上一次都没触发过,三个 bug 叠成一条死链。它教给我一条判据:「没报错」不等于「跑通了」。
—— 被问「这个项目最大的坑是什么」时的回答