今天想讲讲我是如何设计这套评测系统的——用行为保持和结构静态检查,把”重构做没做对”变成一个确定性的 0/1 判定,以及在这个过程中踩了哪些坑、做了哪些关键的技术取舍

1. 什么是代码重构

代码重构,指的是不改变程序行为、只改善代码结构。它是软件工程中最日常的操作之一——提取函数、内联变量、简化条件、移动类型、合并重复逻辑。自从 Coding Agent 兴起以来,重构的需求也越来越多,模型能不能把重构做对,已经不是学术问题而是生产力问题。

我对现有的代码重构评测集做了系统梳理,发现它们各有侧重但均存在显著盲区:

评测维度SWE-RefactorRefactorBenchSWE-Compass本工作
多语言覆盖✗(仅 Java)✗(仅 Python)✓(10 种)✓(9 种)
重构类型细分✓(6 种,原子+复合)✗(无显式分类)✗(粗粒度单类别)✓(10 种分类)
数据来自真实提交✗(人工+大模型构建)✓(真实 PR)
跨文件重构部分✓(强制,平均 4.3 文件)✓(40%)
功能测试✓(完整测试套件)✗(仅 AST 结构测试)✓(可执行测试)✓(Docker 内完整单测)
结构正确性验证✓(Python ast)✓(tree-sitter)
不依赖大模型判分
评测 agent 工作流✗(模型直接生成)✓(Claude Code 脚手架)

SWE-Refactor 在”严格验证”上做得最扎实(同时跑测试套件和 RefactoringMiner 做结构验证),但只有 Java;RefactorBench 最贴近真实 agent 场景(强制多文件、三级指令详细度),但本质上没有行为等价性验证——只看 AST 结构不跑测试,一个通过结构检查的改动仍可能已经改变了程序行为;SWE-Compass 语言覆盖最广,但重构只是八类任务之一,没有细粒度的重构类型分析。

核心观察:重构的定义是”改变结构但不改变行为”——这两件事缺一不可。 我们的核心设计就是同时验证这两面:用 Docker 跑原项目单测确保行为不变,每个任务用 Opus4.6 生成 Checklist 做静态结构匹配确保结构改对了,并且人工逐一复查 Checklist 的合理性。

2. 数据集怎么建的

数据集是整套系统的地基。我选择从真实开源项目的提交历史里挖重构案例,而不是人工构造题目。这样最能体现模型在真实任务中重构任务能力的体现,而且人工构造的重构场景永远比不上真实项目的复杂度和多样性。

从真实提交里抽取

每条记录对应一段历史上真发生的重构提交。我抽出该提交的父提交作为基线(模型看到的”改前”版本),提交本身作为 Ground Truth(“改后”版本),并保留原项目当时的单测套件。模型完全看不到”改后”版本——它只能根据”改前”代码加一段重构描述去改。

最终的数据集覆盖:

  • 11 个项目:django、pytest、sqlalchemy、commons-lang、curl、fiber、fmt、jellyfin、laravel-framework、nest、fastify
  • 9 种语言:Python、Java、Go、TypeScript、JavaScript、C、C++、C#、PHP
  • 80 条记录:筛选出有模型区分度、且较难的任务
  • 10 种重构类型:提取函数(Extract Function/Method)、删除死代码(Remove Dead Code)、重命名(Rename)、简化条件(Simplify Conditional)、合并重复(Consolidate)、复合重构(Composite Refactoring)、提取类型(Extract Type)、移动(Move)、内联(Inline)等

几个数据集设计上的关键决策

(a)跨文件比例刻意拉高

单文件重构对强模型基本没有区分度。经实验过程中发现:随着模型能力逐步提升,Opus4.6 和 Sonnet4.6 在单文件上 Pass@1 分别 77.5% / 75.0%,差距微乎其微;但在跨文件记录上分别 65.9% / 59.1%——跨文件才能真正拉开差距。所以最终数据集里 32 条是跨文件(40%),提高跨文件任务的比例。

(b)重构类型配比要看区分度,不是均匀分布

早期版本有个严重问题:提取函数(22 条)+ 删除死代码(17 条)+ 重命名(16 条)占了 65%,但这些对头部模型几乎全过——53.6% 的记录是所有模型都能过,16.7% 是都挂了,真正能区分模型的只有 29.8%。复盘后发现,复合重构和合并重复这种需要”全局视野”的类型区分度最高。

3. 评测方法:行为保持和结构静态检查

这是整套系统最核心的设计。我把”重构是否完成”拆成两个独立的检查:

Pass@1 = BP(行为不变)∧ Checklist(结构改对)

两个检查都过才算 1,否则算 0。80 条记录求平均,得到模型的 Pass@1。

