把历史 review 拒掉的改动做成负样本,微调后模型还会犯同样的错吗
如果只用被拒改动作负样本,微调后模型确实会显著减少“一字不差地复现同一类坏模式”的概率,但别指望它就此学会“不犯错”。更常见的结果是:模型绕开了这几个具体坑,转头掉进相邻的坑里。原因不复杂——负样本只告诉模型“不要这样写”,没告诉它“应该怎么写”,而代码生成是一个在巨大空间里做条件采样的过程,单纯压制某些路径,剩余概率质量会流向别处,不一定是更安全的地方。
负样本能改变什么,不能改变什么
先说我做过的一个实验。我们团队维护一个 Go 服务,历史 review 里有一类高频驳回:错误处理时把 err 打日志后继续执行,导致后续逻辑在零值上跑,线上出过两次事故。我把过去一年里 214 条这类驳回改动抽取出来,去掉上下文后构造成 (code_before, rejected_diff) 对,再用 LoRA 在 Qwen2.5-Coder-7B 上做了一轮偏好优化,正样本是同一批 PR 最终合入的版本。
微调前,让模型写一个“读配置失败时降级”的函数,10 次采样里有 7 次出现“log 后继续用空配置”的模式。微调后同样的 prompt 采样 10 次,这个模式一次都没出现。听起来很好,但仔细看输出:模型开始写 if err != nil { return nil, err } 直接返回,或者更微妙地,在降级路径里构造一个默认配置但没有把降级行为通知给调用方。后者在 review 里同样会被打回,只是理由从“吞错误”变成了“静默降级不可观测”。
这个结果很说明问题。负样本训练本质是在做局部概率压制:模型学会了“看到 if err != nil 后面跟 log.Error 再继续往下走”这个 token 序列时降低概率。但它没有学到“错误处理的正确姿势是向上传递或显式降级并记录”,因为这部分信息在负样本里不存在——负样本只展示了坏路径,没有展示坏路径对应的好路径。除非你的正样本覆盖了足够多的“正确替代方案”,否则模型只是从一个坏习惯迁移到另一个坏习惯。
为什么“不要这样”不够
代码生成模型的输出是 token 级别的条件概率链。当你用负样本告诉它“不要生成 A 模式”,它学到的是一组局部的 token 抑制规则。但代码写作是强结构化的:一个函数里总得有错误处理、总得有返回值、总得有资源释放。如果模型在“错误处理”这个位置被抑制了 A 模式,它必须用别的 token 填充这个位置。填充出来的东西好不好,取决于模型在“非 A”的空间里有没有学到好的分布。
而 review 负样本几乎不会覆盖“非 A”空间的正确分布。一条被拒的改动,通常只包含坏代码本身,不包含 reviewer 给出的修改建议。即便你有完整的 review 评论,把它对齐到 token 级别做训练也是一个工程难题。所以模型被训练的信号是“A 不好”,而不是“B 好、C 好、D 取决于场景”。在概率空间里,你只是在 A 上挖了一个坑,没在别处填土。
还有一个更隐蔽的问题:负样本的分布偏差。历史 review 里被拒的改动,往往集中在几个高频模式上——错误处理、空指针、资源泄漏、并发问题。这些模式被反复压制后,模型在生成代码时会过度回避某些本应合理出现的写法。我见过一个微调后的模型,写任何涉及文件读写的代码都要先 os.Stat 检查存在性,再 os.Open,再 defer f.Close(),再显式 f.Sync()——这些单独看都没问题,但组合起来就是过度防御,函数体膨胀三倍,review 时被批“冗余、啰嗦”。这就是负样本训练的另一面:模型学会了避坑,但没学会判断坑在什么场景下才是坑。
怎么构建负样本才更有效
如果目标是“降低同类坏模式的比例”,单纯把被拒 diff 塞进偏好对里做 DPO 或对比学习,效果有,但天花板低。我实际测下来,下面几个做法能把效果往前推一截。
第一,负样本必须配高质量正样本,比例至少 1:2。 光有“不要这样”没有“要这样”,模型学不到替代方案。正样本最好是同一条 PR 里 reviewer 最终接受的版本,而不是随便从仓库里找一段“好代码”。因为同一个函数、同一个上下文、同一个意图,模型才能学到“在这个具体场景下,正确的做法是什么”。我用的正负样本比例是 2:1,如果正样本不足,宁可用 reviewer 评论里明确指出的修改方向来构造合成正样本,也不要拿不相关的代码凑数。
第二,负样本要按模式聚类,不要一股脑全给。 214 条驳回改动里,吞错误、空指针、goroutine 泄漏、忽略 context 取消是四个主要模式。如果混在一起训练,模型学到的信号是“这些 token 序列都不好”,但它没有能力归纳出“吞错误不好”这个抽象规则——它只是在四个具体模式上各自挖了坑。按模式分组,每个模式单独做一轮微调或至少在同一批数据里做标记,效果会更好。我按模式分组后,同一模式下的负样本只需 30-50 条就能看到明显压制效果,混在一起则需要 150 条以上。
第三,构造“近邻负样本”比直接用原始被拒 diff 更有效。 原始被拒 diff 往往带有大量与坏模式无关的噪声——变量命名、注释风格、代码顺序。这些噪声也会被模型学到“不好”,造成误伤。我的做法是:对每条被拒 diff,用模型自己生成 5 个“功能等价但写法不同”的变体,然后人工标出哪些变体同样包含坏模式、哪些不包含。把包含坏模式的变体作为负样本,不包含的作为正样本。这样模型学到的信号更聚焦在“坏模式本身”,而不是某一条具体 diff 的所有特征。
第四,用 reviewer 评论做条件信号,而不是只做偏好对。 很多 review 评论其实写得很具体:“这里应该用 errors.Wrap 而不是直接返回 err”“这个锁要在 defer 里释放”“这个 goroutine 需要 context 控制生命周期”。如果只做 DPO,这些信息全丢了。更好的做法是把 reviewer 评论作为 prompt 的一部分,让模型学习“给定这段代码和这条 review 评论,生成修正后的代码”。本质上这是把负样本转化成了正样本训练——模型学到的是“被指出问题后怎么改”,而不是“不要写什么”。这个方向的训练信号密度远高于纯负样本压制。
实测数据:一个具体的对照
我在一个内部 Java 项目上做过一组对照,数据量不大但趋势清晰。训练集是 2023 年全年 review 记录,共 486 条被拒改动,按模式聚成 7 类,每类 30-80 条不等。模型是 DeepSeek-Coder-6.7B,LoRA 微调。
| 配置 | 微调前坏模式出现率 | 微调后同类坏模式出现率 | 新增相邻坏模式出现率 |
|---|---|---|---|
| 只用原始负样本做 DPO(正样本随机采样) | 38% | 21% | 14% |
| 原始负样本 + 同 PR 正样本(1:2) | 38% | 11% | 9% |
| 近邻负样本 + 同 PR 正样本(1:2) | 38% | 6% | 5% |
| 近邻负样本 + reviewer 评论条件训练 | 38% | 4% | 3% |
测试方法是在 50 个真实开发任务 prompt 上各采样 5 次,人工标注坏模式出现率。“同类坏模式”指与训练集中被拒模式相同的写法;“新增相邻坏模式”指功能意图相同但写法不同、同样会被 reviewer 打回的模式。
数字本身不大,但趋势是明确的:负样本的质量和正样本的配合,比负样本的数量更重要。最后一行里,“新增相邻坏模式”降到 3%,说明模型不仅学会了避开原坑,还学会了在相邻空间里做出更合理的选择——这主要归功于 reviewer 评论条件训练,因为评论里通常包含了“为什么不好”和“应该怎么做”两层信息。
一个容易被忽略的坑:数据泄漏
做这个方向的人经常会踩一个坑:用历史 review 数据训练,然后用同一批数据或同一时期的代码做测试。如果你的测试 prompt 和训练集里的 code_before 高度相似,模型可能只是在“背答案”,而不是真正学会了模式。我的做法是:训练集用 2023 年的 review 数据,测试集用 2024 年 Q1 的新 review 任务,确保测试 prompt 在训练集里没有出现过。如果你手上的数据量不够分开,至少要在 prompt 层面做改写,避免直接复用训练时的 code_before。
另外,被拒 diff 里的代码片段,往往来自同一个开发者或同一个模块。如果一个开发者的代码风格被大量标记为负样本,模型可能会学到“这个人的写法都不好”,而不是“这个模式不好”。这种身份泄漏在 review 数据里很常见,因为 review 数据天然带有作者信息。建议在构造训练数据时去掉作者相关的注释、import 顺序、命名习惯等身份特征,否则模型会学到一些你完全不想要的偏见。
常见问题
Q:负样本数量多少才够?
按模式聚类后,每个模式 30-50 条高质量负样本就能看到明显效果。低于 20 条,模型学不到稳定的抑制信号;超过 100 条,边际收益递减,更多取决于正样本的质量和配比。关键不是总数,而是每个模式下的覆盖度。
Q:只用负样本做 SFT 可以吗?
可以但不推荐。SFT 的本质是让模型模仿训练数据分布,只给负样本等于让模型学习“生成会被拒的代码”。正确做法是偏好优化(DPO/PPO)或对比学习,至少要有正样本作为参照。如果一定要用 SFT,负样本必须和 reviewer 评论配对,让模型学习“根据评论修正代码”,这实质上已经把负样本转化成了正样本。
Q:微调后模型会过度保守,什么都不敢写怎么办?
这是负样本训练最常见的副作用。缓解办法:正样本里要有意识地保留“看起来像坏模式但实际上合理”的代码,比如“log 后继续执行”在某些非关键路径上是可接受的,让模型学会根据上下文判断,而不是一刀切回避。另外可以在推理时加一个简单的后处理规则或 prompt 约束,告诉模型“不要过度防御性编码”。
Q:历史 review 数据太脏,很多被拒改动其实不是代码问题,怎么过滤?
用 reviewer 评论做关键词过滤是第一层。更有效的是让一个较强的模型(比如 GPT-4o 或 Claude)对每条被拒改动做二分类:这条改动被拒的原因是否是“代码模式错误”(而不是需求理解、命名偏好、性能争议等)。只保留被判定为代码模式错误的样本。我实际用这个方法过滤掉了约 40% 的噪声,训练效果有明显提升。