执行 harness:一次 trial 的端到端主线
30 秒导读: harness 是 Terminal-Bench 的评测引擎。给它一个任务目录(里面有指令、Docker 配置、测试脚本),它就负责把一个 AI agent 塞进真实的沙箱终端里干活,然后另起一个会话跑测试,最后解析测试输出、判定这次到底 pass 还是 fail。本 章讲清这条从"任务目录"到"一条 pass/fail 结果"的中央控制流——它是整个项目所有齿轮咬合的地方。
本章的位置:01-task-anatomy 讲一个任务长什么样(数据这一半),本章讲引擎怎么消费这份数据。沙箱怎么起(Docker + tmux 的巧劲)留给 03-terminal-tmux;被评测的 agent 内部长什么样留给 04-agents;测试输出怎么解析成分数、各种指标怎么算、断点续跑的锁文件细节留给 05-scoring-results。
1. 这是什么(零基础也能懂)
一句话定义: harness 是一台评测流水线的总控——输入一个任务,输出这个任务被某个 AI agent 做没做出来。
它要解决的问题。 你想公平地测"AI agent 在真实终端里到底行不行"。这件事天生麻烦,因为它不是"调一次模型看回答对不对"那么简单,而是要:
- 给 agent 一个干净、隔离的真实 Linux 环境(不能污染你的机器,也不能让上一个任务的残留影响下一个);
- 让 agent 在里面自由敲命令、装包、改文件,爱怎么折腾怎么折腾;
- agent 说"我做完了"之后,用一套它碰不到的测试去验收;
- 把整个过程录下来,好复盘"它当时到底干了啥"。
harness 干的就是把这套流程自动化、标准化、还能并发地跑几百个任务。
用起来什么样。 用户几乎不直接碰 Harness 类,而是敲一行 CLI:
# 用 terminus agent + 某个模型,跑整个数据集
tb run --agent terminus --model anthropic/claude-3-7-latest
CLI 在背后就是构造一个 Harness 对象、调它的 .run()。跑完你会在输出目录里得到一棵产物树:每个任务、每次尝试各一个文件夹,里面躺着终端画面快照、会话录像、agent 日志、还有一份 results.json。
一句话直觉。 把 harness 想成一个监考 + 阅卷的机器人:它给每个考生(agent)单独开一间考场(Docker 容器)、发卷子(instruction)、全程录像、时间一到收卷、然后用标准答案(测试脚本)判分。本章就是跟着这个机器人走一遍完整的监考流程。
2. 顶层全景(它大概怎么转)
2.1 五层调用栈
harness 的主线是一条五层嵌套的调用链,从"跑一整个 benchmark"层层收窄到"跑一次 trial"。先建立这张骨架,后面每一节都是在放大其中一层。
Harness.run() # 一整个 benchmark run(写元数据+锁 → 跑 → 收尾上传)
└─ _execute_tasks() # 并发调度:线程池铺开 所有任务 × n_attempts
└─ _execute_single_trial() # 一个 trial 的外壳:建 TrialHandler、兜底 try/except、落 results.json
└─ _run_trial() # ★ 端到端主线:起容器→agent→测试→解析→判定
└─ _run_agent() # 把 agent.perform_task 包进 asyncio 超时里跑
术语先点破:一次 trial(试次) = "某个任务的第 k 次尝试"。同一个任务可以跑
n_attempts次(为了算 pass@k 这种指标),每一次就是一个独立 trial,有独立的容器、独立的产物目录。
2.2 各层职责一句话
| 层 | 方法 | 干什么 | 位置 |
|---|---|---|---|
| L1 总控 | Harness.run | 非续跑时写 run 元数据 + 锁文件,跑任务,收尾上传 | harness/harness.py:1226 |
| L2 调度 | _execute_tasks | ThreadPoolExecutor 并发跑 len(dataset) × n_attempts 个 trial,边完成边写聚合结果 | harness/harness.py:1099 |
| L3 外壳 | _execute_single_trial | 建 TrialHandler,调 _run_trial,把返回结果写进该 trial 的 results.json;异常兜底成 UNKNOWN_AGENT_ERROR | harness/harness.py:991 |
| L4 主线 | _run_trial | 本章主角:起容器 → agent → 测试 → 解析 → 判定,全程写快照 | harness/harness.py:703 |
| L5 跑 agent | _run_agent | 用 asyncio.wait_for 给 agent.perform_task 套超时,把各种异常翻译成 FailureMode | harness/harness.py:633 |
2.3 一次 trial 的主线(高层,先不进代码)
_run_trial 是整台机器的中心。它的时序是一条直线,读者先记住这七步:
┌──────────────── 一个 Docker 容器的生命周期 ────────────────┐
输入:任务目录 ──▶ ①起沙箱 ──▶ ②开 agent 会话 ──▶ ③放 agent 干活 ──▶ ④(按需)另起 tests 会话
│ spin_up create_session _run_agent create_session
│ _terminal ("agent") (超时包裹) ("tests")
│ │
│ ⑤跑测试 ◀───────────────── ──────┘
│ _run_tests
└────────────────────────────│──────────────────────────────┘
▼ 容器销毁后才解析
⑥解析测试输出 ──▶ ⑦判定 pass/fail ──▶ 输出:一条 TrialResults
_parse_results _is_resolved
怎么读这张图: 从左到右是时间顺序。①~⑤都发生在同一个容器还活着的时候(with spin_up_terminal(...) as terminal: 块内);⑥⑦在容器已经关掉之后才做——因为解析只需要第⑤步抓下来的那份终端文本,不再需要容器。
一个关键设计先在这里点出:agent 用的会话和跑测试的会话,默认是两个不同的 tmux 会话、甚至不同的用户身份(agent 是配置用户,tests 是 root)。这样 agent 在自己 shell 里设的环境变量、别名不会污染测试环境——除非任务显式要求 run_tests_in_same_shell。这一点第 3.4 节展开。