原文:https://dev.to/dannwaneri/i-almost-shipped-a-rag-assistant-that-lied-about-apis-that-dont-exist-3426(作者 @dannwaneri)
几周前我在 X 上写过:
我刚刚得到一个非常糟糕的提醒:这些 LLM 本质上是统计鹦鹉。我让它写了一些我平时不会信任它写的代码(基础设施代码,大量独特行为),然后,天哪。
写那段话的时候,我说的并不是自己的项目。后来 StacksNG 在我试图赢下的一场黑客松里,用自己的语料证明了我的观点。
让我的 RAG 助手去验证 Interswitch 的 webhook 签名,它没有说"不在我的知识库中"。它写出了一整套认证流程——看起来真实的端点、看起来真实的请求头——并且引用了一个来源 URL。这个 URL 不在我的语料库里。它哪儿都不在。模型凭空编造了一个引用,内容也是编造的,而且没有任何保留。
我正在为 Africa Deep Tech Challenge 2026 构建 StacksNG——一个限定在非洲金融科技栈的离线编码助手:Paystack、Flutterwave、Monnify、Termii。提交之前,我用自己的流水线跑了一批 20 条对抗性提示。A 类(语料内基线)和 D 类(措辞脆弱性)都干净通过。B 类——五条提示询问我故意没有抓进语料的支付服务商:Kuda、PalmPay、Interswitch、Paga、OPay——没有通过。
五条里有三条无视了系统提示中已经用直白语言写明的"如果上下文没有足够信息,请说明"。
这正是那种在准确率占总分 50% 的黑客松里能让一半分数归零的失败模式。
我的第一个理论是错的,而且我能证明
我的直觉是:这是检索置信度的问题。设定一个相似度阈值,低于阈值就拒绝回答,完事。
在写那个修复之前,我核对了实际数字。
| | Top-1 相似度 | 发生了什么 |
|---|---|---|
| 语料内正确答案 | 0.718 | 正确 |
| 最严重的编造(Interswitch) | 0.712 | 完全虚构,假引用 |
| 正确拒绝(域外话题) | 0.691 | "不在我的知识库中" |
最严重的幻觉,其检索相似度比最干净的正确拒绝还要高。不存在一个阈值既能放行好案例又能拦截坏案例——它们处在彼此相反的一侧。置信度截断会是一个"感觉对但什么用都没有"的修复。
实际发生了什么
我的检索为"Interswitch Quickteller"拉回的文本块是真实的——Monnify 的快速入门、Paystack 的收款指南。确实是相似的话题:认证、结账、webhook。不是域外混淆,而是同域品牌替换。模型对话题并不困惑。它从未检查检索到的文本是否真的提到了我问的那个服务商,而不是另一个服务商在讲类似的东西。
这比"不知道自己在不知道什么"更隐蔽。它是"知道某个相邻的东西,却没注意到相邻"。
修复只有一段话,不是架构变更
我没有重新训练任何东西。我没有碰检索。我在系统提示里加了一条规则:
回答之前,检查问题中提到的具体服务商是否真的出现在上下文片段中。检索基于相似度,有时会因为话题相似而给你另一个服务商的片段——这不等同于所问服务商已被覆盖。如果没有提到,请说明。不要用另一个服务商的说明冒充所问服务商,也不要编造来源 URL。
重新跑了五条失败的提示外加两条对照。Kuda、PalmPay、Interswitch、Paga、OPay:五条全部正确拒绝。一条原本会把自己答进矛盾的跨服务商提示——"你不应该回退到 X",紧接着又完整解释如何回退到 X——现在干净地拒绝了。语料内对照提示不受影响,仍然正确。
一个诚实的回归:我的域外对照提示变得稍微含糊了一点。它以前会直说"不在我的知识库中"。现在它会在到达结论之前,主动画一个类似服务商的类比——仍然没有编造具体内容,只是比需要的更啰嗦。三个编造变成零个,代价是一条提示变得不够干净。我愿意接受这个交换。我把回归写了下来,而不是假装修复是完美的。
不过那只是一次运行。我把它作为结果发布了。
独立检查发现那个修复不是修复,而是抛硬币
我请 Antigravity(无法访问我的语料作者身份、我的提示或这篇文章)冷启动复现提交:全新克隆、下载模型、运行官方分析器、重新测试上面五条对抗性提示。不是一次,而是每条三次,共十五次试验。
十五次里十次干净。不是五分之五,而是三分之二。
而且这不是均匀分布在各个服务商上的随机噪声。它干净地分成两半。Interswitch、Paga、OPay:九次九中,100% 可靠。Kuda 和 PalmPay:三次中一次、三次中零次。PalmPay 每次运行都编造了一个 x-palmpay-signature 请求头和一个完整的 HMAC 处理器。Kuda 在三次中有两次被悄悄路由到 Paystack 的实时收费端点,并编造了一个银行代码当作事实陈述。
有两件事是真实的,而我之前只检查了一件。第一:聊天调用以 temperature=0.2 运行,没有固定种子,所以同样的提示并不总能产生同样的答案。我最初的"五分之五"只是从一个分布中抽取的一次结果,而不是修复的属性。第二:失败并非跨服务商随机。它恰好集中在检索最模糊的地方。Kuda 和 PalmPay 的 webhook 验证内容在话题上与 Paystack 和 Monnify 几乎相同,同样的 HMAC-SHA512 形状,同样的请求头模式。它们被检索到的文本块处在我测量到的最紧密、最易混淆的相似度区间(余弦 0.654–0.676,五个文本块彼此相差在 0.022 以内)。我写的指令要求模型注意检索到的文本块是否真的提到了所问的服务商。而恰恰当检索到的文本块足够接近、看起来合理时,它最没有能力注意到这一点。
一条软性指令永远无法可靠地弥合这个差距,因为它对抗的东西——近乎重复话题之间的检索相似度——不会因为我好好说话就消失。
真正的修复:别问了,开始检查
语料只覆盖四个服务商。这是一个很小的、可枚举的集合。这意味着"这个服务商是否真的在范围内"这个问题根本不需要 LLM 的判断。我在检索之前加了一个确定性闸门:一个约 25 个不在语料中的已知非洲金融科技和银行品牌名称列表,按词边界与传入问题匹配。提到其中一个而没有同时提到语料内的服务商,问题就会在检索或生成运行之前被拒绝。没有温度、没有种子、没有编造的机会——只是一个字符串匹配。
再次让 Antigravity 跑同样的十五次试验、同样的语料、重新克隆:十五次十五中,每个响应几乎瞬间返回,完全没有调用 LLM。回归检查也干净。语料内的问题仍然完整运行检索加生成流水线,不受影响;一个真正的比较问题("Kuda 与 Paystack 在 webhook 处理上相比如何?")会正确落到较软的指令上,而不是被一刀切拒绝,因为那是一个闸门本就不打算回答的合法问题。
它不能泛化。一个我没有想到要枚举的服务商,仍然依赖那条对三个服务商测得 100%、对两个服务商测得 0-33% 的软指令。这是一个真实的局限,不是已解决的问题,而且在仓库里被如实写下来,而不是含糊带过。
为什么这是支持 RAG 而非微调的理由,而不只是推理
Paystack 的文档是公开的。Flutterwave 的是公开的。Termii 的是公开的。一个前沿实验室可以访问所有这些,就像它可以访问散布在公共网络上的伊博语和约鲁巴语文本一样。可访问不等于行为。我在 X 上就语言问题表达过同样的观点,然后才在支付 API 上表达:
前沿实验室能访问一个数据集,和前沿实验室真的去训练它,是两回事。公开存在的伊博语或约鲁巴语文本,仍然会被预训练中英语的绝对权重淹没。模型并不是不懂你的语言,它只是统计上对它漠不关心。在数据堆中的代表性,不等于在模型行为中的代表性。
Paystack 的文档在预训练语料中,相对于 Stripe 的文档只是一个舍入误差。这不是我能通过更礼貌地提问来修复的知识缺口。这正是语料存在的原因——RAG 在查询时重新注入被淹没的内容,而不是寄希望于它能在预训练中幸存。
我早就决定坚持只用 RAG,而不是把 GPU 额度花在微调上——语料是文档,不是指令对,而且引用比它们被固化进权重里更能存活。幻觉 bug 是证据,而不只是推理。我在一个下午里发现了一个真实正确性 bug,而且即使那个后来被证明不完整的修复,也只是一段英文,以及后来的一串字符串——而不是一次训练运行。如果这发生在权重里,发现我的第一次尝试只有三分之二成功率,将意味着重新训练,而不是重读一个 diff。
真正重要的数字
在这之前,我有一个提交:对训练数据之外五个服务商中的三个,编造看起来能用的代码和假引用——在一个支付助手里,"看起来对但实际不对"比"不知道"更糟。
我差点在第一次修复后没有重新运行我的对照提示。那样我就会把编造数量当作零发布,并错过那个唯一变得更糟而不是更好的地方。而且我差点就停在那里:一次干净运行,五分之五,勾选完成。
不过那二十条原始提示都是我写的,由写修复的同一个我写的,而这正是让 bug 藏起来的设置。所以我两次去找一个不是我写的测试。第一次,是一个我无法写出的测试:X 上的一个开发者和 Reddit 上的一个陌生人,互不相识,都卡在同一件真实的事情上:让 Paystack 的 webhook 处理器幂等。两者都没有想到我的语料。两者都没有我的提示。我还是跑了。Top 检索文本块是 Monnify,和导致原始 bug 的错配形状相同。它停留在 Paystack 上,给出了真正的修复,并且只引用了它实际检索到的内容。
第二次,Antigravity 重新运行了我自己的对抗性集合(我已经测过的那组),不是一次,而是十五次。正是那次运行发现修复只有三分之二可靠,而不是完全可靠。十五次试验的数字不如我差点发布的五分之五好看,但它是真正的事实。取代软指令的确定性闸门也以同样的方式被检查:不是"看起来对吗",而是"当一个与答案没有利害关系的人运行足够多次、足以抓住那次倒霉的抽样时,它是否站得住"。
这个 bug 并不无聊。我对它的第一次修复并没有完成。
StacksNG 是 Africa Deep Tech Challenge 2026 的参赛项目。代码、语料抓取器和完整的压力测试记录都在[仓库](https://github.com/dannwaneri/stacksng)里。
原文:https://dev.to/dannwaneri/i-almost-shipped-a-rag-assistant-that-lied-about-apis-that-dont-exist-3426(作者 @dannwaneri)



