那天晚上,我在给自己的项目文章做发布前自测。预检报告弹出一行字:
⚠️ 疑似与《Spring 事务传播机制详解》内容重复,相似度 0.92
我盯着这行字看了几秒。这篇文章是我自己刚写的,一个字都没抄过。
0.92 不是"有点像",是"几乎一样"——我自己写的文章像不像,我还不知道吗。
我的第一反应:查重坏了。
直到我去搜了那个标题。
那篇文章三天前才发布,跟我写的东西没有任何关系。
0.92 这个数,是模型自己填进去的。
它填得很像真的:JSON 格式合法,分数落在合理区间,标题看着也像那么回事。整条链路没有一行代码质疑过它。
修它只用改十几行。但这十几行改的不是 bug,是我原本没想清楚的一件事:这个字段到底该由谁说了算。
一、它是怎么混进来的
先说清楚这份报告是怎么生成的。
发布预检是我给作者做的一个自检工具。文章点发布前跑一遍,把该说的都说了。
有没有违规、质量分多少、推荐什么标签、摘要怎么写,还有一条相似内容预警。
报告背后是一个大模型 Agent:它读一遍文章,吐出一段 JSON,后端解析成 VO 展示给作者。
相似度那三个字段——相似文章 ID、标题、分数——就在这段 JSON 里。也就是说,它们是模型填的。
但同一份报告里还有另一条路。
Agent 里还注册了一个检索工具 search_similar_article,走 pgvector 向量检索。
规则是:排除作者自己,相似度 0.72 以上才预警。
这条路查出来的是真实数据,能定位到具体哪篇文章。
所以这个字段有两个来源:模型填的,和检索查的。
两个来源并存本身不是问题。问题是我从来没定义过:到底哪个说了算。
二、"命中才覆盖"——我以为这样就够了
我最初那段代码是这样的:
<span>// ❌ 我最初的写法</span>
<span>if</span> (best != <span>null</span>) {
vo.setSimilarArticleId(best.id());
vo.setSimilarTitle(best.title());
vo.setSimilarity(best.similarity());
}
<span>// best == null 时什么都不做 —— 我以为这是"没有预警"</span>
这三行你单独看,是没问题的。查到了就覆盖,没查到就不动。
问题出在"不动"这两个字上。
因为 VO 里的初始值不是空的,是模型填的。
我把"检索没命中"理解成了"没有新信息"—— 既然没有新信息,那就不动它。
但"没命中"其实是一个结论:库里没有与它足够相似的文章。
结论是要落到字段上的。
我没落,于是字段里留着的还是模型填的那个 0.92 —— 作者看到的,就是一条查无实据的"疑似重复"。
反过来说:如果这个字段初值是空的,"什么都不做"和"显式清空"没有任何区别,这三行也不会有问题。
它出问题,是因为另一个不可信来源先往这个字段里写过值。
于是链路走成了这样:
sequenceDiagram
autonumber
participant U as 作者
participant B as 后端
participant M as 模型
participant R as 向量检索
B->>M: 请求预检
M-->>B: 输出 JSON(similar_* 由模型自己填)
B->>B: 解析进 VO —— 编造值在这步落地
B->>R: 执行相似度检索
R-->>B: 未命中,best = null
Note over B: 我的代码"命中才覆盖" → 什么都不做
B-->>U: 编造值原样展示
最麻烦的地方是:这个 bug 你很难看见。
模型填的值格式完全合法。ID 是个正常的数字,分数在 0 和 1 之间,标题看起来也像平台上的文章标题。
你不收到异常,没有日志报警,报告看起来一切正常。
只有真的去搜一下那个标题,才会发现它根本不存在。
顺便说一句:你可以现在就翻翻自己的代码——有没有哪个字段,既被大模型的输出写过、又被业务代码写过?有的话,同样的雷就在那里躺着,只是还没炸。
读到这里你可能会问:这算不算 prompt 写得不行?把 prompt 改好不就行了?
最讽刺的是,我的第一版 prompt 里其实写着"禁止编造工具结果"—— 就在同一条规则里。 而同一条规则的另一半说"某专家异常时也要输出完整 FINAL",schema 又规定字段不能缺失。
我自己写的两条规则在打架。 打架的结果不出意外:模型挑了那个能"交差"的解。 概率模型遇到约束冲突,会选让它"完成任务"的那个——它不管哪条是真心话。
那把 prompt 打磨到不打架呢?我也改了。但 prompt 是概率性约束:写得再严格, 保证的也只是"大概率遵守"。这条链路输出的是给作者的"抄袭警告",哪怕 99% 遵守、 1% 编造,落到那 1% 的作者头上就是事故。
所以修复分了两层:prompt 层把边界说清楚,代码层做结构性的兜底 —— 后者是下面要讲的。
三、改法:这个字段只由检索说了算
改法就一句话:不管有没有命中,这三个字段都以检索结果为准。
命中了就写入,没命中或者检索失败,一律清空。
写入收敛成一个方法,放在 SimilaritySearchTool 里(就是执行检索的那个类):
<span>/** 唯一写入出口:best 为 null 即"未命中 / 检索失败 / 未查重" → 显式清空 */</span>
<span>public</span> <span>static</span> <span>void</span> <span>applyToVo</span><span>(AiPrecheckVo vo, SimilarArticle best)</span> {
<span>if</span> (vo == <span>null</span>) {
<span>return</span>;
}
<span>if</span> (best == <span>null</span>) {
vo.setSimilarArticleId(<span>null</span>);
vo.setSimilarTitle(<span>null</span>);
vo.setSimilarity(<span>null</span>);
<span>return</span>;
}
vo.setSimilarArticleId(best.article().getId());
vo.setSimilarTitle(best.article().getTitle());
vo.setSimilarity(Math.round(best.similarity() * <span>10000</span>) / <span>10000.0</span>);
}
两条入口各自只剩一行:
<span>// 主路径:DAG 的 DUPLICATE 阶段已经检索过</span>
SimilaritySearchTool.applyToVo(vo, best);
<span>// 兜底路径:自己检索,再落</span>
SimilaritySearchTool.applyToVo(vo,
SimilaritySearchTool.pickAlert(searchSimilar(content), articleId));
(pickAlert 就是原来那段"排除自身 → 取最相似一篇 → 过阈值"的循环,顺手也收了进来。)
这段代码里只有一个真正反直觉的决定,值得单独讲:
检索失败也清空。
直觉上,"检索失败 → 数据不可信 → 保留原值"听起来更保守。
但原值是谁?是模型编的。保留它,等于把幻觉当兜底。
两边都不可信的时候,我选不显示。
理由是:这条预警是提示性质的。漏报不会造成事故;误报会直接伤害作者对平台的信任——谁都不喜欢被告知"你抄了",还查无实据。
四、往上想一层:哪些字段根本不该让模型碰
修完之后我一直觉得,这个坑值钱的地方不在那三行代码。
清空是止血 —— 让不可信的值不显示出来。但更该问的是:这个字段为什么一开始会由模型填?
顺着往下想,得到的是一个更普遍的问题:模型能输出某个字段,不代表这个字段应该由模型产出。
(后来我把这三个字段从模型输出 schema 里删掉了 —— 既然它不该由模型产出,就不该出现在给模型的问题里。上面那个"未命中就清空"的方法仍然保留:万一将来有人又从别的来源往这几个字段写值,它还是会把它们覆盖掉。)
我把 Agent 输出的字段分成了三类:
| 字段类型 | 例子 | 该由谁产出 | 为什么 |
|---|---|---|---|
| **语言性** | 摘要、标题建议、推荐理由 | 模型 | 它的强项,本来就没有唯一正确答案 |
| **判断性** | 是否违规、质量分、标签 | 模型(可给约束) | 主观评估,模型比一堆 if-else 覆盖更广 |
| **事实性** | 相似文章 ID、相似度数值、引用列表、时间戳 | **系统** | 有唯一正确答案,模型只会"补全得像",不会"算得对" |
第三类是重灾区,也是最容易把你坑进去的一类 —— 因为它特别隐蔽。
模型填的格式完全正确。它会给你一个合法的 ID、一个自洽的分数、一个看起来像真的标题。
**格式正确和事实正确,在模型输出里是完全解耦的两件事。**光看输出,你分辨不出来。
你的 Agent schema 里照样可以留这些字段,但值一律由代码在模型输出之后覆写(或者显式清空)。
这件事必须靠代码保证,不能指望模型自觉。
我现在的习惯是:写任何 Agent 的结构化输出之前,先把这张表过一遍。
一个很实用的判据:
如果这个字段可以用一个函数从数据库算出来,那它就不该让模型填。
模型输出的这类字段,只有"提示系统去查"的价值,没有"作为数据"的价值。
五、这套改动的边界
说清楚两件没做的事,你可以自己判断这个结论的可信度:
一是只有样例级验证。 我拿违规、正常两类样例确认过"不再出现查无实据的预警",但没统计过 1000 篇输入里模型编造字段的概率——这个修复的"治愈率"没有数字。
二是阈值 0.72 和"只取最相似一篇"都是继承来的,没有重新标定。 更合理的做法是先对相似度分布做一次统计,再定阈值——这是下一步。
如果这篇只留三句话:
- "命中才覆盖"是个危险的默认写法。它把"未命中"从一个结论降级成了"不用管",于是另一个不可信来源的值留了下来。有多个写入来源的字段,一定要有唯一出口。
- **AI 幻觉最危险的地方不是它答错,而是它答得像对的。**结构化输出会放大这个问题 —— 格式合规让人放松警惕。
- **判断一个字段该不该交给模型,问自己一句:这题有唯一正确答案吗?**有唯一正确答案,就自己算。
这次修复总共十几行代码。但它改的不是一个 bug,是一句关于"谁说了算"的设计约定。
一次真实幻觉事故的完整复盘:多来源写入、未命中不清空,是极易埋雷的默认写法。适合做AI应用后端与Agent结构化输出的开发者参考。