98次嵌入调用,98次报错。测试套件从头到尾显示绿色。

打开网易新闻 查看精彩图片

这不是"跑通了但没发现问题",而是一次成功的调用都没有发生过。所有被信任的信号——绿色的测试、正常触发的检测器、干净的遥测数据——都在说"没问题"。发现它的唯一线索,是汇总文件里一行一直被略过的数字。

这类问题,往往源于一行 except Exception: return None。而这一行,大多数人都写过。

那次看起来很好的运行

当时正在用 agentwatch 里的 LLM 增强检测器跑 100 条合成 agent 轨迹。agentwatch 是 agentic-suite 中的一个工具,后者是一套开源、与 harness 无关的 AI agent 安全工具集。汇总结果回来,看起来确实不错:

  • traces_processed: 100
  • chat_calls: 363
  • embed_calls: 98
  • errors: 98
  • total_tokens: 52,578
  • json_parse_success: 363
  • json_parse_fail: 0

363 次对话调用,JSON 解析成功率 100%,52,578 个 token。检测器触发情况:hallucination 98 次,confusion_pattern 2 次,quality_degradation 1 次。数字全都说得通。

然后是一行:embed_calls: 98,errors: 98。

每一次嵌入调用都失败了。而这一点没有被注意到——因为语料运行统计的是"触发次数",不是"未触发次数"。一个从未触发的检测器不会显示为"0",它是直接缺席。而缺席不等于零。缺席意味着这个检测器根本没跑过。

这也不是第一次。更早的一次运行 FT-11b 中,24 次嵌入调用对应 24 次报错——同样的 100% 失败率,同样的隐身。这个 bug 横跨两次完整的语料运行(FT-11b 跑 25 条轨迹,FT-11d 跑 100 条轨迹),两次之间共 122 次嵌入调用全部失败。错误率一直躺在遥测数据里,只是没进断言。

错误其实全程都在日志里:

LLM embed call failed: Error code: 404 - 模型 'all-MiniLM-L6-v2' 未找到。可用模型列表里只有 Llama-3.2-3B-Instruct-4bit、Qwen3-4B-Instruct-2507-4bit、Qwen3-8B-4bit、Qwen3.5-4B-4bit、Qwen3.5-9B-MLX-4bit。

这条日志记了 98 次。测试通过了,所以没人去找它。

让问题隐身的代码路径

客户端被设计成有韧性的——一次嵌入失败不应该让整条流水线崩溃:

异步的 embed 方法里,调用嵌入接口,成功就返回向量;一旦抛出异常,就记录错误、打一条警告日志,然后 return None。这就是静默失败。

检测器把 None 当作"没有可用向量",这个处理是合理的:拿不到当前向量,就返回 None,意思是"无法比较向量,因此不判定异常"。

测试则把 None 读作"没有触发",这对负向用例来说也是正确的:fired 等于 anomaly 是否非空,再和场景期望的触发状态比对,False 对 False,通过。

三种完全不同的含义,坍缩进了同一个 None:

  • 一种是"我检查过了,没发现异常";
  • 一种是"我没法检查,因为接口失败了";
  • 一种是"我压根没调用接口"。

没有调用遥测,这三者无法区分。它们看起来都像一个通过的负向用例。

这个问题在语料运行中一直藏着,原因是结构性的。那些运行统计的是触发次数。output_drift 在异常计数里不是"0",而是缺席。而当你扫一眼汇总时,缺席读起来和"检测器跑了、什么也没发现"一模一样。

让检测器真正工作

那个端点根本没有嵌入模型。OMLX 只提供对话补全,all-MiniLM-L6-v2 不在列表里。所以当 embed 返回空时,现在改为让同一个模型给两段文本的相似度打分,范围 0 到 1。

如果当前向量和基线向量都存在,就用余弦相似度;否则走基于对话的相似度回退,距离等于 1.0 减去相似度,超过阈值就返回异常,并在证据里标注方法为 chat-fallback。

提示词很小也很严格:比较两段文本,按 0.0 到 1.0 给语义相似度打分,只返回 JSON。距离就是 1.0 减去相似度。它比向量余弦更粗——模型给的是判断,不是连续指标——但触发与否的决策是对的。

这里有个差点被忽略的细节:第一次调用必须建立基线,所以无论走哪条路径都会返回 None。回退只在第二次调用起生效,那时才有基线文本可比。顺序搞错,就会在第一条轨迹上误触发,那是噪声。

本该最先写的检查

这是大多数套件会跳过的一步。每个场景开始前,快照客户端的调用计数器;结束后,测量该场景的增量。

如果 LLM 从未被调用,场景就判失败——哪怕检测器返回了"正确"的答案。一个零调用的静默 None,是隐藏的跳过,不是通过。

这个检查就是真实通过和隐藏跳过之间的差别,也是大多数套件没有的那一个。它还很便宜:客户端本来就为自己的遥测记录调用次数,harness 直接读就行。

修复之后,两个场景都带着真实调用和真实证据运行:ED1 的 output_drift 触发为真、严重级别 critical、llm_calls 为 1、llm_errors 为 2;ED2 的 output_drift 不触发、严重级别为空、llm_calls 为 1、llm_errors 为 2。

那两个 llm_errors 就是失败的嵌入调用——仍然是 404,仍然被记录,但已经不再决定结果。那一个 llm_calls 是对话回退在真正干活。模型给出的实际判定是:ED1 两段文本完全不同,相似度 0.1,距离 0.9,触发 critical;ED2 两段文本几乎相同,相似度 0.95,距离 0.05,静默,正确。

两次判断都对。而这一次,能证明模型确实做了判断。

问题不在嵌入

这个形状会在任何"依赖失败时返回哨兵值"的地方重演。每一次,失败都穿着"合法的空"的外衣。测试通过,系统是坏的,没人知道——直到有人去数调用次数或检查状态码,而这恰恰是测试没做的事。

修复的形状总是一样的:证明依赖被真正执行过,而不只是检查输出。数调用,查响应状态,断言网络被触达。一个 None 返回值,只有在你能证明调用发生过时才可信。

具体改了几件事:

  • 缺失的异常类型不等于"零异常",而是"这个检测器从没跑过"。要检查存在性,不只是计数。
  • 日志警告不是告警。那个 404 在一次运行里记了 98 次,更早一次记了 24 次,没人看见。如果失败重要,就把它变成断言,而不是一行日志。
  • None 是个糟糕的哨兵,因为它不告诉你自己属于三种含义中的哪一种。"我查了""我没法查""我没试过"坍缩成同一个值,也坍缩成同一个通过的测试。

更深层的习惯改变在断言上。在此之前,测试问的是"检测器有没有返回正确答案";在此之后,测试还要问"它到底有没有做产生答案的那份工作"。前一个问题可能被偶然满足,后一个不会。

这些都在 agentwatch v0.1.0 中修复,每个 LLM 场景都强制启用 llm_called_ok 检查。

值得对自己的仓库问一句:哪一个绿色测试,在它背后的服务已经挂掉时仍然会通过?答案通常是那个只断言输出、从不断言依赖执行过的测试。