背景
在做一个 Kubernetes 故障诊断 Agent时候,发现大模型是真会编,就像是做题目一样,能没有解题步骤照样猜对答案,又或者编一个看起来很专业的"证据"。这肯定是要不得的,所以得设计一个防幻觉的校验器
1. 设计:证据必须"可追溯"
思路很简单,但我觉得是有效的:要求 Agent 输出的每一条证据,都必须能对应到一次真实发生过的工具调用。
具体落地是这样:系统提示里明确约定:每条证据的 query_or_id 字段,必须是你实际执行过的 PromQL / LogQL / trace_id / 事件原文,不得改写、不得引用没检索到的东西。Agent 每调用一次工具,就记一条日志:
跑完以后,校验器把报告里每条证据拿去跟这份日志比对,按证据类型分别匹配:
|-------------|---------------------------------------|
| 证据类型 | 匹配规则 |
| metric | 归一化空白后做双向子串匹配(容忍空格差异) |
| trace | trace_id 或前 8 位出现在已执行调用里 |
| log | LogQL 前 40 字符双向匹配(至少 service_name 对上) |
| k8s_event | 证据文本前 30 字符出现在调用参数或返回内容的前 200 字符里 |
算通过率:还有两个刻意的设计决定,我觉得挺重要:
- 校验在 Agent 外部做,不靠 Agent 自报。 报告 schema 里虽然有个
grounding_score字段,但模型输出的值会被直接丢弃、由 harness 覆盖写入。理由很简单:让被考核者自己打分,等于没打分。
- 只校验"可追溯性",不校验"事实正确性"。 这个边界我后面会讲它的代价。
2.过程
2.1 第一轮结果
第一轮结果:0.889,两个场景被标红
几个故障场景跑下来(Pod 崩溃 / 延迟 / 丢包 / CPU 压力 / 内存打满 / 前端崩溃 / 等等):
|------------------------------|--------------|
| 场景 | Grounding |
| product-catalog-high_latency | 1.000 |
| payment-http_abort | 1.000 |
| shipping-high_latency | 1.000 |
| recommendation-cpu_stress | 0.889 |
| cart-memory_stress | 0.889 |
| frontend-pod_crash | 0.778 ❌️ |
| checkout-pod_crash | 0.667 ❌️ |
7 场景 62 条证据,55 条通过,均值 0.889。
两个场景被打了 [HALLUCINATION WARNING]。
说实话,第一反应是"果然,模型还是编了"。但我多看了一眼失分明细,方向就完全变了。
2.2 失分项分析
我把所有失分项拉出来看,发现一个非常整齐的规律:
所有失分项,全部是
k8s_event类型。没有一条 metric / trace / log 失分。
而且失分的事件是这些东西:
- checkout:
Killing: Container checkout definition changed, will be restarted
- checkout:
Back-off pulling image "gcr.io/google-containers/pause:latest"
- frontend:
SuccessfulCreate: Created pod: frontend-76cbbdfbcc-zm8l8
- cart:
Started: Container started (count=2)
关键在于:这些事件在快照的 k8s/events.json 里是真实存在的,而且 Agent 确实调用过 get_pod_events。
也就是说------模型没有编。它引用的东西是真的。
那为什么会判失分?
回看第 1 节的匹配规则就明白了:k8s_event 这一路,我是拿"证据文本前 30 字符"去比对"调用参数 + 返回内容的前 200 字符"。
而 get_pod_events 返回的是一串事件列表,我只在日志里留了 前 200 个字符 的预览。只要被引用的事件排在 200 字符之后,就必然匹配不上。
这是我的校验器的匹配策略问题,不是模型的诚信问题。
2.3 换个模型,同样的失分复现了
到这一步还只是"怀疑"。要确认它跟模型无关,最直接的办法是换个模型再跑一遍。
于是我把同一套代码、同一批快照,从 deepseek-flash 换成 glm-5.3(推理型)重跑:
|-----------|----------------------------------------|---------------------|
| | deepseek-flash | glm-5.3 |
| Grounding | 0.889 | 0.886 |
| 失分分布 | checkout 3 条 + frontend 2 条 + cart 1 条 | checkout 4 条 |
| 失分类型 | 全部是 k8s_event | 全部是 k8s_event |
两个不同厂商、不同架构的模型,复现了同一个失分模式。
这就基本可以定性了:
这是系统性问题(提示词 / 输出契约层面),不是模型能力问题。 换模型治不好它,因为病根不在模型。
顺便说一下,这次对照还有个副产品:两个模型精度完全持平 (服务与故障类型都全中),但延迟差了 12 倍(26.7s vs 326s)------因为推理型模型的 reasoning token 吃掉了完成预算的 89%~99%,还偶发触发截断。
2.4 格式错误 ≠ 事实错误
继续看明细时,我又发现有一类失分性质完全不同:
recommendation-cpu_stress 那条失分证据,内容是这样的:
模型把自己"准备调用哪个工具"的签名,写进了要求放"工具返回内容"的字段里。
这说明什么?
- 第 3 节那类失分:证据是真的,但校验器匹配不上(契约/匹配侧问题)
- 这一类失分:模型压根没去取证据,直接把自己打算做什么写成了证据(输出契约遵循问题)
两者的修复手段完全不同:前者要改校验策略或收窄工具过滤条件;后者要在 schema 的 few-shot 示例里收紧。
如果不做这个区分,这两种问题都会被笼统地算进"幻觉",那你就永远修不对。
2.5 所以 0.889 到底说明什么
修正后的结论是这样的:
❌ 错误的读法:"模型有 11% 的概率在编证据。"
✅ 正确的读法:
0.889 反映的不是"模型爱编",而是"证据引用契约没被严格遵循 "。 而且------有 4 个场景做到了 1.000 (payment / product-catalog / shipping / 以及另一个), 说明这个契约是可满足的,不是模型做不到,是约束不够硬。
这个区别非常重要。如果按第一种读法,我的下一步动作会是"换个更强的模型"------而那是完全错误的方向(第 4 节已经证明了换模型没用)。
2.6 如何修
基于上面的定性,三条修法:
① prompt 侧:强制"先取证、再引用" 明确要求:每条 k8s_event 证据必须直接摘自 get_pod_events 的返回原文,不得改写;引用前必须确认该事件确实出现在返回结果里。
② 工具侧:放宽可能过窄的过滤条件 pod_name_prefix / since_seconds 如果收得太紧,会导致"被引用的事件不在返回集内"。要么放宽窗口,要么在提示词里要求 Agent 自己对齐。
③ 校验侧:做文本归一化 如果模型对事件文本做了合理截断或格式改写,严格字符串包含会误伤。可以在校验层做归一化------但要小心别放宽到"什么都能匹配",那校验就失去意义了。
这第三条要克制。 校验器一放松,误报率下来了,但漏报率上去了。我倾向于 先修 ① 和 ②(把 Agent 的行为修对),而不是先修 ③(把尺子改松)
2.7 沉淀下来的一条设计原则
这次复盘让我想清楚一件事,算是这个项目里最有价值的认识:
-
要能定位到"哪一类证据、哪个字段、什么原因"失分。 只给一个总分是没用的------我如果只看 0.889 这个数字,第一反应就是去换模型。
-
要有对照实验(换模型 / 换 seed)来区分系统性问题和偶发问题。 换模型复现 = 病在代码不在模型。这一步几乎是用最少成本拿到最强结论的方式。
-
校验器的失效边界必须自己先知道。 我这个校验器的三个已知边界:
k8s_event依赖 200 字符预览会误判;证据数为 0 时返回满分(这是个逻辑漏洞,回头单独修);只校验"查询是否调过"而不校验"数值是否一致"(弱校验)。一个你不知道边界的校验器,比没有校验器更危险------因为它会给你虚假的安全感。
2.8 指标口径必须清晰
最后说一下数字的口径,这也是我做评测时踩出来的习惯。
上面那 7场景的根因定位是 7/7 全中,但我从不在外面直接说"准确率 100%",因为:
- 只有 7 个场景,样本量小到没有统计意义;
- 每个场景只跑了一次,无法评估稳定性;
- 故障类型是有限枚举 (6 类),而且系统提示里给了"信号 → 故障类型"的映射表,所以故障类型判断准确有一部分是提示词工程的功劳,不能全算推理能力;
- 用的模型不是上游基准模型,不能跟任何公开数字对比。
真正可复现、可对比的,是延迟、成本、证据可追溯性这三个工程指标。
这个习惯其实也是被逼出来的:我早期真出现过"Acc@1=1.0 但 grounding=0.0"的跑批------表面满分,实际是幻觉被自己的校验器抓了个正着。那次之后我就再也不看单一指标了。
3. 总结
- 我给 Agent 做的反幻觉校验,逻辑是"每条证据必须能对应到一次真实工具调用 ",且由外部 harness 计算、不让 Agent 自报。
- 第一轮 0.889,两个场景被标警告。但失分项全是
k8s_event,而且事件是真实存在的------是校验器匹配策略的问题。
- 换模型复现同一模式 → 定性为系统性问题,不是模型能力问题。
- 更细一层:还要区分"契约未遵循"和"事实编造",两者修法完全不同。
- 三条修法:先修 Agent 的行为(prompt / 工具过滤),再考虑放宽校验器。
- 最大的收获:反幻觉这件事,难点不在"抓",而在"抓准"------以及自己知道自己这把尺子哪里不准。
本来只是留个200字符预览给人看,结果校验器那它做校验了,后续加长预览、或对 K8s 事件类证据改为对照快照原文核验,而不是对照预览效果会更好些。