上一篇结束在一份你读完的 diff 上。但更常发生的是另一件事:代码跑起来就报错,或者不报错但结果不对。这时候最顺手的动作是复制最后一行报错,说一句“帮我修”。然后进入一个循环:你贴一个新报错,它还你一个新改法,报错换了一行,你再贴回去。十几轮之后,代码已经不是原来那段,也说不清哪一版是对的。

问题不在它笨,而在这个循环里没有新信息进来。它看不到你的终端,读不到你的数据,唯一的信息源是你打的那几行字。信息接近零时,它能做的只有一件事:给出一个听起来最像原因的解释,并据此重写。重写是它的强项,判断自己重写得对不对,不是。

有实验标出了这条界线。让模型只靠自己判断反复修改答案,在算术推理、代码生成这类任务上不但没有提升,有时还更差;一篇综述的结论是,公平设定下目前没有哪项工作证明“只靠模型自己生成的反馈”能稳定纠错 23。反过来,让代码先真的执行一遍,把运行结果(尤其是单测通过或失败)当成反馈喂回去,提升最明显;一旦去掉执行反馈,同样的方法在 GPT-4 上就只剩 3.6% 的提升,而且模型更容易对第一版答案过度自信 1。

一句话:它弱在“发现问题”,强在“根据事实生成改法”。所以你的活是把事实生产出来。

先看这个案例

orders.csv,真实导出有 12 万行,这里只留三行:

1user_id,amount
2u_101,79.00
3u_102,"1,299.00"
4u_103,N/A

export.py 里出问题的那个函数(其余部分省略):

1import csv
2
3def load(path):
4    rows = []
5    with open(path, newline="", encoding="utf-8") as f:
6        for row in csv.DictReader(f):
7            rows.append({
8                "user_id": row["user_id"],
9                "amount": float(row["amount"]),
10            })
11    return rows

跑 python export.py 的完整输出是:

1Traceback (most recent call last):
2  File "export.py", line 33, in <module>
3    main()
4  File "export.py", line 21, in main
5    rows = load(path)
6  File "export.py", line 9, in load
7    "amount": float(row["amount"]),
8ValueError: could not convert string to float: '1,299.00'

预期结果:u_101 记 79.0,u_102 记 1299.0,u_103 当作缺失值单独记一笔,不要混进金额里。

你可以先想一下自己会怎么问它,再对照下面的顺序。

第一步:把现场变成事实,再开口

多数人第一句话就丢了两样东西。

完整 traceback。 报错的价值不只在最后那一行,还在它上面几行:哪个函数、哪一行、从谁调过来的。最后一行说“类型不对”,上面几行说“哪一次调用”。Go 官方给提问者的建议很直白:贴完整的失败输出,不要只挑一行你觉得是原因的行 5。

能触发它的输入。 报错离开输入就没法验证。上面这条虽然带出了 '1,299.00',但它来自哪一行、哪一列,只有数据能说清。

第三件事是把材料缩到最小:12 万行砍成能复现的三行。Stack Overflow 的提问指南要求三条:最小,用尽可能少的代码复现同一个问题;完整,别人拿着这份材料就能跑起来;可复现,你亲手跑过,确认它真的还报错 6。对 AI 来说“最小”还有一层好处:变量从几万个减到三个,它不用猜你的数据长什么样。这直接决定了后面每条假设能不能被验证。

第二步:第一句话要假设,不要补丁

第一句话决定后面是收敛还是发散。可以照这个形状写:

这段 load() 处理 orders.csv 时报 ValueError: could not convert string to float: '1,299.00',完整 traceback 和能复现的三行数据都附在下面。 先不要改代码。列出 3 个可能导致这一行失败的原因,每个配一条验证方式:用一条命令或一行 print 就能跑出结果,并说明结果长什么样算它成立。 我没有告诉你的信息,标成“待确认”,不要替我假设。

