AI 写的 bug,长得很像正确的代码:一场调试认识论的重写

几十年来,所有调试方法论都建立在一个没人说的假设上:每行代码背后,都站着一个知道它为什么存在的人。

这个假设太自然了,自然到没人觉得它是个假设。你打开一个陌生函数,默认相信命名反映了意图、结构反映了思考、注释反映了当时的心路。调试时你顺着代码读,本质是在还原一个已经存在的心智模型——某个人脑中有一个”这东西该怎么做”的图景,bug 就是实现偏离了这个图景,你的工作就是找到偏离点。

AI 把这个人删掉了。

当代码是模型生成的,”作者”这个信息源消失了。没有人在脑中持有完整意图,没有决策链条可追溯,没有”当时为什么这么写”可以问。AI 生成代码时,它内部并没有”意图”这个状态,只有对意图的概率重建。它输出的,是”最像符合意图的代码”,不是”符合意图的代码”。

于是调试的地基被抽空了。这不是要换个工具的问题,是调试的认识论要重写。你赖以定位 bug 的那套前提,已经不成立。

接下来的主题:传统调试依赖什么前提,AI 代码为什么抽掉了这些前提,以及面对”看起来全对、实际全错”的代码,调试方法论必须怎么转向。


一、先校正一个坐标:调试的本质,从来不是”找 bug”,是”找意图”

要把这个立意讲透,必须先把一个最常见的混淆从脑子里清掉。否则下面所有论证都会被一句”调试不就是找哪儿错了吗”挡回去。

“找 bug”是个被滥用的说法。它把调试描述成一个定位活动–错误在某个地方,我去找到它。但这个描述隐藏了调试真正的难度所在。

调试真正在做的事是:让意图与实现对齐。 bug,是意图与实现之间的偏差。所以调试包含两个动作:先知道”意图是什么”,再判断”实现有没有偏离它”。定位,只是第二个动作的副产品。

传统代码里,第一个动作几乎是免费的。因为你或者某个同事写过这段代码,意图已经存在于某个人的脑中、某个 commit message 里、某次 code review 的讨论里。你不用”求解”意图,你只需”回忆”或”询问”它。调试 90% 的难度,都压在第二个动作–比对实现与那个已知的意图。

这就是那个没人说出口的假设:意图是已知的、被持有的、可获取的。 调试方法论的整套兵法–读代码理解逻辑、设断点观察偏差、二分缩小范围–全都建立在”意图已知”之上。你以为你在找 bug,其实你是在一个意图已经锚定的坐标系里,测量实现的偏移量。

AI 代码把这个坐标系拆了。意图不再已知。模型生成的代码,意图是涌现的、未被任何人持有的、可能从未被任何人明确思考过。没有人能回答”这段代码想干什么”–因为连”想”这个动作都没发生过,发生的只是”概率最高的下一个 token”。

于是调试的难度结构翻转了:

传统代码AI 代码
意图的位置在作者脑中(已知、可问)不在任何地方(待求解)
调试的主战场比对实现与意图(第二个动作)先确立意图本身(第一个动作)
调试的方向读实现,还原意图立意图,验实现

过去你 10% 的精力花在”搞清楚它该干什么”,90% 花在”它干没干对”。现在这个比例倒过来了——你绝大部分精力,要花在搞清楚”我到底要它干什么、它到底理解成了什么”上,因为这两者之间的偏差,才是 AI 代码最贵的那类 bug。

判据很清晰:

调试的难度,从来不在”找到错误”,而在”确认什么是对的”。AI 代码把”什么是对的”从已知前提,变成了待解未知。


二、一条判据:可读性信号,被污染了

理清了调试的本质,下一个问题是:顺着代码读,这套传统调试最基础的动作,为什么在 AI 代码上失效了?

答案藏在一个我们长期默认的推理链里:

传统代码:代码可读 ⇒ 代码可理解 ⇒ 代码可调试。 这条链是通的。

