跳到主要内容

第 2 章 · 评测主流程(TestSpec 与三层 Docker)

本章追一条主线:从 predictions.json 到「测试输出日志」。核心是两个东西——TestSpec(把一道题编译成可执行脚本)和三层 Docker 镜像(让重活只干一次、可缓存复用)。

2.1 入口:哪些题要跑

顶层入口是 mainswebench/harness/run_evaluation.py:474)。它先把预测加载成 instance_id → prediction 的字典,再用 get_dataset_from_preds:374)筛出真正要跑的题:

  • 没有对应预测的 → 跳过;
  • 已经跑过(report.json 已存在)的 → 跳过(断点续跑,:436-456);
  • 空补丁的 → 跳过(:458-470)。

--predictions_path gold 是个特例:直接把数据集里的 gold patch 当作预测返回(get_predictions_from_fileswebench/harness/utils.py:42-52),用来自检环境。

2.2 TestSpec:把一道题「编译」成 bash

make_test_specswebench/harness/test_spec/test_spec.py:174)把一个 SWEbenchInstance 转成一个 TestSpec 数据类。TestSpec 的本质是三段 bash 命令列表

字段干什么生成函数
repo_script_list克隆仓库、reset 到 base_commit、装本仓库make_repo_script_list
env_script_list建 conda 环境、装依赖make_env_script_list
eval_script_list打测试补丁、跑测试、再还原测试make_eval_script_list

三个 make_* 函数按语言分派(swebench/harness/test_spec/create_scripts.py:17-53),但分派粒度并不一致make_repo_script_list:17-26)和 make_env_script_list:29-38)只有 py 一个专门分支,其余语言(含 JS)一律走 *_common;只有 make_eval_script_list:41-53)同时有 jspy 两个分支。换句话说 「JS 走 *_js」只对 eval 脚本成立,建仓/装环境的 JS 仍走 common。具体用哪套依赖/测试命令,查 MAP_REPO_VERSION_TO_SPECS[repo][version]test_spec.py:209)——这正是「versioning」子系统标注 version 的意义。

2.2.1 一个 spec「配方」长什么样

以 Python 为例,MAP_REPO_VERSION_TO_SPECS_PY 里每个 (repo, version) 是一份配方,含 python/packages/install/test_cmd 等键。例如(swebench/harness/constants/python.py:48-60 附近):

# 真实数据节选(某 Python 仓库的某版本配方)
{
"python": "3.9",
"packages": "requirements.txt", # 用 requirements.txt 建环境
"install": "python -m pip install -e .", # 怎么装本仓库
"test_cmd": TEST_PYTEST, # "pytest -rA"
}

test_cmdpytest -rAconstants/python.py:9):-rA 让 pytest 在结尾打印每个测试的逐行状态(PASSED/FAILED/…),这正是后面日志解析要的格式。

注意:constants/python.pyTEST_PYTEST两处赋值——:2"pytest --no-header -rA --tb=no -p no:cacheprovider")与 :9"pytest -rA")。后定义的 :9 覆盖前者,所以实际生效的是 :9"pytest -rA";若只看到 :2 会以为是另一串命令,别被误导。

Django 则用 ./tests/runtests.pyTEST_DJANGO),所以判分时要用不同的解析器。

2.3 三层 Docker 镜像:为什么这么分

直接为每道题从零建镜像太慢——光装 conda、编依赖就要几分钟。SWE-bench 把镜像分三层,越底层越通用、越能被多道题共享

「怎么读:从左到右是 FROM 依赖链,右边的镜像基于左边构建;左边重活只干一次。」

base 镜像 env 镜像 instance 镜像
┌──────────────┐ ┌──────────────┐ ┌────────────────┐
│ Ubuntu+conda │ ──FROM─►│ + conda 环境 │ ─FROM─►│ + 克隆好的仓库 │
│ 编译工具链 │ │ + 该题的依赖 │ │ 停在 base_commit│
│ (一类语言共享)│ │ (同环境的题共享)│ │ (每道题独有) │
└──────────────┘ └──────────────┘ └────────────────┘
sweb.base.* sweb.env.* sweb.eval.*

