🛠️ 第2章 AI 编程的最小原理——知道边界在哪里
📄 一句话概括

它犯的错可以归成三类:事实错误(记错了)、范围错误(改多了)、一致性错误(断链了)。 三类的止损办法完全不同,认错了类别就会用错药。 事实错误靠查文档,范围错误靠写死边界,一致性错误靠跑全量测试。

📝 本章和前面几章的关系

上一篇讲「信息给不全」。这一篇讲「给全了它也会错」。 这两个边界合起来,就是这一章的标题:知道边界在哪里。 知道边界不是「别用它」,是「知道在哪儿加人」。 三类错误的分法在教材 2.4 里就有,但教材没给「怎么认」。 这一篇补上辨认办法:看错的样子。 事实错误是「跑起来结果怪」,范围错误是「diff 太大」,一致性错误是「单测过了但集成挂了」。

一、事实错误:库和 API 记错了

这一类最好认:代码语法对、函数名也是真的,但行为不是你以为的那样。 它不是在瞎编,它是把「见过的类似写法」往上凑。 Demo 里列了八条,六条跟直觉不一样。 最典型的一条:round(2.5) 得到 2,不是 3。 因为 Python 用的是「银行家舍入」——逢五取偶。 这条不是 bug,是设计;但它跟学校里教的「四舍五入」不一样。 还有一条更隐蔽的:re.match('a', 'ab') 返回 True。 很多人以为 match 是全匹配,其实它只看开头——全匹配要用 fullmatch。 这一条错在「名字的字面意思」上—— 它叫 match,你自然会以为是「匹配」,而它实际是「从开头匹配」。 这类错误为什么难查?因为它不报错。 round(2.5) 返回 2,程序照跑,结果差一点,可能三个月后才发现账对不上。 所以这一类只能靠「跑一遍看结果」或者「查文档」。

二、范围错误:顺手改了不该改的

这一类最难防,因为它「改对了」,只是「改多了」。 你要它改一行判断,它顺手把引号统一了、把空行删了、把一个没用的 import 加了。 Demo 里量过:需求只有一处改动,diff 本该是「加 1 删 1」,实际变成「加 4 删 6」,合计 10 行。 多出来的 8 行全是「顺手」。 为什么它会顺手? 因为训练数据里「提交前顺手整理一下」是常见做法——很多教程甚至鼓励这么做。 但它不知道:在一个有审查流程的团队里,每一行 diff 都要人看一眼。 多 8 行,就是多看 8 行。 更麻烦的是「看起来都对」。 引号统一了、空行删了,代码确实更整齐了——审查的人会犹豫「这个改动要不要留」。 一犹豫,审查就慢下来了。 而慢下来的成本,比那 8 行本身高得多。

三、一致性错误:局部对,整体不通

这一类最典型:它改了一个函数的签名,那个文件本身完全正确——三个参数相加,逻辑没问题。 但调用方还按两个参数传,一跑就崩。 Demo 里的报错是 TypeError: add() missing 1 required positional argument: 'c'。 注意这个错不是「它写错了」,是「它没看别处」。 在它眼里,api.py 这个任务已经完成了。 这一类错误的根源是「视野」——它改的时候只看了一个文件,而这个改动的真实影响面在别的文件里。 补的办法只有一个:跑全量测试。 单测过不算过,集成过才算。 顺带说一个相关的坑: 如果签名改了但加了默认值,调用方不会报错,会「静默地按默认值跑」。 这种错比崩溃更难查——因为程序照常出结果,只是结果不是你想要的。

四、三类错误各自的止损办法

类别 怎么认出来 止损办法
事实错误 结果怪但不报错 跑一遍看结果、查文档
范围错误 diff 行数远超预期 把范围写进任务、盯 numstat
一致性错误 单测过、集成挂 跑全量测试、搜全仓库引用

这张表最有用的是中间那一列。 三类错误的样子完全不同,所以「怎么认」比「怎么修」更值得记。 而且认出来之后,你会知道该加哪一道防线: 事实错误多,说明该给它文档或者让它跑测试;范围错误多,说明该在任务里写死边界; 一致性错误多,说明该配一条全量测试的命令。 三类错误的共同点是:没有一类能靠「让它更认真」解决。 都得靠外部机制——测试、边界、检查。 这也是后面第 7 章「先约束再放权」的来源。

五、动手 Demo:三类工程错误:记错、改多、断链

这个 Demo 分三段,一类错误一段,全部真跑。 第一段列八条「看着对、其实不对」的调用,逐条执行看结果。 第二段造一个只该改一行的任务,看「顺手」能把 diff 撑到多大。 第三段改一个函数签名,看调用方怎么崩。 三段都只用 git 和 python,没有 Codex—— 因为这三类错误不是 Codex 特有的,是所有「只看局部」的改法都会犯的。

# 第一段:八条真跑
probes = [("round(2.5)", lambda: round(2.5)),
          ("re.match('a','ab')", lambda: bool(re.match("a", "ab"))),
          ("{}.get('k')", lambda: {}.get("k")),
          ("[].pop()", lambda: [].pop()),
          ("0.1 + 0.2 == 0.3", lambda: 0.1 + 0.2 == 0.3)]

