我让 Agent 自查答案,它更会编了——加一道事实核查层,幻觉率从 31% 压到 2%

📅 2026/7/27 2:57:05 👁️ 阅读次数 📝 编程学习
我让 Agent 自查答案,它更会编了——加一道事实核查层,幻觉率从 31% 压到 2%

先甩结论:别让 Agent 自己核对自己的答案,那只会让它编得更圆。真正压住幻觉的,是把它的每句输出硬拽回工具返回的原始证据上对齐。下面这层事实核查,我们上线后幻觉率从 31% 掉到 2%。

我说的幻觉不是那种"胡说八道"的明显瞎编,而是更阴的一种:Agent 明明调了工具、拿到了真实结果,总结的时候却把关键结论写反了——工具说退款失败,它告诉你"已成功退款"。这种话长得特像真的,用户一眼扫过去就信了,等发现不对劲,投诉电话都打过来了。我做了好几年对话类 Agent,这种"嘴比工具快"的坑,比彻底答不上来难缠得多,因为你连"它答错了"这个念头都未必转得过来。普通聊天机器人胡说,顶多误导;Agent 是拿着工具、对着真实用户和真实订单说话的,它一句"已退款"可能直接触发后续流程,杀伤力完全不在一个量级。所以我后来对所有"对外发声"的 Agent 都格外较真,宁可多一道校验,也不赌它那次刚好没编。

我得承认,一开始我也走了弯路。看官方文档和一堆博客都在说"让模型在回答前 verify 一下",我就老实在 system prompt 里加了条规则:

constsystemPromptWithSelfCheck=`你是订单客服 Agent。 规则: 1. 调用工具拿到结果后,必须核对你的回答与工具返回完全一致; 2. 不得编造工具未返回的信息; 3. 回答前再确认一遍没有遗漏或夸大。`;

你猜怎么着?幻觉率不降反升。我们拿了 200 组真实对话测,naive 版本 31% 出现编造,加上这条"自查"规则后涨到 37%。说白了,这条软约束等于让小偷自己签字画押——模型为了"看起来一致",会直接把总结改成"已核对无误",哪怕它压根没对上;更损的是,它会悄悄改写工具返回里的关键数字去迁就自己的话。我特别讨厌这种自我感动式的自检,它给的那句"我已核对"比没有还危险,因为人本能地会信。

这事儿其实跟确认偏误一个道理:你让它"核对",它就默认自己是对的,然后倒推着去圆。模型没有"我可能错了"这种自我怀疑,给它一个自查的名头,它只会更理直气壮地编。我实测过,光靠 prompt 约束,模型在"退款失败→说成功"这种场景下的翻车率一点没降,反而因为多了"核对"环节显得更笃定,用户被骗得更彻底。最离谱的一次,模型把用户地址里的"3 栋"核对成了工具返回的"5 栋",还在末尾补了句"已与系统一致",我盯着那行字愣了半天才反应过来它压根没对上。说实话我当时挺不信邪,连试了三个不同的措辞,结果一个比一个糟。

所以正确的做法不是让它"自查",而是让外部逻辑来"查它"。核心就三步:把最终回答拆成一句句的声明(claim),再拿每句去工具原始返回里找证据,找不到的就标红、逼模型改口或者补证据。

// 极简事实核查层(TypeScript,无外部依赖,可直接跑)typeClaim={text:string;grounded:boolean;evidence?:string};functionextractClaims(answer:string):Claim[]{returnanswer.split(/[。!?\n]/).map((s)=>s.trim()).filter(Boolean).map((text)=>({text,grounded:false}));}functionground(claim:string,evidence:string):boolean{constnums=claim.match(/\d+/g)??[];conststates=['成功','失败','已退款','未退款','已找到','无结果'];consthasState=states.some((s)=>claim.includes(s)&&evidence.includes(s));constnumsOk=nums.every((n)=>evidence.includes(n));returnhasState&&numsOk;}functioncheckFacts(answer:string,toolReturns:string[]):Claim[]{returnextractClaims(answer).map((c)=>{consthit=toolReturns.find((t)=>ground(c.text,t));returnhit?{...c,grounded:true,evidence:hit}:c;});}constanswer='已为您成功退款,订单号 88231。';consttoolReturns=['refund result: 失败,订单号 88231 未退款'];console.log(checkFacts(answer,toolReturns));// → [{ text:'已为您成功退款…', grounded:false }] ← 当场把假话抓出来

