Jiayuan Zhou
SWE 智能体集成的补丁选择 · SWE-bench Verified

TestGRAD

演化测试套件,直到它能区分候选补丁

多个 SWE 智能体针对同一个 issue 会给出多个候选补丁,集成的效果取决于能否从中选对。TestGRAD 依据执行结果来选:它把仓库自身的测试套件当作被优化的对象,每轮更新一次,不断演化,直到有测试能把候选补丁区分开。

Pengfei He1, Jiayuan Zhou2, Ruiqi Pan3, Shaowei Wang1

1University of Manitoba, Canada · 2Huawei, Canada · 3Huawei Software Engineering Application Technology Lab, China

核心观点补丁选择就是测试空间优化。直接优化仓库的测试套件:让它瞄准候选补丁之间的行为差异,允许它读取、新建、更新和删除测试,并把反复出现的失败凝练成紧凑的动量,带入下一次尝试。
01 · 问题

补丁选择必须以程序行为为依据。

不同的 SWE 智能体各有所长:在某个 issue 上失败的智能体,在另一个 issue 上可能给出最好的补丁。集成方法让多个智能体处理同一个 issue,然后从它们的候选补丁中选出一个。

LLM 评审可以比较这些候选补丁,但没有执行证据时,它看到的是表面上的合理性,而不是补丁在仓库中的实际行为,而且容易受到补丁呈现顺序的影响。补丁选择应当基于测试:生成测试,在每个候选补丁上执行,再根据结果找出正确的补丁。难点在于得到真正能区分候选补丁的测试。

现有方法的不足

现有的测试生成方法是不完善的优化器。

缺少有效的损失

一个测试只是通过或失败,并不一定对选择有用。现有生成方法并没有以“按行为区分候选补丁”为优化目标。

2
差分损失阶段 2 · 优化目标

更新方向不完整

大多只根据 issue 新建一个测试,最多再按名称匹配删掉冲突的测试。已有的 fixture、辅助函数和过时的断言都没有被利用或修正。

4
完整的 CRUD 梯度阶段 4 · 更新方向

一次性生成

第一次生成的测试没有区分度时,没有机制把这次失败转化为下一次尝试的依据。

3
失败模式动量阶段 3 · 跨尝试的记忆
02 · 方法

把测试套件的演化看作梯度下降。

TestGRAD 把仓库测试套件视为优化器的参数状态。

前向传播时,它在每个候选补丁上执行当前测试套件,检查补丁之间的行为是否不同。反向传播时,LLM 优化器把这一执行信号,连同从此前失败中挖掘出的动量,转化为对测试套件的下一次 CRUD 编辑。一旦有一个测试能区分候选补丁,或迭代预算用完,循环即结束。

类比 · 论文表 1

带动量的梯度下降,逐个角色对应。

角色带动量的梯度下降TestGRAD
状态θt当前参数向量。 yt当前的候选测试套件。
目标𝓛(θt)损失函数。 𝓛(yt)差分损失,由执行结果定义。
保留强度β ∈ [0, 1]上一步速度保留的比例。 β = MinSupport失败序列必须达到的最小出现频次。调低它会保留更多序列,使动量的影响面更广。
动量β ∇t−1此前各步累积的方向。 mt = Mineβ(Mt−1 ‖ ∇t−1)从记忆 M(含最近一次失败更新)中反复出现的失败序列提炼而来。
更新方向∇t = β ∇t−1 + (1 − β) ∂𝓛/∂θt瞬时梯度与动量的结合。 ∇TestGrad = Optimizer(𝓛, yt, mt)当前反馈与动量的结合,由 LLM 优化器写出。
执行更新θt+1 = θt − η ∇t参数更新。 yt+1 = yt + ∇TestGrad对测试套件的一组 CRUD 操作。

怎么读每一行把带动量梯度下降中的一个元素(中间列)与 TestGRAD 中扮演同一角色的部分(右列)对应起来。

说明了什么单步 CRUD 编辑相当于瞬时梯度;反复出现的失败编辑序列相当于跨尝试保留的动量。这种对应是角色层面的:这里的“梯度”是 LLM 写出的代码编辑,而不是数值导数。

总览 · 论文图 1 重绘

一轮迭代,四个阶段。