行为保持:BP(Behavior Preservation,行为保持)

模型改完后,在原项目的 Docker 环境里跑两遍单测:重构前一遍、模型完成重构任务后一遍,然后逐项对比。

判分条件(全部满足才算行为保持过关):

compile_passed == 1          # 编译/导入没坏
post_passed   >= pre_passed  # 没让原本过的测试变挂
tests_passed  == 1           # 综合判断行为未变

结构静态检查:Checklist(结构检查清单,结构改对了吗?)

BP 只能告诉你”行为没变”,但模型完全可以一行不改照样过这一关。Checklist 用 Tree-sitter 做静态结构匹配:每条记录都有 3 到 6 个检查函数,遍历重构后的语法树,验证”该删的删了、该提取的提取了”,同时防止了模型什么都不改也能通过最终的检查。

每个Checklist生成时接收一个上下文对象,里面有改前的全部实体(函数/类/方法)、改后的全部实体,以及一系列基于 Tree-sitter 的高层查询方法(统计某个函数被调用几次、某个实体的语法树规模、某段代码里的条件分支数等)。

4. 几个关键的技术设计决策

做这套系统的过程中踩了不少坑,也沉淀了一些我觉得比较有价值的设计。

(1)Checklist 的五步生产流水线

写出”对所有合法实现都公平”的 Checklist 远比想象中难。我设计了一条闭环迭代的流水线:

  1. 规则生成初版:Opus4.6 结合按重构类型和提交的审计信息(where / what / why)和前后代码差异,产出第一版检Checklist
  2. Ground Truth 自检过滤:拿 Ground Truth跑一遍 Checklist,连ground truth都过不了的检查项直接淘汰
  3. 跑一轮模型:用多个模型各跑一次,形成一张”模型 × 检查项”的通过/失败矩阵
  4. 模型复审:人工结合 Opus4.6 当分析员,区分”模型确实没做到”和”检查项写得苛刻”
  5. 产出终版:应用上一步的修改,再过一遍 Ground Truth 自检兜底,最后实现最终版本的Checklist

(2)Shape over Names

Ground truth 只是某个工程师写出来的”一种”正确实现。换另一个工程师做同样的重构,可能会给新提取的函数取一个不同但同样合理的名字、用不同的中间表示、把新符号放在稍微不同的位置。

所以 Checklist 必须基于结构形状(shape),不能写死名字(names)。具体做法是用集合差来检测”出现了一个新东西”:

# 反例:写死了新函数名
def check_helper_exists(ctx):
    return ctx.helpers.entity_exists('getPrimitiveClass', where='after'), ''

# 正例:检测"新出现的函数中有至少一个被调用了"
def check_new_helper_invoked(ctx):
    new_fns = ({e.name for e in ctx.after_entities.values() if e.kind == 'function'}
             - {e.name for e in ctx.before_entities.values() if e.kind == 'function'})
    for nm in new_fns:
        if ctx.helpers.count_calls(nm, where='after') >= 1:
            return True, f'new helper {nm} invoked'
    return False, 'no new function is called'

这样不管模型取什么名字,只要结构上”确实多了一个函数并被调用了”,就算通过。

(3)自动跳过:锚点找不到时,宁可跳过也不误判

很多检查项在写的时候需要一个”锚点”——先按名字定位到某个实体,再验证它的变化。比如一条提取重构的检查可能这么写:

def check_caller_grew(ctx):
    # 先按名字定位调用方,再看它的体积有没有变大
    caller = _find_by_name(ctx.after_entities, 'create_contenttypes')
    if caller is None:
        return False, 'caller not found'
    ...

问题在于:如果模型在重构的同时合法地把 create_contenttypes 改了名或挪了位置,这个锚点在改后版本里就找不到了,检查会直接判不通过。但这并不是模型重构做错了,而是 Checklist 写死的名字跟不上模型的合法改动——属于误伤

自动跳过机制就是为这种情况兜底:当一个检查项失败的原因是”它依赖的锚点在改后版本里根本不存在”时,判分器不把它算作失败,而是降级为跳过。说明模型合法地重命名或移动了它——判分器会把这条检查跳过而不是判失败,避免误伤。其余检查项从其他角度兜底验证。

(4)该给模型什么样的指令才合理

给模型的重构指令怎么写,直接决定了你到底在测什么能力。这里有一个微妙的平衡:

  • 太具体——比如”把 foo 函数里的重复逻辑提取为 getPrimitiveClass(className) 这个包级私有方法”——就等于把 Ground Truth 的函数名和签名直接喂给了模型。它根本不需要理解代码在做什么,照抄就能过。这时候测的不是重构能力,是抄写能力。
  • 太模糊——比如”优化一下这个文件”——则任务无法判定,模型往哪个方向改都说得通,Checklist 也没法对齐一个明确的目标。