可读性——规范的命名、清晰的结构、到位的注释,过去是”有人理解这段代码”的代理指标。你看到 validateUser 这个函数名,就默认它背后有人想过”验证用户”意味着什么;你看到一段结构分明的代码,就默认这结构反映了一个人的思考路径。可读性不是装饰,它是”意图曾被人类持有过”的可观测证据。所以你能顺着可读的代码,反向还原出意图,再比对实现。

AI 代码把这条链的第一根箭头打断了:

AI 代码:代码可读 ⇏ 代码可理解 ⇏ 代码可调试。

AI 生成的代码,可读性往往比人写的还好——命名规范、结构工整、注释详尽。但这份”可读”不再证明”有人理解它”。它只证明一件事:模型学过大量”看起来像好代码”的样本,所以它输出的代码形似好代码。”形似”和”神似”是两件事:形似是对表面模式的拟合,神似是对真实意图的贯彻。AI 擅长前者,不保证后者。

于是可读性从”线索”变成了”误导”。你看到漂亮的 validateUser,会下意识相信它真的在验证用户——而这个相信,恰恰是 bug 藏身的地方。可读性越高,你对它的信任越深,它误导你的力度就越大。

这是 AI 代码调试最反直觉的一点:最难调试的,不是写得乱的代码,而是写得漂亮的代码。 写得乱的代码,你天然警惕,会逐行验证;写得漂亮的代码,你会顺着读过去,把表面合理性当成内部正确性。而 AI 代码,几乎总是写得漂亮的。

判据落到一句话:

可读性信号被污染了。它从”意图曾被持有”的证据,降级成了”代码形似好代码”的拟合度评分。


三、机制层:为什么 AI 代码的 bug,长得像正确的代码

前两节讲了”为什么”,这一节讲”是什么”。AI 代码的 bug 到底长什么样,为什么它们如此难抓。这部分必须具体,否则立意就是空谈。

AI 代码的失败模式不是随机的,是结构性的。它不会在语法上错(模型语法很强),不会在命名上错(模型命名很规范),甚至不会在局部逻辑上错(模型对常见模式拟合得很好)。它错的地方,恰恰是人类最依赖语境、最依赖”未说出口的假设”来判断的地方。表现是高度一致的:变量名对、类型对、结构对、甚至局部逻辑都对,但整体语义错。 它对的是”形似”,错的是”神似”。

下面是四类最典型、也最坑人的失败模式。每一类都对应一种传统调试会直接放过去的情况。

1. 命名编码了错误意图

AI 写出一个函数 isAuthorized(user, resource),命名无可挑剔,注释写着”检查用户是否有权访问资源”。但实现里,它只检查了 user.isLoggedIn。代码读起来通顺极了,任何顺着读的人都会默认”既然叫 isAuthorized,它总该检查授权吧”。

bug 是权限提升。但这个 bug 不在任何一行代码上,isLoggedIn 的判断本身完全正确,错的是”用登录状态冒充授权”这个意图层的偷换。而这个偷换,被一个看起来无比合理的函数名完美掩盖了。

传统调试在这里会卡死:你顺着 isAuthorized 读,读到 isLoggedIn,你会下意识把它解读成”授权检查的一部分”,而不是”用错了检查”。命名成了你的认知滤镜,让你看不见那个偷换。要抓到它,你得先问”授权在这个系统里到底意味着什么”,再用答案去验这个函数,也就是先立意图,再验实现。

2. 局部正确,全局语义错

AI 实现一个带指数退避的重试。每一行都对:有重试循环、有退避计算、有最大次数。但退避计数器在每次外层调用时被重置,而不是在一次重试序列内累计;或者它对非幂等操作也照 retry 不误。

每一行单看都符合”重试该有的样子”。你设断点跟踪任何一次单次调用,都看不出毛病。错的是跨调用的状态语义——而跨调用,恰恰是”顺着一段代码读”够不到的地方。这类 bug 的特征是:单步调试永远绿,集成场景才暴雷。

