小步开发测试与调试闭环
18

系统化调试:让 AI 基于证据排障

AI 写代码快,猜原因也快。

这在调试时很危险。

你贴一段报错:

TypeError: Cannot read properties of undefined (reading 'status')

AI 很可能马上给出修复建议:

这里加一个空值判断即可。

有时它是对的。更多时候,它只是把症状压下去了。真正的问题可能是上游查询没有返回订单、mock 数据漏字段、异步初始化顺序错了、接口响应 shape 变了,或者测试环境没有加载 fixture。

调试不是“看到错误就修”。调试是先证明问题从哪里开始,再修根因。

AI Coding 里的系统化调试,就是强制 Agent 先走证据循环:

复现 -> 收集证据 -> 缩小边界 -> 提出单一假设 -> 最小验证 -> 修根因 -> 回归

系统化调试证据循环

先禁止直接修

调试任务的第一条指令,应该是:

先不要修改代码。

这句话要放在最前面。

很多 Agent 默认会进入“修复模式”。它看到错误,就尝试补空值、改条件、重跑测试。如果第一次没过,再补一层判断。几轮之后,原始错误可能没了,但代码里多了一堆防御性分支,根因仍然存在。

调试输入可以这样写:

先不要修改代码。

请基于下面的错误输出做系统化排障:
- 读完整错误和堆栈。
- 找到触发命令。
- 说明是否能稳定复现。
- 找出最近相关 diff。
- 沿调用链追踪 `status` 从哪里来。
- 只提出一个最可能假设。
- 给出最小验证动作。

在我确认前,不要修复。

这能把 AI 从“猜修复”切到“找证据”。

复现要写成命令,不要写成感觉

很多调试失败,是因为复现不清。

坏记录:

订单取消测试偶尔失败。

好记录:

npm test -- order-cancel

输出:

FAIL test/order/order-cancel.test.ts
TypeError: Cannot read properties of undefined (reading 'status')
  at cancelOrder (src/order/order-cancel-service.ts:42:18)
  at test/order/order-cancel.test.ts:87:24

还要记录:

Reproduction:
- command: `npm test -- order-cancel`
- frequency: 5/5
- branch: `feature/paid-cancel-refund`
- last known passing state: before Task 3

如果不能稳定复现,不要让 AI 直接修。

应该先收集更多数据:

这个失败不是稳定复现。
请先提出可以增加的诊断信息:
- 日志位置
- 关键变量
- 组件边界
- 需要重复运行的命令
不要修改业务逻辑。

不稳定问题最怕随机补丁。先让它变得可观察。

读完整错误,不要只读最后一行

AI 很容易被错误最后一行带偏。

例如:

Cannot read properties of undefined (reading 'status')

最后一行说 status 是 undefined,但根因不一定在读取这一行。要看:

  • 哪个对象是 undefined。
  • 它从哪个函数返回。
  • 这个函数的输入是什么。
  • 输入为什么变了。
  • 最近 diff 是否改过 fixture、mock、查询条件或状态转换。

可以让 AI 这样做:

请完整读取堆栈。

输出:
- first failing line
- caller chain
- value that became undefined
- where this value is created
- which recent change could affect it

如果它直接给修复方案,要求它回到证据:

先不要修。
你还没有证明 `order` 为什么是 undefined。
请沿调用链往上追踪来源。

沿数据流往上追

深层错误要逆着调用链追。

比如错误发生在:

if (order.status === 'paid') {
  // ...
}

不要先加:

if (!order) throw new Error('order not found');

这可能只是把错误换了个形式。

先追:

order 从哪里来?

可能是:

const order = await orderRepository.findById(orderId);

继续追:

orderId 从哪里来?

可能是测试里:

const order = await fixtures.createOrder({ state: 'paid' });
await cancelOrder(order.id);

再追:

fixtures.createOrder 返回的 id 是否真实落库?

这时可能发现根因:Task 3 改了 fixture 默认字段,把 status 写成 state,repository 查询条件仍然按 status 查。

要修的不是 cancelOrder 的空值判断,而是 fixture 或 repository 的字段映射。

根因和症状的差别就在这里。

一次只提出一个假设

