用 AI 调试:报错定位、二分排查与假设验证
报错摆在面前时,最容易犯的错是把「我这一段好像有问题」丢给 AI,然后拿到一段看起来对、跑起来不对的代码。调试效率不取决于 AI 多聪明,而取决于你给它的信息是否完整、是否可复现,以及你有没有把它当「假设生成器」而不是「答案生成器」。
第一步:完整报错 + 最小复现
两类信息缺一不可:
- 完整报错:从第一行到底部的 goroutine 栈都要贴,包错误链(wrapped error)也要保留。截掉
caused by那段,等于把关键线索扔掉。 - 最小复现:能单独跑起来的最少代码加一条命令。能稳定复现的问题才值得问 AI;偶发问题先想办法提高复现率(加日志、压并发、注入延迟)。
一个可直接套用的提示词:
【环境】Go 1.22、gin、MySQL 8.0.36、本地可复现
【期望】POST /api/order 返回 200 并写入 orders 表
【实际】返回 500,完整日志与 goroutine 栈见下
【最小复现】go run ./cmd/repro -case=order 必现,代码见下
【已排除】MySQL 连接正常(mysql 客户端手工 insert 成功)
【要求】先不要给修复代码:列出 3 个可能原因并按可能性排序,
每个原因给出一条能证实或证伪它的命令或日志观察点
反例是这种问法:「我的 Go 程序报错了,帮我看看」。AI 只能顺着最通用的模式猜,命中率和自己翻文档差不多。
第二步:先要假设,再逐个验证
把 AI 的输出限制成可验证的假设,而不是可粘贴的补丁:
| 阶段 | 你要它产出 | 你要做的动作 |
|---|---|---|
| 生成假设 | 3 个候选原因 + 可能性排序 | 挑最便宜验证的那条先做 |
| 设计验证 | 每个假设配一条验证手段 | 立刻执行并记录结果 |
| 收敛 | 根据结果确认或删除假设 | 每轮只动一个变量 |
要求它「可验证」是关键。能跑的命令、能看的日志、能加的断言才有价值;「可能是并发问题」这种话接不住也验不了,而「打开 -race 跑 100 次,若报 concurrent map writes 则确认」才叫假设。
一个典型例子:接口偶发 500,日志里是 sql: transaction has already been committed or rolled back:
func (s *OrderService) Create(ctx context.Context, req *CreateReq) (*Order, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback() // 提交后调用返回 ErrTxDone,可安全忽略
if _, err := tx.ExecContext(ctx, "INSERT INTO orders(order_no) VALUES(?)", req.OrderNo); err != nil {
return nil, fmt.Errorf("insert order: %w", err) // %w 保留错误链
}
if err := s.afterCreate(ctx, tx, req); err != nil {
return nil, err
}
return nil, tx.Commit() // 只提交一次,别在 afterCreate 里重复提交
}
可竞争的假设至少有三条:afterCreate 内部重复提交、ctx 超时导致驱动提前结束事务、连接池并发下事务对象被复用。每条都有对应的验证动作,比让 AI 直接重写整个函数靠谱得多。
第三步:二分排查
假设太多、范围太大时,用二分把范围砍半。三种方式按成本从低到高:
- 提交二分(Bisect):能用
git bisect就别用眼睛看。
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
git bisect run go test ./internal/order/... -run TestCreateOrder -count=1
git bisect reset # 定位到第一个 bad 提交后复位
- 代码二分:注释掉一半逻辑、短路掉一半分支,看报错是否消失。适合「不知道哪段代码触发」的情况。
- 数据二分:把输入对半砍,定位到具体哪个订单、哪条记录、哪个字段触发。适合「只有部分数据出错」的情况。
二分的每一步都可以让 AI 参与决策:把当前范围、上一轮结果、下一轮要砍的位置告诉它,让它给出切分点,而不是重新描述一遍问题。
什么时候别再问 AI,去读源码
| 信号 | 该做的事 |
|---|---|
| AI 连续两轮给同一批猜测 | 停下来,读堆栈里最底部那一帧的源码 |
| 报错发生在标准库/驱动/框架内部 | go doc、跳转源码、翻上游 issue |
| 涉及协议、字节序、SQL 语义等硬事实 | 查官方文档,模型记忆可能过期 |
| 竞态、内存越界等底层问题 | -race、pprof、调试器比对话有效 |
| 只在生产环境复现 | 先补可观测性(日志、指标、链路追踪),再谈定位 |
检查清单
| 检查项 | 判断标准 |
|---|---|
| 报错是否完整 | 首行 + 错误链 + goroutine 栈全部粘贴 |
| 是否最小可复现 | 一条命令能跑出同样结果 |
| 环境是否说清 | 语言版本、依赖版本、数据库版本 |
| 是否先要假设 | 拿到 3 条可验证假设再动手改 |
| 是否一次一个变量 | 每轮只改一处,改完立刻复跑 |
| 是否保留证据 | 每轮结果写下来,不靠记忆 |
| 是否设了止损点 | 两轮无进展就换策略(读源码、加日志、二分) |
常见坑
- 粘贴截断的日志:只贴最后一行
error: ...,丢掉真实触发点和 goroutine 栈。 - 让它「重写一遍」:大段重写会顺手改掉无关行为,修好一个问题引入两个。
- 拿日志当调试器:临时
log.Printf打满全屏且忘记清理;调试输出要标记清楚并及时删除。 - 丢失错误链:把
%w写成%v,之后errors.Is/As全部失效,AI 也判断不了根因。 - 相信幻觉配置项:AI 给的配置键名、启动参数、环境变量,必须先到官方文档核对再落地。
- 只在症状处改:在报错那一行加
if err != nil { return nil }把错误吞掉,问题只是被藏起来了。
小结:用 AI 调试的正确姿势是「你负责提供完整事实和复现,它负责提出可验证的假设」。先给完整报错与最小复现,逼它输出三个可验证的原因;范围不清就用提交、代码、数据三种二分砍半;连续两轮没进展就关掉对话,去读源码、加日志、上调试器。