用 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 直接重写整个函数靠谱得多。

第三步:二分排查

假设太多、范围太大时,用二分把范围砍半。三种方式按成本从低到高:

  1. 提交二分(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 提交后复位
  1. 代码二分:注释掉一半逻辑、短路掉一半分支,看报错是否消失。适合「不知道哪段代码触发」的情况。
  2. 数据二分:把输入对半砍,定位到具体哪个订单、哪条记录、哪个字段触发。适合「只有部分数据出错」的情况。

二分的每一步都可以让 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 调试的正确姿势是「你负责提供完整事实和复现,它负责提出可验证的假设」。先给完整报错与最小复现,逼它输出三个可验证的原因;范围不清就用提交、代码、数据三种二分砍半;连续两轮没进展就关掉对话,去读源码、加日志、上调试器。

笔记加载中…