从 PR 构造 Benchmark 题目:SWE-bench 题目构造工程
理解了 "PR = 题源" 之后,下一步:怎么把一个真实 GitHub PR 转成 SWE-bench 题目?(SWE-bench:SWE = Software Engineering 软件工程 + bench = benchmark 基准测试,用真实 GitHub issue 评测代码 agent 的软件工程基准) 这一页讲5 步构造流程(找候选 PR → 提取元数据 → 隔离 test_patch → 验证 F2P/P2P → 入选数据集)——含每步的命令/脚本示例; 讲7 个常见坑(没测试、破坏回归、太大、合并冲突、breaking change、依赖漂移、issue 描述模糊); 给真实例子(django__django-10914 文件权限 bug)从 PR 一步步到 SWE-bench 题目。 读完你就能自己构造一道 SWE-bench 题目。
概览
| 项目 | 说明 |
|---|---|
| 本卡定位 | 协作基础 · 1.0 章节 第 3 张 |
| 前置 | github-vs-git.html + github-pull-request.html + swe-bench-intro.html + swe-bench-data-schema.html |
| 读完会什么 | 能讲清"SWE-bench 题目怎么从一个真实 PR 构造出来";知道 5 步流程 + 7 个常见坑;能写 PR 题目构造脚本 |
| 姊妹 | swe-bench-evaluation.html(构造完怎么评测) |
§ 1 为什么 PR 是天然的题目源
设计一个能客观评测 agent 能力的题目,需要 4 个要素:
- 题面(问题描述)——给 agent 看的,要清晰、可执行
- 答案(修复方案)——用于评分 + 训练参考,ground truth
- 起点(基线状态)——agent 从这里开始改
- 评测(怎么打分)——客观可重复的判分标准
PR 作题目源的优势:① 题面真实——issue body 是真用户写的 ② 答案独立——PR diff 是另一个开发者写的(issue 作者 ≠ PR 作者) ③ 评测独立——F2P/P2P 测试是 PR 自带的(不是作者事后编的) ④ 不可篡改——Git commit hash 是内容寻址的,GitHub 上的事实不可争议。
§ 2 5 步构造流程
把一个真实 GitHub PR 转成 SWE-bench 题目,标准 5 步流程。
Step 1: 找候选 PR(issue + merged PR 配对)
↓
Step 2: 提取元数据(problem_statement / patch / test_patch / base_commit)
↓
Step 3: 隔离 test_patch(确保 F2P 在 pre-fix 状态 fail)
↓
Step 4: 验证 F2P / P2P(确保评测契约成立)
↓
Step 5: 入选数据集(写进 swe-bench.jsonl)
issue + merged PR 配对"] P2["Step 2 · 提取字段
problem_statement / base_commit / patch / test_patch"] P3["Step 3 · 构造 test_patch
只含测试文件,剔除源码混入"] P4["Step 4 · 容器内验证 F2P / P2P
pre:F2P fail / P2P pass;post:全 pass"] P5["Step 5 · 写入 jsonl
入选数据集"] RET["验证失败
回退:换候选 PR"] P1 --> P2 P2 --> P3 P3 --> P4 P4 -->|验证通过| P5 P4 -.->|验证失败| RET RET -.-> P1 classDef bad fill:#fdecec,stroke:#d54f4f,color:#7a1f1f; /* Mermaid 不支持 CSS 变量,使用硬编码红色系表示错误/失败 */ class RET bad;
Step 1:找候选 PR(issue + merged PR 配对)
| 标准 | 为什么 | 过滤方法 |
|---|---|---|
| merged PR(不是 closed) | merged PR 有最终答案(merged commit) | GitHub API(Application Programming Interface,应用程序接口): state=closed & merged_at != null |
| 关联 issue(fixes #X) | 保证有"问题描述"来源 | PR body 搜 "fixes #" / "closes #" |
| PR 改了测试文件 | 否则没法定义 F2P(题目没"可验证答案") | diff 中含 test_*.py / *_test.py 等 |
# GitHub GraphQL API:找 django/django 的 merged PR 中关联 issue 的
curl -H "Authorization: Bearer $GH_TOKEN" \
-H "Content-Type: application/json" \
-X POST https://api.github.com/graphql \
-d '{
"query": "{ search(query: \"repo:django/django is:pr is:merged fixes\", type: ISSUE, first: 50) { edges { node { ... on PullRequest { number title baseRefName mergedAt closingIssuesReferences(first: 1) { edges { node { number title body } } } } } } } }"
}' \
| jq '.data.search.edges[] | {pr: .node.number, title: .node.title, issue: .node.closingIssuesReferences.edges[0].node.number}'
Step 2:提取元数据(4 个字段)
| 字段 | 提取源 | 提取命令 |
|---|---|---|
| problem_statement | 关联 issue body | gh issue view {N} --json body |
| base_commit | PR merge commit 的父 commit | git log -1 --pretty=%P {merge_sha}(第一个 hash 是父) |
| patch | PR 的全部分支 diff(不含 merge commit) | git diff base_commit..head_commit |
| test_patch | PR diff 中仅含 test 文件的部分 | git diff base_commit..head_commit -- '**/test_*.py' '**/tests/**' > test.patch |
import subprocess
import json
def extract_pr_metadata(repo, pr_number):
# 1. 拿 PR 信息(merge commit SHA + base SHA + title)
pr_info = subprocess.check_output([
"gh", "pr", "view", str(pr_number),
"--repo", repo, "--json",
"number,title,body,mergeCommit,baseRefName,files"
])
pr = json.loads(pr_info)
merge_sha = pr["mergeCommit"]["oid"]
base_sha = pr["baseRefName"] # 实际是 base ref 名字,不是 SHA
# 2. 拿 base_commit(merge commit 的父 commit)
parents = subprocess.check_output([
"git", "log", "-1", "--pretty=%P", merge_sha
]).decode().strip().split()
base_commit = parents[0] # merge commit 有 2 个父,第一个是 base
# 3. 拿 patch(整 PR 的 diff,不含 merge commit 自己)
patch = subprocess.check_output([
"git", "diff", f"{base_commit}..{merge_sha}^1"
]).decode()
# 4. 拿 test_patch(只含测试文件)
test_patch = subprocess.check_output([
"git", "diff", f"{base_commit}..{merge_sha}^1",
"--", "**/test_*.py", "**/tests/**"
]).decode()
return {
"base_commit": base_commit,
"patch": patch,
"test_patch": test_patch,
}
# 5. problem_statement 从关联 issue 来
# (需要先解析 PR body 里的 "fixes #N",再调 gh issue view)
Step 3:隔离 test_patch(关键!)
test_patch 必须只包含"新增的测试文件 / 改动",不能包含:
| 必须排除 | 原因 |
|---|---|
| 源码文件(src/*.py) | test_patch 是"测试修复",不是"修复"——混入会变成给 agent 提示 |
| 删除/重命名的非测试文件 | 评测时这些操作可能让 agent 的 patch 失败 |
| CI 配置 / 文档 | 不是评测标准 |
Step 4:验证 F2P / P2P(评测契约必须成立)
- 在 base_commit 状态 apply test_patch:跑 test_patch 里列出的测试 → 期望fail(这就是 F2P)
- apply test_patch + patch:再跑同一组测试 → 期望pass
def verify_f2p_p2p(instance):
# 在容器里跑(隔离环境)
container = build_container(base_commit=instance.base_commit).start()
# Step A: apply test_patch(不动 patch)→ 跑测试
apply_patch(container, instance.test_patch)
pre_results = run_tests(container, instance.fail_to_pass + instance.pass_to_pass)
# Step B: apply patch → 再跑
apply_patch(container, instance.patch)
post_results = run_tests(container, instance.fail_to_pass + instance.pass_to_pass)
# 验证 fail_to_pass 确实是 "pre: fail, post: pass"
for t in instance.fail_to_pass:
assert pre_results[t] == "FAIL", f"F2P {t} didn't fail before fix!"
assert post_results[t] == "PASS", f"F2P {t} didn't pass after fix!"
# 验证 pass_to_pass 前后都 pass
for t in instance.pass_to_pass:
assert pre_results[t] == "PASS", f"P2P {t} didn't pass before!"
assert post_results[t] == "PASS", f"P2P {t} broke after fix!"
return True # 全部通过,题目有效
Step 5:入选数据集
- Step 4 验证通过——F2P / P2P 行为符合预期
- 题面清晰——issue body 不是 "请帮我看看" 这种模糊描述
- 答案不过大——patch < 1000 行(太大 agent 处理不了)
{"instance_id": "django__django-10914",
"repo": "django/django",
"base_commit": "a4e35c2f8b9d12e7c...",
"version": "3.10",
"problem_statement": "When I use FileSystemStorage, ...",
"patch": "diff --git a/django/core/files/storage.py ...",
"test_patch": "diff --git a/tests/file_storage/tests.py ...",
"fail_to_pass": ["tests.file_storage.tests.FileStorageTests.test_file_upload_permissions_not_world_writable"],
"pass_to_pass": ["tests.file_storage.tests.FileStorageTests.test_file_upload_permissions", ...]}
§ 3 7 个常见坑(哪些 PR 不能入选)
不是所有 PR 都能入选——以下是 7 个常见坑,踩中任意一个就放弃或修复。
100%"] --> B["Step 1 候选过滤
剩 ~70%"] B --> C["Step 4 容器内验证
剩 ~65%"] C --> D["Step 5 人工审查
入选 50-60%"] B -.->|淘汰| X1["坑:无测试
坑:依赖外部服务"] C -.->|淘汰| X2["坑:F2P 误判
坑:P2P 回归
坑:环境不可复现"] D -.->|淘汰| X3["坑:测试与源码纠缠
坑:题面信息泄露"] classDef out fill:#fdecec,stroke:#d54f4f,color:#7a1f1f; /* Mermaid 不支持 CSS 变量,使用硬编码红色系表示淘汰 */ class X1,X2,X3 out;
| 坑 | 表现 | 解法 |
|---|---|---|
| 1. PR 没改测试 | 找不到 F2P(fail_to_pass 列表空)——agent 没办法知道自己"对不对" | 放弃。SWE-bench 题目必须有可验证的测试 |
| 2. PR 改了非测试文件的源码 | test_patch 包含源码改动——混入答案 → agent 看到 test_patch 就知道答案 | 严格过滤 test_patch 只含 test 目录 |
| 3. PR 破坏回归 | apply patch 后 P2P 测试挂了——这 PR 本身就是"坏的" | 从数据集排除(或标 FAIL_ONLY) |
| 4. PR 太大 | patch 改 100+ 文件 / 2000+ 行——agent 处理不了 | 放弃 or 拆成多个小 PR(但小 PR 不一定有完整 fix) |
| 5. PR 合并冲突 | apply patch 时 git apply 失败——base_commit 不干净 | rebase 到干净 base 后再提取 patch |
| 6. PR 含 breaking change | 改 API signature / 删 public 函数——agent 难以"理解意图"修复 | 放弃 or 拆成"修 bug"和"refactor"两个 PR |
| 7. issue 描述模糊 | "程序崩了,请修"——agent 不知道要修什么 | 放弃(除非 issue 有 stack trace + 复现步骤) |
§ 4 真实例子:django__django-10914 文件权限 bug
从真实 GitHub PR 一步步构造 SWE-bench 题目的完整 walk-through。
# 在 GitHub 上搜 django 的 merged PR 含 "fixes" + 改 test 文件 # 找到 PR #10914: "Fixed #30953: File upload permission default is unsafe on multi-user systems" PR #10914 Author: Django Contributor X Title: Fixed #30953: File upload permission default is unsafe on multi-user systems Base: main (commit abc1234...) Merged: 2023-05-15 Fixes: #30953
# base_commit = merge commit 的父 commit $ git log -1 --pretty=%P abc9876 # merge commit 的 SHA deadbeef cafe5678 # 两个父,deadbeef 是 base # 所以 base_commit = "deadbeef..." # patch = base_commit..head 的整 PR diff $ git diff deadbeef..cafe5678 # diff --git a/django/core/files/storage.py b/django/core/files/storage.py # -os.chmod(full_path, self.file_permissions_mode) # +os.chmod(full_path, self.file_permissions_mode & 0o777) # ...(约 5 行改动) # test_patch = PR diff 中只含 test 文件的部分 $ git diff deadbeef..cafe5678 -- '**/tests/**' # diff --git a/tests/file_storage/tests.py b/tests/file_storage/tests.py # +def test_file_upload_permissions_not_world_writable(self): # + ...(新测试函数,验证 world-writable 修复) # problem_statement = 关联 issue #30953 的 body $ gh issue view 30953 --json body "When using FileSystemStorage, the default FILE_UPLOAD_PERMISSION is 0o644..."
# 检查 test_patch 是否纯测试 $ grep -E "^diff --git" test.patch # diff --git a/tests/file_storage/tests.py ✓ 只含 test 目录 # 如果含 src/ 文件,要剔除(用 git diff -- 'path' 重新过滤)
# 在容器里跑 1. apply test_patch(不动 patch) → pytest test_file_upload_permissions_not_world_writable → 期望:FAIL(这条测试在 base_commit 状态还没人写对应的修复,所以 fail) 2. apply patch → pytest test_file_upload_permissions_not_world_writable → 期望:PASS(patch 加了 & 0o777 屏蔽 world-writable 位) 3. apply test_patch + patch → pytest 整个 test_file_storage/tests.py → 期望:所有原有测试仍 pass(patch 没破坏其他功能) 验证结果:F2P 通过 / P2P 通过 → 题目有效
# 写入 jsonl 的一行
{"instance_id": "django__django-10914",
"repo": "django/django",
"base_commit": "deadbeefcafe...",
"version": "3.10",
"problem_statement": "When using FileSystemStorage, the default FILE_UPLOAD_PERMISSION is 0o644, which means other users on the same system can read uploaded files...",
"patch": "diff --git a/django/core/files/storage.py b/django/core/files/storage.py\n@@ -290,7 +290,7 @@\n- os.chmod(full_path, self.file_permissions_mode)\n+ os.chmod(full_path, self.file_permissions_mode & 0o777)",
"test_patch": "diff --git a/tests/file_storage/tests.py b/tests/file_storage/tests.py\n@@ -50,6 +50,15 @@\n+ def test_file_upload_permissions_not_world_writable(self):\n+ # ...新测试...",
"fail_to_pass": ["tests.file_storage.tests.FileStorageTests.test_file_upload_permissions_not_world_writable"],
"pass_to_pass": ["tests.file_storage.tests.FileStorageTests.test_file_upload_permissions", "tests.file_storage.tests.FileStorageTests.test_save_overwrite", ...200+ 个]}
# 这道题被 SOTA 模型(如 Claude Sonnet 4)解决需要 ~3 分钟 / $0.05
§ 5 6 维度质量评估(题目"好不好")
题目入选数据集前,用 6 个维度打分,太差的丢弃或修复。
| 维度 | 满分标准 | 权重 |
|---|---|---|
| 题面清晰度 | issue body 包含:问题描述 + 复现步骤 + 期望行为 + 实际行为 | 20% |
| 答案最小性 | patch < 50 行(避免 agent 找错位置) | 15% |
| F2P 真实性 | F2P 测试在 pre-fix 状态确实 fail(不是写错测试) | 20% |
| P2P 稳定性 | apply patch 后整个仓库的回归测试全 pass | 15% |
| 环境可复现 | Docker 镜像能拉、依赖能装、测试能跑(不依赖私有环境) | 15% |
| 难度适中 | SOTA(State of the Art,当前最强)模型解决率 30-70%——太简单没区分度,太难都做不出 | 15% |
· 解决率 > 90%:太简单,所有模型都满分,没区分度
· 解决率 < 10%:太难,所有模型都零分,没区分度
· 解决率 30-70%:sweet spot,能分出模型能力差异
§ 6 题目审查清单(提交前必查)
把题目写进数据集前,按这张清单一项项勾——少一项就可能引入 bug。
- ☐ 关联 issue 真实存在——gh issue view 验证
- ☐ PR 是 merged 状态——gh pr view --json state,mergedAt 验证
- ☐ PR 改了测试文件——git diff --stat 含 test_ / tests/
- ☐ test_patch 纯测试——不混源码(grep ^diff --git 看路径)
- ☐ base_commit 干净——apply test_patch 不报 merge conflict
- ☐ F2P 真的 fail——apply test_patch 跑测试,期望 fail
- ☐ F2P 真的 pass——apply test_patch + patch 跑测试,期望 pass
- ☐ P2P 全部 pass——apply patch 后跑全仓库回归,期望 pass
- ☐ issue 描述清晰——含复现步骤 + 期望行为
- ☐ patch < 1000 行——避免 agent 处理不了
§ 7 上游 vs 本仓的题目构造对比
SWE-bench 官方"collect"流水线 vs 本仓"自建数据集"流程的差异。
SWE-bench 官方仓库的 swebench.collect 模块做上述全流程——自动爬 GitHub、提取 PR metadata、跑 F2P/P2P 验证、生成 jsonl。
| 步骤 | 上游实现 | 本仓实现 |
|---|---|---|
| 爬 PR | swebench.collect.run_collect(PyGithub) | 同上 |
| 提取元数据 | get_instance_from_pr | 同上 |
| 验证 F2P / P2P | swebench.harness.run_evaluation(在 Docker 里跑) | answer_evaluator.harness.run_evaluation(本地实现) |
| 生成 jsonl | build_dataset → 写 swe-bench.jsonl | database/build_swe_bench.py(本仓替代实现) |
本仓 database/build_swe_bench.py 是上游 collect 的"自建 mini 版"——同样的 5 步流程,不依赖云端。
引用与配套资料
| 来源 | 链接 / 路径 | 说明 |
|---|---|---|
| 上游 SWE-bench 仓库 | github.com/SWE-bench/SWE-bench | 官方流水线(含 collect / harness) |
| 本仓自建流水线 | database/build_swe_bench.py | 本地构造数据集的脚本 |
| GitHub API 文档 | docs.github.com/en/rest/pulls/pulls | Pull Requests API |
| 本仓姊妹讲义 | github-vs-git.html | Git vs GitHub 区别 |
| 本仓姊妹讲义 | github-pull-request.html | PR 设计 |
| 本仓姊妹讲义 | swe-bench-intro.html | SWE-bench 是什么 |
| 本仓姊妹讲义 | swe-bench-data-schema.html | SWE-bench 数据 schema |
| 本仓姊妹讲义 | swe-bench-evaluation.html | 评测流程 |