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