跳到主内容
快讯直播
AI智模界
教程

搭建私有污染控制评测套件:隔离你的编码智能体 Harness 效应

这套东西能给你什么

做完这套套件,你会得到三个具体产物:

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_passpass_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. 定期加新任务、淘汰旧任务。任务是会过期的:模型整体变强之后,原本有区分度的题会变成送分题。保持任务集"每年换掉一部分",比一次性造一份完美题库现实得多。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。