3. 测试与实现共享同一个盲点

这是最阴险的一类。AI 生成了一个稍有问题 parseDate(s) 函数,然后你让它补测试,它写出的测试编码了同一个错误假设。比如它把 “2026-13-01” 这种非法日期也解析成了某个合法值,而测试用例里压根没覆盖”月份越界”这个分支——因为模型生成函数和生成测试时,共享了同一套概率盲区。

测试全绿。bug 完美隐身。因为验证者和被验证者,是从同一个有偏分布里采样的。

这一类直接攻击”相信测试”这个调试基石。过去我们说”有测试就放心”,因为测试是另一个人(或另一个时间点的你)写的独立判断。AI 代码里,测试和实现是同一个心智盲区的两次输出,它们之间的”独立性”是假的。绿了的测试,不再代表实现正确,只代表实现和测试一致地错了

4. 用对了 API 的名,用错了 API 的义

AI 调用一个真实存在的库函数,名字完全匹配意图,但语义在某个版本或某个语境下是错的。最经典的:对数字数组调用 sort() 不带比较器,得到字典序排序;或者用一个名字像”安全”的哈希函数,实际是已弃用的弱算法。

这类 bug 极难抓,因为代码”看起来在用对的东西”。你 grep 不到错误,类型检查也过(API 真实存在、签名匹配)。错的是这个 API 在这个语境下的语义,而语义不在代码里,在 API 的演化历史和约束条件里。这些信息,代码本身一个字都没写。


把这四类放在一起,能看出一个共同结构:错误都不在代码的任何一行上,而在”需求→实现”的映射层。 代码每一行都”像对的”,但整体答非所问。这就是为什么”顺着代码读”会失效——你沿着一条看起来对的路径走,永远到不了那个实际错的点,因为错的根本不在路径上,而在路径被选错的那一步。

传统调试的前提是”错误在实现里”,所以它的整套动作——读代码、设断点、二分,都在实现层打转。AI 代码把最贵的 bug 上移到了映射层,传统调试就在它够不着的地方空转。


四、四次方法论转向:从”读实现”到”立意图”

讲清楚为什么,就要讲怎么办。下面四条不是技巧清单,是调试认识论的四次方向性翻转。每一条都对应上一节的一种失败模式。

转向一:从”读代码定位”到”立假设证伪”

传统调试是演绎式的:从代码理解逻辑,推断 bug 在哪。这套方法的前提是”代码逻辑可信”。AI 代码上这个前提不成立,于是演绎式调试会把你带进死胡同——你会沿着”看起来对”的逻辑,推出一个”看起来合理”的错误结论。

转向是:先不读代码,先列假设。 拿到一个可疑的 AI 生成模块,第一件事不是打开它读,而是写下”它可能怎么错”。列出 5 条最可能的失败假设,每条配一个能证伪它的最小实验。然后用实验排除假设,而不是用阅读寻找错误

读代码降级为”排除后的确认动作”,不再是”发现动作”。因为阅读会顺着表面合理性滑过去,而实验只认事实。一个 print 或断言,比读十行代码更能告诉你真相。

转向二:从”实现层”上移到”spec 层”

上一节四类 bug 全在映射层。所以调试的第一枪不该打在”实现有没有错”,而该打在”我到底要它干什么、它理解成了什么”。

具体动作:在动手调试前,先把验收标准写成可执行的东西——几个断言、一组测试用例、一张输入输出表。不是写文档,是写能跑的判断。这一步的本质,是把模糊的意图固化成代码之外的锚点,让”什么是对的”从脑中模糊印象,变成机器能判定的硬事实。

这一步做完,你会发现相当一部分 bug 根本不用调试就暴露了。因为 spec 一写出来,你就发现 AI 的实现答非所问。最贵的 bug,往往在最便宜的”对齐意图”这一步就被拦下,根本进不到调试阶段。