TestGRAD:一轮优化的四个阶段Issue 和代码仓库交给 N 个 SWE 智能体,各自产出候选补丁。仓库测试套件 y_t 是优化状态。阶段 1 前向传播:每个测试在每个候选补丁上执行。阶段 2 差分损失:若有测试在部分补丁上通过、在其余补丁上失败,损失为 0,否则为 1。损失为 0 时由检查器选出最终补丁。损失为 1 时进入阶段 3:失败的更新写入失败记忆,并被挖掘为失败模式动量 m_t。阶段 4 反向传播:LLM 优化器根据损失、当前测试套件和动量写出一次 CRUD 更新,得到下一版测试套件 y_t+1。输入Issue代码仓库候选补丁p1​p2​p3​pN​…每个 SWE 智能体一个补丁,集成中共 N 个智能体状态 · 测试套件yt​即仓库自身的测试,每轮做一次 CRUD 编辑y0​ = 原始测试套件1前向传播执行2差分损失L(yt​) ∈ {0, 1}停止 · 损失 = 0由检查器选出最终补丁在优化循环之外只调用一次3动量失败更新 → 失败记忆挖掘频繁序列mt​ = Mineβ​(M ‖ ∇)4反向传播LLM 优化器写出一次 CRUD 编辑 ∇yt+1​ = yt​ + ∇y0​L = 0L = 1mt​∇

怎么读从左侧开始,沿箭头到损失,再沿底部一行回到测试套件。数字标出下文逐一介绍的四个阶段。蓝色是 TestGRAD 优化和写出的内容,绿色是出口。

说明了什么N 个智能体的候选补丁与当前测试套件 yt 一起执行(1)。如果某个测试能区分候选补丁,损失为零(2),检查器(checker)在独立的上下文中只调用一次,据此选出最终补丁。否则,失败的更新写入失败记忆,并被挖掘为动量(3),LLM 优化器随后写出下一次 CRUD 更新(4)。实验中循环最多运行 8 步。

1阶段 1 · 前向传播

在每个候选补丁上运行测试套件。

前向传播完全基于执行。TestGRAD 在每个候选补丁上运行当前测试套件 yt,记录通过或失败。只有当至少一个测试是差分测试,即在部分候选补丁上通过、在其余补丁上失败时,测试套件才对选择有用。结果一致(全部通过或全部失败)的测试不提供任何选择信号。

测试p1p2p3p4信号
test_a✓✓✓✓一致 · 无信号
test_b✕✕✕✕一致 · 无信号
test_c✕✓✕✓差分测试

怎么读行是测试,列是候选补丁;每个格子是一次执行,通过(✓)或失败(✕)。结果为示意。

说明了什么test_a 和 test_b 在每个候选补丁上结果都相同,无法帮助选择。test_c 在 p2 和 p4 上通过、在 p1 和 p3 上失败:有了它,测试套件就能区分候选补丁。

2阶段 2 · 差分损失

一旦有测试区分出补丁,损失即为零。

损失由执行结果定义,沿用差分测试的思路:不同执行之间的行为差异会暴露语义上的区别。它为优化器提供了明确的目标,也不同于现有流程在最终选择时采用的“数通过个数”。

TestGRAD · 差分损失

寻找行为上的差异

𝓛(yt) = 0 若 yt 含差分测试;否则为 1
𝓛 = 0优化停止。检查器智能体在循环之外、以独立的上下文只调用一次,利用差分结果选出最终补丁。 𝓛 = 1失败的更新成为下一轮迭代的优化信号。
对照 · 按通过数选择(Agentless、TRAE)

选通过测试最多的补丁

这种做法奖励的是“通过”,而不是“差异”。非差分测试仍可能偏向看似合理或测试不足的补丁,也没有任何机制推动生成器去找在不同候选补丁上结果不同的测试。

消融实验(RQ3)中,用这条规则替换差分损失和检查器后,Pass@1 从 84.2% 降到 80.8%。

3阶段 3 · 失败模式动量

把反复出现的失败挖掘为动量。

如果一次更新后测试套件仍是非差分的,说明这次更新没能区分候选补丁,TestGRAD 就把它写入失败记忆 M。它不重放每一条原始失败,而是从记忆中挖掘极大频繁失败序列:

mt = Mineβ(Mt−1 ‖ ∇t−1)

一个序列只有在至少 β 个失败测试套件中出现才会被保留(实验中 β 为 2)。得到的结果记录了哪些方向已经失败过,使下一次更新能避开同样的死路。与另调一次 LLM 写出的总结不同,它是确定性的,忠实于观察到的失败;与直接附加原始历史不同,它始终保持紧凑(RQ4)。

例子 · 论文图 3sphinx-doc__sphinx-10673

从检查警告文本,转向检查 toctree 的结构

(a) 当前测试套件 yt · 检查警告
# Read and Reuse: app/warning fixtures
def test_toctree_special_pages(
    app, status, warning):
  app.build()
  warnings = warning.getvalue()
  assert "...genindex'" not in warnings
  assert "...modindex'" not in warnings
  assert "...search'" not in warnings
  assert 'index' in
      app.env.toctree_includes
在各候选补丁上结果一致:非差分。
(b) 动量 mt = Mine(M)
|M| = 3 failed suites
-> retained frequent sequence
-> all converge on warning checking

  app.build()
  warn = warning.getvalue()
  assert "...genindex'" not in warn
  assert "...modindex'" not in warn
  assert "...search'"   not in warn

Dead end: "assert no warning"
  exhausted (3/3 attempts)
Steer: inspect AST via assert_node()
三次失败的共同核心,以及避开它的方向。
(c) 更新后的测试套件 yt+1 · 探查 AST
# avoid dead-end warning-check
# new: probe toctree AST structure
def test_toctree_index(app):
  app.build()
  toctree = app.env.tocs['index']
  node = toctree[0][1][1]
  assert_node(node, addnodes.toctree,
    caption="Indices",
    entries=[(None, 'genindex'),
             (None, 'modindex'),
             (None, 'search')])
为检查器提供有区分度的信号。

怎么读从左到右依次是:失败的测试套件、从失败记忆中挖掘出的动量(高亮行是保留下来的频繁序列),以及优化器接下来写出的测试套件。

说明了什么记忆中的三个失败测试套件都落在“检查警告文本”上,结果一致。动量把这个方向标记为死路,并引导优化器转向检查 AST;新测试用 assert_node() 探查 toctree,产生了有区分度的信号。

4阶段 4 · 反向传播

对仓库测试做一次完整的 CRUD 更新。

优化器接收损失、当前测试套件和动量,返回下一个测试空间梯度 ∇t = Optimizer(𝓛, yt, mt),新的测试套件为 yt+1 = yt + ∇t。这个梯度是对原始仓库测试套件的一次编辑。现有流程大多只新建一个测试,最多再按名称删掉冲突的测试;而仓库测试往往依赖 fixture、初始化流程和辅助函数,其中的期望值也常常需要随修复一起改变。

优化器提示词 · 论文图 2(节选)

Given issue {I}, current test suite {y_t}, execution result {loss L(y_t)}, and momentum {m_t}:

  1. Explain why the current suite is still non-differential.
  2. Identify the failed direction preserved in momentum.
  3. Return one CRUD update over the original repository tests: [R]ead + Reuse, [C]reate, [U]pdate, [D]elete.

损失给出目标,动量指出已经失败的方向,因此更新会避开此前没有区分度的尝试。

四个更新方向
R
Read + Reuse(读取并复用) C/D 之外

收集仓库的测试知识:fixture、导入、断言工具和初始化约定。

C
Create(新建)

新增有区分度的测试。

U
Update(更新) C/D 之外

修改那些期望行为随修复而改变的过时断言或测试体。

D
Delete(删除)

只删除确认已过时的测试,而不是按名称匹配删除。

Read 收集什么 · 补充材料

六个方面,来自 200 个抽样的 SWE-bench 实例(不含 SWE-bench Verified)。每一项都出现在超过 60% 的目标测试文件中,横条表示出现频率。

96%项目 API 与导入
91%断言约定
83%setUp 与 fixture
79%测试基类
71%期望值
64%辅助函数
例子 · 补充材料django__django-12325

指向父模型的 OneToOneField 应当隐含 parent_link=True

