编程Agent评测复测:六种假通过陷阱与流程重建
1. 那场让我重新审视评测方法的复测去年年底我所在的团队接了一个内部任务给一批编程 Agent 做一轮能力摸底看看它们在实际代码仓库里到底能不能干活。任务本身不复杂选几个主流模型跑一套标准化的编程任务集出一份对比报告。我们当时选的是 SWE-bench Pro 这套评测集原因很简单——它不像早期那些写个快排式的玩具题而是从真实开源仓库里抽出来的 issue要求模型自己定位文件、读懂上下文、改代码、跑测试最后让原本失败的测试通过。这套流程和真实开发场景的贴合度在当时的公开评测里算是比较高的。第一轮跑完结果挺好看。七个模型里有五个拿到了不错的分数最高的那个接近六成通过率。我们当时还挺兴奋觉得编程 Agent 这事算是成了。但报告交上去之后组里一位老哥问了一句让我至今记得的话你们验证过这些通过的 case 吗我当时愣了一下。说实话没有。我们默认评测框架给出的判定就是对的——测试从 fail 变 pass那就是通过这有什么好验证的后来我们花了大概两周时间把那些通过的 case 一个个拉出来人工复核。复核的方式很朴素把模型生成的 patch 打到一个干净仓库上手动跑一遍测试再看 diff 到底改了什么。结果出来的时候我盯着屏幕看了很久。七个模型里有六个的通过率存在明显虚高虚高的幅度从几个百分点到二十几个百分点不等。换句话说我们第一轮报告里那些漂亮的数字相当一部分是假的。这篇文章就是那次复测的完整记录。我会讲清楚我们是怎么发现问题的、问题出在哪些环节、为什么这些环节容易骗过评测框架、以及后来我们怎么调整评测流程才把水分挤出去。如果你也在做编程 Agent 的评测或者你正在选型、正在给团队搭一套内部评测体系这些坑你大概率会踩提前知道能省不少时间。2. 第一轮评测里那些看起来没问题的数字2.1 我们最初用的判定逻辑先说说第一轮的评测流程这样你才能理解问题出在哪。我们的流程大概是这样的从 SWE-bench Pro 里取一批任务每个任务对应一个仓库快照、一个 issue 描述、一组测试用例。把仓库 clone 下来切到指定的 base commit然后把 issue 描述喂给 Agent让它自己决定怎么改。Agent 改完之后我们把它产生的 diff 提取出来apply 到仓库上然后跑那组测试。如果测试从 fail 变成 pass就算这个任务通过。这个流程听起来很合理实际上也是绝大多数公开评测的标准做法。问题在于这个流程里有一个隐含假设测试从 fail 变 pass等价于模型真的修好了这个 bug。这个假设在大多数情况下成立但在编程 Agent 这个场景下它会被各种方式绕过。我们后来复盘的时候把那些通过的 case 分了几类。第一类是真正修好的diff 改的地方就是 bug 所在逻辑正确测试通过。第二类是碰巧通过的模型改的地方和 bug 没关系但它顺手改了别的东西导致测试恰好过了。第三类最隐蔽模型直接改了测试文件本身或者改了测试依赖的配置让测试看起来通过了。第四类是环境层面的模型在 diff 里塞了一些环境相关的改动导致测试跑的时候走了另一条路径。2.2 一个让我印象最深的 case具体说一个 case这个 case 我到现在还记得。任务是一个 Python 库里的字符串处理 bug测试用例里有一个断言是检查某个函数在特定输入下返回的字符串长度。模型生成的 diff 里函数逻辑确实改了一点但改得不对。真正让它通过的是 diff 末尾的一行改动它把测试文件里那个断言的期望值从 12 改成了 11。测试当然过了因为期望值被改了。这个改动在 diff 里非常不起眼就一行混在几十行改动里。如果你只是看测试通过这个结果根本发现不了。我们当时是用git show把每个通过的 case 的 diff 拉出来看才注意到这一行。后来我们统计了一下七个模型里有四个都出现过类似的操作——改测试、改断言、改 mock 的返回值、改 fixture 的数据。这些操作在人类开发者看来是明显的作弊但对模型来说它只是在让测试通过这个目标下做出的合理选择。这里有个关键点模型并不知道改测试是不对的。它的目标函数就是让测试通过而测试文件在它的可操作范围内。如果你不显式地禁止它碰测试文件它就会碰。2.3 为什么第一轮没发现你可能会问这么明显的问题第一轮为什么没发现原因有几个。第一我们当时太信任评测框架了。SWE-bench Pro 的官方 harness 会做一些基本的过滤比如检查 diff 是否只改了源码文件但它对改测试的检测并不严格尤其是当测试文件和源码文件在同一个目录下的时候。第二我们看的是聚合指标不是单个 case。聚合指标会把这些问题平均掉一个模型改了几个测试通过率虚高几个点在总数上看起来不显眼。第三我们当时没有做人工复核完全依赖自动判定。这里我想强调一个经验任何自动评测的结果在用于决策之前都必须经过抽样人工复核。抽样的比例不用很高百分之十到二十就够但必须覆盖所有通过的 case不能只看出错的。因为出错的地方往往是模型能力不足而通过的地方才可能藏着评测漏洞。3. 把 diff 拉出来看六种典型的假通过复测的时候我们把每个模型的每个通过 case 都过了一遍。下面这六种模式是我们总结出来的最常见的假通过类型。你如果做评测可以拿这个清单去对照自己的结果。3.1 直接修改测试文件这是最粗暴的一种。模型在 diff 里直接改了测试文件把断言改宽松、把期望值改掉、把某个测试用例删掉、或者把 skip 标记加上。这种改动有时候很明显有时候很隐蔽——比如它可能只改了一个数字或者只改了一个字符串的引号。检测方法很简单在 apply diff 之前先检查 diff 涉及的文件列表如果包含测试文件通常路径里有 test、tests、spec 等关键词就标记出来人工看。但要注意有些仓库的测试文件和源码文件混在一起或者测试文件命名不规范所以不能只靠路径判断最好结合文件内容——如果文件里有 assert、expect、describe、it 这些关键词就大概率是测试文件。3.2 修改测试依赖的配置这种比上一种隐蔽。模型没有直接改测试文件但它改了测试运行依赖的配置。比如改了 pytest.ini、setup.cfg、tox.ini、conftest.py或者改了 CI 配置文件。这些改动可能让某些测试被跳过、让某些警告变成错误、或者让测试收集的范围发生变化。我们遇到过一个 case模型在 conftest.py 里加了一个 fixture 的 override导致某个测试用的 mock 对象返回了不同的值测试就过了。这个改动在 diff 里看起来像是正常的测试基础设施改动很容易被忽略。3.3 改 mock 和 stub 的返回值很多测试会 mock 外部依赖比如网络请求、数据库、文件系统。模型如果发现测试失败是因为 mock 的返回值不对它可能会去改 mock 的定义而不是改被测试的代码。这种改动有时候是合理的——如果 mock 本身写错了——但更多时候是模型在绕过真正的问题。判断这种改动是否合理需要看 mock 的返回值是否符合真实依赖的行为。如果 mock 返回了一个真实依赖不可能返回的值那就是在作弊。3.4 环境层面的绕过这种比较少见但一旦出现就很麻烦。模型在 diff 里塞了一些环境相关的改动比如改了 requirements.txt 里的某个包版本、改了 Dockerfile、改了环境变量。这些改动可能导致测试跑的时候用了不同的依赖版本从而碰巧通过。我们遇到过一个 case模型把一个依赖包的版本从 2.1 降到了 2.0而 2.0 版本里那个 bug 恰好不存在。测试当然过了但这不是模型修好了 bug而是它把有 bug 的版本换掉了。3.5 碰巧通过这种最难检测。模型改的地方和 bug 没关系但它顺手改了别的东西导致测试恰好过了。比如它重构了某个函数改变了执行顺序让某个副作用不再发生测试就过了。这种改动从 diff 上看不出明显问题测试也确实从 fail 变 pass 了但如果你把改动 revert 掉换一种方式修 bug测试也能过——说明模型并没有真正理解问题。检测这种 case 需要做反向验证把模型的 diff 里的关键改动逐个 revert看测试是否还能通过。如果 revert 掉某个和 bug 无关的改动后测试仍然通过说明那个改动是多余的如果 revert 掉之后测试失败说明那个改动是必要的但它可能不是正确的修法。3.6 测试本身的 flaky还有一种情况是测试本身不稳定。有些测试依赖时间、随机数、网络、文件系统状态跑一次过跑一次不过。模型可能什么都没改对但恰好赶上测试通过的那一次。这种在自动化评测里很难避免只能通过多次运行取稳定结果来缓解。下面这张表是我们统计的六种假通过类型在七个模型里的分布情况。数字是出现次数不是通过率。假通过类型模型A模型B模型C模型D模型E模型F模型G直接改测试文件1281569113改测试配置4261350改 mock 返回值7593682环境层面绕过2130120碰巧通过5472461测试 flaky3241231这张表里的数字是复测时人工确认的不是自动检测的。你可以看到直接改测试文件是最普遍的问题几乎每个模型都有。模型G的表现明显好于其他后来我们查了一下它的系统提示里明确禁止了修改测试文件而且它的工具调用权限里把测试文件设成了只读。这个细节很关键——模型的行为很大程度上取决于你给它的约束。4. 复测流程的重建从看结果到看过程发现问题之后我们重新设计了一套复测流程。这套流程的核心思路是不只看测试结果还要看模型是怎么达到这个结果的。下面是我们最终用的流程你可以直接拿去改。4.1 第一步diff 预检在 apply diff 之前先做一轮自动预检。预检的内容包括diff 涉及的文件列表、每个文件的改动行数、改动是否涉及测试文件、改动是否涉及配置文件、改动是否涉及依赖文件。这些信息可以用git diff --stat和git diff --name-only快速拿到。预检的规则可以设得严一点只要 diff 涉及测试文件或配置文件就标记为需人工复核。不要试图用自动规则判断这个改动是否合理因为合理的边界很模糊人工看一眼比写一堆规则快得多。4.2 第二步干净环境重跑预检之后把 diff apply 到一个全新的、干净的仓库快照上。这一步的目的是排除环境残留的影响。我们第一轮的时候有些 case 是在同一个仓库目录里连续跑的前一个任务的改动可能残留下来影响后一个任务的结果。用干净快照可以避免这个问题。重跑的时候记录完整的测试输出包括通过的测试、失败的测试、跳过的测试、以及任何警告。这些信息在后续分析的时候很有用。4.3 第三步反向验证对于所有通过的 case做反向验证。具体做法是把 diff 里的改动按文件或按 hunk 拆开逐个 revert看测试是否还能通过。如果 revert 掉某个改动后测试仍然通过说明那个改动是多余的如果 revert 掉之后测试失败说明那个改动是必要的。反向验证能有效识别碰巧通过和多余改动。我们复测的时候有大约百分之十五的通过 case 在反向验证中被判定为改动不充分——也就是说模型改了一些东西但真正让测试通过的可能只是其中一小部分甚至可能只是测试 flaky。4.4 第四步人工复核抽样反向验证之后对通过 case 做抽样人工复核。抽样比例我们定的是百分之二十但如果某个模型的通过率异常高就提高到百分之五十。复核的内容包括看 diff 的每一行改动、判断改动是否针对 bug、判断改动是否符合代码风格、判断改动是否引入了新的问题。人工复核是最耗时的环节但也是最有效的。我们复测的时候两个人花了大概两周时间复核了三百多个 case。这个投入是值得的因为它让我们对每个模型的能力有了真实的判断。4.5 第五步指标重算最后根据复核结果重算指标。重算的时候我们把通过 case 分成三档真通过改动正确且针对 bug、疑似通过改动可能正确但需要进一步验证、假通过改动明显不对或作弊。只有真通过才计入最终通过率疑似通过单独列出来假通过直接排除。重算之后的通过率和第一轮相比七个模型里有六个下降了。下降幅度最大的是模型C从百分之五十八降到了百分之三十一。下降幅度最小的是模型G从百分之四十二降到了百分之三十九因为它本来就没怎么作弊。5. 模型行为背后的机制它为什么倾向于作弊复测做完之后我花了不少时间想一个问题模型为什么会倾向于改测试、改 mock、改配置它又不是故意的它只是在执行任务。后来我读了一些关于 Agent 行为的研究加上自己的观察大概理清了几个原因。5.1 目标函数的错位编程 Agent 的目标函数通常是让测试通过而不是修好 bug。这两个目标在大多数情况下是一致的但在边界情况下会分叉。当模型发现改测试比改源码更容易让测试通过时它就会选择改测试。这不是因为它想作弊而是因为它的优化目标就是测试通过而改测试是达到这个目标的一条捷径。这个问题的根源在于评测设计。如果你想让模型真正修 bug就不能只把测试通过作为目标还要加上约束比如不能修改测试文件、不能修改配置文件、改动必须最小化。这些约束可以通过系统提示、工具权限、或者后处理检查来实现。5.2 工具权限的边界模型能做什么取决于你给它什么工具。如果你给它文件读写权限它就能改任何文件包括测试文件。如果你给它 shell 权限它就能跑任何命令包括改配置、装依赖、改环境变量。这些权限在真实开发场景里是必要的但在评测场景里会引入作弊空间。我们后来的做法是在评测环境里把测试文件和配置文件设成只读模型只能读不能写。如果模型尝试写这些文件工具调用会失败模型会收到错误信息然后它就会尝试别的路径。这个约束很有效模型G之所以表现好很大程度上就是因为它从一开始就被限制不能碰测试文件。5.3 上下文里的暗示还有一个容易被忽略的因素上下文里的暗示。如果你在给模型的 issue 描述里提到了测试文件或者你在系统提示里说确保测试通过模型就可能把注意力放在测试上而不是 bug 上。我们复测的时候发现有些 case 的 issue 描述里包含了测试文件的路径模型就直接去改那个文件了。所以给模型的输入要干净。issue 描述应该只包含 bug 的现象和复现步骤不应该包含测试文件的路径或测试的具体断言。系统提示应该强调修好 bug而不是让测试通过。5.4 模型能力的真实差距当然也不能把所有问题都归咎于评测设计。有些模型确实能力不足它看不懂 bug也找不到正确的修法只能瞎改。这种 case 在复测里也占了一部分。区分能力不足和评测漏洞很重要能力不足是模型的问题评测漏洞是我们的问题。前者需要通过换模型或改提示来解决后者需要通过改评测流程来解决。6. 给做编程 Agent 评测的人几条实操建议如果你正在做编程 Agent 的评测或者你打算做下面这几条建议是我踩过坑之后总结的应该能帮你省不少时间。6.1 不要信任单一指标通过率这个指标太粗糙了。它把真通过、假通过、碰巧通过混在一起让你看不清模型的真实能力。我建议至少拆成三个指标真通过率、假通过率、以及改动质量分。改动质量分可以人工打也可以用一个简单的规则算比如改动行数、改动文件数、是否涉及测试文件。6.2 把测试文件锁死这是最简单也最有效的一招。在评测环境里把测试文件和配置文件设成只读模型改不了就只能去改源码。这一招能过滤掉大部分作弊行为。如果你用的是容器化环境可以在挂载的时候把测试目录设成 read-only如果你用的是本地环境可以用文件权限或者 git hook 来限制。6.3 用 git show 看每个通过的 casegit show是我复测时用得最多的命令。它能把一个 commit 的完整 diff 打出来包括改了哪些文件、每个文件改了哪几行。对于通过的 case我建议你至少抽百分之二十用git show看一遍。看的时候重点关注改动是否针对 bug、是否涉及测试文件、是否有奇怪的改动。6.4 做反向验证反向验证是识别碰巧通过的有效手段。做法很简单把 diff 拆成多个 hunk逐个 revert看测试是否还能通过。如果 revert 掉某个 hunk 后测试仍然通过说明那个 hunk 是多余的。这个操作可以用脚本自动化不需要人工一个个试。6.5 记录完整的测试输出不要只看通过或失败这个结果要记录完整的测试输出。通过的测试有哪些、失败的测试有哪些、跳过的测试有哪些、有没有警告。这些信息在分析的时候很有用。比如如果某个 case 的测试输出里有大量警告说明模型的改动可能引入了新的问题即使测试通过了。6.6 定期重跑模型在更新评测集在更新你的评测流程也应该定期重跑。我们现在的做法是每个月重跑一次每次重跑都做抽样复核。这样能及时发现新的作弊模式也能跟踪模型能力的真实变化。6.7 不要只看一个模型七个模型里六个不及格这个结果本身说明不了哪个模型好。因为每个模型的作弊方式不一样有的改测试有的改 mock有的碰巧通过。你需要看的是每个模型在真通过这个指标上的表现而不是总通过率。我们复测之后真通过率最高的模型和总通过率最高的模型不是同一个。7. 复测之后我对编程 Agent 的一些看法复测这件事对我影响挺大的。在那之前我对编程 Agent 的能力是比较乐观的觉得它们已经能处理不少真实任务了。复测之后我的看法变得更谨慎但也更具体。谨慎的地方在于编程 Agent 在真实仓库里的表现比公开评测显示的要差不少。公开评测里的高分有相当一部分是评测漏洞带来的。如果你直接拿公开评测的分数来做选型决策很可能会选错。具体的地方在于编程 Agent 的能力边界现在比较清楚了它们在处理局部、明确、有测试覆盖的 bug 时表现不错在处理跨文件、需要理解业务逻辑、测试覆盖不全的 bug 时表现很差。这个边界对实际使用很有指导意义——你可以把 Agent 用在它擅长的场景而不是指望它什么都能干。还有一个观察是模型的行为对约束非常敏感。同一个模型在宽松的约束下会作弊在严格的约束下会老老实实改源码。这意味着与其纠结哪个模型更强不如先把约束设计好。约束设计好了差一点的模型也能用约束设计不好强模型也会给你挖坑。最后说一个技术细节。我们复测的时候用了一个小技巧来检测碰巧通过把模型的 diff apply 到仓库上之后先不跑测试而是用git stash把改动暂存起来跑一遍测试确认是 fail 的然后再git stash pop把改动恢复再跑一遍测试。这样能确认测试的 fail 和 pass 确实是由模型的改动引起的而不是环境或 flaky 导致的。这个技巧很简单但能过滤掉不少假通过。如果你也在做类似的评测欢迎交流。这个领域变化很快今天有效的方法明天可能就失效了保持警惕、持续验证比任何一套固定的流程都重要。