补丁选择必须以程序行为为依据。
不同的 SWE 智能体各有所长:在某个 issue 上失败的智能体,在另一个 issue 上可能给出最好的补丁。集成方法让多个智能体处理同一个 issue,然后从它们的候选补丁中选出一个。
LLM 评审可以比较这些候选补丁,但没有执行证据时,它看到的是表面上的合理性,而不是补丁在仓库中的实际行为,而且容易受到补丁呈现顺序的影响。补丁选择应当基于测试:生成测试,在每个候选补丁上执行,再根据结果找出正确的补丁。难点在于得到真正能区分候选补丁的测试。
现有的测试生成方法是不完善的优化器。
缺少有效的损失
一个测试只是通过或失败,并不一定对选择有用。现有生成方法并没有以“按行为区分候选补丁”为优化目标。
更新方向不完整
大多只根据 issue 新建一个测试,最多再按名称匹配删掉冲突的测试。已有的 fixture、辅助函数和过时的断言都没有被利用或修正。
一次性生成
第一次生成的测试没有区分度时,没有机制把这次失败转化为下一次尝试的依据。
把测试套件的演化看作梯度下降。
TestGRAD 把仓库测试套件视为优化器的参数状态。
前向传播时,它在每个候选补丁上执行当前测试套件,检查补丁之间的行为是否不同。反向传播时,LLM 优化器把这一执行信号,连同从此前失败中挖掘出的动量,转化为对测试套件的下一次 CRUD 编辑。一旦有一个测试能区分候选补丁,或迭代预算用完,循环即结束。
带动量的梯度下降,逐个角色对应。
| 角色 | 带动量的梯度下降 | 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 写出的代码编辑,而不是数值导数。
一轮迭代,四个阶段。
怎么读从左侧开始,沿箭头到损失,再沿底部一行回到测试套件。数字标出下文逐一介绍的四个阶段。蓝色是 TestGRAD 优化和写出的内容,绿色是出口。
说明了什么N 个智能体的候选补丁与当前测试套件 yt 一起执行(1)。如果某个测试能区分候选补丁,损失为零(2),检查器(checker)在独立的上下文中只调用一次,据此选出最终补丁。否则,失败的更新写入失败记忆,并被挖掘为动量(3),LLM 优化器随后写出下一次 CRUD 更新(4)。实验中循环最多运行 8 步。
在每个候选补丁上运行测试套件。
前向传播完全基于执行。TestGRAD 在每个候选补丁上运行当前测试套件 yt,记录通过或失败。只有当至少一个测试是差分测试,即在部分候选补丁上通过、在其余补丁上失败时,测试套件才对选择有用。结果一致(全部通过或全部失败)的测试不提供任何选择信号。
| 测试 | p1 | p2 | p3 | p4 | 信号 |
|---|---|---|---|---|---|
| test_a | ✓ | ✓ | ✓ | ✓ | 一致 · 无信号 |
| test_b | ✕ | ✕ | ✕ | ✕ | 一致 · 无信号 |
| test_c | ✕ | ✓ | ✕ | ✓ | 差分测试 |
怎么读行是测试,列是候选补丁;每个格子是一次执行,通过(✓)或失败(✕)。结果为示意。
说明了什么test_a 和 test_b 在每个候选补丁上结果都相同,无法帮助选择。test_c 在 p2 和 p4 上通过、在 p1 和 p3 上失败:有了它,测试套件就能区分候选补丁。
一旦有测试区分出补丁,损失即为零。
损失由执行结果定义,沿用差分测试的思路:不同执行之间的行为差异会暴露语义上的区别。它为优化器提供了明确的目标,也不同于现有流程在最终选择时采用的“数通过个数”。
寻找行为上的差异
𝓛(yt) = 0 若 yt 含差分测试;否则为 1选通过测试最多的补丁
这种做法奖励的是“通过”,而不是“差异”。非差分测试仍可能偏向看似合理或测试不足的补丁,也没有任何机制推动生成器去找在不同候选补丁上结果不同的测试。
消融实验(RQ3)中,用这条规则替换差分损失和检查器后,Pass@1 从 84.2% 降到 80.8%。
把反复出现的失败挖掘为动量。
如果一次更新后测试套件仍是非差分的,说明这次更新没能区分候选补丁,TestGRAD 就把它写入失败记忆 M。它不重放每一条原始失败,而是从记忆中挖掘极大频繁失败序列:
mt = Mineβ(Mt−1 ‖ ∇t−1)一个序列只有在至少 β 个失败测试套件中出现才会被保留(实验中 β 为 2)。得到的结果记录了哪些方向已经失败过,使下一次更新能避开同样的死路。与另调一次 LLM 写出的总结不同,它是确定性的,忠实于观察到的失败;与直接附加原始历史不同,它始终保持紧凑(RQ4)。
sphinx-doc__sphinx-10673从检查警告文本,转向检查 toctree 的结构
# 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
|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()
# 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,产生了有区分度的信号。
对仓库测试做一次完整的 CRUD 更新。
优化器接收损失、当前测试套件和动量,返回下一个测试空间梯度 ∇t = Optimizer(𝓛, yt, mt),新的测试套件为 yt+1 = yt + ∇t。这个梯度是对原始仓库测试套件的一次编辑。现有流程大多只新建一个测试,最多再按名称删掉冲突的测试;而仓库测试往往依赖 fixture、初始化流程和辅助函数,其中的期望值也常常需要随修复一起改变。
Given issue {I}, current test suite {y_t}, execution result {loss L(y_t)}, and momentum {m_t}:
- Explain why the current suite is still non-differential.
- Identify the failed direction preserved in momentum.
- Return one CRUD update over the original repository tests: [R]ead + Reuse, [C]reate, [U]pdate, [D]elete.
损失给出目标,动量指出已经失败的方向,因此更新会避开此前没有区分度的尝试。
收集仓库的测试知识:fixture、导入、断言工具和初始化约定。
新增有区分度的测试。
修改那些期望行为随修复而改变的过时断言或测试体。
只删除确认已过时的测试,而不是按名称匹配删除。
六个方面,来自 200 个抽样的 SWE-bench 实例(不含 SWE-bench Verified)。每一项都出现在超过 60% 的目标测试文件中,横条表示出现频率。
django__django-12325指向父模型的 OneToOneField 应当隐含 parent_link=True
@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
+ @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(), [])
def test_render(self):
OneToOneField(
'migrations.Tag',
CASCADE,
- parent_link=False,
+ parent_link=True,
primary_key=True,
to_field='id',
serialize=False,
...)
- 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()
- 复现脚本手工搭建运行环境(
DJANGO_SETTINGS_MODULE、django.setup()、app_label),并断言旧的ImproperlyConfigured,因此在正确和错误的补丁上都会通过。 - 过时的
test_render要么被按名称匹配删掉,丢掉了一个只需改一处就能成为强区分信号的测试;要么保持不变,结果在正确补丁上失败、在错误补丁上通过。
怎么读四个框是同一次 CRUD 更新的四个部分。Read 中的蓝色行是新建测试所复用的测试基础设施。虚线框展示只做 Create/Delete 的流程在同一个 issue 上的做法。
说明了什么修复之前,这个模型会抛出 ImproperlyConfigured;修复把这个异常变成了被接受的行为,所以有用的测试必须断言新行为,而不是复现旧的报错。先读取测试套件,新测试就能在 Django 自己的测试环境中运行;更新 test_render 则保住了一个高信号的测试,既没有丢弃它,也不会让它误伤正确补丁。
在 SWE-bench Verified 上达到 84.2% Pass@1。
所有选择方法使用相同的候选补丁,只有选择模块不同。Pass@1 指每个实例选出一个补丁后,500 个实例中在 SWE-bench 评测框架下被解决的比例。单智能体基线(只用 Livesweagent)的解决率为 79.2%。
500 个经人工验证的 GitHub issue,在官方 SWE-bench Docker 镜像中运行。
Livesweagent、Sonar、Trae_doubao、OpenHands、Gemini-Live、Atlassian,按此顺序依次加入(N = 1…6)。
不执行代码的 LLM 评审;Create/Delete 式测试生成;Create/Delete 加仓库浏览。
Minimax-m2.7、Deepseek-v3.2、GLM-5.1;最多 8 步优化;最小支持度为 2。
在三个基座 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)。
各种规模下都领先 TRAE,N = 4 时最好。
怎么读智能体按上文所列顺序逐个加入。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 个百分点的差距,对应那些存在正确补丁、但生成的测试未能把它区分出来的实例。
去掉任何一个组件,Pass@1 都会下降。
怎么读每一行去掉或替换 TestGRAD 的一个部分;Δ 是相对完整系统下降的百分点,在相同实例上测得。虚线标出单智能体基线(79.2%)。
说明了什么在 CRUD 梯度中,Read 最重要:没有它,生成的测试只能依赖裸导入和临时搭建的环境,常常无法在项目的测试运行器中运行。没有 Update 时,断言旧期望值的测试会被删除而不是修正。两者都去掉后,方法接近于一个 Create/Delete 式的生成器(−4.8)。用按通过数选择替代差分损失下降 3.4 个百分点,去掉动量下降 1.6,完全去掉迭代优化下降 9.2。
动量让失败历史保持紧凑。
怎么读每轮优化迭代中失败历史上下文的平均 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.7 | Deepseek-v3.2 | GLM-5.1 | MiniMax-m2.7 | Deepseek-v3.2 | GLM-5.1 |
| Augment | 1.3 | 1.8 | 1.4 | 2.8 | 3.2 | 2.9 |
| Agentless | 7.2 | 8.6 | 7.5 | 14.8 | 16.2 | 15.3 |
| TRAE | 19.3 | 26.7 | 20.8 | 318 | 412 | 334 |
| TestGRAD | 12.2 | 23.0 | 20.7 | 670 | 1,670 | 1,307 |
怎么读每个实例的平均耗时和 token 数。TestGRAD 的数值来自运行日志;基线的数值根据对话日志和 API 调用记录估算。
说明了什么TestGRAD 的 token 用量最多,因为 Read 要探索仓库,而且最多可能运行八步优化。它的耗时与 TRAE 相当,在 MiniMax-m2.7 上更短(12.2 对 19.3 分钟),因为一旦找到有区分度的测试,循环就会停止。
引用
论文正在审稿中,公开后补充引用信息。