R 读取 · tests/invalid_models_tests/
@isolate_apps('invalid_models_tests')
class InvalidModelsTests(SimpleTestCase):
    def test_missing_parent_link(self):
        with self.assertRaisesMessage(
                ImproperlyConfigured, msg):
    self.assertEqual(Model.check(), [])
    self.assertEqual(Model.check(), [Error(id=...)])
from django.core.checks import Error, Warning
提取出四项测试基础设施(蓝色),供新测试复用。
C 新建一个有区分度的测试
+ @isolate_apps('invalid_models_tests')
+ class TestParentLink(SimpleTestCase):
+     def test_onetoone_parent(self):
+         class Place(models.Model): pass
+         class ParkingLot(Place):
+             other = OneToOneField(
+                 Place, CASCADE,
+                 related_name='other_parking')
+         self.assertEqual(
+             ParkingLot.check(), [])
断言修复后的新行为,无需任何配置样板代码。
U 更新一个已有断言
  def test_render(self):
      OneToOneField(
          'migrations.Tag',
          CASCADE,
-         parent_link=False,
+         parent_link=True,
          primary_key=True,
          to_field='id',
          serialize=False,
          ...)
更新后的测试恰好在正确补丁上通过,在其余补丁上失败。
D 删除一个过时的测试
- def test_missing_link(self):
-     msg = ('Add parent_link=True'
-            ' to ParkingLot.parent.')
-     with self.assertRaisesMessage(
-             ImproperlyConfigured, msg):
-         class ParkingLot(Place):
-             parent = OneToOneField(
-                 Place, CASCADE)
-         ParkingLot.check()
修复之后,它所期望的异常不会再抛出。
只做 Create/Delete,同一个 issue
  • 复现脚本手工搭建运行环境(DJANGO_SETTINGS_MODULE、django.setup()、app_label),并断言旧的 ImproperlyConfigured,因此在正确和错误的补丁上都会通过。
  • 过时的 test_render 要么被按名称匹配删掉,丢掉了一个只需改一处就能成为强区分信号的测试;要么保持不变,结果在正确补丁上失败、在错误补丁上通过。

怎么读四个框是同一次 CRUD 更新的四个部分。Read 中的蓝色行是新建测试所复用的测试基础设施。虚线框展示只做 Create/Delete 的流程在同一个 issue 上的做法。

说明了什么修复之前,这个模型会抛出 ImproperlyConfigured;修复把这个异常变成了被接受的行为,所以有用的测试必须断言新行为,而不是复现旧的报错。先读取测试套件,新测试就能在 Django 自己的测试环境中运行;更新 test_render 则保住了一个高信号的测试,既没有丢弃它,也不会让它误伤正确补丁。

03 · 实验评估

在 SWE-bench Verified 上达到 84.2% Pass@1。

所有选择方法使用相同的候选补丁,只有选择模块不同。Pass@1 指每个实例选出一个补丁后,500 个实例中在 SWE-bench 评测框架下被解决的比例。单智能体基线(只用 Livesweagent)的解决率为 79.2%。

基准SWE-bench Verified

500 个经人工验证的 GitHub issue,在官方 SWE-bench Docker 镜像中运行。

候选补丁6 个排行榜提交

Livesweagent、Sonar、Trae_doubao、OpenHands、Gemini-Live、Atlassian,按此顺序依次加入(N = 1…6)。

基线Augment · Agentless · TRAE

不执行代码的 LLM 评审;Create/Delete 式测试生成;Create/Delete 加仓库浏览。

TestGRAD3 个基座 LLM 作优化器

Minimax-m2.7、Deepseek-v3.2、GLM-5.1;最多 8 步优化;最小支持度为 2。

RQ1 · 整体有效性 · 4 个智能体的集成

在三个基座 LLM 上,Pass@1 均为最高。

怎么读每个面板中所有方法使用同一个基座 LLM,因此面板内只有选择方法不同。虚线是单智能体基线;每根横线从基线画到该方法的 Pass@1,旁边的小数字是相对基线的提升。

说明了什么TestGRAD 分别达到 84.2%、83.6% 和 84.0%,比单智能体高 4.4 到 5.0 个百分点,比各面板中最强的基线高 3.4 到 3.6 个百分点。TRAE 和 Agentless 最多只提升 1.4 和 1.2 个百分点。不执行代码、只做评判的 Augment 没有超过单智能体(−0.6 到 ±0.0)。

