作者亲身经历,AI生成的代码通过了所有静态检查却在生产环境造成静默数据损坏:无错误日志、无异常、只有用户告知才发现。
我在过去的两个月里一直在做一个面试练习工具,全程和 Claude 一起写。不是那种"给我生成一个应用"的模式——而是普通的日常工作:我布置任务,看结果,跟它争论,然后重写。
关于 AI 写代码有多快,已经有大量的文章讨论了。我发现另一个问题更有意思:它在哪些地方以你看不见的方式弄坏了东西?
下面四个 bug 都是真实的,来自我自己的项目。它们共同的特点是:没有一个会抛出异常。没有异常,没有 500 错误,日志里也没有红线。每一个在代码审查时看起来都是对的。你发现问题的时机,要么是用户告诉你的时候——要么是永远都不会发现。
免费方案,每月三次面试。逻辑很简单:生成了题目,计数器就加一。
if (!isPaid) {
incrementInterviewsUsed(profile.userId).catch(() => {})
}
return NextResponse.json({ questions })
这看起来没问题。错误被故意吞掉了,所以数据库的小故障不会打断某个人的面试。配额检查在上面,写入在下面。
只是——写入根本没有发生。
这是 Vercel 的serverless 环境。函数返回响应后,平台完全有权立即冻结它。一个没有被 await 的 Promise 根本到不了数据库。有时候能写入,有时候不能。没有任何规律。
问题暴露的方式是这样的:某个用户跑了第四次面试,控制台显示"1"。
if (!isPaid) {
try { await incrementInterviewsUsed(profile.userId) }
catch (e) { console.error('[Quota] increment failed', profile.userId, e) }
}
这不是有趣的部分。有趣的部分是——为什么这个问题存活了好几周。
我对配额写过测试。它们检查的是:当 used = 3 时请求被拒绝。换句话说,他们测试的是读取路径。坏掉的东西是写入路径——也就是负责让 used 达到 3 的那个路径。
我总结并记下来的规则是:任何强制执行限制或涉及金钱的操作,都要在写入路径上测试,而不是在它周围的逻辑上测试。
产品是为某个角色和资历级别生成面试题。用户的反馈是:"我选了 senior,得到的却是 junior 的题。"
prompt 大致是这样组装出来的:
const prompt = `
${roleGuide}
${mixRule} // "60% theory, 40% practice"
${levelGuide[difficulty]} // "for senior: architecture, trade-offs..."
`
而 mixRule 大致是这样说的:"这些比例会覆盖上面的 block。"
level block 在它之后被插值进去。
模型不会把 prompt 当作有层级的规格说明书来读。它把它当作文本。当一个地方说"这个覆盖上面的 block",而后面某处说了不同的话,离末尾最近的那个说了算。
同一个问题还有第二层。比例规则历史上是放在 QA 角色分支里的。二十三个角色中有二十一个从来没见过这条规则。这一个不是 AI 的错误——是我的错误,它在添加新角色时忠实地复刻了现有模式。
修复方式是重排 block 的顺序,并把规则从分支里提取出来。更广泛的教训:prompt 是一种没有编译器的代码。没有任何东西会告诉你两条指令互相矛盾。发现它的唯一方式是拿起组装好的 prompt,从头到尾读一遍,用模型看它的方式去看。
有一个 B2B 方案:一个团队,有一个管理后台显示每个成员。除了其他信息,还有每个人做了多少次面试。
那个数字来自 profile 上的 interviews_used。
就是 bug 一里的那个字段。免费方案的配额计数器,它故意不为付费方案递增——为什么要去统计不受限制的东西呢?
团队方案是付费方案。
所以在客户花二十个座位钱来看的那个界面上,每个员工都永久显示为零。数据是存在的——面试在进行,分数在保存。只是控制台读取了错误的列。
这不是技术错误。这是关于一个字段有两个不同含义的错误,而名字里没有任何提示。interviews_used 读起来像是"这个人做过的面试"。实际上它的意思是"从免费配额里消耗了多少"。
现在所有数据都从会话本身来计数了,配额字段就还是做它配额字段该做的事。
四个里最糟糕的一个,尽管它没有破坏任何东西。
产品跑在两张表上——面试会话,和每个答案的反馈。我是在某个时候通过 Supabase 仪表盘手动创建这两张表的。快速、方便、能用。
仓库里的 migrations 文件夹描述的是不同的表。那些是项目最初用、早就停用的表。
什么都没有失败。什么都不可能失败:生产环境有这些表,代码查询它们,一切正常。它只会在一个时刻分叉——搭建 staging 环境的时候,或者事故后恢复数据库的时候。那时候应用会去找不存在的表,然后启动失败。
我很久都没注意到它,恰恰因为根本没有任何症状。它是在我写团队报告时偶然发现的,当时我去检查一张表实际有哪些列。
同一轮排查中的另一个细节。最初的 migration 有:
check (plan in ('free', 'basic', 'pro'))
团队方案是后来加的。我在仪表盘里修复了约束,但从来没有把它同步回仓库。在一个全新的数据库上,每一笔 B2B 交易都会被 Postgres 拒绝。
修复方案:写一个描述实际运行状况的 migration,外加一个测试——从源代码中读取所有表引用,要求每个表名都在某个 migration 中被创建。下一次手工建的表会在 CI 里失败。
for (const [table, files] of queriedTables()) {
it(`creates ${table}`, () => {
const created = new RegExp(`create table (if not exists )?${table}\\b`)
.test(migrationSql)
expect(created, `${table} is queried in ${files[0]} but no migration creates it`)
.toBe(true)
})
}
有一个不错的尾声。第一次对生产数据库跑那个 migration 的时候,它失败了,报错是 cannot change return type of existing function。我版本里的一个函数有两列顺序和真实的不一样。应用按字段名读取,不 care——但 Postgres 把输出参数集合当作一种行类型,直接拒绝。数据库本身告诉我,描述已经和现实偏离了多远。
写这篇文章的时候我又发现了一个第五个,这是唯一一个真正造成损失的。
面试页面在每次挂载时都请求题目。生成题目会扣一次免费面试次数。所以一次刷新、前进后退,或者已安装的应用被重新启动,都会悄悄消耗掉某人三次中的一次。
第一个发现这个产品的陌生人恰好就做了这件事:两个会话,隔了四十秒,两个里面都是零答案。她离开的时候,三分之二的免费配额已经没了,却一次都没见过这个工具真正运行的样子。
现在生成题目是免费的,只有在发送第一个答案时才扣面试次数。重新加载会从断点继续。
而当我为它写测试的时候,测试失败了——因为数据库 mock 在一次写入后返回了空,而真实的 PostgREST 会返回它触及的行。产品代码是对的;那个用来验证它的东西是错的,而且以一种最令人信服的方式失败——安静地报告什么都没发生。
让我触动的不是 AI 会犯错。每个人都会犯错。
是它犯错的方式。不理解任务的人写出的代码,你能看出是错的——代码很笨拙,编译不过,第一次跑就倒下。模型写的代码看起来像是经验丰富的人写的——合理的命名、整洁的结构、贴心的错误处理。而恰恰在它缺乏上下文的地方——serverless 函数如何被冻结、某个列历史上是什么意思——它会自信地写出看起来合理但实际是错的东西。
根据我的经验,有三件事真的有用:
测试写入路径,而不是它周围的逻辑。 我最贵的 bug 发生在我自己写、自己验证的一行代码里——从读取那一侧。
写那些把代码和外部世界做对比的测试。 不是"这个函数返回了正确的值",而是"migrations 声明的内容和代码请求的内容一致"。这些测试能捕获到单元测试按定义就看不见的一整类问题。
在运行的产品上验证。 上面五个里有四个在代码里根本看不出来。只有打开界面看那个数字,才能发现。
这些建议都不是新的。新的,是当你自己没写的、看起来可信的代码量级增长了一个数量级的时候,这些建议有多重要。