第 2 章 · 评测主流程(TestSpec 与三层 Docker)
本章追一条主线:从
predictions.json到「测试输出日志」。核心是两个东西——TestSpec(把一道题编译成可执行脚本)和三层 Docker 镜像(让重活只干一次、可缓存复用)。
2.1 入口:哪些题要跑
顶层入口是 main(swebench/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_file,swebench/harness/utils.py:42-52),用来自检环境。
2.2 TestSpec:把一道题「编译」成 bash
make_test_spec(swebench/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)同时有 js 与 py 两个分支。换句话说 「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_cmd 用 pytest -rA(constants/python.py:9):-rA 让 pytest 在结尾打印每个测试的逐行状态(PASSED/FAILED/…),这正是后面日志解析要的格式。
注意:
constants/python.py里TEST_PYTEST有两处赋值——:2("pytest --no-header -rA --tb=no -p no:cacheprovider")与:9("pytest -rA")。后定义的:9覆盖前者,所以实际生效的是:9的"pytest -rA";若只看到:2会以为是另一串命令,别被误导。
Django 则用 ./tests/runtests.py(TEST_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_key,test_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_image,test_spec.py:113-115;拉取在 build_container,docker_build.py:494-505),省去本地几小时构建。ARM/Mac 上加 --namespace '' 改为本地构建(README NOTE)。force_rebuild 与 namespace 互斥(run_evaluation.py:510-511)。
2.4 跑一道题:run_instance
核心在 run_instance(swebench/harness/run_evaluation.py:71)。一道题的容器内时间线:
- 建容器:
build_container(docker_build.py:470)——本地则先建 instance 镜像,远程则拉取;容器command="tail -f /dev/null"让它挂着待命(:516-524)。 - 打模型补丁:把
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,命中即停。全失败就判这道题「打补丁失败」。
- 跑 eval 脚本:把
test_spec.eval_script写成/eval.sh拷进容器,用exec_run_with_timeout跑(:198-220)。超时会kill并标记timed_out(docker_utils.py:175-217)。 - 收输出 + 判分:测试输出写进
test_output.txt,交给get_eval_report判分(:236-251),结果写report.json。
并行:run_instances(:276)把每道题打包成 payload,丢进 run_threadpool(utils.py:80)按 --max_workers 并行,进度条实时显示 ✓/✖/error(:352-368)。
2.5 eval 脚本内部:先还原、再打测试补丁、再跑
make_eval_script_list_py(swebench/harness/test_spec/python.py:405-462)生成的 eval.sh 顺序很讲究:
- 激活 conda、
cd到/testbed,必要时重跑install(让模型补丁里的依赖变化生效)。 - 还原测试文件到 base_commit:对已存在的测试文件
git checkout base_commit <file>,对新测试文件rm -f(:417-423)。这一步避免模型补丁里夹带的测试改动干扰裁判。 - 打
test_patch:用 heredoc 把官方测试补丁git apply进去(:424-426)。 - 在
>>>>> Start Test Output和>>>>> End Test Output两个标记之间跑test_cmd + 测试目录(:455-460)。 - 跑完再
reset还原测试(:461)。
这两个标记(START_TEST_OUTPUT/END_TEST_OUTPUT,constants/__init__.py:90-91)是给判分器精确切出测试输出段用的——见第 3 章。
2.6 小结
TestSpec= 把一道题编译成三段 bash (建仓/装环境/跑测试)。- 三层镜像 base→env→instance 用内容哈希当缓存键,重活只干一次。
run_instance起容器 → 多级 fallback 打补丁 → 跑带超时的 eval.sh → 收日志。- eval.sh 先还原并重新打官方测试补丁,再在标记之间跑测试。
下一章:日志怎么变成「resolved 与否」。