这套东西能给你什么
做完这套套件,你会得到三个具体产物:
1. 一个只属于你的任务集:几十条来自私有仓库真实改动历史(或手工编写)的编码任务,永远不会出现在公开互联网上,所以模型不可能"背过答案"。
2. 一个可替换 Harness 的运行器:AI 词典:系统提示词">系统提示词、工具集、上下文裁剪策略、重试逻辑、编辑格式这些东西,全部变成可以一行配置切换的插件。
3. 一张能说话的对比表:同一个任务集、同一套预算下,Harness A 和 Harness B 的配对差异,带不确定区间,而不是"我感觉换了工具之后好像变强了"。
它解决的问题很朴素:公开 benchmark 上的分数差异,可能来自模型本身,也可能来自 Harness、来自记忆、来自评测脚本的细节。你没法用公开榜单判断"我该不该换掉现在这套 agent 脚手架",只能自己搭一套。
前置条件清单
- 一个私有 Git 仓库,且它有真实的提交历史(哪怕只有你一个人写)。这是任务集的最佳来源。
- 容器运行时(Docker 或同类),能起一个断网的、带 Git 和语言运行时的镜像。
- 一个模型 API key,且你能从响应里读到模型标识字段(用来判断 provider 有没有偷偷换模型,具体字段名以官方文档为准)。
- Python 环境,标准库够用;分析阶段可能想装 numpy/pandas,非必需。
- 一份 Harness 变量清单:先想清楚你要对比什么。这份清单没写好,后面全是白跑。
- 心理准备:这套东西的价值在于"可信",不在于"跑得多"。每条任务跑 5 次、10 条任务,就已经能排除掉大部分假阳性结论。
第一步:把"Harness 效应"拆成可开关的变量
Harness 指的是模型外面那一圈脚手架。常见可开关变量:
| 变量 | 典型取值 |
|---|---|
| 系统提示词 | 极简 / 带工作流规定 / 带仓库地图 |
| 工具集 | 只有 shell / shell + 结构化读写文件 / 加搜索 |
| 编辑格式 | 整文件覆写 / 搜索替换块 / 补丁 |
| 上下文策略 | 全量保留 / 超长截断 / 摘要压缩 |
| 回合预算 | 固定轮数 / 固定 token / 固定墙钟 |
| 失败反馈 | 不回传测试结果 / 回传失败测试名 / 回传完整堆栈 |
关键纪律:一次只动一个变量。 你想知道"加个搜索工具有没有用",那就只加搜索工具;别顺手把提示词也重写了。否则最后你只知道"新版比旧版好",不知道为什么好,也没法迁移到别的任务上。
第二步:搭目录骨架与任务规格
```text
private-eval/
├── tasks/
│ └── fix-orders-dedup/
│ ├── task.yaml # 任务规格
│ ├── start.tar.gz # 起始代码快照(不含修复)
│ └── hidden/ # 隐藏测试,评分阶段才注入
├── harnesses/
│ ├── base.py
│ ├── with_search.py
│ └── patch_edit.py
├── runner/
│ ├── run_episode.py
│ ├── grade.py
│ └── report.py
├── docker/Dockerfile
└── results/ # 每次运行的 trace 落盘在这里
```
task.yaml 是整个套件的中心。一份够用的长这样:
```yaml
id: fix-orders-dedup
prompt: |
订单导出接口在并发调用时会出现重复行。请定位原因并修复,
不要改动已有测试的语义。
source: private-repo
base_commit: "填入私有仓库里修复前的 commit hash"
fail_to_pass: # 修好之后必须通过
- tests/test_orders.py::test_dedup_under_concurrency
pass_to_pass: # 修好之后不许弄坏的既有测试
- tests/test_orders.py::test_basic_export
- tests/test_orders.py::test_empty_input
budget:
max_turns: 60
wall_seconds: 900
```
fail_to_pass 和 pass_to_pass 这两栏是精髓。只看前者,模型可能靠删测试过关;加上后者,它必须既修好又别弄坏。
第三步:从私有仓库里造任务(防污染的核心)
不要手写"请实现一个快排"这种题——那类题在网上有几十万份参考答案,你测的是记忆力。
正确做法:从私有仓库的历史里挖"先红后绿"的提交对。
```python
tools/harvest.py —— 从私有仓库自动挖候选任务
import subprocess, yaml, pathlib
def commits(repo, limit=200):
out = subprocess.run(
["git", "-C", repo, "log", "--reverse", "--format=%H"],
capture_output=True, text=True, check=True).stdout.split()
return out[:limit]
def touched_tests(repo, sha):
out = subprocess.run(
["git", "-C", repo, "show", "--name-only", "--format=", sha],
capture_output=True, text=True, check=True).stdout.split()
return [p for p in out if "test" in p and p.endswith(".py")]
for sha in commits("/path/to/private-repo"):
tests = touched_tests("/path/to/private-repo", sha)
if not tests:
continue
人工过一遍:改动太小、太大、纯重构、纯配置的,都丢掉
print(sha, tests)
```
挖出来之后人工筛选,标准是:
- 改动量适中(大概几十到几百行级别,你自己感受);
- 题面能一句话说清,且不需要外部业务知识;
- 能分离出明确的
fail_to_pass。
选完之后,把修复前的代码打包成 start.tar.gz,把测试抽出来放进 hidden/,并且从起始快照里删掉这些测试——评测时再注入。
再加三个防污染手段:
1. 金丝雀字符串:在私有仓库里放一个绝不可能自然出现的唯一标识(比如某个罕见词组),如果模型在没有任何提示的情况下把它吐出来,说明这份数据可能被喂进过训练集。
2. 重命名扰动版:把任务的变量名、模块名、目录结构全部改一遍,生成一个"孪生任务"。真会做题的模型在两个版本上表现接近;靠记忆的模型在扰动版上会明显掉。
3. 盲跑版:只给题面、不给仓库。如果模型能凭题面直接写出正确补丁,说明这题太套路化,区分度低,可以扔掉。
第四步:沙箱与容器契约
容器要满足:断网、可复现、每次从同一个快照开始。
```dockerfile
docker/Dockerfile
FROM python:3-slim
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /work
依赖要锁死,否则半年后同一份评测自己会挂
COPY requirements.lock /work/requirements.lock
RUN pip install --no-cache-dir -r /work/requirements.lock
```
启动时:
```bash
docker run -d --name ep-001 \
--network none \
--memory 4g --cpus 2 \
--read-only --tmpfs /tmp \
-v "$PWD/tasks/fix-orders-dedup/start.tar.gz:/in/start.tar.gz:ro" \
private-eval-base sleep infinity
docker exec ep-001 sh -c 'mkdir -p /work && tar xzf /in/start.tar.gz -C /work'
```
--network none 一定要加。否则模型可能去搜答案,也可能无意中调用外部服务,你的实验就不可复现了。另外每次评测都把 hidden/ 目录从容器外只读挂载注入,容器内的 agent 进程根本看不到路径。
第五步:把 Harness 抽成可替换插件
所有 Harness 实现同一个接口,运行器只认接口。
```python
harnesses/base.py
class Harness:
name = "base"
def initial_messages(self, task):
"""返回给模型的第一条 messages 列表。"""
return [
{"role": "system", "content": self.system_prompt()},
{"role": "user", "content": task["prompt"]},
]
def system_prompt(self):
return "你是一个在容器里工作的编码助手。修复用户描述的问题。"
def tool_schemas(self):
"""工具定义。字段名以你所用的 SDK 官方文档为准。"""
return [
{
"name": "run_shell",
"description": "在 /work 目录下执行 shell 命令",
"parameters": {"type": "object",
"properties": {"cmd": {"type": "string"}},
"required": ["cmd"]},
}
]
def parse(self, resp):
"""把 SDK 响应统一成 {"text": str, "tool_calls": [...]}"""
raise NotImplementedError
```
一个具体变体,只改工具集,其余一字不动:
```python
harnesses/with_search.py
from .base import Harness
class WithSearch(Harness):
name = "with_search"
def system_prompt(self):
return super().system_prompt() # 提示词完全继承,保证只动一个变量
def tool_schemas(self):
return super().tool_schemas() + [{
"name": "search_code",
"description": "在仓库内做正则搜索,返回匹配行",
"parameters": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]},
}]
```
运行器主循环:
```python
runner/run_episode.py
import time, uuid, json
def run_episode(harness, task, sandbox, model_client, budget, out_dir):
trace = {
"episode_id": str(uuid.uuid4()),
"task_id": task["id"],
"harness": harness.name,
"model": None, # 从响应里读,用来发现 provider 静默换模型
"steps": [],
"tokens": 0,
}
messages = harness.initial_messages(task)
started = time.time()
for turn in range(budget["max_turns"]):
resp = model_client.chat(
messages=messages,
tools=harness.tool_schemas(),
temperature=0.0, # 尽量降随机性,具体取值范围以官方文档为准
)
parsed = harness.parse(resp)
trace["model"] = trace["model"] or getattr(resp, "model", None)
trace["tokens"] += getattr(resp, "usage_total", 0)
trace["steps"].append(parsed)
if not parsed["tool_calls"]:
break
messages.append({"role": "assistant",
"content": parsed["text"],
"tool_calls": parsed["tool_calls"]})
for call in parsed["tool_calls"]:
result = sandbox.exec_tool(call) # 见下一步
messages.append({"role": "tool",
"tool_call_id": call.get("id"),
"content": result["output"][:8000]})
if time.time() - started > budget["wall_seconds"]:
trace["aborted"] = "wall_clock"
break
trace["seconds"] = round(time.time() - started, 2)
with open(f"{out_dir}/{trace['episode_id']}.json", "w") as f:
json.dump(trace, f)
return trace, messages
```
预算必须三个 Harness 完全一致:同样的轮数上限、墙钟上限、token 上限。否则你测的是"谁更舍得烧钱",不是"谁的设计更好"。
第六步:评分器要有对抗性
评分只做三件事,但每件都要做扎实。
```python
runner/grade.py
def grade(sandbox, task, trace):
report = {"task_id": task["id"], "harness": trace["harness"]}
1. 先把隐藏测试覆盖回去,防止 agent 改测试作弊
sandbox.exec(f"cp -r /hidden/. /work/{task.get('test_dir', 'tests')}/")
2. 检查它到底改了哪些文件
changed = sandbox.exec("cd /work && git status --porcelain").splitlines()
report["changed_files"] = changed
if any("test" in line for line in changed):
report["cheated_tests"] = True # 动过测试就单独标记,不直接判负,留给你看
3. 跑指定用例,解析结果
report["fail_to_pass"] = sandbox.run_tests(task["fail_to_pass"])
report["pass_to_pass"] = sandbox.run_tests(task["pass_to_pass"])
report["passed"] = (
all(r["ok"] for r in report["fail_to_pass"]) and
all(r["ok"] for r in report["pass_to_pass"])
)
return report
```
常见的作弊手法和对应防线:
- 改测试:覆盖回去 + 记录改动文件。
- 写死返回值:
pass_to_pass里放几条输入输出组合不同的用例。 - 吞异常:加一条"故意传非法参数,必须抛指定异常类型"的用例。
- 删代码绕过:加一条"模块必须仍导出某几个公开函数"的静态检查。
- 提前读答案:隐藏测试只读挂载,容器内路径不可见。
第七步:跑实验——配对、重复、记录
```bash
伪代码,实际用你的编排脚本
for TASK in tasks/*; do
for HARNESS in base with_search patch_edit; do
for i in 1 2 3 4 5; do
run_one --task "$TASK" --harness "$HARNESS" --repeat "$i"
done
done
done
```
三个原则:
1. 配对:每个 Harness 跑同一批任务。永远不要拿"A 在 10 条任务上的成绩"对比"B 在另外 10 条任务上的成绩"。
2. 重复:同一任务同一 Harness 至少跑 3–5 次。编码任务本身就是高方差的,单次结果没有意义。
3. 全量留痕:完整的消息序列、工具调用、退出码、耗时、token 数全部落盘。结论出问题时,你要能回放。
第八步:读结果,别只看平均数
配对差异比平均分可靠得多。一个够用的自助法区间:
```python
runner/report.py
import random
def paired_delta(a, b, n=10000):
"""a、b 是同一任务集、同一重复顺序下的 0/1 结果列表。"""
assert len(a) == len(b)
diffs = []
for _ in range(n):
idx = [random.randrange(len(a)) for _ in a]
diffs.append(sum(a[i] - b[i] for i in idx) / len(a))
diffs.sort()
return diffs[int(0.025 * n)], diffs[int(0.975 * n)]
```
如果区间横跨 0,就说"没测出差异",别硬解释成"略好一点"。
同时报三个附加指标,它们经常比通过率更有决策价值:
- 每条任务的 token 消耗:有些 Harness 通过率高,但贵一倍。
- 墙钟时间:影响你能不能塞进 CI。
- 失败形态:是"没做完就超时了",还是"做完了但做错了"?前者说明工具或上下文策略有问题,后者说明理解力有问题。这两个的修法完全不同。
第九步:污染审计与例行体检
把这套检查做成定期任务,一个月跑一次:
- 金丝雀检查:从 trace 里搜那个唯一字符串,出现就是泄漏信号。
- 扰动版对比:原版和重命名版的通过率差多少?差得离谱,说明部分任务是记忆题。
- 盲跑基线:不给仓库、只给题面,通过率应该接近 0。如果明显高于 0,考虑淘汰相关任务。
- 基线自检:拿"修复后的代码"当输入跑一遍评分器,必须全绿。这一步是防止你自己的评测脚本有 bug。
- 模型标识漂移:对比历史 trace 里的模型标识,变了就在报告里标注——跨版本比较要谨慎。
常见坑与排错
坑一:pass_to_pass 自己就挂了。
典型原因是环境漂移。锁死依赖版本和基础镜像,最好按镜像摘要(digest)固定,别用浮动标签。
坑二:任务上有 flaky 测试。
在采集任务阶段,对每个候选任务跑 3 次"修复后代码",只要有一次不过就剔除。宁缺毋滥。
坑三:模型把测试文件改了。
覆盖回去是必须的,但不要直接判负——先记录,再看它为什么改。有时候模型改测试是因为题面有歧义,那是你题面的问题。
坑四:跨天比较。
provider 端的模型可能随时更新,你拿上周的 trace 和今天的比,很可能在比两个不同的模型。所以每条 trace 都要记下响应里的模型标识,跨批次的对比结论都要打个问号。
坑五:给不同 Harness 不同预算。
"新版工具多,所以多给 10 轮"——这一句会让整轮实验作废。要对比预算影响,就单独做一组"预算消融"实验。
坑六:容器里时间太长。
墙钟上限设得太宽,你会花大量时间等一个注定失败的回合。先跑小批量估个合理上限,再放大。
坑七:只看总通过率。
20 条任务、通过率 60% 和 70% 的差异,在这个样本量下基本是噪声。看逐任务结果,看哪些任务发生了翻转,往往能直接告诉你 Harness 到底改善了什么。
下一步建议
1. 先做 5 条任务的最小闭环,把运行、评分、报告三段全部跑通,再扩到 30–50 条。一开始就想铺满,通常会在第三周放弃。
2. 把任务集版本化。任务集本身也放在私有 Git 里,加版本号。这样半年后你能说清"分数涨了,是模型换了还是题目换了"。
3. 接进 CI:每次改 Harness 代码就自动跑一个小规模子集(比如 5 条固定任务 × 3 次),当作回归测试。规模不用大,能挡住明显退步就够。
4. 做预算消融:固定 Harness,只变轮数上限,画出"轮数—通过率"曲线。你会发现自己可能一直在给一个早已收敛的 agent 发多余的钱。
5. 定期加新任务、淘汰旧任务。任务是会过期的:模型整体变强之后,原本有区分度的题会变成送分题。保持任务集"每年换掉一部分",比一次性造一份完美题库现实得多。