# 第二段:需求只改一处,实际改了多少行
git diff --numstat          # 加 N 行、删 M 行

# 第三段:改签名不改调用方
def add(a, b):        ->    def add(a, b, c):
add(1, 2)             ->    TypeError: missing 1 required argument

实跑输出:

--------------------------------------------------------------------
三类工程错误:记错、改多、断链
--------------------------------------------------------------------


  ① 第一类:事实错误——名字对了,语义记错了
  --------------------------------------------------------------------
  这一类最迷惑人:它写出来的调用是「真 API」,语法也对,跑起来却不是你想要的。
| 写法 | 实际结果 | 常见误记 |
|---|---|---|
| round(2.5) | 2 | 以为 3,其实 2(银行家舍入) |
| round(3.5) | 4 | 以为 4,其实 4(碰巧对) |
| re.match('a', 'ab') | True | 以为要全等,其实只看开头 |
| {}.get('k') | None | 返回 None,不抛异常 |
| {}['k'] | KeyError | 这个才抛 KeyError |
| [].pop() | IndexError | 以为返回 None,其实抛 IndexError |
| 'abc'.find('z') | -1 | 返回 -1,不抛异常 |
| 0.1 + 0.2 == 0.3 | False | 以为 True,其实 False |

  表里八条,六条跟直觉不一样。
  ! 这类错误靠「读代码」发现不了——因为读起来是对的。只能靠跑,或者靠查文档。

  ② 第二类:范围错误——记对了,但改多了
  --------------------------------------------------------------------
  只改这一处:加 1 行、删 1 行
  顺手格式化之后:加 4 行、删 6 行
  需求只有一处改动,diff 里本该是「加 1 删 1」;实际加 4 删 6,合计 10 行。
  多出来的 8 行全是「顺手」——换引号、删空行、多一行 import。
  ! 审查的人要在 10 行里找那 1 行真改动。改动的倍数不是效率,是审查成本。

  ③ 第三类:一致性错误——局部对,整体断
  --------------------------------------------------------------------
  改之前:退出码 0,输出 3
  改了签名之后:退出码 1
  报错:TypeError: add() missing 1 required positional argument: 'c'
  api.py 自己是对的——三个参数相加,逻辑没问题。
  但它把调用方弄断了。「这个文件改对了」和「这个仓库还能跑」是两件事。
  ! 三类错误对应的止损办法不一样:事实错误靠查文档,范围错误靠写死边界,一致性错误靠跑全量测试。

第一段的表里,八条有六条跟直觉不一样。 这个比例本身就是结论:「我记得这个 API」这件事,准确率大概七成。 七成听着不低,但一次改动里往往有五六个 API 调用——五个七成乘起来,全对的概率不到两成。 第二段那个 10 行对 1 处,是关键数字。 需求只有一处判断改动,diff 本该是「加 1 删 1」;实际加 4 删 6。 多出来的 8 行,全是「顺手」——换引号、删空行、加 import。 第三段的报错值得读一遍:missing 1 required positional argument: 'c'。 注意它报的是「缺参数」,不是「你改错了」—— 这个错在有十个调用方的项目里,会同时出现在十个地方。 三类错误里,第二类最容易被当成「它很勤快」。 因为它确实顺手把代码整理干净了。 但对审查的人来说,那不是勤快,是把 1 行的工作量变成了 10 行的阅读量。

30 秒实验

  1. 下次看到 diff 行数明显超出预期,先别看内容,先问「为什么这么多」。 十次里有七次是「顺手」。
  2. 在你自己的代码里找一个「改了签名、加了默认值」的地方,看看有没有调用方在静默地用默认值。 这种错最难查,因为程序不报错。
  3. 给你的项目加一条命令:一条能跑完全部测试的命令。 没有这条命令,「一致性错误」就永远要等到上线才发现。

常见误区

  1. 「让它更仔细一点就好了。」 三类错误没有一类能靠「仔细」解决。 仔细不解决「记错」(它自己也不知道记错了),也不解决「视野」(它根本没看别处)。 只能靠外部机制。
  2. 「diff 大说明它改得认真。」 diff 大说明审查成本高。 一次改动只该解决一个问题——混进来的「顺手」会让「这次改动为什么失败」变得无法定位。
  3. 「单测都过了,应该没问题。」 单测过只说明「测到的部分对」。 一致性错误恰恰发生在「测试没覆盖的调用方」上。 所以要看的是「有没有全量测试」,不是「单测绿不绿」。
自测三问(点开对答案) 1. 三类错误各自怎么认出来? —— **事实错误:结果怪但不报错**(比如 round(2.5) 给 2);**范围错误:diff 行数远超预期**; **一致性错误:单测过、集成挂**。**认出来才知道加哪道防线。** 2. 为什么「加了默认值的签名改动」比「直接崩」更危险? —— **因为它不报错,会静默地按默认值跑。** **程序照常出结果,只是结果不是你想要的**——这种错可能几个月后才被发现。 3. 三类错误的共同点是什么? —— **没有一类能靠「让它更认真」解决。** **都得靠外部机制:测试、边界、检查。** 这也是第 7 章「先约束再放权」的来源。

下一篇 2.5 收这一章:知道两条边界之后,具体该怎么配合——三条方法,都能量出成本。

相关笔记