AI 调试时常见坏味道是一次给多个可能原因:

可能是数据未初始化、异步时序问题、mock 配置错误或状态字段不一致。

这没有错,但不能直接进入修复。

下一步要选一个最可能假设,并说明证据:

Hypothesis:
- `fixtures.createOrder` now writes `state`, but `orderRepository.findById` expects `status`.

Evidence:
- failing test creates order through fixture.
- stack shows `order` is undefined inside cancel service.
- recent diff changed fixture field names.
- repository query still filters by `status`.

Minimal verification:
- inspect the created fixture record before cancel.
- run only `npm test -- order-cancel -- --test-name-pattern paid`.

这才是可验证假设。

如果假设失败,不要叠加第二个补丁。回到证据:

这个假设不成立。
请记录验证结果,并基于新证据提出一个新的单一假设。

调试最怕“改一点再改一点”。每一轮必须能说明上一轮假设为什么成立或失败。

最小验证动作不是完整修复

假设阶段的验证动作应该尽量小。

例如怀疑 fixture 字段写错,可以先加临时诊断或直接查看测试数据,而不是马上改业务代码。

可用动作包括:

npm test -- order-cancel -- --test-name-pattern paid
git diff -- test/order/order-cancel.test.ts
rg -n "createOrder|status|state" test src

前端问题可以用 Playwright 调试:

npx playwright test order-cancel.spec.ts --debug

CI 问题可以先看 workflow logs。GitHub Actions 官方文档也把 workflow run logs 作为排障入口。不要在本地猜一个“可能的环境变量问题”,先看日志里哪一步失败、输入是什么、环境差异是什么。

最小验证的原则:

一次只验证一个变量。
不要把验证动作和修复动作混在一起。

修复前最好有失败用例

如果问题能被测试表达,先写失败用例。

例如你确认根因是 duplicate cancel 会创建两条退款请求,那么修复前先补:

test('duplicate paid cancellation creates only one refund request', async () => {
  const order = await fixtures.createOrder({ status: 'paid' });

  await cancelOrder(order.id);
  await cancelOrder(order.id);

  assert.equal(await refundRequests.countForOrder(order.id), 1);
});

然后再修。

如果问题很难写自动化测试,也要保留复现脚本、日志、截图或命令输出。不要只让 AI 写“已修复”。

修复阶段的指令可以写:

现在只修已确认根因:duplicate paid cancellation lacks idempotency.

要求:
- 保留刚才新增的失败测试。
- 只修改 refund request 幂等逻辑。
- 不改 API response shape。
- 不改 payment callback。
- 跑 `npm test -- order-cancel`。
- 完成后停止。

这和“修一下测试失败”完全不同。

多层系统要定位坏在哪一层

真实项目常见的调试不是单函数问题,而是多层链路:

前端页面 -> API -> service -> repository -> database

或者:

CI workflow -> build script -> test command -> environment variables

这时不要在最深处猜。

要按边界收集证据:

API 收到的 payload 是什么?
service 传给 repository 的参数是什么?
repository 返回了什么?
数据库里实际记录是什么?
测试 fixture 写入了什么?

可以让 AI 先画出链路:

先不要修。
请画出这条失败路径的数据流:
- test input
- service call
- repository query
- database record
- returned object

标出每一层当前证据和缺失证据。

然后只补缺失证据。

不要让 AI 直接“检查一下相关代码”。先让它补齐缺失证据。

证据要分三层

调试时,证据可以分成三层。

第一层是症状证据:

哪个命令失败。
哪条断言失败。
实际值和期望值是什么。
第一条错误在哪里。

第二层是路径证据:

失败路径经过哪些函数、组件、接口、配置。
本次 diff 改了哪些路径上的文件。
哪些路径没有被改。

第三层是根因证据:

哪个输入、状态、数据或环境变量导致错误。
用什么最小验证证明它就是根因。
修复后同一证据如何变化。

很多 AI 调试只停在第一层:看到错误,补一个判断。系统化调试要求至少走到第二层,复杂问题要走到第三层。

可以让 Agent 按这个格式输出:

Evidence layers:

Symptom:
- `npm test -- order-cancel` fails on duplicate paid cancellation.
- Expected refund request count 1, received 2.

Path:
- test -> `cancelOrder` -> `RefundRequestService.create`
- changed files include cancel service and refund request service.

Root cause candidate:
- refund request creation lacks idempotency by order id.

Minimal verification:
- call cancel twice and inspect refund request count.

这比“可能是幂等问题”更可审。

Debug worksheet 可以固定下来

调试过程不要只留在聊天里。

可以在任务目录里放一个 debug.md:

Debug worksheet

模板如下:

# Debug Worksheet

## Reproduction
- Command:
- Frequency:
- Environment:
- Last known passing state:

## Error
- First failing line:
- Full stack:
- Actual:
- Expected:

## Evidence
- Recent diff:
- Relevant files:
- Logs / screenshots:
- Data flow:

## Boundary
- Works:
- Fails:
- Unknown:

## Hypothesis
- I think:
- Because:

## Minimal Verification
- Command or check:
- Expected result:

## Result
- Confirmed / rejected:
- New evidence:

## Fix
- Root cause:
- Changed files:
- Why this fixes root cause:

## Verification
- Focused:
- Broader:
- Residual risk:

这个文件不需要每次都完整填写。遇到复杂 bug、CI 失败、线上问题、间歇性测试失败时,它能减少排障时的遗漏。

调试时怎么和 AI 对话

可以按阶段给指令。

复现阶段:

先不要改代码。
请读取失败输出,整理 reproduction、first failing line、caller chain。
如果信息不足,只列出需要补充的命令。

证据阶段:

请使用 `rg` 查找相关调用点和最近修改文件。
输出 working path 和 failing path 的差异。
不要提出修复。

假设阶段:

基于当前证据,只提出一个最可能根因。
写出支持证据、反证可能、最小验证动作。

修复阶段:

只修已确认根因。
不做无关重构。
保留失败测试。
跑指定验证命令。
完成后停止。

回归阶段:

请汇总:
- root cause
- changed files
- focused verification
- broader verification
- residual risk

这样做会让 AI 少猜很多。

什么时候要停下来回到设计

调试中经常会发现:问题不是一个 bug,而是设计假设错了。

比如订单取消失败,看起来是空指针,追下去发现根因是:

paid -> refund_requested 需要事务保证,
但当前实现先更新订单状态,再创建退款请求。
退款请求失败后,订单已经变成 refund_requested。

这不是加空值判断能解决的问题。它涉及状态更新和副作用顺序,应该回到 design。

出现下面情况,要停止调试并升级为设计问题:

  • 修复需要改变事务边界。
  • 修复需要改变 public API response shape。
  • 修复需要新增状态、事件或数据库字段。
  • 根因涉及多个入口共用规则。
  • 当前 tests 无法表达正确行为,需要重写 requirement。
  • 需要引入新依赖或新基础设施。

这时可以写:

Debug result:
- Symptom: refund request failure leaves order in wrong state.
- Root cause: state is persisted before side effect succeeds.
- This is a design boundary issue, not a local null-check bug.

Action:
- stop implementation.
- update design.md failure handling.
- update tasks.md with transaction / rollback behavior.
- add failing test before changing implementation.

AI 调试时最容易一路修下去。程序员要判断这是不是还在 bug 修复范围内,还是已经进入设计变更。

CI 失败不要直接本地乱改

CI 失败经常和本地不同。

常见差异包括:

  • Node、Java、Python 版本不同。
  • 环境变量缺失。
  • 数据库、浏览器、系统依赖不同。
  • 测试顺序不同。
  • 时区、locale、文件路径不同。
  • 缓存或依赖锁文件问题。

看到 CI 红,不要直接说:

修复 CI。

更好的输入:

先不要改代码。
请根据 CI 日志做排障:
- 哪个 job、step、command 失败。
- 本地是否有同样命令。
- CI 环境和本地环境差异。
- 最近 diff 是否影响该 step。
- 最小复现命令是什么。
- 一个最可能假设是什么。

如果本地无法复现,就不要假装已经验证。要写清:

Not locally reproduced.
Evidence from CI log:
- ...
Next verification:
- rerun failed job after config change