合理的做法是只交代重构的目标、范围和动机,把”具体怎么实现”留给模型。比如:“某个(多个)代码文件里有一段复合的元组解包条件可以拆分简化,请在保持行为不变的前提下重构它”——它点明了改哪里、改什么类型、为什么改,但不泄漏新名字、新中间变量、具体写法。

在我们的评测集里,我们为每条记录都校准了这个颗粒度:把”改哪里、改什么、为什么改”说清楚,但刻意不出现 Ground Truth 里的新符号名和字面量。这样模型必须真正读懂改前的代码、自己决定实现路径,测出来的才是它对重构语义的理解,而不是对答案的复述。

(5)退役的 Gate 们:不是不好用,是被更优的方案替代了

早期版本跑过很多额外的检测:AST 修改检测、复杂度不退化检测(LLM Judge)、类型检查,以及多维度大模型质量打分。最终全部撤掉。但撤掉的原因各不相同,值得分开说:

  • AST 修改检测 / 复杂度不退化检测 —— 被 Checklist 替代。 它们本质上都在判断”结构有没有按预期变化”,但用的是粗粒度的全局信号(比如整体语法树节点数有没有变)。Checklist 出现后,用逐条手写的精确检查替代了这些笼统判断——信息量更大、误判更少。它们不是不好用,而是有了更精确的替代品,留着只是冗余。
  • 类型检查 —— 被 BP 覆盖。 类型检查是指单独跑一遍静态类型分析(如 Java 的编译器类型系统、TypeScript 的 tsc),验证模型有没有引入参数类型不匹配、调用不存在的方法等错误。但对静态类型语言来说,BP 阶段的编译 + 单测本来就会暴露类型错误——编译不过即类型有错。单独再跑一遍既慢又重复。
  • 五维大模型质量打分 —— 主动放弃。 它依赖大模型来判定任务完成度、打质量分,恰恰是我们最想避开的:分数膨胀、跨次运行结果漂移,而且每条记录都要额外调用一次大模型,慢且不可复现。

砍掉这些之后还有一个直接收益:判分变快并且更准确而且可复现。 判分链路只剩 BP(容器内跑测试)和 Checklist(纯 Tree-sitter,不调大模型),单条记录的验证从”跑多个检查加多次大模型调用”压缩成两步确定性检查。

5. 踩坑与心得

做了几轮迭代之后,总结几条比较深刻的:

  1. 项目自带的单测和编译,是最硬的评判标准。 用过这么多的检测方式之后回头看,最不会骗人的信号其实是最朴素的那个——代码能不能编译、原项目的单测过不过。它是项目维护者亲手写的、长期演进沉淀下来的行为契约,比任何我们后加的结构检查都更权威、更难被钻空子。Checklist 解决的是”结构改对没”,但”行为有没有变”这个重构的根本前提,最终还是要靠项目原生的编译加单测来守。任何评测如果绕开了这一关、只在静态层面比对结构,得到的结论都是不牢靠的。
  2. Checklist 比 BP 难做一个数量级。 BP 是确定性的——跑测试、比数字,逻辑清晰。但 Checklist 要在”不冤枉合法实现”和”不放过没做到的模型”之间走钢丝。Shape over Names 原则听起来简单,在实际 80 条记录里落地时才发现反模式层出不穷。
  3. Ground Truth 自检是不可省略的兜底。 每一版 Checklist 都必须让 Ground Truth 自己先过——如果连真人写的最优实现都过不了你的检查,那检查一定写得太苛。这条规则帮我们在早期就干掉了大量”看起来合理但实际偏颇”的检查项。
  4. 数据集区分度要刻意设计。 自然分布的提交大部分太简单或太难,真正能区分模型的”中间地带”需要精心挑选。53.6% 全过加 16.7% 全挂,等于 70% 的数据没有区分度,这个教训让我们在下一批数据抽取中彻底改了配比策略。
  5. 可复现性是评测系统的命门。 指令不固定、Docker 环境有波动、Checklist 迭代后没同步重跑——任何一个环节的非确定性,都会让不同批次之间的对比变得没有意义。做评测不是做一次性实验,而是做一套可持续运行的基础设施

写在最后

代码重构是Coding Agent 日常使用中最高频的场景之一,这套评测体系的核心价值在于:用确定性手段(隔离环境测试 + Tree-sitter 解析语法树)而非主观判断,把重构评测变成一个可复现、可持续、可信赖的 0/1 判定。 它帮助我们精确定位不同模型在不同语言、不同重构类型上的能力边界,也在持续驱动我们的 agent 策略迭代。

希望这些思路对做类似评测工作的同学有参考价值。


韩曙斌

写于 2026.6.16