“LLM 代码自动修复”这个话题,我最近半年反复搭过好几套方案。说实话,真正跑起来之后你会发现,让大模型给出修改代码反而是整条链路里最轻松的一环,真正的难点在于缺陷怎么精确定位、上下文怎么组织、修复完怎么验证、以及怎么防止模型把代码改得更糟。这篇文章把我个人从零搭建代码自动修复流水线的全过程、踩过的坑和最终沉淀下来的方案完整写出来,关注点会放在“可复现”和“能落地”上。适合正在被一堆静态告警淹没的团队、维护老项目想引入自动修复的人,以及想把 CI 自动化再往前推一步的工程师。
1. 代码自动修复的整体思路:先搞清楚它替你做的是哪一段
1.1 自动修复不是一个“输入报错、输出代码”的黑盒
很多人第一次接触 LLM 自动修复时,脑子里浮现的画面是:把编译错误或者测试失败的信息直接扔给模型,模型花几秒钟吐出一段正确的代码,然后一切搞定。我一开始也是这么想的,直到被本地验证现实教育了几次之后,才逐渐把系统拆成了四个环节:缺陷定位 → 上下文组织 → 补丁生成 → 修复验证。
在这四个环节里,LLM 真正承担的只有“补丁生成”这一步。它不是从一个空白的代码库里凭空变出解药,而是在你既定的问题范围内做一次概率性的修改推断。所以整个流水线的设计重心并不在“怎么调用模型”,而在于:
- 怎么把缺陷位置描述清楚,让模型不至于大海捞针;
- 怎么把相关代码上下文组织好,让模型看得懂“函数在干什么”;
- 怎么设计验证关卡,让模型生成的结果能通过语法检查、测试回归和静态分析三道关;
- 怎么在修复失败时带着错误信息重新组织输入,而不是无脑重试。
如果把四步闭环理解为“定位 → 修复 → 验证 → 收敛”,那么 LLM 只是这中间那个执行修复动作的“手”。其余三步都在你手里,都需要用工程手段解决。想清楚这一点,就不会被网上那些看起来非常酷炫、但没有任何验证层的 Demo 带偏节奏。
1.2 不同的缺陷来源,决定了修复流程的接入方式
另外一个很容易被忽略的点是:并不是所有“代码问题”都适合用同一种方式自动修复。我实践中把它们分成三类,每一类的上下文结构、验证手段、适合的批量方式都不一样。
第一类是静态分析工具报出来的告警。这类问题的特点是:位置精确、规则明确、输出通常是结构化的 JSON 或行号。比如未使用变量、空指针风险、资源未关闭等等。因为告警本身就带着文件路径、行号和规则编号,上下文组织起来非常方便,适合做成批量流水线,一次性处理几十上百条告警。
第二类是单元测试失败。问题定位是最大的难点,因为失败信息通常是 stack trace,对应的根因往往不在测试调用的那一层,而在更深的函数调用链里。这类修复需要把“失败的测试用例 + 被测函数 + 相关辅助函数”组合起来作为上下文,验证手段也最硬:能跑通的测试必须变绿。
第三类是代码评审意见。这种输入是自然语言,模型理解难度最高,但修复意图也最明确。比如“这里需要处理边界情况”“变量命名不符合规范”这类意见,往往对应的是语义层面的修改,不是一两行能解释清楚的。这类场景更适合交互式,也就是在人工审查的辅助下,逐条让模型给出修改建议,而不是全自动批量执行。
三类缺陷源我用一个表格来表示它们的区别,方便你选择切入场景:
| 缺陷来源 | 问题定位难度 | 上下文组织难度 | 验证手段 | 适合自动化程度 |
|---|---|---|---|---|
| 静态分析告警 | 低,行号与规则明确 | 低,局部代码就够 | 静态分析重新扫描 | 高,可批量全自动 |
| 单元测试失败 | 高,根因可能在调用链深处 | 中高,需要函数与测试堆栈 | 跑测试 | 中,可全自动但需兜底 |
| 代码评审意见 | 中,语义理解为主 | 高,需要结合意图 | 人工确认 | 低,适合人机交互 |
我的建议是,第一类场景作为切入点最稳。因为它天然有“小步快跑”的条件,即使修错了,回滚成本也很低。第二类适合作为进阶目标,涉及的工程复杂度很高。第三类暂时不要勉强自动化,先通过辅助模式发挥作用,避免误改造成更大的维护负担。
2. 修复前的关键准备:缺陷定位与上下文组织
2.1 缺陷定位:给模型指路,别让它大海捞针
即使是最强的模型,在面临“一个项目里某个地方有 bug,你来找找”这样开放式的问题时,表现也不会好。因为 LLM 的注意力窗口有限,同时整个代码仓库的规模和隐含的依赖关系远远超出它的有效理解范围。所以修复流程的第一件事,不是“问模型怎么修”,而是通过工具先把可疑范围锁死。
我常用的做法是组合两个层面的信息。第一层是静态分析工具输出精确定位信息,比如文件路径、行号区间、规则类型、涉及的符号名称。第二层是版本管理系统的 diff 信息,也就是最近几次提交里改了哪些文件、哪些函数。把这两个信息交集起来,基本就能确定“嫌疑函数”集合。
举个例子,如果一个项目最近刚改过一个工具函数,而这个函数又触发了空指针告警,那么根因大概率就在这次改动的逻辑里。这时候我把告警内容、函数代码、以及最近的 diff 一起喂给模型,修复成功率会明显高于直接给报错行号。
还有一个细节:行号在模型眼里并不重要,重要的是函数名和上下文关系。模型阅读理解代码的方式是按语义块来的,不是按行数来的。所以在组织输入时,不要只截取报错那一行,要往上往下各带一段完整结构,最好是按函数整体截取,而不是按行号截取。
2.2 什么样的上下文才能让模型“看懂”这个问题
上下文组织是我试下来最影响成功率的一个变量,但它也同时受两个约束:模型上下文窗口上限,以及每次投入的成本。所以我最终采用的原则是“最小充分上下文”,也就是只给模型能够理解和修复这个缺陷所需的最少量代码。
一个标准的修复上下文包含四个部分:
- 被修复的完整函数或方法体;
- 该函数直接调用的辅助函数定义;
- 报错信息或被违反的规则说明;
- 如果修复目标是让测试通过,还要附上失败的测试用例代码。
很多人第一次写自动修复脚本时,习惯把整个文件、甚至整个目录塞给模型。这样做不仅浪费 token,还会因为无关代码干扰模型的注意力,让它在真正需要关注的地方反而丢了细节。我实测发现,上下文只要包含以上四块,成功率能稳定保持在七成以上,如果再加到整个文件的级别,成功率反而可能因为噪声太多而下滑。
这里还有个实用技巧:先给最小上下文,验证失败后再扩上下文。比如第一次修复没让测试变绿,第二次重试时,可以把错误信息原样带回,再补充一层上游调用链。这种“渐进式上下文扩展”比一次性给满的做法更省钱,也更容易收敛。因为很多时候模型第一次已经找对了大概方向,只是缺少某个细节,这时候你把缺失的那块补给它,比重新换一套上下文要高效得多。
3. 修复 Prompt 的设计与参数调整
3.1 一段可复用的修复 Prompt 模板
Prompt 是 LLM 自动修复里最容易被低估的环节。很多人把报错信息和代码拼在一起就直接丢给模型,输出质量完全靠运气。我经过多轮调试后沉淀了一套比较稳定的模板结构,核心思路是把模型当成一个“有经验的开发者”而不是“代码生成器”。
repair_prompt = """ 你是一名资深后端工程师,正在修复一个代码缺陷。 请严格遵循以下要求: 任务描述: 以下代码触发了{rule_type}告警(或测试失败),失败信息为: {error_message} 需要修复的代码: ```python {code_snippet}修复约束:
- 只修改与缺陷直接相关的代码,不要改动无关的函数或逻辑;
- 保持函数签名、参数名称和调用方式不变;
- 不要引入需要额外安装的依赖;
- 修复结果必须是完整可编译的代码,用代码块输出。
请直接输出修复后的完整函数。在代码块之外不要附加任何解释。 """.format( rule_type=rule_type, error_message=error_message, code_snippet=code_snippet, )
这段模板里的关键设计有三个。第一个是把“任务描述”和“代码块”分开,让模型明确知道它要处理的职责是什么;第二个是把“修复约束”单独列出来,用否定式表达限制模型擅自改动无关部分;第三个是要求直接输出修复后的完整函数,不给解释。避免模型输出一大段分析但最后代码是错的,也方便后续程序做代码提取。 实际测试下来,模板里的约束句子数量在三条左右效果最好。约束太少模型会自由发挥,约束太多反而会让模型瞻前顾后,输出质量下降。上面的模板基本能做到“开箱即用”,具体场景下你可以把 `rule_type` 换成实际的告警规则名,比如空指针、未闭合资源、边界判断等。 ### 3.2 采样参数怎么调:temperature、top_p、max_tokens LLM 的采样参数对代码修复的影响,比很多人想象中要大得多。我几次调试下来,结论是:**自动修复场景必须偏向保守,而不是偏向创新**。 `temperature` 是最核心的参数,默认值在不同模型里通常是 0.7 或 1.0,但自动修复场景我一般把它压在 0.2 到 0.3 之间。这个值会让模型倾向选择概率最高的 token 组合,输出更稳定、更符合常见代码模式。如果温度偏高,模型可能产生“创造性修复”,也就是把好的代码改成另一个风格也很好的代码,但这两个风格之间没有行为等价性,很容易引入隐性 bug。 `top_p` 我保持在 0.9 附近。它和 temperature 是配合使用的,控制的是候选 token 的累加概率范围。取值太接近 1.0 会引入过多罕见词,取值太接近 0 会让模型过于保守,连代码缩进和括号结构都可能导致输出不稳定。 `max_tokens` 要根据被修复函数的规模来设。如果函数大概在几十行左右,1500 到 2000 个 token 就够了;如果修复的是一个大的类方法,可能需要 4000 以上。这里有一个容易踩的坑:很多模型在超出 max_tokens 后会自动截断输出,截断位置可能正好在代码中间,导致生成的代码直接没法编译。我看到过不少自动修复系统在统计成功率时被这个问题拖垮,明明模型已经给出正确思路,结果就被截断了。 除了调这三个参数,我还会使用“多次采样投票”的技巧:同一个修复请求跑 3 到 5 次,把生成的代码放一起对比。如果多个结果在关键行的修改一致,那基本可以确定这个改动是符合模型预期的;如果每次结果差异很大,说明模型对这个问题也不确定,这时候强行采纳某一次结果很容易翻车,不如把问题升级到人工处理。 ### 3.3 Few-shot 示例和“坏示例”的作用 纯靠指令描述约束,模型还是可能产出一些不太符合预期的代码。这时候给一个 few-shot 示例,成功率会有明显提升。示例的作用是给模型一个“输出风格的锚点”:看完示例后,模型会更倾向模仿示例中的修改方式。 我给修复场景设计了两类示例。正面示例展示“前后对比”的修复方式,让模型模仿这种局部修改的思路;负面示例则明确告诉它什么不要做,比如“不要改变函数签名”“不要重新格式化无关行”“不要删除注释”。负面示例配上一句简短解释,往往比在约束列表里多写三条“禁止”都管用,因为模型更容易从具体代码里学到模式。 ```python example_prompt = """ 示例一: 原始代码: def get_user_name(user_id): if user_id: return user_id return None 修复后: def get_user_name(user_id): if user_id: return user_id return "" """别小看这种看起来非常简单的示例,它给模型传递的是“你只需要局部改动”这个信息。这个信息比任何文字描述都更能约束模型的行为边界,减少过度修复的情况发生。
4. 从单次修复到可运行的自动修复流水线
4.1 核心循环:修复、验证、回滚
有了 prompt 模板和采样参数还不够,把“单次修复”变成“自动修复流水线”,需要实现一个带验证闭环的核心循环。这个循环我命名为“修复-验证-回滚”三阶段,是整个系统的骨架。
def repair_loop(initial_snippet, error_message, max_attempts=3): current_code = initial_snippet for attempt in range(max_attempts): prompt = build_repair_prompt(current_code, error_message) generated = call_llm(prompt) candidate_code = extract_code_from_output(generated) if not is_valid_syntax(candidate_code): error_message = "生成的代码存在语法错误,请重新生成" continue test_result = run_tests_for(candidate_code) if test_result.passed: return candidate_code, attempt + 1 else: error_message = f"测试仍然失败,失败信息:{test_result.error}" current_code = candidate_code return None, max_attempts这个代码逻辑很直白,但有几个地方值得展开讲。第一个是current_code的更新策略:每次失败后,我把“本轮修复结果”赋给current_code,下一次 prompt 中让模型在上一轮结果的基础上继续修,而不是回退到原始代码重新来。这样可以避免模型重复犯同样的错误。代价是如果中途某次生成引入了很严重的偏离,后面几次可能越修越糟。所以我在循环里额外加了一个“偏离检测”:如果新的代码和上一版之间 diff 的行数超过了阈值,比如 20 行,说明改动幅度过大,直接回退到原始代码重来。
第二个关键点是对“测试仍然失败”这种信息的利用。很多实现只是简单地把同样错误信息再发一遍给模型,模型大概率会给出同样的修复,陷入死循环。正确做法是把新产生的错误信息追加到上下文中,让模型看到“你上一次的修复结果是什么,新的失败信息是什么”,它才有机会从新的信息中学到东西。
4.2 验证关卡怎么设计:语法、测试、静态分析三层
自动修复流水线最影响最终效果的部分,其实是验证关卡。我对“修复成功”的定义是:语法能编译、原有测试不变红、静态分析没新增问题。三层都要过,才算修复成功。
第一层语法检查是最廉价的,可以在本地用解析器直接跑一遍,比如对 Python 代码做 AST 解析,对前端代码做编译检查。这一层能在几毫秒内排除掉模型生成的不完整代码截断、缺括号、缩进错误等问题。
第二层跑相关测试。如果项目已经有测试,直接针对修复影响到的模块跑一遍单元测试;如果项目测试覆盖率不高,可以写一个最小复现脚本,也就是把触发缺陷的输入场景固化成一个小用例,挂在修复循环里反复跑。
第三层静态分析很重要,但很多人会忽略。LLM 修复一个空指针告警后,很可能解决了表面问题,却引入了另一条路径上的资源泄漏或者类型不一致。修复完成后重新跑一遍静态分析,对比修复前后的告警集合,只有告警数量没有增加、且目标告警消失,才算真正过关。
三层验证的执行顺序也有讲究:语法检查放最前面,静态分析其次,测试最后跑。因为堆栈、断言这些错误信息本身也能作为下一轮修复的上下文,如果先跑测试,得到的错误信息包含的信息量更大,后续迭代修复也更有依据。当然在工程实现里,测试通常更耗时,所以先做完前两关,再跑测试,能省不少计算资源。
4.3 控制成本与并发:别让自动修复拖垮账单
自动修复不是一个完全没有成本的工程决策。每次调用模型都会产生 token 开销,多轮重试、多次采样,成本会成倍叠加。所以流水线在设计时就要考虑成本治理。
第一是“先干便宜的事”:能用本地静态规则判断的,不要动用模型。比如行尾分号缺失、引号不匹配这类问题,一个格式化工具几毫秒就能修复,完全没有必要让模型参与。LLM 应该花在真正的逻辑缺陷上。
第二是缓存。模型对同一个 prompt 的响应可以缓存起来,避免同一个告警在重复跑流水线时反复计费。这个听起来像废话,但如果你把修复系统接到 CI 上,每次提交都触发一次全量告警修复,没有缓存的话,一个月跑下来 bill 会非常吓人。
第三是并发控制。批量修复上百个告警时,如果一次全部打给模型 API,很可能触发限流,甚至造成服务不稳定。我建议把并发限制在 3 到 5 个请求以内,加一个简单的信号量控制就好。也别让单个任务无限重试,建议最多跑三轮,超过就挂起给人工。
5. 我在实战中踩过的坑和排查方法
5.1 症状一:模型输出格式不稳定,代码块里混着解释文字
最开始搭系统时,我发现模型输出偶尔会出现“解释一段、贴一段代码、再解释一段”的情况。我的提取代码逻辑只认第一个代码块,结果把纯解释文字当成代码拿去跑语法检查,失败率直线上升。
后来我加了一个强约束:要求模型只输出一个代码块,并且代码块外不允许有任何解释。即使这样,仍然有少量输出格式不标准,所以我额外写了解析兜底逻辑:优先提取第一个代码块内容;如果没有代码块,就按函数边界做文本截取;如果提取结果语法校验失败,就判定本轮生成无效,重新请求一次。
这个“重新请求一次”很关键,它给格式异常留了一个恢复路径,比单纯依赖模型自觉更可靠。
5.2 症状二:模型“看似修好了”,但测试还是失败
这种情况是最常见的。模型给出的代码形态上很正确,变量名也对,没有语法错,但跑测试依然失败。我排查后发现,大多数时候原因是上下文里缺少测试用例本身的约束。模型看到的是一个孤立的函数,它并不知道这个函数到底要满足什么行为,于是“修”出来的代码在语法上完美,但行为上还是不符合测试期望。
正确的做法是把失败测试的断言信息直接放到 prompt 里,让模型自己推理要满足的行为约束。甚至可以把一个精简版的最小用例写进上下文,让模型看着它来改。模型需要理解“什么是对”,才能输出“对的代码”,而不是只靠编程风格猜测。
5.3 症状三:模型把没坏的地方改坏了
这是自动修复系统口碑变差的最大原因,术语叫“过度修复”。模型在修复一个缺陷时顺手把周围的代码风格也改了,或者把一个方法重构成它认为更好的写法,但引入了一堆潜在风险。
我在 prompt 里明确加了“只改直接相关的部分,不要重排代码顺序”,但这样的文字约束并不足够。后来我加了 diff 范围校验:修复前后的 diff 如果涉及函数声明、公共签名、import 列表、或超过 15 行的非必要改动,就直接判定为修复异常并回滚。这是硬性规则,不是让模型自觉。实际运行中,这个方法把因为过度修复导致的问题率降到了很低的水平。
5.4 症状四:循环一直重试,永远不收敛
有些缺陷模型反复修不对,每次生成一个新版本,带着上一个版本的错继续改,最终进入死循环。我给修复循环设置了严格的终止条件:最多尝试 3 次,超过次数直接标记为“需人工介入”,并保留最后一次模型给出的分析性文字,方便开发者照着手动修。
另外,我在输入 prompt 时会附带一条“如果当前代码已经符合要求,请原样输出”的提示。这条提示防止了模型在代码已经正确时为了证明自己有存在价值而强行修改。看似荒诞,但真的发生过。模型收到了一个几乎正确的版本,却因为“用户要求修复”而在正确代码上乱动,最后把测试改红了。
5.5 常见问题速查表
| 症状 | 可能原因 | 对策 | 一句话心得 |
|---|---|---|---|
| 输出带着解释文字,提取失败 | 格式约束不严格 | 强制代码块输出,非法格式重试一次 | 解析兜底比纯粹让模型守规矩更靠谱 |
| 修复后测试仍失败 | 上下文缺测试行为约束 | 补充断言和最小复现用例到 prompt | 让模型知道“对”是什么样子 |
| 无关代码被大改 | 过度修复 | diff 范围校验,超阈值回滚 | 别让模型在正确代码上发挥创造力 |
| 重试多次无进展 | 循环死锁 | 最大轮数限制,挂起人工处理 | 不收敛的问题及时捞出来给人处理 |
| 多进程并发触发限流 | 并发过载 | 信号量限流,控制请求频率 | 自动修复也要讲基本法,别硬怼 API |
6. 从离线脚本到 CI 集成的实战建议
6.1 接入 CI 的路线,以及先做什么后做什么
如果把修复脚本只在本地跑,它能帮到的场景很有限,真正能发挥价值的是接入 CI,在每次代码变更后自动对新增告警做修复尝试。但这里我强烈建议分两步走,不要一上来就全自动。
第一步是做“修复提案”,也就是让 CI 在告警发生后自动生成修复建议,然后以评论或工单的形式通知开发者,由人来决定是否采纳。这个模式能积累成功率数据,也避免误改造成的线上事故。第二步等修复成功率稳定到七成以上,再考虑对低风险的告警类别做全自动合并、自动跑完整测试、自动提交分支。
选择从哪类告警开始做全自动,也会直接影响落地难度。资源关闭、空指针判空、未使用变量这类规则明确、改动范围小的告警,适合优先全自动;涉及业务逻辑的大范围重构类告警,永远保留人工确认环节。这不是模型能力不够,而是风险评估的结果。
6.2 人工介入的兜底设计
自动修复系统做得再好,也必须有人工兜底。我设计了一个简单的人工介入机制:修复失败的告警会汇总到一个待处理列表中,附带上模型尝试的每一轮结果、错误信息、以及模型最后给出的分析性文字,方便人快速判断问题方向。这个列表每天汇总一次,不打断正常开发流程。
人工介入还有一个作用:为模型积累修复样本。你把人工最终修改后的真值保存下来,以后遇到类似告警时,可以作为 few-shot 示例或者微调数据重新注入到系统中,让模型行为逐渐向“人工认可的方向”靠拢。这就是整个系统的自我进化之路,靠的不是更强的基座模型,而是对“什么样的修复是合格的”这个标准做持续积累。
6.3 后续可以做的扩展方向
这套流水线跑稳之后,可以朝几个方向扩展。第一个是引入增量缓存:同一个仓库里频繁出现同类告警,把修复结果缓存成模板,后续新的告警直接匹配历史修复方案,能省掉大量模型调用。第二个是结合本地轻量模型与商用模型做分级:简单告警走本地模型,复杂问题再调更强模型,成本可以降到单一大模型方案的二成。第三个是构造真实历史缺陷数据集,把修过的 bug 变成训练样本,对本地模型做指令微调。这三个方向我都在探索中,最推荐先做第一个,收益最直接,工程成本也低。
回到最开始说的那句话:LLM 自动修复真正难的不是模型,而是流水线。模型给你的是一个未经检验的候选答案,你把上下文组织好、把验证关卡设计好、把回滚机制搭扎实,这条路才能真正走通。根据我个人的经验,最有用的一个细节是把每一次失败的日志完整保存下来,不要急着清理。很多后来看起来“为什么模型又修不对了”的问题,翻出日志对比一下,答案基本都在里面。