这比“本地测试通过,应该没问题”可靠得多。

间歇性失败先提高可观察性

间歇性失败最容易被 AI 随机修坏。

比如:

order-cancel E2E occasionally fails waiting for 退款处理中.

不要马上加超时。

先收集:

  • 失败频率。
  • 失败时网络请求是否成功。
  • 前端状态是否更新。
  • 后端日志里订单状态是什么。
  • 测试数据是否唯一。
  • 是否有并发测试污染。

可以让 Agent 先做诊断计划:

先不要修改业务逻辑。
请为这个 flaky test 设计诊断步骤:
- 需要增加哪些临时日志或 trace。
- 需要重复运行哪个命令多少次。
- 如何区分前端等待问题、接口失败、数据污染和异步竞态。
- 哪些诊断改动最终必须删除。

Playwright 的 trace、截图、视频、网络日志都能帮助定位。后端测试可以加唯一 id、隔离数据库、固定时间和随机数。

只有确认是等待条件不准确,才考虑调整等待逻辑。没有证据时直接把 timeout 从 5 秒改 30 秒,通常只是把问题藏起来。

修复后要删除诊断噪音

调试过程中可能会加日志、临时断言、临时脚本。这些东西不一定都该进最终 diff。

修复完成后,让 Agent 自查:

请检查本次 diff 中是否还有临时诊断内容:
- console.log / print / debug output
- temporary comments
- skipped tests
- widened timeouts
- broad catch blocks
- diagnostic-only scripts

如果保留,请说明为什么它是生产需要,而不是调试残留。

调试残留很常见,尤其是 AI 修复多轮失败后。它可能留下过宽的 catch、过长的 timeout、无意义日志,表面上测试通过,代码质量下降。

真正的修复应该比调试过程干净。

修复说明要能被 reviewer 复查

调试完成后,不要只写“修复了空指针”。

可复查的修复说明应该包含:

Root cause:
- `fixtures.createOrder` wrote `state`, but repository lookup expected `status`.

Fix:
- align fixture field with production schema.
- keep repository query unchanged.

Why this is root cause:
- before fix, created test record lacked `status`.
- after fix, `orders.find(order.id)` returns the paid order.

Verification:
- `npm test -- order-cancel -- --test-name-pattern paid`: PASS
- `npm test -- order-cancel`: PASS

Not changed:
- cancel service null handling
- API response shape
- payment callback flow

这段说明能让 reviewer 复查:修的是根因,不是把症状掩盖掉。

如果修复说明写不出“为什么这就是根因”,说明调试还没完成。

回归验证不能只跑失败用例

失败用例通过,只能证明当前症状消失。

修复后至少要跑两层:

focused reproduction:
- 证明原失败用例通过。

neighbor regression:
- 证明相邻旧行为没坏。

订单取消例子里,修 duplicate paid cancellation 后,不能只跑 duplicate 测试。还要跑 pending、paid、shipped、refund failure 这些相邻路径。

可以要求 Agent:

修复后请运行:
1. 原失败用例。
2. 同文件全部测试。
3. 受影响模块测试。

分别说明每个命令证明什么。

如果只跑原失败用例,很可能把一个 bug 修成另一个 bug。

一个 CI 失败案例:本地绿,CI 红

假设本地验证通过:

npm test -- order-cancel
PASS

但 CI 失败:

FAIL test/order/order-cancel.test.ts
duplicate paid cancellation creates one refund request
Expected: 1
Received: 2

不要让 AI 直接“修 CI”。先让它整理差异:

CI evidence:
- job: test-linux
- command: `npm test -- order-cancel`
- failing test: duplicate paid cancellation creates one refund request
- actual: 2 refund requests
- expected: 1 refund request

Local evidence:
- same command passed locally.

Diff surface:
- `src/order/order-cancel-service.ts`
- `src/refund/refund-request-service.ts`
- no lockfile change
- no CI config change

Environment difference:
- CI runs tests in parallel.
- local run uses default serial mode.

这时一个合理假设是:幂等逻辑在并发情况下不成立。

最小验证不是改代码,而是先复现并发:

Minimal verification:
- run duplicate cancellation test with parallel workers or concurrent requests.
- inspect whether idempotency key has unique constraint.
- inspect refund request creation transaction.

修复可能需要数据库唯一约束、事务或 upsert。这个结论已经超出“本地绿 CI 红”的表面现象。

如果直接加 sleep、延长 timeout、重试测试,可能让 CI 暂时绿,但生产里的重复退款问题仍然存在。

这个案例说明:CI 失败不是麻烦,它经常暴露本地没覆盖的执行条件。让 AI 基于 CI 证据排障,才能避免把真实并发问题当成环境问题。

给 Agent 的排障指令可以固定成四段

复杂调试时,直接贴错误让 AI 发挥,通常会得到一堆猜测。可以把指令固定成四段。

第一段,先锁住只读:

先不要修改代码。
你现在只做排障,不做修复。

第二段,要求复现和证据:

请整理:
- failing command
- first failing line
- actual vs expected
- stack / caller chain
- changed files that may affect this failure

第三段,要求单一假设:

只提出一个最可能根因。
必须写支持证据和反证可能。
不要列 5 个可能原因后直接修。

第四段,要求最小验证:

给出一个最小验证动作。
验证动作不能同时修改业务逻辑。
验证完成前不要进入修复。

完整指令可以这样写:

先不要修改代码。

请对下面失败做系统化排障:
1. 记录 failing command、first failing line、actual vs expected。
2. 沿调用链找出值从哪里来。
3. 对照 git diff,判断失败路径是否经过本次修改。
4. 只提出一个最可能根因。
5. 给出最小验证动作。

输出排障报告,不要修复。

这段指令的重点不是格式,而是阻止 AI 从错误直接跳到补丁。

排障报告的最低标准

一次排障报告至少要包含:

## Reproduction
- command:
- frequency:
- environment:

## Failure
- failing test / scenario:
- actual:
- expected:
- first failing line:

## Path
- caller chain:
- data source:
- changed files on path:

## Hypothesis
- root cause candidate:
- supporting evidence:
- possible counter-evidence:

## Minimal verification
- command / check:
- expected result:

## Stop condition
- what would make this a design issue:

如果 Agent 输出里没有 command、actual/expected、caller chain,只给修复建议,直接退回。

调试工作不是靠“看起来懂了”推进,而是靠每一步证据能被复查。

不要把防御性代码当成修复

AI 调试时最常见的“假修复”是加防御性代码。

比如:

if (!order) {
  return null;
}

或者:

if (!order?.status) {
  return { status: 'unknown' };
}

这类代码可能让报错消失,但它没有解释为什么订单不存在,也没有证明业务应该返回 null 或 unknown。

防御性代码只有在满足下面条件时才合理:

  • 这个输入在生产中确实可能为空。
  • requirements 或接口合同定义了空值行为。
  • 有测试覆盖空值路径。
  • 不会掩盖上游数据错误。

否则它只是把异常藏起来。

可以要求 Agent:

如果你建议加 guard clause,请先证明:
- 空值是合法业务输入,还是上游 bug。
- 合法时应该返回什么。
- 哪个测试覆盖这个分支。
- 为什么不是修上游数据来源。

这条规则能挡住大量“看起来稳妥”的错误修复。

线上问题要保留可复现材料

线上问题不一定能在本地完全复现,但也不能只靠文字描述。

至少保留:

request id / trace id
时间范围
用户角色或租户
关键日志
输入 payload
实际响应
相关数据快照
当前版本或 commit

给 Agent 时,先脱敏,再让它基于这些材料找路径。

先不要修改代码。
基于以下脱敏 trace,找出请求经过的代码路径和可能的状态来源。
不要根据业务常识猜测缺失字段。

线上排障尤其不能让 AI 直接改。它缺少生产上下文,必须先把 trace、日志、数据和代码路径对齐。

调试后的 PR 描述要写根因,不写过程流水

PR 里不用贴完整 debug worksheet,但要留下能审的摘要。

Root cause:
- Duplicate paid cancellation created two refund requests because refund creation was not guarded by an idempotency key.

Fix:
- Use `order:<orderId>:cancel` as idempotency key.
- Reuse existing refund request when the key already exists.

