AI自动修Bug实战:从一句Bug描述到复现、根因定位、最小修复与回归验证
AI编程 Agent 智能体 Bug修复 软件测试 自动化测试 Python 代码审查 软件工程 持续集成
一个 Bug 只有一句自然语言描述时,AI 能不能真正完成“复现—定位—修复—验证”,而不是只生成一段看似合理的代码?本文用一个可运行的金额舍入缺陷,从零搭建 Repair Agent 闭环:先把用户描述编译成 BugSpec,再生成稳定失败的回归测试;随后通过仓库地图、符号检索、失败栈和最小实验建立根因证据,用 Diff 预算约束补丁范围,最后依次执行原始复现、边界测试、模块测试和全量回归。示例中,旧实现 7 个测试有 2 个失败,修复后 7/7 通过。文章进一步给出状态机、工具接口、停止条件、权限护栏、失败回退和 CI/PR 接入方式,让 AI 修 Bug 从“代码生成演示”变成可复现、可审计、可回滚的工程流程。
1. 一个“只差 1 分钱”的 Bug,为什么值得做成完整闭环
凌晨的订单告警里出现一条投诉:原价 199.90 元的商品打 85 折后显示 169.91 元,但业务规则要求金额按“分”四舍五入,正确结果应该是 169.92 元。
这类 Bug 对人类开发者并不难。真正困难的是:如果只把这句话交给 AI,它怎样证明自己不是“碰巧改对”?
●它必须先把用户描述变成可执行的验收条件,而不是直接猜代码。
●它必须在补丁前制造一个稳定失败,否则“测试通过”没有证明力。
●它必须给出根因证据,而不是只说“可能是浮点精度”。
●它必须控制修改半径,避免顺手重构导致新的回归面。
●它必须用补丁后的全量回归结果,证明修复没有破坏已有行为。
所以,AI 自动修 Bug 的核心问题不是“模型会不会写代码”,而是能不能把一次修复变成一条有入口、有证据、有门禁、有退出条件的工程流水线。
图1AI自动修复闭环:每一步都必须留下可审计证据
2. 先看最终结果:旧实现 5/7 通过,修复后 7/7 通过
验证项 | 输入/条件 | 期望 | 旧实现 | 修复后 |
用户报告场景 | 19990 分,85% | 16992 分 | 失败:16991 | 通过 |
0.5 舍入边界 | 101 分,50% | 51 分 | 失败:50 | 通过 |
无折扣 | 12345 分,100% | 12345 分 | 通过 | 通过 |
零折扣 | 12345 分,0% | 0 分 | 通过 | 通过 |
整分结果 | 20000 分,85% | 17000 分 | 通过 | 通过 |
非法价格 | -1 分,85% | 抛 ValueError | 通过 | 通过 |
非法折扣 | 100 分,101% | 抛 ValueError | 通过 | 通过 |
这里最重要的不是“7/7”这个数字,而是验证集合同时覆盖了原始现象、舍入边界、正常路径和输入不变量。只有原始用例转绿还不够,因为针对单个样本写特判,同样可以让一个测试通过。
3. 最小工程:先把实验环境缩到任何人都能复现
为了把重点放在闭环本身,示例只依赖 Python 标准库。目录如下:
bugfix-demo/
├─ app/
│ ├─ __init__.py
│ └─ pricing.py
├─ tests/
│ └─ test_pricing.py
└─ repair_agent.py
示例适用于 Python 3.11+;核心 Decimal 与 unittest API 在 Python 3.x 中长期稳定。真实项目若换成 pytest、Java/JUnit、Go testing 或前端测试框架,闭环结构不变,只需要替换工具适配层。
4. 第一步不是搜代码:先把自然语言编译成 BugSpec
Issue 往往把现象、输入、期望、猜测甚至情绪混在一起。Repair Agent 的第一步应该是抽取“可验证事实”,并把未知信息显式保留为 unknown,而不是自行补全。
图2从一句自然语言投诉到结构化BugSpec
{
"module_hint": "pricing",
"entry_symbol": "final_price",
"input": {
"price_cent": 19990,
"discount_percent": 85
},
"actual": 16991,
"expected": 16992,
"invariants": [
"金额最小单位为分",
"0.5 边界按 ROUND_HALF_UP 处理",
"price_cent 不允许为负数",
"discount_percent 必须位于 0..100"
],
"acceptance": [
"用户报告场景由红转绿",
"舍入边界测试通过",
"全量回归通过"
]
}
BugSpec 不是为了把 JSON 写得漂亮,而是为了统一后续所有动作的目标:测试生成看它、根因判断看它、补丁评审看它、最终验收仍然看它。长链路 Agent 最怕目标在多轮推理中漂移,结构化契约就是第一道防漂移机制。
5. 第二步:没有稳定的“红”,就不允许改生产代码
原始错误实现如下:
def final_price(price_cent: int, discount_percent: int) -> int:
if price_cent < 0:
raise ValueError("price_cent must be >= 0")
if not 0 <= discount_percent <= 100:
raise ValueError("discount_percent must be in [0, 100]")
return int(price_cent * (discount_percent / 100))
先写测试,不改函数:
import unittest
from app.pricing import final_price
class PricingTest(unittest.TestCase):
def test_reported_case(self):
self.assertEqual(final_price(19990, 85), 16992)
def test_round_half_up(self):
self.assertEqual(final_price(101, 50), 51)
def test_no_discount(self):
self.assertEqual(final_price(12345, 100), 12345)
def test_zero_discount(self):
self.assertEqual(final_price(12345, 0), 0)
def test_exact_integer(self):
self.assertEqual(final_price(20000, 85), 17000)
def test_negative_price_rejected(self):
with self.assertRaises(ValueError):
final_price(-1, 85)
def test_invalid_discount_rejected(self):
with self.assertRaises(ValueError):
final_price(100, 101)
执行:
python -m unittest discover -s tests -v
补丁前的关键结果:
test_reported_case ............. FAIL
test_round_half_up ............. FAIL
test_no_discount ............... ok
test_zero_discount ............. ok
test_exact_integer ............. ok
test_negative_price_rejected ... ok
test_invalid_discount_rejected . ok
Ran 7 tests
FAILED (failures=2)
第二个失败用例非常关键:101 分 × 50% 的数学结果是 50.5 分,业务规则要求进位到 51 分。它把问题从“199.90 这个输入有问题”提升成“整个函数的舍入语义有问题”。
6. 第三步:用证据漏斗定位,而不是把整个仓库塞给模型
图3证据漏斗:搜索空间逐层收缩
层级 | Agent 动作 | 产物 | 停止/继续条件 |
仓库地图 | 读取目录、测试入口、构建配置 | 模块地图 | 找到 pricing 业务域 |
关键词检索 | price、discount、cent、amount | 候选文件集合 | 候选仍多则继续 |
符号检索 | final_price 定义与引用 | 定义/调用者 | 锁定执行入口 |
失败栈 | 读取 failing test stack | 真实调用路径 | 与 BugSpec 对齐 |
局部读取 | 目标函数上下文 | 可疑表达式 | 提出可证伪假设 |
最小实验 | 单独计算边界输入 | 假设证据 | 能解释全部失败才进入 PATCH |
这种定位方式解决两个现实问题:一是减少 Token 和上下文噪声;二是让“根因”可以追溯。模型不能因为看到一个可疑函数就直接修改,它需要先解释为什么这个函数能够同时解释已知失败。
7. 根因分析:真正的问题是“截断”与“业务舍入规则”不一致
图4旧实现的截断语义与业务要求的四舍五入语义
很多人看到金额 Bug 会第一时间说“不要用 float”。这个方向没有错,但如果只停在这里,分析仍然不够准确。对本例而言,即使中间结果恰好是 16991.5,`int(16991.5)` 也会得到 16991,因为对正数它执行向零截断。
因此可以形成一条完整证据链:
●用户报告:19990 × 85% 期望 16992,旧实现得到 16991。
●边界实验:101 × 50% 期望 51,旧实现得到 50。
●源码表达式:`int(...)` 明确执行截断,而业务要求 HALF_UP。
●根因结论:金额计算的数值表示与舍入策略没有被显式建模。
这比“可能存在浮点精度问题”更强,因为它是可以被反驳、被验证、被代码直接修正的工程结论。
8. 第四步:最小修复——把金额语义写进代码,而不是针对样本打补丁
修复实现:
from decimal import Decimal, ROUND_HALF_UP
def final_price(price_cent: int, discount_percent: int) -> int:
if price_cent < 0:
raise ValueError("price_cent must be >= 0")
if not 0 <= discount_percent <= 100:
raise ValueError("discount_percent must be in [0, 100]")
amount = (
Decimal(price_cent)
* Decimal(discount_percent)
/ Decimal(100)
)
return int(
amount.quantize(
Decimal("1"),
rounding=ROUND_HALF_UP
)
)
图5最小补丁:只改变金额计算语义
为什么不用 `round()`?因为金融/计费代码最重要的是把舍入规则显式写出来。Python 内置 `round()` 使用 bankers rounding(ties-to-even)语义,而这里的业务规则明确要求 HALF_UP。代码应该表达业务,而不是让读者猜运行时默认规则。
9. 第五步:验证要像金字塔,不能只跑刚新增的那个测试
图6四层验证:从原始复现到全量回归
层级 | 目的 | 命令示例 |
原始复现 | 证明用户报告场景由红转绿 | python -m unittest ...test_reported_case -v |
边界测试 | 证明根因修复而非样本特判 | python -m unittest ...test_round_half_up -v |
模块测试 | 检查 pricing 邻近行为 | python -m unittest tests.test_pricing -v |
全量回归 | 发现跨模块副作用 | python -m unittest discover -s tests -v |
修复后的实际结果:
test_reported_case ............. ok
test_round_half_up ............. ok
test_no_discount ............... ok
test_zero_discount ............. ok
test_exact_integer ............. ok
test_negative_price_rejected ... ok
test_invalid_discount_rejected . ok
Ran 7 tests
OK
必须明确证据边界:7/7 通过只证明当前测试集合覆盖的行为成立,并不等于“系统绝对没有其他缺陷”。成熟 Agent 应把“已验证什么”和“没有验证什么”同时写进报告。
10. 把流程固化成 Repair Agent 状态机
图7Repair Agent状态机:验证结果控制状态转换
state = "PARSE"
while state not in {"DONE", "HUMAN"}:
if state == "PARSE":
bug = llm.to_bug_spec(issue_text)
state = "REPRODUCE"
elif state == "REPRODUCE":
repro = llm.generate_repro_test(bug, repo_map)
result = tools.run_tests(repro)
state = "LOCALIZE" if result.failed_as_expected else "HUMAN"
elif state == "LOCALIZE":
evidence = tools.collect_evidence(
keywords=bug.keywords,
failing_stack=result.stack,
symbol=bug.entry_symbol,
)
hypothesis = llm.propose_root_cause(bug, evidence)
state = "PATCH" if tools.can_verify(hypothesis) else "HUMAN"
elif state == "PATCH":
patch = llm.generate_patch(
bug=bug,
evidence=evidence,
max_files=2,
max_changed_lines=80,
)
state = "VERIFY"
elif state == "VERIFY":
report = tools.verify(patch)
if report.all_green:
state = "DONE"
elif report.attempts >= 3:
state = "HUMAN"
else:
tools.rollback_patch()
state = "LOCALIZE"
关键点在于:`DONE` 不是模型可以自由输出的一句话,而是由验证工具的结构化结果触发。这样即使模型非常自信,也不能越过门禁。
11. Agent 到底需要哪些工具
第一版不要追求“万能 Shell”。越通用的工具越难控制权限,也越难让输出结构化。六类窄接口已经足够覆盖大部分低风险缺陷:
工具 | 输入 | 输出 | 权限 |
repo_map | 仓库根目录 | 目录、语言、测试入口 | 只读 |
search_code | 关键词/正则 | 文件、行号、片段 | 只读 |
find_symbol | 符号名 | 定义、引用、调用者 | 只读 |
read_file | 路径 + 行范围 | 局部源码 | 只读 |
run_tests | 测试选择器 | 退出码、日志、失败栈 | 执行 |
apply_patch | 统一 Diff | 修改后的隔离工作区 | 受限写入 |
show_diff | 基线与当前工作区 | 文件级差异 | 只读 |
rollback | 当前补丁 | 恢复干净基线 | 受控写入 |
工具输出尽量结构化。例如测试工具不要只返回一大段 stdout,而应额外返回 `exit_code`、`failed_tests`、`stack`、`duration`。结构化结果越多,模型越少需要从日志文本中猜状态。
12. Prompt 只负责约束推理,真正的权限必须由程序控制
你是代码修复 Agent。
目标:
1. 复现用户报告的 Bug;
2. 基于仓库证据定位根因;
3. 生成最小补丁;
4. 完成定向测试与全量回归。
硬约束:
- 没有稳定失败,不修改生产代码;
- 根因必须引用失败栈、源码或最小实验;
- 默认最多修改 2 个生产文件、80 行;
- 不删除测试,不弱化断言,不关闭 CI;
- 测试未全绿,不得宣告修复完成;
- 涉及数据迁移、权限、安全边界、公共 API 破坏时升级人工;
- 连续 3 轮失败后停止自动循环。
注意:Prompt 里的规则不能替代沙箱、文件白名单和命令过滤。模型“知道不应该删除测试”和系统“根本不给它删除测试的权限”是两个完全不同的安全等级。
13. 六道工程护栏:让 Agent 有能力,但没有无限权限
图8自动修复的权限与风险护栏
建议把风险控制放在模型外部:隔离工作区保证可回滚;只读优先降低误写概率;写入白名单控制修改半径;危险命令 deny-by-default;Diff 预算阻止修复漂移;人工升级负责处理模型不应该独立决定的问题。
尤其不要允许 Agent 为了“让 CI 变绿”修改测试断言、跳过测试或关闭 lint。对自动修复系统而言,测试是裁判,Agent 不能同时拥有修改裁判规则的无限权限。
14. 失败分支同样重要:复现不了时,正确答案不是编一个测试
情况 | 正确行为 | 错误行为 |
缺少输入数据 | 列出缺失字段,请求最小样本 | 自行编造生产数据 |
环境差异 | 记录 OS、版本、配置、依赖差异 | 默认本地等于线上 |
间歇性失败 | 重复运行并记录触发频率/条件 | 一次没失败就判定不存在 |
外部依赖不可用 | 使用明确的 mock 或升级人工 | 静默跳过依赖 |
补丁引入新失败 | 回滚,保留失败证据 | 继续叠加更多修改 |
连续多轮失败 | 停止并生成证据包 | 无限循环直到“看起来成功” |
一个可靠 Agent 的价值不仅体现在成功修复,还体现在失败时能准确说清楚“停在哪里、缺什么、下一步需要谁决定”。
15. 如何防止“测试投机”:别让 Agent 学会只为测试写代码
自动修复还有一个典型风险:模型观察到测试输入后,写出能通过测试但不满足真实业务语义的补丁。例如针对 19990 和 85 直接返回 16992。
可以用三类机制抑制这种行为:
●测试多样化:原始用例之外加入边界、等价类和不变量测试。
●隐藏验证:CI 中保留 Agent 不可见的回归测试,避免完全针对公开断言优化。
●语义审查:要求补丁解释“为什么修改能推广到同类输入”,并检查是否出现硬编码样本值。
对于金额、时间、权限、并发等高风险逻辑,还可以加入属性测试(property-based testing)或差分测试,让验证从几个例子升级为一组性质。
16. 一个更接近真实项目的 repair_agent.py
下面给出一个不依赖具体大模型 SDK 的骨架。`planner` 可以替换为任意支持结构化输出的模型,`tools` 则封装仓库操作。
from dataclasses import dataclass
from enum import Enum, auto
class State(Enum):
PARSE = auto()
REPRODUCE = auto()
LOCALIZE = auto()
PATCH = auto()
VERIFY = auto()
DONE = auto()
HUMAN = auto()
@dataclass
class Budget:
max_files: int = 2
max_changed_lines: int = 80
max_attempts: int = 3
class RepairAgent:
def __init__(self, planner, tools, budget=Budget()):
self.planner = planner
self.tools = tools
self.budget = budget
self.attempts = 0
def run(self, issue_text: str):
bug = self.planner.to_bug_spec(issue_text)
repro = self.planner.make_repro(bug, self.tools.repo_map())
before = self.tools.run_tests(repro.selector)
if not before.failed_as_expected:
return self.tools.handoff("无法稳定复现", bug, before)
while self.attempts < self.budget.max_attempts:
self.attempts += 1
evidence = self.tools.localize(
bug=bug,
failing_stack=before.stack,
)
patch = self.planner.make_patch(
bug=bug,
evidence=evidence,
budget=self.budget,
)
if not self.tools.patch_within_budget(patch, self.budget):
return self.tools.handoff("补丁超出预算", bug, evidence)
self.tools.apply_patch(patch)
report = self.tools.verify_all()
if report.all_green:
return self.tools.build_report(
bug=bug,
evidence=evidence,
patch=patch,
report=report,
)
self.tools.rollback()
return self.tools.handoff("超过自动修复轮次", bug, evidence)
这个骨架刻意把“模型能力”和“工程控制”分开:planner 负责生成候选计划,tools 负责执行事实动作,RepairAgent 负责状态和门禁。以后更换模型时,安全边界不需要跟着重写。
17. 上下文工程:大型仓库不要一次性读取全部源码
上下文层 | 包含什么 | 何时加载 |
L0 仓库地图 | 目录、语言、构建命令、测试入口 | 任务开始时一次 |
L1 任务切片 | 关键词命中、失败栈、目标符号 | 复现后加载 |
L2 邻接上下文 | 调用者、被调用者、邻近测试 | 定位阶段按需 |
L3 扩展证据 | 配置、接口、历史提交、覆盖率 | 证据不足时再加载 |
这类分层比“扩大上下文窗口”更重要。窗口再大也不等于信息选择正确。Repair Agent 应该像调试者一样逐步取证,而不是像搜索引擎一样把所有相关文本一次性塞进提示词。
18. 团队落地:第一阶段的目标应是自动生成可评审 PR
图9Issue → Repair Agent → CI → PR的团队级闭环
在真实团队中,最稳妥的自治边界通常不是“自动合并”,而是“自动生成高质量 PR”。Agent 负责把机械但耗时的工作做完:复现、搜索、定位、补丁、测试、报告;人负责业务判断和最终合并。
PR描述可以固定包含:
●BugSpec:输入、实际、期望、不变量。
●复现证据:失败测试名、命令、关键失败日志。
●根因:文件、符号、表达式和为什么能解释全部失败。
●补丁:修改文件、统一 Diff、修改语义。
●验证:定向、边界、模块、全量回归结果。
●风险:没有覆盖的路径、外部依赖、版本边界。
●回滚:恢复到哪个提交或如何撤销补丁。
19. 哪些 Bug 适合自动闭环,哪些应该尽早交给人
类型 | 自动化适配度 | 原因 |
纯函数/算法边界错误 | 高 | 输入输出清晰,容易构造失败测试 |
解析、格式化、校验错误 | 高 | 可用样例和不变量验证 |
强测试覆盖的业务逻辑 | 较高 | 回归证据充分 |
依赖外部服务的集成问题 | 中 | 需要稳定 mock 或测试环境 |
并发、竞态、性能抖动 | 中低 | 复现成本高,证据易受环境影响 |
数据库迁移/数据修复 | 低 | 副作用大、回滚复杂 |
鉴权/支付/安全边界 | 低 | 风险高,应强制人工决策 |
架构级重构 | 低 | 目标开放,难以用有限测试定义完成 |
自动化程度应该由“风险 × 可验证性”决定,而不是由模型看起来有多聪明。低风险、强测试、明确验收的任务可以更自治;高风险、弱测试、开放目标的任务应该更早升级人工。
20. 修复报告:最终产物应该长什么样
{
"status": "fixed",
"reproduction": {
"failed_before_patch": true,
"failed_tests": [
"test_reported_case",
"test_round_half_up"
]
},
"root_cause": {
"symbol": "app.pricing.final_price",
"reason": "int() 截断与 HALF_UP 业务舍入规则不一致"
},
"patch": {
"files_changed": 1,
"strategy": "Decimal + ROUND_HALF_UP"
},
"verification": {
"targeted": "pass",
"module": "pass",
"full_regression": "7/7 pass"
},
"remaining_risk": [
"当前示例未覆盖多币种规则",
"真实生产系统应按币种配置最小货币单位"
]
}
这份报告比“已修复”三个字更有价值,因为它可以被 PR、CI、审计系统和下一次 Agent 任务继续消费。
21. 从这个 1 分钱 Bug,可以抽象出一套通用修复协议
阶段 | 输入 | 必须产物 | 门禁 |
解析 | Issue/告警 | BugSpec | 验收条件可执行 |
复现 | BugSpec + 仓库 | 失败测试 + 失败栈 | 补丁前稳定失败 |
定位 | 失败证据 | 根因假设 + 源码证据 | 能解释全部已知失败 |
修复 | 根因证据 | 最小 Diff | 不超预算、不越权限 |
验证 | 补丁 | 分层测试报告 | 全量回归通过 |
交付 | 全部证据 | PR/修复报告 | 风险与回滚明确 |
这套协议与语言、框架、模型无关。你可以把 Python 换成 Java,把 unittest 换成 JUnit,把本地仓库换成 CI worktree,把单 Agent 换成 planner + executor;只要这六个阶段和门禁仍然存在,系统就保留了可审计性。
22. 最后:AI 修 Bug 的终点不是“代码变了”,而是“证据闭合了”
判断一个自动修复系统是否真正可用,可以只问四个问题:为什么相信 Bug 被复现?为什么相信这里是根因?为什么相信这个补丁是必要且足够的?为什么相信它没有破坏已有行为?
对应的工程答案分别应该是:失败测试、定位证据、最小 Diff、回归报告。
当这四份证据能够自动产生、彼此对齐,并且状态机不允许模型绕过它们时,AI 才真正从“帮你写几行代码”进入软件工程闭环。低风险缺陷可以因此被大幅自动化;高风险问题即使不能自动修,也能在交给人之前完成复现、收集证据和缩小范围。
真正值得自动化的,从来不只是最后那一下提交代码,而是从一句模糊的 Bug 描述,到一个可验证、可解释、可回滚结论之间的全部工程动作。
附录 A 完整测试文件
import unittest
from app.pricing import final_price
class PricingTest(unittest.TestCase):
def test_reported_case(self):
self.assertEqual(final_price(19990, 85), 16992)
def test_round_half_up(self):
self.assertEqual(final_price(101, 50), 51)
def test_no_discount(self):
self.assertEqual(final_price(12345, 100), 12345)
def test_zero_discount(self):
self.assertEqual(final_price(12345, 0), 0)
def test_exact_integer(self):
self.assertEqual(final_price(20000, 85), 17000)
def test_negative_price_rejected(self):
with self.assertRaises(ValueError):
final_price(-1, 85)
def test_invalid_discount_rejected(self):
with self.assertRaises(ValueError):
final_price(100, 101)
if __name__ == "__main__":
unittest.main()