# 报错之后：把现场信息交出去，才能让它追根因，而不是一路瞎改

从完整 traceback、最小复现和一条带验证方法的假设清单，到“报错搬家”就该停手的判据

> AI 编程协作 · 调试 · 最小复现 · 约 7 分钟 · 10 月 21 日

## 本篇要点

1. 模型最不可靠的一环是“发现问题”，最可靠的一环是“拿到事实后生成改法”，所以调试的关键动作是把运行现场变成它可以核对的事实。
2. 完整的 traceback、能触发报错的那份输入，以及缩到不能再缩的最小复现，是它能一次用上的三样材料；只贴报错最后一行等于把信息量砍到最低。
3. 第一句话要让模型先交假设清单，每条假设必须配一个能跑出结果的验证方式，否则无法判定真假，只能变成又一轮猜测。
4. 一次只验证一条最吻合现象的假设，读原始输出（如 `repr` 打印出的字符串字面形式），一条结果常能同时否掉其他几条。
5. 报错消失不等于修好：去掉千分位逗号会让欧陆格式 `'1.299,00'` 静默变成 `1.29900`，问题只是从可见处搬到了不可见处。
6. 判断是否该停手的标准是“报错在不在搬家”：改一处、报错换到另一处再换回来，说明在追症状，应该退回重新取事实而不是继续改。

---

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

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

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

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

## 先看这个案例

`orders.csv`，真实导出有 12 万行，这里只留三行：

```
user_id,amount
u_101,79.00
u_102,"1,299.00"
u_103,N/A
```

`export.py` 里出问题的那个函数（其余部分省略）：

```python
import csv

def load(path):
    rows = []
    with open(path, newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            rows.append({
                "user_id": row["user_id"],
                "amount": float(row["amount"]),
            })
    return rows
```

跑 `python export.py` 的完整输出是：

```
Traceback (most recent call last):
  File "export.py", line 33, in <module>
    main()
  File "export.py", line 21, in main
    rows = load(path)
  File "export.py", line 9, in load
    "amount": float(row["amount"]),
ValueError: 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()` 之前：

```python
print(repr(row["amount"]))
```

跑那三行数据，输出是：

```
'79.00'
'1,299.00'
'N/A'
```

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

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

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

```mermaid
flowchart LR
  A["现场事实：完整 traceback、能触发的输入、最小复现"] --> B["假设清单：每条配一个能跑出结果的验证"]
  B --> C["跑一条验证，读原始输出"]
  C --> D{"结果能判定假设？"}
  D -->|"能"| E["限定范围改代码，补一条会失败的测试"]
  D -->|"不能"| A
  E --> F{"报错是消失了，还是搬家了？"}
  F -->|"原地消失"| G["通过"]
  F -->|"搬去别处"| A
```

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

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

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

“只改一个函数”不是洁癖，它保证这次因果链只有一环：改完还不对，你能确定问题不在别处。这也是上一篇那句话的延伸——[你读不完的 diff，最后一定会被点通过](https://pangzhengboyin.com/articles/reviewing-ai-generated-code-plausible-diff-d168d6b2)。

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

## 什么时候该停手

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

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

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

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

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

## 术语表

- 外部反馈：来自代码实际运行的结果，比如报错、日志、测试通过与否；只有这类信息能真正改变模型的下一步，它对自己输出的评价不算。
- 最小复现：把触发问题的输入和代码砍到不能再砍，同时保证别人照着这份材料能跑出同一个报错。
- 假设清单：让模型先列出可能的原因，每条附一个能跑出结果的验证方式，由你逐条排除，而不是先改代码。
- 报错搬家：改完一处后报错换到别的位置，说明追的是症状而不是根因。
- `repr`：打印一个值时显示它在代码里的写法，会带出引号、空格和转义字符，比 `print` 多暴露一层真实内容。

## 来源

1. [Teaching Large Language Models to Self-Debug — 执行反馈（含单元测试结果）对调试效果的作用](https://arxiv.org/abs/2304.05128v2)
2. [When Can LLMs Actually Correct Their Own Mistakes? — 自我纠错综述：只靠模型自生成反馈难以稳定纠错](https://arxiv.org/html/2406.01297v3)
3. [Large Language Models Cannot Self-Correct Reasoning Yet — 缺少外部反馈时自我纠错可能反而变差](https://arxiv.org/abs/2310.01798)
4. [LLMs cannot find reasoning errors, but can correct them given the error location — GPT-4 逐步判断对错准确率 52.87%](https://arxiv.org/abs/2311.08516)
5. [Go Wiki: How-to-ask-for-help — 贴完整失败输出，说明做了什么、预期什么、实际发生什么](https://go.dev/wiki/How-to-ask-for-help)
6. [Stack Overflow: How to create a Minimal, Reproducible Example — 最小、完整、可复现的复现材料](https://stackoverflow.com/help/minimal-reproducible-example)

---

原文：https://pangzhengboyin.com/articles/debugging-with-ai-facts-before-fixes-ad92c864

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