Verification:
- `npm test -- order-cancel`: PASS
- duplicate paid cancellation test added
- refund failure path still keeps order paid

Not changed:
- payment provider callback
- public API response shape

这样的摘要比“修复重复取消问题”更有价值。它让 reviewer 能判断修复是否对着根因,测试是否覆盖风险,边界是否保持。

调试完成后的上线前检查

调试修复进入交付前,再做一遍短检查:

Root cause:
- 是否一句话说得清。

Patch:
- 是否只改根因相关文件。
- 是否没有临时日志、skip、only、扩大 timeout。

Regression:
- 原失败用例是否通过。
- 相邻路径是否通过。
- broader command 是否跑过或记录了原因。

Risk:
- 是否改变接口、数据、权限、副作用。
- 是否有未验证外部依赖。

这段检查适合放在 PR 自查里。

AI 可以协助生成自查报告,但你要用 diff 和命令输出核对。尤其要看它有没有为了让测试通过而保留不该保留的诊断代码。

如果调试修复很小,也至少保留:

root cause + changed files + verification command

没有这三项,后续再出问题时很难判断当时修了什么。

还有一个简单判断:如果你删掉修复代码,原失败用例不会重新失败,那这个用例就没有锁住根因。此时要补回归测试,或者至少保留可复现脚本。否则这次调试只是一次临时处理,不能算真正闭环。

如果修复依赖外部条件,例如特定时区、特定浏览器、特定 CI runner,也要把条件写进记录。否则后续换环境后,同一个问题会被重新误判。

还有一种检查很有用:反证检查。

修复后可以让 Agent 回答:

如果这个根因判断是错的,哪些现象应该不会出现?
当前证据里有没有反例?
有没有其它假设也能解释同一组现象?
我们是否验证过这些假设?

这不是让流程变学术,而是防止“第一个看起来合理的解释”变成根因。线上问题、CI 偶发失败、异步竞态、缓存问题,尤其需要这一步。

反证检查还可以帮助你判断是否应该继续让 Agent 修改代码。如果仍有多个假设能解释同一现象,下一步应该是补证据,而不是补补丁。

常见错误

错误一:看到报错就补空值判断。

空值判断可能需要,但先证明为什么为空。

错误二:一次改多个可能原因。

同时改 fixture、service、repository,最后不知道哪个修好了问题。

错误三:没有复现命令。

没有复现,就没有可靠验证。

错误四:只读最后一行错误。

堆栈、上游调用、最近 diff 才能定位根因。

错误五:调试过程只留在聊天记录。

复杂问题要沉淀 debug worksheet,否则后面很难复盘。

错误六:CI 失败按本地经验猜。

先看 CI job、step、command、环境差异和日志。

改完后重点看什么

一次调试完成后,看这些结果:

  • 是否有稳定复现或明确说明无法稳定复现。
  • 是否定位到 first failing line 和上游来源。
  • 是否提出并验证过单一假设。
  • 修复是否针对根因,而不是只压症状。
  • 是否有失败用例或等价复现证据。
  • focused 和 broader verification 是否都跑过。
  • 是否记录了 residual risk。

如果这些都没有,只是 AI 改完后测试绿了,你还不能确信它修对了。

调试的目标不是让错误消失,而是让错误为什么出现、为什么被修复、为什么不会回归,都有证据。

常见问题

简单 bug 也要写 debug worksheet 吗?

不需要。简单、稳定复现、根因明显的问题,可以直接记录命令和修复。但只要开始猜,或者第二次修还没好,就应该写下来。

AI 已经知道怎么修,还要先排查吗?

让它先说证据。能用代码、日志、堆栈证明的“知道”,才值得相信。

没有测试怎么验证修复?

先争取写最小复现测试。写不了就保留脚本、日志、截图、手工步骤和预期结果。

调试阶段可以加日志吗?

可以,但要区分诊断日志和最终代码。临时日志用于定位,修复后要删除或转成有意义的结构化日志。

多个假设都很可能怎么办?

按最小验证成本排序,一次验证一个。不要把多个修复混在一个 diff。

可复查资料

搜索全部 42 篇文章。

查看配图