“先不要改代码”把两件事拆开了:找错是一件事,改是另一件事。而它替你做的“找错”恰恰最不可靠——在一项专门测“能否指出推理链里哪一步错了”的研究里,GPT-4 逐步判断对错的准确率只有 52.87% 4。让它先交假设,你就不必相信它,只把它当候选清单用。

“怎么验证、结果长什么样”是关键。一条没法用运行结果判定的假设等于没有价值:“可能是编码问题”这种,改不改都能自圆其说,是噪声;“可能是 amount 列混进了非数字字符”可以判定,把那个值原样打出来看就行。

“标成待确认”是把它的填空行为挪到明面上。你不说数据是干净的,它就假设数据是干净的 [7d94a1b3]。让这些假设显形,比事后在 diff 里找省力得多。

第三步:一次验证一条,让结果说话

上面那个请求大概会换回三条候选。其中“表头列名对不上”可以先降权:列名对不上应该报 KeyError,而现在的报错说明它取到了字符串、只是转不成数字。剩下最吻合的一条,验证只要一行,放在 float() 之前:

1print(repr(row["amount"]))

跑那三行数据,输出是:

1'79.00'
2'1,299.00'
3'N/A'

为什么用 repr 而不是 print:print 把字符串原样吐到终端,末尾的空格、制表符、引号都不显示,而这类看不见的东西恰恰在真实导出文件里最常见;repr 打的是“这个值在代码里怎么写”,带引号、带转义,能看见的比 print 多一层。

这条输出一次否掉两个假设,还把剩下的问题劈成两半:'1,299.00' 是格式问题,'N/A' 是缺失问题。这就是原始事实比解释值钱的地方——它不站队,却能同时砍掉好几条路。

反过来,如果在第三步就开始改,会得到另一个结局。最顺手的一改是 float(row["amount"].replace(",", "")),报错确实消失了,但只消失一行:'N/A' 紧接着炸。更糟的是完全不炸的情况——某处金额写成欧陆格式 '1.299,00',去逗号后变成 '1.29900',float 高高兴兴接受,你的总额从此错得毫无声响。报错消失从来不是“修好了”的证据,它只说明问题从能看见的地方搬到了看不见的地方。

绘制中

第四步:改的时候限定范围,并要求一条能失败的测试

信息齐了,可以让它动手,但边界要写死:

按这三行数据修 load():amount 去掉千分位逗号再转 float;'N/A' 视为缺失,不要用 0 顶替,也不要用 try/except 吞掉,把缺失行单独计数一起返回。 只改这一个函数,export.py 其他地方先别动。 改完补一条测试,用这三行数据,断言金额和缺失计数都对;先确认这条测试在旧代码上是失败的。

“只改一个函数”不是洁癖,它保证这次因果链只有一环:改完还不对,你能确定问题不在别处。这也是上一篇那句话的延伸——你读不完的 diff,最后一定会被点通过。

“先确认测试在旧代码上失败”是整套流程里最便宜的一次验收:它同时证明这条测试真的在测这段逻辑,以及断言没有被顺手写成一句怎么都成立的话。

什么时候该停手

有个判据很好用:看报错在不在搬家。

改了 A 处,报错从 A 搬到 B;再改 B,报错又搬回 A——你追的是症状。症状会移动,根因不会。走到第二次搬家就该停手,回到第一步重新取事实。注意不是换一种改法,是换一批事实:把中间变量打出来,把输入再砍一半,在能跑和不能跑的两份输入之间找出那一条差别。

另一个该停手的信号,是你开始收到“应该可以了”“这样试试”这类没有验证方法的话。那不是信息,是噪声。

这套做法适用于有可观测事实的 bug:有报错、有输入、有预期。性能问题、只在某个环境复现的问题,事实不在报错里,得先想办法把事实造出来,方法一样,只是第一步更费劲。

它也解释了为什么“让它自己再看看”没有用:它重写的时候,看到的是自己上一版的输出,不是运行时发生了什么。