上周我分享了AI如何编造了一个完整的JSON结构,并用DTO、固件和测试对其进行包装。90个测试全部通过。全是假的。
那篇文章是诊断。这篇是治疗。
发现这个灾难后,我做了任何自尊心受挫的工程师都会做的事:连续数天疯狂研究,确保不再发生。我阅读论文、试用工具、分析应用API的真实数据,并为我的应用构建了一套防御系统。
我的发现令人惊讶。在我识别的5种应对措施中,只有3种真正有效。其他两种充其量是善意的表演。
思维模型:你与AI的对抗(字面意思)
在深入这些措施之前,你需要理解框架。我找到的最佳类比来自深度学习。
在GAN(生成对抗网络)中,有两个神经网络在竞争:
- 生成器产生内容(图像、文本等)
- 判别器试图检测内容是真实的还是虚假的
系统之所以改进,是因为两者相互推动。生成器学会更好地欺骗。判别器学会更好地检测。
当你与大语言模型编程时,你就处在一个无意的GAN中:
- **大语言模型是生成器。**它产生代码、DTO、测试、固件。
- **你是判别器。**你必须检测什么是真实的,什么是编造的。
但存在一个残酷的不对称:生成器不知疲倦,而你会疲劳。大语言模型可以不费力地生成50个文件。你检查10个就累了,第11个文件就不看了。
这就像我在1Password一天要求Touch ID 47次中提到的授权疲劳。依赖人类永远保持警觉的安全就是纸板安全。
判别器应该监控什么
你不能(也不应该)检查每一行。你需要监控的是边界——你的代码接触外部世界的地方:
| 边界 | 关键问题 |
|---|---|
| 外部API | DTO中的字段在真实API中存在吗? |
| 包 | 依赖项存在并且名称正确吗? |
| 数据库模式 | 表真的有这些列吗? |
| URL/端点 | 端点存在并返回我们期望的内容吗? |
**原则:大语言模型对外部世界的所有声明在验证前都是可疑的。**它说得很自信并不是证据。Anthropic在自己的文档中承认了这一点:
“Claude有时会生成包含虚假信息的响应…以自信、权威的方式呈现。”
一个说"我确定"的大语言模型和一个说"我认为"的大语言模型犯错的概率完全相同。
自动化判别器
最终目标是不再依赖你的纪律性,而是自动化验证:
之前:
LLM生成 → 你审查(有时) → 合并
之后:
LLM生成 → CI对真实数据验证 → 你审查差异 → 合并
接下来的5种措施是自动化判别器角色的方法。有些有效。有些不太行。
硬数据(给怀疑者)
在你认为"我不会遇到这种情况"之前,这里是真实研究的数字:
- **21.7%**的开源大语言模型推荐的包是编造的。在商业模型中下降到5.2%,仍然是每20个包中有一个。
- GPT-4o对不常见API只能达到**38.58%**的有效调用率。不到40%。还不如抛硬币。
- 目前定位代码幻觉的最佳方法只能达到22-33%的精度。换句话说:我们能检测出四分之一。
- 一位研究人员上传了一个空包,包名是大语言模型经常幻觉的。3个月内3万次下载。他们称之为slopsquatting。
而且有正式的分类法。CodeHalu论文(AAAI 2025)定义了4类代码幻觉:
| 类别 | 定义 | 真实例子 |
|---|---|---|
| 映射 | 字段映射错误 | 混淆user_id和account_id |
| 命名 | 编造的名称 | response.quota.percentage实际是response.utilization |
| 资源 | 不存在的资源 | API中没有的active_flags字段 |
| 逻辑 | 看似合理但错误的逻辑 | isPaid = !activeFlags.isEmpty但字段总是空的 |
我的案例是资源类型转化为逻辑类型。字段不存在,但依赖它的逻辑看起来完美。连贯的虚构小说。
措施1:对真实API的契约测试
想法
定义API返回内容的"契约",并自动验证你的代码是否兼容。如果你的DTO有契约中未定义的字段:警报。
工作原理
想象你有这样一个DTO:
| |
契约测试获取API的真实响应,提取JSON的键,并与你的DTO的CodingKeys进行比较。如果你的DTO有一个API不返回的字段,那就是PHANTOM——一个幻影字段,可能是编造的。
API真实键: {uuid, name, capabilities, billing_type}
DTO中的键: {uuid, name, activeFlags}
PHANTOM: activeFlags ← 在DTO中但不在API中。幻觉?
UNCONSUMED: capabilities, billing_type ← 在API中但不在DTO中。
优点
- **确定性。**不依赖另一个大语言模型或你的直觉。如果字段不在API中,就会标记。
- **从设计上消除幻影字段。**编造的字段不可能通过。
- **可在CI中自动化。**每次推送时执行。
缺点
- **需要API规范。**如果API没有OpenAPI规范(如Claude的API),你必须手动捕获响应。
- **不检测错误命名。**如果字段存在但名称不同(
active_flagsvscapabilities),无法自动发现。 - **需要凭据。**要捕获真实响应需要有效会话。
各技术栈的工具
| 技术栈 | 工具 | 方法 |
|---|---|---|
| Python | Pydantic extra='forbid' | 拒绝模型中未声明的JSON字段 |
| TypeScript | Zod .strict() | 相同概念,拒绝额外字段 |
| Swift | 自定义解码器或手动键比较 | Codable默认忽略未知键 |
| Dart | json_serializable + disallowUnrecognizedKeys | 拒绝未声明字段 |
| 通用 | oasdiff, Specmatic | 比较OpenAPI规范 |
我的实现
在我的应用(Swift/SPM)中,Claude API没有OpenAPI规范。所以我手动构建了双向验证:
make capture下载所有API的真实响应并保存为Fixtures/real/中的固件SchemaValidationTests将每个DTO的CodingKeys.allCases与真实固件的键进行比较- 如果有差异 → PHANTOM(DTO中有但API中没有的字段)或UNCONSUMED(API中有但我们不消费的字段)
$ make doctor
✅ OrganizationInfo: 4个共同字段,0个幻影,8个未消费
✅ UsageResponse: 9个共同字段,0个幻影,1个未消费
⚠️ StatsCache: PHANTOM字段'totalSpeculationTimeSaved' — 不在真实数据中
有意不消费的字段放在有文档说明原因的允许列表中。如果明天API中出现新字段,测试会失败并标记为UNCONSUMED,我就知道了。
**结论:最重要的措施。**如果只实现一个,就选这个。
措施2:固件验证(真实固件,非编造)
想法
测试固件应该来自捕获的真实数据,而不是大语言模型手写的。如果大语言模型生成固件,你就是在用虚构验证虚构。
解决的问题
George Tsiokos在2025年2月的一篇文章中说得很准:
“测试不验证软件是否满足业务需求——它们只是确认代码完全按照编写的那样工作,包括错误。”
当大语言模型生成代码、测试和固件时:
LLM编造字段 → LLM编写包含该字段的固件 → LLM编写测试
→ 测试通过 ✅ → 没有人验证现实 ❌
解决方案:记录重放
记录重放框架记录真实的HTTP响应并在测试中重放它们。没有编造的可能性,因为固件来自API,而不是模型。
| 技术栈 | 工具 |
|---|---|
| Python | VCR.py, pytest-recording |
| TypeScript | Polly.js (Netflix), MSW |
| Swift | Replay (mattt) |
| 通用 | Hoverfly |
优点
- **不可能编造。**固件来自网络,不是模型。
- **包含元数据。**URL、时间戳、状态码。你可以追踪来源。
- **提交到仓库。**审查者能准确看到API返回的内容。
缺点
- **固件会老化。**如果API改变,捕获的固件不再具有代表性。
- **CI中的凭据。**需要能够调用API来记录。
- **不能扩展到所有变化。**捕获一个响应,但API可能返回许多不同形式。
我的实现
两层固件:
Tests/Fixtures/ ← 静态的,由大语言模型编写
用于解码的单元测试
可能包含错误(这是可以接受的)
Tests/Fixtures/real/ ← 由make capture捕获
带有.meta文件(捕获时间戳)
模式验证的真理来源
静态固件对测试边缘情况(截断的JSON、空字段、奇怪格式)很有用。但"这些字段真的存在吗?“的验证总是使用真实固件。
每个真实固件都有带捕获时间戳的.meta文件。如果固件超过30天,你就知道该更新了。
**结论:作为契约测试的补充必不可少。**单独不够(需要措施1的比较),但没有真实固件,措施1就没有比较对象。
措施3:使用真实数据的冒烟测试(make doctor)
想法
在认可更改之前,对API进行真实调用并验证你的DTO能够解析响应而不会静默丢失。
工作原理
| |
这是一个步骤中的make capture + make test。捕获生产环境的新鲜数据并与DTO交叉检查。
优点
- **最诚实的防御。**真实数据,直接比较,明确结果。
- **快速。**本地30秒。
- **检测漂移。**如果API添加或删除字段,你会立即知道。
缺点
- **需要活动会话。**需要登录才能捕获。
- 不能在CI中运行(在我的情况下)。Claude API没有服务凭据,只有会话cookie。
- **是手动的。**依赖你记住执行。
我的实现
make doctor是我项目中最重要的命令。我在以下情况执行:
- DTO更改后
- 每周一次作为例行程序
- 当应用中有东西"闻起来不对"时
对于不能在CI中调用的API,技巧是将doctor的结果保存为进入仓库的真实固件。CI对该固件进行验证。不是实时的,但比什么都没有好。
此外,系统在运行时发出早期信号:如果SessionFileReader读取没有usage字段的assistant类型行,会记录.notice。如果SessionTokenService读取文件但找到0个新条目,也会记录。想法是让应用在格式改变时发出警告,即使不崩溃(因为优雅降级可能隐藏问题)。
**结论:最实用的措施。**低成本,高价值。如果你有30秒,就有make doctor。
措施4:解析中的异常检测(字段总是null)
想法
在运行时监控模型的哪些字段用真实数据填充,哪些总是nil。一个连续50次解析都是nil的字段怀疑是编造的。
思维模型
GraphQL已经解决了这个问题。像Apollo GraphOS这样的工具报告按字段使用情况:请求多少次,返回数据多少次,第一次和最后一次使用。0%使用的字段标记为删除。
对于REST,没有等价物。你必须自己构建。
优点
- **在生产中检测。**不需要手动捕获;应用的使用本身就产生数据。
- **补充其他措施。**通过契约测试(在API中存在)但在实践中总是null的字段仍然可疑。
缺点
- **需要量。**有5次调用不能得出任何结论。需要数百次。
- **误报。**一个字段95%的时间可能合法地为null(例如,如果你那周不使用Opus,我API中的
seven_day_opus: null是正常的)。 - **手动实现。**没有现成的工具。你必须编写监控器。
- **在客户端应用中,没有APM。**在有Datadog或Sentry的后端中,你发出自定义指标。在macOS菜单栏应用中,你只能靠自己。
我的实现
部分实现。我没有正式的字段总是nil监控器,但在日志中有早期信号:
| |
这是异常检测的低技术版本。不按字段计数,但检测大问题:“我在读取数据但没有得到有用的东西”。
**结论:作为警报信号有用,但不是主要防御。**这是矿井中的金丝雀,不是墙。
措施5:生成后的语义差异(LLM-as-Judge)
想法
使用第二个大语言模型(或相同的模型但不同提示)审核生成的代码,寻找无法对照已知文档验证的字段或结构。
技术现状
有严肃的工具在做这个:
| 工具 | 功能 |
|---|---|
| VERDICT (Haize Labs) | 模块化管道:验证+辩论+聚合 |
| DeepEval | 带HallucinationMetric的类pytest框架 |
| Patronus Lynx | 幻觉检测SOTA模型,开源 |
| Vectara HHEM | 模型+API,在企业中将幻觉降低到~0.9% |
还有自制选项:要求GPT-4o在不看你代码的情况下为同一API生成DTO,然后比较:
Claude说: activeFlags: [String]
GPT-4o说: capabilities: [String]
→ 差异:至少有一个在幻觉。对照真实API验证。
优点
- **无需手工扩展。**放入CI中自动执行。
- **检测微妙模式。**第二个模型可能注意到你没注意到的事情。
缺点
这里事情变糟了。
- **法官也可能幻觉。**如果第二个大语言模型不知道API,可能"确认"编造的字段。
- **系统性幻觉。**如果两个模型都用类似数据训练,可能共享相同的编造。SelfCheckGPT(剑桥,EMNLP 2023)证明了多样本一致性不检测系统性幻觉。
- 精度糟糕。Collu-Bench:最佳方法只能达到22-33%的精度定位代码幻觉。你检测出四分之一。这不是防御,这是抽奖。
- **成本。**每层乘以大语言模型调用。你在为一个三分之一时间正确的检测器付费。
- **位置偏见。**大语言模型法官偏好更长的回答和首先出现的回答。他们不判断;他们有审美偏好。
Evidently AI用一个破坏性问题总结了它:
“你如何用另一个偶尔幻觉的系统监控一个偶尔幻觉的系统?”
我的实现
什么都没有。零。
这是一个有意识的决定。确定性措施(1、2和3)给我可靠、可重复的检测,没有误报或按调用计费。让一个大语言模型监督另一个大语言模型就像让一个实习生监督另一个实习生。不如放个摄像头。
**结论:有趣的研究,生产中尚未成熟。**当精度从33%提高到90%时,我们再谈。今天,这是有研发预算的表演。
最终得分
| 措施 | 可靠性 | 成本 | 是否实现? | 为什么? |
|---|---|---|---|---|
| 1. 契约测试 | 高 | 中 | 是 | 机械地检测幻影字段 |
| 2. 固件验证 | 高 | 低 | 是 | 真实固件消除虚构验证虚构 |
3. 冒烟测试(make doctor) | 高 | 低 | 是 | 30秒,最大价值 |
| 4. 异常检测 | 中 | 低 | 部分 | 日志中的信号,非正式监控 |
| 5. LLM-as-Judge | 低 | 高 | 否 | 22-33%精度=抽奖 |
措施1、2和3形成三脚架。每个覆盖不同角度:
- 契约测试回答:“这些字段存在吗?”
- 固件验证回答:“这些数据是真实的吗?”
- 冒烟测试回答:“这现在有用吗?”
结合起来,它们使编造的字段必须通过三个独立过滤器。不是不可能,但比用编造的固件欺骗单元测试难得多。
黄金法则
我想以从中得出的最重要规则结束:
验证系统必须独立于生成器。
如果大语言模型生成:
- 代码 → OK,这是它的工作
- 逻辑测试 → OK,验证行为
- 固件 → 否,必须来自真实数据
- 模式 → 否,必须来自API规范
- 数据正确性验证 → 否,由确定性系统完成
这是应用于开发的权力分立。制定法律的人不能是审判的人。生成代码的人不能是验证代码正确的人。
你可以有200个绿色测试并生活在黑客帝国中。或者你可以有一个30秒内告诉你数据是真实还是虚构的make doctor。
我更喜欢红色药丸。
**完整系列:**这篇文章是关于生产中AI故障的无意系列的第四章。首先是44封编造的邮件(擅自行动的AI)。然后是MEMORY.md(健忘的AI)。接着是静默失败(编造并通过测试的AI)。现在是防御措施。每次故障不同,一个共同点:我们需要机械系统,不是良好行为的承诺。