上周我讲述了我的AI如何凭空编造完整JSON结构并将其封装进DTO、测试夹具和验证测试中的故事。90个测试全部通过,却是一场骗局。

那篇文章是诊断报告。而本文是治疗方案。

发现问题后,我的反应和任何自尊心受损的工程师一样:连续数天疯狂研究防止重蹈覆辙的方法。我阅读论文、测试工具、分析API真实数据,最终为应用构建了一套防御系统。

调查结果让我惊讶:在五类应对措施中,只有三种真正奏效。剩下两种充其量只是"善意表演"。

思维模型:你与AI的对决

在介绍具体措施前,需要先建立认知框架。最好的类比来自深度学习领域:

在**生成对抗网络(GAN)**中存在两个互相竞争的神经网络:

  • 生成器负责产出内容(图像/文本等)
  • 判别器负责鉴别真伪

系统通过两者的对抗持续进化。生成器变得更擅长欺骗,判别器变得更擅长识别。

当使用LLM编程时,你正身处一个非自愿的GAN环境:

  • LLM是生成器。它产出代码、DTO、测试和夹具。
  • 你是判别器。必须辨别真实与虚构的内容。

但存在一个残酷的不对称性:生成器永不疲倦,而你会。LLM可以不费吹灰之力生成50个文件。你检查到第10个就开始疲劳,第11个文件就可能被漏检。

这就是我在1Password每天47次TouchID验证中提到的"授权疲劳"。依赖人类时刻保持警觉的安全措施不过是纸糊的墙。

重点监控边界

你不需要(也不应该)逐行检查。需要重点盯防的是边界——代码与外部世界交互的环节:

边界核心问题
外部APIDTO字段是否真实存在于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_flags vs capabilities),无法自动识别
  • 需有效凭证。捕获真实响应需登录态

技术栈工具

技术栈工具核心机制
PythonPydantic extra='forbid'拒绝JSON中的未声明字段
TypeScriptZod .strict()同上
Swift自定义Decoder或手动键名比对原生Codable默认忽略未定义键
Dartjson_serializable + disallowUnrecognizedKeys拒绝未声明字段
通用oasdiff, Specmatic对比OpenAPI规范差异

我的实施

由于Claude API没有OpenAPI规范,我在Swift/SPM项目中手动实现了双向验证:

  1. make capture命令下载所有API真实响应,保存至Fixtures/real/
  2. SchemaValidationTests测试用例将每个DTO的CodingKeys.allCases与真实数据键名比对
  3. 发现差异时标记为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而非模型,彻底杜绝虚构可能。

技术栈工具
PythonVCR.py, pytest-recording
TypeScriptPolly.js, MSW
SwiftReplay
通用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翻译。