门户首页
教学网站 · 协作基础

从 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 题目。

生成时间:2026-09-02 · 版本 v0.2 · 生成 Agent:MiniMax Code (LLM: MiniMax-M3) · 载体:agentsoft-research-platform teaching-web-platform

概览

5
构造步骤
7
常见坑
6
质量评估维度
1
真实案例
项目说明
本卡定位协作基础 · 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 个要素:

题目设计 4 要素
  1. 题面(问题描述)——给 agent 看的,要清晰、可执行
  2. 答案(修复方案)——用于评分 + 训练参考,ground truth
  3. 起点(基线状态)——agent 从这里开始改
  4. 评测(怎么打分)——客观可重复的判分标准
人造题目的痛点:HumanEval / MBPP 是人造题目,作者自己出题自己写答案自己评分——容易"答案就是题目反向工程"(作者知道答案 → 题目提示暗示答案)。
PR 作题目源的优势:① 题面真实——issue body 是真用户写的 ② 答案独立——PR diff 是另一个开发者写的(issue 作者 ≠ PR 作者) ③ 评测独立——F2P/P2P 测试是 PR 自带的(不是作者事后编的) ④ 不可篡改——Git commit hash 是内容寻址的,GitHub 上的事实不可争议。

§ 2 5 步构造流程