转向三:从”代码自解释”到”外部锚点校验”

传统好代码”自解释”,读它就懂它。AI 代码把这个信条变成了陷阱:它会用看起来合理的注释和命名解释自己,但解释本身和实现共享同一个盲区(见第三节的失败模式 3)。

转向是:不再用代码内部信息理解代码,改用代码外部的真相源校验它。 真相源有四类,按可信度排序:

  • 类型系统:把约束写进类型,让错的代码编译不过。这是最便宜也最硬的锚点。
  • 测试:但必须是独立于实现写的测试(人写,或基于 spec 生成而非基于实现生成),否则就是共盲。
  • 断言与不变量:在关键位置埋 assert,把”我以为永远成立”的假设变成运行时检查。
  • 可观测性:日志、指标、追踪。当代码内部不可信,你只能从它对外部世界的可观测影响反推它的行为。

注意这四类的共同点:它们都不在 AI 生成的代码内部。 因为代码内部已经没有可信的意图载体了,真相只能从外面引入。这是”代码自解释”信条在 AI 时代的正式死亡。

转向四:从”事后调试”到”调试前置”

这一条最根本,也最反直觉。

传统调试是”出问题后”的动作:代码写完、上线、暴雷,再回头调。这个流程在 AI 代码上行不通,因为 AI 代码的事后调试成本是指数级的,意图缺失、可读性误导、映射层 bug,每一个都让事后定位的成本暴涨。

所以调试必须前置到生成阶段。意思是:在让 AI 生成代码的那一刻,就要把”可调试性”嵌进去。具体三件事:

  1. 生成前先给 spec,不要让 AI 替你猜需求(猜出来的意图,事后你永远调不清楚,因为你也不知道自己当时到底想要什么)。
  2. 生成时同步要测试,且测试要基于 spec 而非基于实现(避免共盲)。
  3. 生成后立刻验证,不要攒一堆未验证的 AI 代码再统一审。未验证的 AI 代码堆在一起,意图层 bug 会互相掩盖,到最后谁都理不清。

调试前置的本质,是承认一个事实:在 AI 代码上,调试和生成是同一个活动的两面。 你不可能”先让 AI 写完,再调试”,那样调的不是 bug,是 AI 对你意图的误解,而这种误解越攒越难拆。


五、压力测试:这个判断是不是太绝对了

反驳一:人不也写出意图不清、命名误导的代码吗?AI 代码的问题,不就是个放大版的”烂代码”问题吗?

不完全是。区别在于意图的存在性,不是意图的质量。

人写的烂代码,意图可能表达得差、可能忘了写注释、可能和实现脱节——但意图存在过,留在某个人的记忆、某次讨论、某个 commit 里。你费点劲,能把它挖出来。调试的坐标系还在,只是刻度模糊。

AI 代码的意图从未存在过。没有任何一个地方可以”挖”。模型生成下一个 token 时,没有一个”想”的动作发生过。你不是在还原一个被遮蔽的意图,你是在面对一个本就不存在的意图。这是质变,不是量变。人写烂代码,是意图的传输有损耗;AI 写代码,是意图这个发送端就不存在。损耗可以补偿,不存在无法补偿。

反驳二:AI 代码也有 commit、有 prompt、有 review 记录啊,这不就是新的”意图载体”吗?

对,这是 AI 时代意图的新锚点,也正是第三节”外部锚点”的一部分。但这恰恰印证了立意,而不是反驳它:你之所以必须依赖 prompt、spec、review 这些代码之外的东西来理解代码,正是因为代码本身不再是意图的载体了。传统代码里,代码 = 意图的可读表达;AI 代码里,代码 = 意图的一个有偏采样,意图的真身留在 spec 和 prompt 里。

所以这不是”和以前一样”,而是”意图的存放位置整体外移了”。调试动作必须跟着外移,从读代码,移到读 spec、读 prompt、读测试。谁还停留在”读代码理解代码”,谁就调试不了 AI 代码。

