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. 第一步不是搜代码先把自然语言编译成 BugSpecIssue 往往把现象、输入、期望、猜测甚至情绪混在一起。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 unittestfrom app.pricing import final_priceclass 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 ............. FAILtest_round_half_up ............. FAILtest_no_discount ............... oktest_zero_discount ............. oktest_exact_integer ............. oktest_negative_price_rejected ... oktest_invalid_discount_rejected . okRan 7 testsFAILED (failures2)第二个失败用例非常关键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.5int(16991.5) 也会得到 16991因为对正数它执行向零截断。因此可以形成一条完整证据链●用户报告19990 × 85% 期望 16992旧实现得到 16991。●边界实验101 × 50% 期望 51旧实现得到 50。●源码表达式int(...) 明确执行截断而业务要求 HALF_UP。●根因结论金额计算的数值表示与舍入策略没有被显式建模。这比“可能存在浮点精度问题”更强因为它是可以被反驳、被验证、被代码直接修正的工程结论。8. 第四步最小修复——把金额语义写进代码而不是针对样本打补丁修复实现from decimal import Decimal, ROUND_HALF_UPdef 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),roundingROUND_HALF_UP))图5最小补丁只改变金额计算语义为什么不用 round()因为金融/计费代码最重要的是把舍入规则显式写出来。Python 内置 round() 使用 bankers roundingties-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 ............. oktest_round_half_up ............. oktest_no_discount ............... oktest_zero_discount ............. oktest_exact_integer ............. oktest_negative_price_rejected ... oktest_invalid_discount_rejected . okRan 7 testsOK必须明确证据边界7/7 通过只证明当前测试集合覆盖的行为成立并不等于“系统绝对没有其他缺陷”。成熟 Agent 应把“已验证什么”和“没有验证什么”同时写进报告。10. 把流程固化成 Repair Agent 状态机图7Repair Agent状态机验证结果控制状态转换state PARSEwhile state not in {DONE, HUMAN}:if state PARSE:bug llm.to_bug_spec(issue_text)state REPRODUCEelif state REPRODUCE:repro llm.generate_repro_test(bug, repo_map)result tools.run_tests(repro)state LOCALIZE if result.failed_as_expected else HUMANelif state LOCALIZE:evidence tools.collect_evidence(keywordsbug.keywords,failing_stackresult.stack,symbolbug.entry_symbol,)hypothesis llm.propose_root_cause(bug, evidence)state PATCH if tools.can_verify(hypothesis) else HUMANelif state PATCH:patch llm.generate_patch(bugbug,evidenceevidence,max_files2,max_changed_lines80,)state VERIFYelif state VERIFY:report tools.verify(patch)if report.all_green:state DONEelif report.attempts 3:state HUMANelse: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. 复现用户报告的 Bug2. 基于仓库证据定位根因3. 生成最小补丁4. 完成定向测试与全量回归。硬约束- 没有稳定失败不修改生产代码- 根因必须引用失败栈、源码或最小实验- 默认最多修改 2 个生产文件、80 行- 不删除测试不弱化断言不关闭 CI- 测试未全绿不得宣告修复完成- 涉及数据迁移、权限、安全边界、公共 API 破坏时升级人工- 连续 3 轮失败后停止自动循环。注意Prompt 里的规则不能替代沙箱、文件白名单和命令过滤。模型“知道不应该删除测试”和系统“根本不给它删除测试的权限”是两个完全不同的安全等级。13. 六道工程护栏让 Agent 有能力但没有无限权限图8自动修复的权限与风险护栏建议把风险控制放在模型外部隔离工作区保证可回滚只读优先降低误写概率写入白名单控制修改半径危险命令 deny-by-defaultDiff 预算阻止修复漂移人工升级负责处理模型不应该独立决定的问题。尤其不要允许 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 dataclassfrom enum import Enum, autoclass State(Enum):PARSE auto()REPRODUCE auto()LOCALIZE auto()PATCH auto()VERIFY auto()DONE auto()HUMAN auto()dataclassclass Budget:max_files: int 2max_changed_lines: int 80max_attempts: int 3class RepairAgent:def __init__(self, planner, tools, budgetBudget()):self.planner plannerself.tools toolsself.budget budgetself.attempts 0def 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 1evidence self.tools.localize(bugbug,failing_stackbefore.stack,)patch self.planner.make_patch(bugbug,evidenceevidence,budgetself.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(bugbug,evidenceevidence,patchpatch,reportreport,)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 unittestfrom app.pricing import final_priceclass 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()