Dockerfile 模板见 swebench/harness/dockerfiles/python.py_DOCKERFILE_BASE_PY(装 apt 包 + miniconda + 建 nonroot 用户)、_DOCKERFILE_ENV_PY(跑 setup_env.sh 建环境)、_DOCKERFILE_INSTANCE_PY(跑 setup_repo.sh 克隆仓库)。

2.3.1 缓存键的妙处:内容哈希

镜像怎么知道「能不能复用」?靠内容哈希当镜像 tag。env 镜像的 key 是对 env_script_list(+docker_specs)取 sha256 前 22 位(TestSpec.env_image_keytest_spec.py:89-104):

# 真实源码节选
hash_key = str(self.env_script_list)
...
val = hash_value[:22]
return f"sweb.env.{MAP_REPO_TO_EXT[self.repo]}.{self.arch}.{val}:{self.env_image_tag}"

这是它在干嘛:环境脚本只要变一个字,哈希就变,镜像 key 就变,自动触发重建;脚本没变就命中旧镜像、跳过重建。base 镜像同理(:71-87)。这是「正确性来自内容寻址」的经典手法。

2.3.2 远程镜像 vs 本地构建

默认 --namespace swebench:从 DockerHub 直接拉官方预建镜像(is_remote_imagetest_spec.py:113-115;拉取在 build_containerdocker_build.py:494-505),省去本地几小时构建。ARM/Mac 上加 --namespace '' 改为本地构建(README NOTE)。force_rebuild 与 namespace 互斥(run_evaluation.py:510-511)。

2.4 跑一道题:run_instance

核心在 run_instanceswebench/harness/run_evaluation.py:71)。一道题的容器内时间线:

  1. 建容器build_containerdocker_build.py:470)——本地则先建 instance 镜像,远程则拉取;容器 command="tail -f /dev/null" 让它挂着待命(:516-524)。
  2. 打模型补丁:把 model_patch 写成 patch.diff 拷进容器,逐个尝试三种命令直到成功(:166-186):
# 真实源码:多级 fallback(run_evaluation.py:64-68)
GIT_APPLY_CMDS = [
"git apply --verbose",
"git apply --verbose --reject",
"patch --batch --fuzz=5 -p1 -i", # 最宽松:容忍行号/上下文偏移
]

这是它在干嘛:模型给的补丁经常和真实文件有轻微偏差,于是从最严格的 git apply 一路降级到最能容错的 patch --fuzz=5,命中即停。全失败就判这道题「打补丁失败」。

  1. 跑 eval 脚本:把 test_spec.eval_script 写成 /eval.sh 拷进容器,用 exec_run_with_timeout 跑(:198-220)。超时会 kill 并标记 timed_outdocker_utils.py:175-217)。
  2. 收输出 + 判分:测试输出写进 test_output.txt,交给 get_eval_report 判分(:236-251),结果写 report.json

并行:run_instances:276)把每道题打包成 payload,丢进 run_threadpoolutils.py:80)按 --max_workers 并行,进度条实时显示 ✓/✖/error(:352-368)。

2.5 eval 脚本内部:先还原、再打测试补丁、再跑

make_eval_script_list_pyswebench/harness/test_spec/python.py:405-462)生成的 eval.sh 顺序很讲究:

  1. 激活 conda、cd/testbed,必要时重跑 install(让模型补丁里的依赖变化生效)。
  2. 还原测试文件到 base_commit:对已存在的测试文件 git checkout base_commit <file>,对新测试文件 rm -f:417-423)。这一步避免模型补丁里夹带的测试改动干扰裁判。
  3. test_patch:用 heredoc 把官方测试补丁 git apply 进去(:424-426)。
  4. >>>>> Start Test Output>>>>> End Test Output 两个标记之间跑 test_cmd + 测试目录:455-460)。
  5. 跑完再 reset 还原测试(:461)。

这两个标记(START_TEST_OUTPUT/END_TEST_OUTPUTconstants/__init__.py:90-91)是给判分器精确切出测试输出段用的——见第 3 章。

2.6 小结

  • TestSpec = 把一道题编译成三段 bash(建仓/装环境/跑测试)。
  • 三层镜像 base→env→instance 用内容哈希当缓存键,重活只干一次。
  • run_instance 起容器 → 多级 fallback 打补丁 → 跑带超时的 eval.sh → 收日志。
  • eval.sh 先还原并重新打官方测试补丁,再在标记之间跑测试。

下一章:日志怎么变成「resolved 与否」。