TL;DR:你的AI可能会凭空臆造出合理但不存在的API字段。解决办法不是祈祷它正确,而是:在编写代码之前下载真实的_schema_,使用API的实际返回值作为_fixtures_,并将_fetch_与数据处理分离,以便无需网络测试代码。这就是对抗式编程:假设你的AI助手会撒谎。
你是否有过这样的经历:写了针对某个API的代码,一切都编译通过,测试也没问题,逻辑看起来合理……但连接真实API时,完全行不通?
如果你独自开发,这往往是因为你读错了文档。而如果你和AI一起编程,会是因为AI编造了那份文档。
不像“幻觉”的幻觉
我当时正在用Rust构建一个命令行工具(CLI),用于与某个GraphQL API交互。我让我的AI助手为实现一个按优先级排序的过滤器编写代码,它返回了这样一段:
query {
issues(orderBy: { priority: ASC }) {
nodes {
id
title
priority
}
}
}
---
整洁、合理、完全符合预期。唯一的问题是:这个API的`orderBy`字段并不接受`priority`作为值。实际的枚举类型叫做`PaginatedOrder`,合法值包括`createdAt`和`updatedAt`,而不是`priority`。
我是如何发现的?当API返回一个毫无意义的400错误时,我花了20分钟才弄明白问题不在我的代码,而是因为我使用了一个**根本不存在的字段**。
## 总是相似的模式
这并不是个例。在几周的开发过程中,AI一再出现类似的“幻觉”:
- **不存在的过滤字段**——比如建议`state.id.or`代替实际的`state.type.in`。听起来合乎逻辑,但API使用了完全不同的模式。
- **凭空捏造的枚举**——提供的值名称看起来合理,但实际API中从未定义过。
- **完全错误的生态系统方法**——在Rust项目中,AI建议使用`fcntl.flock`来处理文件锁定。这是Python的做法,而在Rust中应该用`fs2::FileExt`。
这些错误具有一个共同点——**它们都看上去非常可信**。没有一个错误显而易见。一个新手程序员在粗读文档后可能会犯下完全相同的错误。这正是危险所在:它们不像“幻觉”,更像是对领域知识“一知半解”的代码。
## 为什么LLM会编造API?
简单来说:LLM不知道你的API具体有哪些字段。在训练中它可能见过成千上万的GraphQL APIs,而当你要求它使用其中一个时,它就像一个有着良好直觉但没有文档的开发者:**猜测**。
而且它猜得挺好,足以让你信服。然而偏偏这个“差不多”就足以让你的项目进度完全崩盘。
这就像和一个从不看文档但总是自信满满的聪明同事一起工作。对方会无比肯定地告诉你:“是的,这个端点接受名为`priority`的字段。”你完全信了,结果运行时发现它是编造的。
## 解决方案:对抗式编程
在经历了一周内三次类似的“幻觉”后,我采取了一种不同的策略。从“先信任,后验证”,改为**“先怀疑,先验证”**。我称之为**对抗式编程**:假设你的AI助手总是会编造一些东西。
这不是敌意,而是一种开发安全手段。
### 1. 编码前进行Schema自省
如果你在使用GraphQL API,请在向AI提出任何需求之前,下载真实的Schema:
```bash
# 下载API的完整Schema
curl -s https://api.example.com/graphql \
-H "Authorization: token-here" \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { types { name fields { name type { name kind ofType { name } } } } } }"}' \
> schema.json
现在你有了真实的内容。当AI告诉你“使用orderBy: { priority: ASC }”时,你可以在Schema里查找priority字段并验证。然后将相关部分的Schema片段交给AI,明确告诉它“只使用这些字段”。猜测就此终结。
对于REST APIs,可以下载相应的OpenAPI规范文件。不论具体实现是什么,核心原则是:在开始写代码前,确保手上有真实的参考文档。
2. 使用真实的Fixtures,而非臆造数据
另一个防御措施是捕获API的真实返回值,并将它们保存为测试用的Fixtures:
# 捕获一个真实的API响应
curl -s https://api.example.com/graphql \
-H "Authorization: token-here" \
-d '{"query":"{ items(first: 5) { nodes { id title state { name } } }"}' \
> tests/fixtures/items_real.json
这个JSON是真实的,直接来源于API。它包含实际的字段、类型和值。当你编写一个解析器时,通过运行测试核验,如果你的数据传输对象(DTO)无法反序列化这个真实返回值,测试就会失败。这种方法彻底杜绝了虚假基础上的编程。
关键在于保持原则:永远不要让AI生成测试的Fixtures。如果任由它生成,你其实是在用虚假的输入数据测试一段虚假的输出逻辑,最终构成一个完美的空中楼阁。
3. 分离数据获取与处理逻辑
这是架构上的关键。如果你的代码同时执行fetch + parse + transform,那么你无法在脱离网络的情况下测试数据解析功能。而如果你需要对HTTP进行mock,就又回到了完全依赖AI生成假的数据上。
正确的做法是将这两部分分开:
┌─────────────────────┐
│ Client (fetch) │ ← 与真实API通信
│ 仅处理HTTP和JSON │
└────────┬────────────┘
│ 传送原始JSON
┌────────▼────────────┐
│ Processor │ ← 解析、转换、格式化
│ 仅处理数据 │
└─────────────────────┘
客户端很轻量化——只需要发送HTTP请求并返回原始JSON。而处理器接收该JSON并将其转换。要测试处理器功能,你直接引入之前捕获的真实Fixtures,而无需HTTP mock和真实网络。通过这种方式,你避免了AI对于JSON结构的臆测。
4. 对抗式编程检查清单
在接受任何与外部API交互的代码之前,先通过以下检查清单:
| 检查项 | 如果答案为否…… |
|---|---|
| 项目中是否有该API的Schema/规范文件? | 获取它之后再继续开发 |
| 代码中使用的字段是否真的存在于Schema中? | 查找确认,若不存在,AI在编造 |
| 测试用的Fixtures是否来源真实API? | 捕获真实数据,AI生成的无效 |
| 解析逻辑是否可以脱离HTTP请求完成单独测试? | 分离fetch与处理逻辑 |
| 代码中的类型定义是否与API的Schema一致? | 比对Schema,确保严格匹配 |
五个问题,三十秒检查,可以节省数小时的调试时间。
自我实践的阵痛
在构建这个CLI的过程中,我亲身经历了它试图解决的问题所带来的痛苦。
这款CLI的初衷是为与一个任务管理API的交互提供便利,而在开发过程中,每当需要创建一个任务来追踪bug时……我都得和自己正在封装的API较劲。这个“自我食用”的过程并非刻意为之,而是被迫的考验。
以下是一些具体的坑点:
- 破碎的JSON转义。 为了给任务添加带引号的描述,需要在Shell、JSON和GraphQL三个层级进行转义。一个小括号放错地方就会得到API的神秘报错。逃离这种地狱花费了我比修复bug更多的时间。
- UUID依赖的查找嵌套性。 想将一项任务分配到一个项目上?不能直接用项目名称,必须提供UUID。而如何获取UUID?又是一段查询。创建一个任务还需要label?再查一次UUID。最终,创建一个带有项目、状态和标签的任务需要四个嵌套查询。
- 32次请求建立依赖。 想创建8个任务并彼此之间建立依赖?每个依赖关系都需要一条单独的变更操作。8个创建任务的请求,加上24次依赖关系变更——总计32次API调用才能完成一份简单的任务布局。
正是这些亲身的实践经历,直接塑造了工具的功能设计。破碎的转义 → 支持文件输入,而非嵌套式表达。UUID问题 → 自动解析名称到UUID映射。32次调用 → 批量操作支持。
对抗现实,从Schema开始,用实际数据验证,永远不要被AI的错误所迷惑。
直面事实,全速前行。