把一个真实 GitHub PR 转成 SWE-bench 题目,标准 5 步流程。

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)
flowchart TD P1["Step 1 · 找候选 PR
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;
图 1 · 5 步构造流程:找候选 → 提取字段 → 构造 test_patch → 容器内验证 → 写入 jsonl(验证失败回退换候选)

Step 1:找候选 PR(issue + merged PR 配对)

找候选 PR 的 3 条标准
标准为什么过滤方法
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 等
找候选 PR 的 GitHub API 示例
# 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 个字段)

4 个字段提取方法
字段提取源提取命令
problem_statement关联 issue bodygh issue view {N} --json body
base_commitPR merge commit 的父 commitgit log -1 --pretty=%P {merge_sha}(第一个 hash 是父)
patchPR 的全部分支 diff(不含 merge commit)git diff base_commit..head_commit
test_patchPR diff 中仅含 test 文件的部分git diff base_commit..head_commit -- '**/test_*.py' '**/tests/**' > test.patch
完整提取脚本(Python)
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(评测契约必须成立)

F2P / P2P 验证的 2 个步骤
  1. 在 base_commit 状态 apply test_patch:跑 test_patch 里列出的测试 → 期望fail(这就是 F2P)
  2. 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:入选数据集

入选数据集的 3 个标准
  1. Step 4 验证通过——F2P / P2P 行为符合预期
  2. 题面清晰——issue body 不是 "请帮我看看" 这种模糊描述
  3. 答案不过大——patch < 1000 行(太大 agent 处理不了)
写入 jsonl(JSON Lines,每行一个 JSON(JavaScript Object Notation,人类可读的文本数据格式)对象)的格式
{"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 个常见坑,踩中任意一个就放弃或修复。

flowchart LR A["候选 PR 池
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;
图 2 · 入选漏斗:候选 PR 经 Step 1 过滤 / Step 4 验证 / Step 5 审查三关,7 个常见坑分别在不同阶段被淘汰
7 个常见坑
坑表现解法
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 + 复现步骤)
7 个坑的过滤顺序:Step 1 排除 没改测试 / issue 描述模糊(占比 ~30%)→ Step 4 排除 破坏回归(占比 ~5%)→ Step 5 排除 太大 / breaking change(占比 ~10%)。最终入选率 ~50-60%——原始候选 PR 一半能进数据集。

§ 4 真实例子:django__django-10914 文件权限 bug

从真实 GitHub PR 一步步构造 SWE-bench 题目的完整 walk-through。

Step 1:找到候选 PR
# 在 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
Step 2:提取元数据
# 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..."
Step 3:隔离 test_patch(验证只含 test)
# 检查 test_patch 是否纯测试
$ grep -E "^diff --git" test.patch
  # diff --git a/tests/file_storage/tests.py  ✓ 只含 test 目录

# 如果含 src/ 文件,要剔除(用 git diff -- 'path' 重新过滤)
Step 4:验证 F2P / P2P
# 在容器里跑
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 通过 → 题目有效
Step 5:入选数据集
# 写入 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 个维度打分,太差的丢弃或修复。

6 维度评估
维度满分标准权重
题面清晰度issue body 包含:问题描述 + 复现步骤 + 期望行为 + 实际行为20%
答案最小性patch < 50 行(避免 agent 找错位置)15%
F2P 真实性F2P 测试在 pre-fix 状态确实 fail(不是写错测试)20%
P2P 稳定性apply patch 后整个仓库的回归测试全 pass15%
环境可复现Docker 镜像能拉、依赖能装、测试能跑(不依赖私有环境)15%
难度适中SOTA(State of the Art,当前最强)模型解决率 30-70%——太简单没区分度,太难都做不出15%
"难度适中"是核心:题目难度要让 SOTA 模型能做对一些、做错一些——这样模型之间能分出高下。
· 解决率 > 90%:太简单,所有模型都满分,没区分度
· 解决率 < 10%:太难,所有模型都零分,没区分度
· 解决率 30-70%:sweet spot,能分出模型能力差异

§ 6 题目审查清单(提交前必查)

把题目写进数据集前,按这张清单一项项勾——少一项就可能引入 bug。

题目审查清单(10 项)
  1. ☐ 关联 issue 真实存在——gh issue view 验证
  2. ☐ PR 是 merged 状态——gh pr view --json state,mergedAt 验证
  3. ☐ PR 改了测试文件——git diff --stat 含 test_ / tests/
  4. ☐ test_patch 纯测试——不混源码(grep ^diff --git 看路径)
  5. ☐ base_commit 干净——apply test_patch 不报 merge conflict
  6. ☐ F2P 真的 fail——apply test_patch 跑测试,期望 fail
  7. ☐ F2P 真的 pass——apply test_patch + patch 跑测试,期望 pass
  8. ☐ P2P 全部 pass——apply patch 后跑全仓库回归,期望 pass
  9. ☐ issue 描述清晰——含复现步骤 + 期望行为
  10. ☐ patch < 1000 行——避免 agent 处理不了
10 项检查 ~5-10 分钟/题,但能避免 90% 的"垃圾题目入数据集"问题。这一关是质量保证,比"造更多的题"更重要。

§ 7 上游 vs 本仓的题目构造对比

SWE-bench 官方"collect"流水线 vs 本仓"自建数据集"流程的差异。

上游 SWE-bench 的"collect"模块

SWE-bench 官方仓库的 swebench.collect 模块做上述全流程——自动爬 GitHub、提取 PR metadata、跑 F2P/P2P 验证、生成 jsonl。

步骤上游实现本仓实现
爬 PRswebench.collect.run_collect(PyGithub)同上
提取元数据get_instance_from_pr同上
验证 F2P / P2Pswebench.harness.run_evaluation(在 Docker 里跑)answer_evaluator.harness.run_evaluation(本地实现)
生成 jsonlbuild_dataset → 写 swe-bench.jsonldatabase/build_swe_bench.py(本仓替代实现)
本仓的"自建"原因:① swebench.collect 依赖 modal.com(云端执行),本仓希望纯本地可复现 ② 官方流水线跑全量 2,294 题要几天,本仓用 Lite 300 题跑得更快 ③ 方便加内部题目(不在 GitHub 上的私有仓库)。
本仓 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/pullsPull Requests API
本仓姊妹讲义github-vs-git.htmlGit vs GitHub 区别
本仓姊妹讲义github-pull-request.htmlPR 设计
本仓姊妹讲义swe-bench-intro.htmlSWE-bench 是什么
本仓姊妹讲义swe-bench-data-schema.htmlSWE-bench 数据 schema
本仓姊妹讲义swe-bench-evaluation.html评测流程