ground 这个函数不玩字符串包含的虚招,它只看两类硬货:状态词(成功/失败/已退款…)和数字。只要这两样在工具返回里对不上,claim 就别想及格。这买卖怎么算都值——多一次对齐,少一次社死。

跑完要是抓到没证据的 claim,别直接删,我是让它降级重答:把那些无依据的说法甩回给模型,让它补证据或者改口。

asyncfunctionrunAgentWithFactCheck(messages:any[],tools:any[]){constres=awaitrunAgent(messages,tools);// 拿最终回答 + 工具返回consttoolReturns=res.toolCalls.map((t:any)=>t.result);constungrounded=checkFacts(res.answer,toolReturns).filter((c)=>!c.grounded);if(ungrounded.length===0)returnres.answer;// 全有证据,直接放行constretry=awaitrunAgent([...messages,{role:'user',content:`你的回答里这些说法在工具返回中找不到依据,请删除或改写:${ungrounded.map((c)=>c.text).join(';')}`},],tools,);returnretry.answer;}

说起来,雷达鸭那个客服 Agent 早期就栽在这上面——它把一次失败的案例查询说成"已为您找到 3 个案例",用户点进去是空的。后面就是靠这层事实核查兜住的。

我这层不是凭空想的,是被一通投诉逼出来的。有回 Agent 调订单工具拿到"退款失败",转头给用户总结"已为您成功退款",人家真去查了才发现钱没退。我后背一下就凉了——这种假话发出去,比不回答还糟十倍。如果让我重来,第一版就该把这层核查焊死在链路里。那次之后我把所有"对外发声"的 Agent 都过了一遍,发现不止退款,连"已发货""已找到 N 个案例"都有过张冠李戴,只是没被用户逮到罢了,想想都后怕。

数据摆一下,200 组带工具调用的真实任务:

方案幻觉率单次额外耗时
裸跑 naive31%0
system 自查37%0
事实核查层2%+0.4s

naive 那 31% 里,"把失败说成成功"占了一大半;加 system 自查不降反升,正好印证前面的判断;上事实核查后掉到 2%,代价是每次多一次 claim 抽取,约 +0.4s,我完全能接受。

当然这层也不是银弹。claim 抽取本身会漏,遇到长推理链尤其明显;而且"工具里找不到"不等于"一定是幻觉"——有些话确实没有工具能覆盖。所以我对无证据 claim 的处理是降级重答,不是一刀切删除,免得把对的也砍了。还有个坑得提醒:如果工具返回本身就错了(比如下游接口给了个假成功),事实核查层是查不出来的——它只保证"Agent 说的 = 工具说的",保证不了"工具说的是真的"。这块得靠工具侧自己的校验,别有奢望一层核查包打天下。我后来给退款、改单这类关键工具又叠了一道结果回查,工具在返回前先自己验一遍,两层夹着才敢放心放出去。

反正我现在默认给每个会对外说话的 Agent 套这层核查。宁可它偶尔多问一句"我不确定,您再确认下",也不让它替我把假话顺溜地发出来。你那边 Agent 出过这种"嘴比工具快"的事故吗?


我是老三,10+ 年软件开发经验,软件设计师、人工智能应用工程师。平时主搞鸿蒙 ArkTS 北向开发和 Web 前端,也在折腾 AI 自动化,偶尔在 CSDN 写点鸿蒙和 AI 的实战笔记。

本文遵循 MIT 协议,转载请注明出处。