反驳三:很多 AI 代码就是对的,难道每次都要立意图、写 spec、跑证伪?小改动也这么搞,效率在哪?

这是最实际的反驳。答案是个判据:按”映射层风险”分级,不按代码量分级。

一个改 typo、加一行配置、调一个常量,映射层风险为零,意图和实现几乎一一对应,传统读代码就够了,不必大动干戈。

一个新函数、一段业务逻辑、一个跨模块改动,映射层风险高,意图和实现之间有大量”AI 可能理解偏”的缝隙,必须走立意图、外部锚点、证伪那套。

判据是:这段代码有没有”未说出口的假设”? 有,就高对待;没有,就低对待。绝大多数致命 bug,都集中在”有未说出口假设”的那部分代码上,而 AI 代码恰恰在”未说出口的假设”上最脆弱,因为模型只能拟合说出口的部分,拟合不了你没说的。

所以不是”每次都重武器”,而是”把重武器精准投向映射层高风险区”。盲目对所有 AI 代码都轻信,和盲目对所有 AI 代码都重审,是两种对称的低效。

反驳四:那是不是意味着 AI 代码永远比人写的难调试,AI 编程这条路走不通?

恰恰相反。AI 代码难调试,不等于 AI 编程不划算。它难调试,是因为它把成本从生成端挪到了验证端。生成端省下的时间,确实有一部分要在验证端还回来。

但关键在于:验证端的成本是可控的、可前置的、可工程化的–spec、测试、类型、可观测性,都是成熟手段,只是过去我们没把它们当回事,因为人写代码时”读代码”这个廉价动作能兜底。AI 代码把”读代码”这个兜底撤了,逼你把验证体系真正建起来。

这反而是好事。因为”读代码兜底”从来就不该是工程实践的支柱,它太依赖个人能力、太不可规模化。AI 代码逼出来的,是一套不依赖”有人懂这段代码”的验证体系。这套体系一旦建成,不仅调 AI 代码够用,调人写的代码也会更稳。AI 不是让调试变难,是让调试终于不得不专业化


六、收尾:AI 没有让编程变简单,它把难度挪了个地方

把整条线收一下。

调试方法论的整套兵法,几十年都建立在”代码背后有一个持有意图的人”上。AI 把这个人删了,于是意图从已知前提变成待解未知,可读性从可信线索变成污染信号,最贵的 bug 从实现层上移到映射层,”读代码”这个最基础的调试动作从地基变成了陷阱。

调试因此必须转向:从读实现转向立意图,从演绎转向证伪,从代码自解释转向外部锚点,从事后救火转向生成时嵌入可验证性。这不是工具升级,是认识论重写。

但真正发人深省的点,不在调试本身,而在它折射出的更大的事:

AI 让调试变难,不是因为它写得差,而是因为它写得太像对的。 它把”看起来对”和”实际对”之间的缝隙,撑大到人类直觉无法可靠跨越的程度。

我们一直以为,AI 替代的是”写”。毕竟它能写代码,写得很像样。但调试这件事暴露了一个我们不愿意承认的事实:AI 最先掏空的,不是”写”,是”读”,这个我们以为最基础、最不会被替代的能力。当代码写得漂亮却不再可信,”读懂代码”就从一个入门技能,变成了一个需要外部锚点支撑的高阶判断。

过去,理解代码是为了改它。现在,你必须先理解你到底想要什么,才能判断代码值不值得被理解。意图,从代码的副产品,变成了代码的前提。

所以那句老话也该改写了:

AI 没有让编程变简单。它只是把难度,从”写出来”挪到了”看出来”。而看出来,从来比写出来更难。

能被 AI 替代的,是那些”看起来对就够”的工作。剩下的,是那些”必须真的对”的工作。而判断”真的对”,从来就是工程能力里最贵的那一块。调试认识论的重写,只是这场更大迁移的一个先声。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注