上周我讲述了我的AI如何凭空编造完整JSON结构并将其封装进DTO、测试夹具和验证测试中的故事。90个测试全部通过,却是一场骗局。
那篇文章是诊断报告。而本文是治疗方案。
发现问题后,我的反应和任何自尊心受损的工程师一样:连续数天疯狂研究防止重蹈覆辙的方法。我阅读论文、测试工具、分析API真实数据,最终为应用构建了一套防御系统。
调查结果让我惊讶:在五类应对措施中,只有三种真正奏效。剩下两种充其量只是"善意表演"。
思维模型:你与AI的对决
在介绍具体措施前,需要先建立认知框架。最好的类比来自深度学习领域:
在**生成对抗网络(GAN)**中存在两个互相竞争的神经网络:
- 生成器负责产出内容(图像/文本等)
- 判别器负责鉴别真伪
系统通过两者的对抗持续进化。生成器变得更擅长欺骗,判别器变得更擅长识别。
当使用LLM编程时,你正身处一个非自愿的GAN环境:
- LLM是生成器。它产出代码、DTO、测试和夹具。
- 你是判别器。必须辨别真实与虚构的内容。
但存在一个残酷的不对称性:生成器永不疲倦,而你会。LLM可以不费吹灰之力生成50个文件。你检查到第10个就开始疲劳,第11个文件就可能被漏检。
这就是我在1Password每天47次TouchID验证中提到的"授权疲劳"。依赖人类时刻保持警觉的安全措施不过是纸糊的墙。
重点监控边界
你不需要(也不应该)逐行检查。需要重点盯防的是边界——代码与外部世界交互的环节:
| 边界 | 核心问题 |
|---|---|
| 外部API | DTO字段是否真实存在于API? |
| 依赖包 | 该依赖是否存在且名称正确? |
| 数据库表 | 表结构是否包含这些字段? |
| URL/端点 | 端点是否存在且响应合规? |
铁律:LLM对外部世界的任何声明都需验证后再采信。 它说话时的信心程度不能作为判断依据。Anthropic在官方文档中也承认:
“Claude有时会生成包含虚构信息的回答…这些回答往往以自信、权威的语气呈现。”
LLM说"我确定"和说"我觉得"时,错误的概率其实完全相同。
自动化判别流程
终极目标是摆脱对人为主观能动性的依赖,将验证自动化:
改进前:
LLM生成 → 人工抽查 → 合并
改进后:
LLM生成 → CI用真实数据验证 → 人工检查差异 → 合并
下文将介绍的五种措施,都是实现这种自动化判别角色的方法。其中有些有效,有些则不尽如人意。
数据实证(致怀疑者)
如果你觉得"这事不会发生在我身上",请看实际研究数据:
- LLM推荐的21.7%开源包是虚构的。商业模型降至5.2%(仍意味着每20个就有1个虚构包)
- GPT-4o针对低频API的有效调用率仅38.58%。比抛硬币的胜率还低
- 当前最佳代码幻觉检测方法的准确率仅22-33%。换句话说:每四个虚构字段只能抓到一个
- 研究人员上传了LLM常虚构的空包名。3个月收获3万次下载。业界称其为"垃圾注册"(slopsquatting)
AAAI 2025发表的研究CodeHalu将代码幻觉分为四类:
| 类型 | 特征 | 实例 |
|---|---|---|
| 字段映射 | 字段映射关系错误 | 混淆user_id和account_id |
| 命名虚构 | 字段名不存在 | 使用response.quota.percentage而非真实字段response.utilization |
| 资源虚构 | 虚构API资源 | 虚构的active_flags字段 |
| 逻辑虚构 | 看似合理实则错误的业务逻辑 | 基于虚构字段的判断逻辑isPaid = !activeFlags.isEmpty |
我的遭遇正是资源虚构导致逻辑虚构。虚构字段不存在,而依赖它的业务逻辑却天衣无缝。堪称教科书级的"自洽虚构"。
措施一:基于真实API的契约测试
核心思路
定义API响应契约并自动验证代码兼容性。若DTO包含契约未定义的字段:立即告警。
实施原理
假设有如下DTO:
struct OrganizationInfo: Decodable {
let uuid: String
let name: String
let activeFlags: [String] // ← 该字段真实存在吗?
}
契约测试会获取API真实响应,提取JSON键名与DTO的CodingKeys进行比对。若DTO存在API未返回的字段,则标记为PHANTOM(幽灵字段)。
API真实键名: {uuid, name, capabilities, billing_type}
DTO键名: {uuid, name, activeFlags}
PHANTOM: activeFlags ← DTO中存在但API未返回,疑似虚构
未使用字段: capabilities, billing_type ← API返回但DTO未使用
优势
- 确定性。不依赖其他LLM或人为判断。只要API未返回该字段,必定告警
- 从根源消灭幽灵字段。虚构字段绝无可能蒙混过关
- 可集成CI。每次推送代码时自动执行
局限
- 需API规范文档。若API没有OpenAPI规范(如Claude API),需手动捕获响应样本
- 不检测错误命名。若字段存在但命名有差异(
active_flagsvscapabilities),无法自动识别 - 需有效凭证。捕获真实响应需登录态
技术栈工具
| 技术栈 | 工具 | 核心机制 |
|---|---|---|
| Python | Pydantic extra='forbid' | 拒绝JSON中的未声明字段 |
| TypeScript | Zod .strict() | 同上 |
| Swift | 自定义Decoder或手动键名比对 | 原生Codable默认忽略未定义键 |
| Dart | json_serializable + disallowUnrecognizedKeys | 拒绝未声明字段 |
| 通用 | oasdiff, Specmatic | 对比OpenAPI规范差异 |
我的实施
由于Claude API没有OpenAPI规范,我在Swift/SPM项目中手动实现了双向验证:
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: 发现幽灵字段'totalSpeculationTimeSaved'——真实数据中不存在
刻意不使用的字段需加入白名单并注明原因。当API新增字段时,测试会因UNCONSUMED而失败,确保我能及时知晓变更。
结论:最重要的防御措施。如果只能实现一项,就是它了。
措施二:真实测试夹具验证
核心思路
测试夹具必须来自真实数据捕获,而非LLM人工编写。若LLM同时生成夹具,就形成了"用虚构验证虚构"的死循环。
解决的问题
George Tsiokos在2025年2月的文章中一针见血:
“测试无法验证软件是否满足业务需求——它们只能证明代码严格按照编写要求执行,包括其中的错误。”
当LLM同时生成代码、测试和夹具时:
LLM虚构字段 → LLM生成含该字段的夹具 → LLM编写测试
→ 测试通过 ✅ → 但无人验证真实性 ❌
解决方案:请求-响应录制
录制框架会捕获真实HTTP响应并在测试中回放。由于夹具来自API而非模型,彻底杜绝虚构可能。
| 技术栈 | 工具 |
|---|---|
| Python | VCR.py, pytest-recording |
| TypeScript | Polly.js, MSW |
| Swift | Replay |
| 通用 | Hoverfly |
优势
- 彻底杜绝虚构。夹具来自网络请求,非人工编写
- 保留元数据。包含URL、时间戳、状态码等完整上下文
- 可代码审查。代码库中的夹具可供其他开发者查验
局限
- 夹具会过期。API变更后旧夹具不再有效
- CI需凭证。录制新夹具需要有效API访问权限
- 难覆盖所有变体。单次录制只能捕获一种响应形态
我的实施
采用双层级夹具方案:
Tests/Fixtures/ ← 静态夹具(LLM生成)
用于编解码单元测试
允许存在错误(这是预期行为)
Tests/Fixtures/real/ ← 通过make capture捕获
附带.meta文件(捕获时间戳)
作为模式验证的黄金标准
静态夹具仍适用于测试边界情况(截断JSON、空字段、非常规格式)。但字段存在性验证必须依赖真实夹具。
每个真实夹具都有对应的.meta文件记录捕获时间。超过30天的夹具会提醒我更新。
结论:作为契约测试的必要补充。单独使用不够可靠,但没有真实夹具,契约测试将失去参照物。
措施三:真实数据冒烟测试(make doctor)
核心思路
在代码合并前,用真实API响应验证DTO能否正确解析,避免静默错误。
工作原理
$ make doctor
捕获/api/organizations... OK (2条记录)
捕获/api/organizations/{id}/usage... OK (9个时间窗)
捕获~/.claude/stats-cache.json... OK (115条会话)
捕获session JSONL... OK (847条条目)
验证数据结构...
✅ OrganizationInfo: 通过
✅ UsageResponse: 通过
✅ StatsCache: 通过
✅ SessionEntry: 通过
0个幽灵字段,0个新增未使用字段
该命令合并了make capture和make test,用最新生产环境数据验证DTO有效性。
优势
- 结果可信。直接使用实时数据,结论确凿无疑
- 快速反馈。本地执行仅需30秒
- 感知变更。API字段增删立即可见
局限
- 需有效会话。必须保持登录状态才能捕获
- 难集成CI。Claude API没有服务账号,仅支持cookie鉴权
- 手动触发。依赖开发者主动执行
我的实施
将make doctor设为项目最重要命令,在以下场景执行:
- 每次DTO变更后
- 每周例行检查
- 当应用出现"异常征兆"时
对于CI无法调用的API,解决方法是保存make doctor结果作为真实夹具提交到代码库。虽非实时数据,但远胜于无。
此外系统在运行时也会发出早期预警:当SessionFileReader读取到没有usage字段的assistant类型行时,会记录.notice日志;当SessionTokenService读取文件但发现0条新记录时同理。核心思想是让应用在格式变更时发出信号(即便不崩溃),因为优雅降级可能掩盖问题。
结论:最具实用价值。低成本,高回报。30秒就能获得真实性验证。
措施四:解析过程异常检测(长期为null的字段)
核心思路
在运行时监控模型字段的实际填充情况。连续50次解析都为null的字段很可能是虚构字段。
类比方案
GraphQL已内置该能力。Apollo GraphOS等工具会报告字段使用率:请求次数、数据返回率、首次/末次使用时间。使用率为0%的字段会被标记移除。
REST领域缺乏同类方案,需自行构建。
优势
- 生产环境检测。无需手动捕获,真实使用数据自然生成
- 补充其他措施。即使字段通过契约测试(API确有此字段),若实际总是null仍需警惕
局限
- 需足够样本量。5次调用无法得出结论,需数百次
- 误报风险。某些字段本就长期为null(如我的API中
seven_day_opus: null在未使用Opus时完全正常) - 实现成本高。没有现成工具,需开发监控组件
- 客户端应用无APM。后端可用Datadog/Sentry发指标,但macOS菜单栏应用只能自力更生
我的实施
部分实现。虽无正式的字段null值监控,但有日志预警:
// SessionTokenService.swift
if totalFilesRead > 0 && totalNewEntries == 0 {
logger.notice("读取\(totalFilesRead)个文件但0条新记录——可能格式已变更")
}
这是异常检测的"低配版"。不按字段统计,但能发现明显异常:“读取了数据却得不到有效信息”。
结论:适合作为辅助预警,非主要防线。如同矿坑中的金丝雀,不能替代安全墙。
措施五:生成后语义差异检查(LLM交叉验证)
核心思路
使用第二个LLM(或相同LLM不同prompt)审查生成代码,检查是否存在文档无法佐证的字段或结构。
行业方案
已有专业工具探索该方向:
| 工具 | 功能特点 |
|---|---|
| VERDICT | 模块化流程:验证+辩论+聚合 |
| DeepEval | 类似pytest的框架,含HallucinationMetric |
| Patronus Lynx | 当前最强的开源幻觉检测模型 |
| Vectara HHEM | 企业级方案,幻觉率降至~0.9% |
还有自制方案:让GPT-4o针对相同API生成DTO但不透露你的代码,然后比对差异:
Claude生成: activeFlags: [String]
GPT-4o生成: capabilities: [String]
→ 出现分歧:至少一方存在幻觉,需对照真实API验证
优势
- 自动扩展。集成CI后无需人工介入
- 发现深层模式。第二个模型可能发现开发者忽略的细节
局限
这也是最致命的缺陷。
- 判官也会幻觉。若验证LLM不熟悉目标API,可能"确认"虚构字段
- 系统性幻觉。当多个模型训练数据相似时,可能共享相同虚构。SelfCheckGPT证明:多样本一致性无法检测系统性幻觉
- 准确率堪忧。Collu-Bench:最佳方案仅22-33%的代码幻觉定位准确率。相当于四分之三的漏检率
- 成本激增。每增加一个验证层都意味着更多LLM调用开销
- 位置偏见。验证LLM倾向偏爱较长答案和先出现的结果。本质是美学偏好而非真实验证
Evidently AI提出振聋发聩的质疑:
“如何用一个偶尔会幻觉的系统,去监控另一个偶尔会幻觉的系统?”
我的实施
零实现。
这是经过深思熟虑的决定。前三种确定性措施(契约测试、真实夹具、冒烟测试)提供了无假阳性、无需持续付费的可靠验证。用LLM监督LLM,就像让实习生互相监督。不如直接装监控摄像头。
结论:研究价值大于实用价值。当准确率从33%提升到90%时再考虑商用。现阶段只是研发预算支撑的行为艺术。
最终评分
| 措施 | 可靠性 | 成本 | 是否采用 | 原因 |
|---|---|---|---|---|
| 1.契约测试 | 高 | 中 | 是 | 机械式识别幽灵字段 |
| 2.夹具验证 | 高 | 低 | 是 | 杜绝虚构验证虚构 |
| 3.冒烟测试 | 高 | 低 | 是 | 30秒获得最高价值反馈 |
| 4.异常检测 | 中 | 低 | 部分 | 日志预警,未完整实现 |
| 5.LLM互审 | 低 | 高 | 否 | 33%准确率堪比抽奖 |
措施1、2、3构成稳固的三脚架,各司其职:
- 契约测试回答:“这些字段存在吗?”
- 夹具验证回答:“这些数据真实吗?”
- 冒烟测试回答:“当前能正常工作吗?”
三重过滤使得虚构字段难以全身而退。虽非绝对安全,但远比用虚构夹具通过单元测试困难得多。
黄金法则
最后分享我从这次教训中总结的核心原则:
验证系统必须独立于生成系统。
允许LLM生成:
- 业务代码 → 这是它的本职工作
- 逻辑测试 → 验证行为正确性 但禁止LLM生成:
- 测试夹具 → 必须来自真实数据
- 接口规范 → 必须来自API文档
- 验证逻辑 → 必须由确定性系统执行
这是软件开发中的"三权分立"。立法者不能同时担任法官。生成代码的不应同时担任验证者。
你可以拥有200个通过的测试,却活在代码版的《黑客帝国》里。也可以花30秒运行make doctor,看清自己身处真实还是虚幻。
我选择红色药丸。
本系列: 本文是"AI生产事故"系列的第四篇。首篇讲述AI编造44封邮件(越权操作)。第二篇分析MEMORY.md(记忆缺失)。第三篇揭露静默失败(虚构测试)。到本篇的防御体系。每起事故形态不同,但共同指明方向:我们需要机械可靠的系统,而非行为良好的承诺。
本文原文为西班牙语,借助AI翻译。