作者构建 LLM 虚构官网检测器时,三次修改让结果数量从 1 跳到 134、41,再降至 12。案例说明把模型间分歧当作触发条件会漏报,应直接用品牌自有域名等事实源验证声明。
我上周写了一篇文章,讲的是某个 LLM 捏造了一家公司的官方网站。随后,我构建了一个检测器,想要大规模找出更多类似案例。
现在,我在一天之内把这个检测器重写了三遍。每个版本都修复了上一个版本的 bug,却又引入了一种新的错误标注方式。同一批数据的检测结果从 1 个变成 134 个,又降到 41 个,最终变成 12 个。
真正有意思的并不是最终数字,而是这三个 bug 本质上都是同一个 bug,只是换了不同的外衣。而且,在第二版和第三版之间,我还发表了一篇专门讨论这个 bug 的文章。
检测器的任务是:找出那些被 LLM 称为某个品牌“官方网站”、但实际上并不属于该品牌的域名。
// v1
if (engineA.saysOfficial(domain) && engineB.saysNoSuchSite(domain)) {
flag(domain);
}
它找到了一例真实案例,我也把它发表了出来。一位评论者指出了其中的漏洞:
分歧应该作为确认信号,而不是触发条件。只要某个引擎把一个 URL 称为官方网站,就可以直接拿它与品牌拥有的域名列表核对。只需要一个引擎,无须跨轮次比较。这样还能捕获所有引擎都给出同一个错误域名的情况,而这才是更糟糕的版本。
他说的每一点都对,其中第三点尤其严重。依靠不一致性触发的规则,在结构上无法发现相关性错误——而相关性错误恰恰是最危险的,因为使用高度重叠语料训练的模型往往会一起犯错。此时,一个错误答案会同时抵达所有用户。
我发表的那一个发现之所以能被检测出来,只是因为六个引擎中碰巧有一个提出了不同意见。如果把那个引擎从评测组中移除,v1 将什么也检测不到。
修复方法看起来显而易见。我手里明明有现成却未使用的 ground truth——每个品牌都有一份自己拥有的域名列表。直接核对模型的说法即可:
// v2
for (const url of urlsIn(answer)) {
const h = hostname(url);
if (ownedDomains(brand).some(o => h === o || h.endsWith(`.${o}`))) continue;
if (officialClaimNear(answer, url.index)) flag({ brand, domain: h, engine });
}
被标记的域名从 1 个增加到了 134 个。我开始起草这篇文章,并列出排名前九的域名。
后来,我没有继续盯着输出的开头,而是查看了其余结果。里面出现了 airtable-official.com、asana-vip.net、wrike-sales.net、basecampchina.net 之类的域名。这些名称的模式更像是为了举例而捏造的假域名,而不是真实存在的官方渠道。
事实也确实如此。下面是一段具有代表性的、被标记出来的内容:
检查域名:官方主站是 wrike.com。任何其他域名(如 wrike-official.com、wrike-sales.net)都需要警惕。(检查域名:官方网站是 wrike.com。警惕任何其他域名,例如 wrike-official.com、wrike-sales.net。)
这个引擎正在警告用户小心假网站。我的检测器却看到某个域名出现在“官方网站”附近,于是把它记录成了一项声明。我把模型的正确行为判定成了 hallucination。
对标记结果进行抽样后发现:notion-cn.com——我原本正准备把它作为最令人震惊的新发现发表——有 86% 的时候都出现在警告语境中。
声明、警告和否认是三种状态,而我的检测器只有两种。
于是,我加入了第三种状态,并复用了三天前为品牌提及检测编写的 polarity 逻辑:
if (negationCues.test(tightClause(answer, i))) return "denied";
if (warningCues.test(block(answer, i, 260))) return "warned";
if (assertCues.test(clause(answer, i))) return "asserted";
return "referenced";
结果标记了 41 个。更好了,但仍然不对。而且这种错误我本应该预料到,因为就在那一周,我还写过一篇讨论它的文章。
再看一遍那段关于 Wrike 的文字。短语“官方主站”大约出现在 wrike-sales.net 前面三十个字符的位置。任何基于邻近距离的规则,都会把这个短语归因给最近的域名。但这个提示词实际描述的是 wrike.com——也就是此前出现的域名。这个句子的结构是:[真实域名,声明],然后是[虚假域名,警告]。
邻近不等于归因。这与我几天前发表的那个问题完全相同:当时,一个围绕品牌名称构建的关键词窗口,捕获了原本属于相邻品牌的情感信息。同一个 bug,只是实体类型不同,而我又一次径直踩了进去。
修复方法是:要求 assertion cue 与正在分类的域名之间不能存在其他域名。
const clause = tightClauseAround(a, index);
const m = ASSERT_CUES.exec(clause);
if (!m) return "referenced";
// Attribution, not proximity: if another domain sits between the cue and this one,
// the cue belongs to that one.
const span = clause.slice(
Math.min(m.index, hereIdx),
Math.max(m.index + m[0].length, hereIdx)
);
const domainsBetween = (span.match(DOMAIN_RE) || []).length;
return domainsBetween > 1 ? "referenced" : "asserted";
对同样存储下来的 4,023 个回答进行检测,最终结果如下:
12 domains asserted as official and not owned
173 domains appearing ONLY in warning contexts
第二个数字才真正值得深思。有 173 个案例中,引擎所做的事情完全正确——提醒买家警惕那些模仿官方网站的域名——但我的第二版检测器却把它们全部算成了由模型 hallucinate 出来的官方渠道。
如果我发布了 v2 的结果,我会写出一篇声称 LLM 捏造了 134 个虚假官方网站的文章。真实情况却是:它们只捏造了少数几个网站,同时对更多可疑网站发出了警告。这几乎与原来的结论完全相反。
三个 bug,同一种形状。每个版本失败的原因,都是我把附近的文本当成了“关于这个事物”的文本。品牌名称周围的窗口、URL 周围的子句、域名周围的文本块——同一个错误,发生在三种不同的粒度上。真正的问题是实体级归因,无论怎样调整窗口大小都解决不了它。现在,我会默认自己编写的任何新 extractor 都存在这个 bug,除非我专门检查过这一点。
只阅读输出的开头,不等于阅读了输出。我曾根据前五十行结果起草过这篇文章的一个版本。但到了输出尾部,整个故事就崩塌了。按数量排序并只看排名靠前的结果,不过是在确认你原本就期待看到的东西。
一条检测规则不仅需要定义 true-positive 类别,还需要定义 false-positive 类别。v2 完全没有“与我要寻找的问题模式相似,但实际上是正确行为”这一概念。当我给这个类别命名后,才发现它占到了所有标记结果的 93%。
现在,在信任任何 extractor 给出的统计结果之前,我都会先问三个问题:
它能否区分模型在声明 X、警告 X 和否认 X?这三种情况在文本中看起来几乎完全相同,表达的含义却截然相反。
当它在某个实体附近发现一个提示词时,凭什么保证这个提示词描述的是该实体,而不是旁边的另一个实体?
如果你要检测的东西存在一种合理的相似形态——也就是某种正确行为在模式上恰好与错误相匹配——你的测试用例中是否包含了这种相似形态?
在一天之内,我把这三个问题全都处理错了,而这个代码库存在的全部目的,恰恰就是捕获这类错误。真正发挥作用的只有两件事:一位陌生人问我的规则能否处理一个我从未考虑过的场景,以及我没有继续看摘要,而是去阅读了原始输出。
Harness、scoring module 和经过标注的 validation sets 已依据 CC BY 4.0 公开:github.com/David88666/china-ai-visibility-benchmark
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。