RQ2 · 集成规模 N = 1…6

各种规模下都领先 TRAE,N = 4 时最好。

TestGRADTRAEOracleExpected(随机选择)AdversaryPass@1 (%)

怎么读智能体按上文所列顺序逐个加入。Oracle 指只要有一个候选补丁正确就算解决,Adversary 指所有候选补丁都正确才算解决,Expected 是随机选择;灰色带覆盖 Oracle 到 Adversary 之间的范围。蓝色竖条标出 N = 4,即 RQ1 使用的设置。

说明了什么当 N ≥ 2 时,TestGRAD 始终高于 TRAE;在 Minimax-m2.7 上,差距从 N = 2 时的 1.2 个百分点扩大到 N = 4 时的 3.6 个百分点。Pass@1 在 N = 4 时达到峰值,在 N = 5 和 6 时略有下降,此时加入的是较弱的智能体(Gemini-Live 77.4%、Atlassian 76.8%)。N = 4 时 Oracle 为 87.0%:还有 2.8 个百分点的差距,对应那些存在正确补丁、但生成的测试未能把它区分出来的实例。

RQ3 · 消融实验 · Minimax-m2.7

去掉任何一个组件,Pass@1 都会下降。

部分变体单智能体 79.2Δ 百分点
完整系统TestGRAD84.2—
CRUD 梯度w/o Read · 不理解测试套件81.2−3.0
w/o Update · 不就地更新测试81.8−2.4
w/o Read 和 Update79.4−4.8
损失w/o 差分损失 · 改为按通过数选择80.8−3.4
动量w/o 失败模式动量82.6−1.6
优化器w/o 迭代优化75.0−9.2
72768084

怎么读每一行去掉或替换 TestGRAD 的一个部分;Δ 是相对完整系统下降的百分点,在相同实例上测得。虚线标出单智能体基线(79.2%)。

说明了什么在 CRUD 梯度中,Read 最重要:没有它,生成的测试只能依赖裸导入和临时搭建的环境,常常无法在项目的测试运行器中运行。没有 Update 时,断言旧期望值的测试会被删除而不是修正。两者都去掉后,方法接近于一个 Create/Delete 式的生成器(−4.8)。用按通过数选择替代差分损失下降 3.4 个百分点,去掉动量下降 1.6,完全去掉迭代优化下降 9.2。

RQ4 · 失败模式动量 · token 开销

动量让失败历史保持紧凑。

原始记忆(逐字保留全部失败测试套件)失败模式动量平均 token 数,对数坐标

怎么读每轮优化迭代中失败历史上下文的平均 token 数,纵轴为对数坐标。原始记忆逐字附加每一个失败的测试套件;动量只保留挖掘出的频繁序列。斜体标注是第 4 和第 6 轮的压缩比(原始记忆 / 动量),与论文中的标注一致。

说明了什么原始记忆持续增长,在 Deepseek-v3.2 上到第 6 轮达到 16.4k token;动量则一直保持在几十到大约 150 个 token。压缩比在 Deepseek-v3.2 上超过 100×,在 GLM-5.1 上为 25–39×,在 Minimax-m2.7 上为 33–34×。去掉动量还会使 Pass@1 从 84.2% 降到 82.6%(RQ3)。

开销 · 每个实例 · 补充材料

token 用量高于各基线,耗时与 TRAE 接近。

平均耗时(分钟)平均 token 数(K)
方法MiniMax-m2.7Deepseek-v3.2GLM-5.1MiniMax-m2.7Deepseek-v3.2GLM-5.1
Augment1.31.81.42.83.22.9
Agentless7.28.67.514.816.215.3
TRAE19.326.720.8318412334
TestGRAD12.223.020.76701,6701,307

怎么读每个实例的平均耗时和 token 数。TestGRAD 的数值来自运行日志;基线的数值根据对话日志和 API 调用记录估算。

说明了什么TestGRAD 的 token 用量最多,因为 Read 要探索仓库,而且最多可能运行八步优化。它的耗时与 TRAE 相当,在 MiniMax-m2.7 上更短(12.2 对 19.3 分钟),因为一旦找到有区分度的测试,循环就会停止。

04 · 引用

引用

论文正在审稿中,公开后补充引用信息。