跳到主要内容

从终端输出到分数:解析、判定、指标、续跑

30 秒导读: Agent 跑完之后,屏幕上只是一屏乱糟糟的终端文字。这一章讲清楚三件事—— 怎么把那屏文字解析成"哪个测试过了"、怎么判定这个任务算不算解决、怎么把成百上千 次尝试聚合成一个 benchmark 分数(accuracy / pass@k),以及怎么用锁文件让一次中断的 大跑能安全续上、不被偷偷改过的配置污染。

这一章接在 02-harness-loop(一次 trial 的主线)之后。02 讲"怎么把 agent 跑起来、 怎么跑测试";本章讲"跑完之后那半段"——从终端 pane 文字到 results.json 到排行榜。


1. 先看全景:一次 trial 的"后半段"

一次 trial 到了尾声,harness 手里只有一样东西:测试脚本跑完后终端窗口的整屏文字post_test_pane)。从这坨文字到最终分数,要过四道关:

终端整屏文字 post_test_pane


① 解析 Parser.parse(pane) ─────► {测试名: PASSED/FAILED} (抽状态)
│ │
│ 解析抛异常 ▼
│ ② 判定 _is_resolved
└──► failure_mode=PARSE_ERROR (全 PASSED 才 True)


③ 落盘 TrialResults → results.json
(每个 trial 一份,单一真相源)

many trials ▼
④ 聚合 BenchmarkResults
accuracy / n_resolved / pass@k


⑤ 上传 S3 整目录 + Postgres 结构化

怎么读这张图: 从上到下是一次结果的生命周期。左边那条岔路是"解析失败"—— 测试根本没跑出预期格式时,直接记一个失败原因(FailureMode),不进后面的判定。

各部件一句话职责:

部件干什么在哪
Parser(如 PytestParser把终端文字抽成 {测试名: 状态}parsers/pytest_parser.py
Harness._is_resolved"全部测试 PASSED"才算解决harness/harness.py:536
TrialResults一次尝试的完整档案(含失败原因、token、各阶段时间戳)harness/models.py:43
BenchmarkResults把 N 次 trial 聚合成 accuracy / pass@kharness/models.py:62
RunLock 一族锁住配置,保证续跑不漂移utils/run_lock.py:185
results.json(每 trial 一份)单一真相源,续跑时重建全局harness/harness.py:1013

2. 解析层:从一屏文字里抠出"谁过了"

2.1 统一契约:BaseParserUnitTestStatus

所有解析器都实现同一个抽象方法,产出同一种字典。这样后面的判定、聚合完全不关心 底层用的是 pytest 还是别的东西。

# 示意,非源码:所有解析器共同的契约
class BaseParser(ABC):
@abstractmethod
def parse(self, content: str) -> dict[str, UnitTestStatus]: ...

真实定义在 parsers/base_parser.py:12BaseParser.parse。关键在于输出类型UnitTestStatus 只有两个值——PASSED / FAILEDbase_parser.py:7-9)。注意这里没有 skipped、没有 xfail:外面世界只有"过"和"不过"两种颜色。测试框架里那些花样,是在解析器 内部就被折叠掉的(见 2.3)。

2.2 PytestParser:只信"short test summary info"那一段

pytest 跑完会输出一大坨,但结尾有一段固定的汇总,长这样:

=========== short test summary info ============
PASSED tests/test_foo.py::test_a
FAILED tests/test_foo.py::test_b - assert 1 == 2
SKIPPED tests/test_foo.py::test_c
====================== 1 failed, 1 passed, 1 skipped ======================

PytestParser.parsepytest_parser.py:82)的策略很干脆:用正则把整屏文字从这一行汇总标题 处劈成两半,只吃后半段

# 示意,非源码:解析的主干思路
parts = re.split(r"=+\s*short test summary info\s*=+", content, maxsplit=1)
if len(parts) < 2:
raise ValueError("No short test summary info found") # 没这段 → 解析失败
summary = parts[1] # 只要标题之后的部分
return self._parse_test_results(summary.splitlines()) # 逐行抽

对应真实符号:切割正则是 SHORT_TEST_SUMMARY_INFO_PATTERNpytest_parser.py:39), 劈不出两段就 raise ValueErrorpytest_parser.py:90-93)——这个异常后面会被 harness 接住、 记成 PARSE_ERROR(见 §4)。

为什么只吃汇总段? pytest 正文里也会出现 "PASSED"、"FAILED" 字样(进度点、traceback), 连正文一起扫会误判。汇总段格式最稳:一行一个测试,状态 路径::测试名

逐行抽取时还有两处细节:

  • _clean_linepytest_parser.py:42FAILED 行尾常挂一句 - assert ... 的原因说明, 用分隔符 " - " 把它切掉,免得污染测试名。
  • _parse_result_linepytest_parser.py:50:按空白切成"状态 + 路径",状态词若不在 已知枚举里就记 UNKNOWN(跳过),测试名取 :: 之后的部分。

2.3 最反直觉的一格:skipped / xfail 记成 PASS

pytest 的原始状态比两值丰富,PytestTestStatuspytest_parser.py:7-17)列了 7 种。落回 UnitTestStatus 时的映射,是这一章最容易踩的坑——skipped 和 xfail 都算过

pytest 原始状态折叠成直觉
PASSEDPASSED正常通过
SKIPPEDPASSED被跳过 = 不拦路,视作过
XFAIL(预期失败,果然失败了)PASSED符合预期 = 过
FAILEDFAILED真失败
XPASS(预期失败,却过了)FAILED意外 = 当失败处理
ERRORFAILED测试自身报错
UNKNOWNFAILED认不出的状态,保守判失败

真实实现是 PytestTestStatus.to_test_statuspytest_parser.py:19-35)的两个 match 分支。 把 SKIPPED/XFAIL 归到 PASS 这件事,直接影响 §3 的"全 PASSED 才算解决"——一个任务里 如果有测试被合理地 skip 掉,不会因此判成没解决。

2.4 为什么还有 swebench / swelancer / mlebench / sweperf 四个解析器

PytestParser 只认得 pytest 的汇总格式。但 terminal-bench 通过 adapter 移植进来的外部 benchmark(见 06-datasets-adapters),它们的测试脚本输出格式各不相同, pytest 的正则对它们无效。所以每种外部 benchmark 配一个专属解析器:

解析器它认的输出长啥样依据
SWEBenchParser夹在 SWEBench results starts/ends here 之间,整块 == PASSED 才过parsers/swebench_parser.py:9-35
MLEBenchParser夹在 marker 之间,整块 == ALL TESTS PASSED 才过parsers/mlebench_parser.py:8-25
SWEPerfParser同 marker 套路,性能 benchmark 的结果块parsers/sweperf_parser.py:11-
SWELancerParserswe lancer success / failure 关键词parsers/swelancer_parser.py:6-

这些解析器共同的暗线:外部 benchmark 的测试脚本自己在输出里打了起止 marker,解析器 只负责在两个 marker 之间取那一块、跟一个"成功字符串"做比对。找不到 marker 就 raise (例如 swebench_parser.py:13-21,注释里说这通常是服务端拉不到仓库,属于环境问题不是 agent 的错)。

2.5 用哪个解析器,由任务自己声明

选解析器不是全局设定,而是每个任务在配置里挑。任务配置有个字段 parser_name: ParserName,默认 PYTESThandlers/trial_handler.py:53-56)。TrialHandler 在初始化时用工厂把它实例化:

# 示意,非源码
self.parser = ParserFactory.get_parser(self.task.parser_name) # trial_handler.py:250

ParserFactoryparsers/parser_factory.py:19)就是一张 ParserName → 解析器类 的查表 (PARSER_NAME_TO_CLASSparser_factory.py:20-26),认不出的名字直接 raise ValueErrorParserName 枚举(parser_factory.py:11-16)列全了上面五种。这样加一个新 benchmark, 只要"写个解析器 + 注册进枚举和查表",判定/聚合那半段一行都不用动。


3. 判定层:一票否决的"全 PASSED 才算解决"

解析出 {测试名: 状态} 之后,判定极其简单,也极其严格:

def _is_resolved(self, parser_results): # harness.py:536
if parser_results is None:
return False # 没结果 = 没解决
return all(r == UnitTestStatus.PASSED for r in parser_results.values())

三条语义要记住:

  • 一票否决:只要有一个测试是 FAILED,整个任务判 is_resolved=False。没有"部分得分"。
  • 空字典的坑all([]) 在 Python 里是 True。也就是说,如果解析器抽出了一个结果 (一个测试都没匹配到),会被判成"解决"。所以解析器"抽到东西"这件事本身很重要—— 抽不到通常应该走 raise(§2.2/2.4)而不是返回空字典。
  • None 与空字典不同:解析异常时 harness 传的是失败原因、parser_results 保持 None_is_resolved(None) 明确返回 False

判定的调用点在 _run_trial 尾部(harness.py:819):先把 parser_results 存进 TrialResults, 再算 is_resolved


4. 失败原因:FailureMode 十一态,以及它们在哪被贴上

一次 trial 不是只有"解决/没解决",还要记为什么没解决——这对 debug 和统计极重要。 FailureModeagents/failure_mode.py:4-15)是个 11 值枚举。下面按"在流水线哪一步被贴上"排:

FailureMode含义在哪被设置(依据:)
UNSET初始占位,还没跑到判定TrialResults 默认值 models.py:49
NONE这一步没出错(成功路径)agent 成功返回 base_agent.py:23;测试/解析成功 harness.py:595,603
AGENT_TIMEOUTagent 超时(仍会继续跑测试)harness.py:673_run_agent
CONTEXT_LENGTH_EXCEEDED上下文超长harness.py:682
OUTPUT_LENGTH_EXCEEDED输出超长harness.py:688
FATAL_LLM_PARSE_ERROR解析不了 LLM 自己的回复harness.py:694
UNKNOWN_AGENT_ERRORagent 返回 None / 兜底异常harness.py:658,695,701;trial 顶层兜底 harness.py:1028
TEST_TIMEOUT测试脚本超时harness.py:593_run_tests
PARSE_ERROR解析器抛异常(如没找到汇总段)harness.py:611_parse_results
AGENT_INSTALLATION_FAILED装 agent 就失败了agents/installed_agents/abstract_installed_agent.py:166(见 04-agents
UNKNOWN枚举里留了,但源码中未见显式赋值 (inferred)failure_mode.py:7

这些贴标签的时机,主线在 02-harness-loop 里。这里补两处判定优先级的细节 (都在 _run_trialharness.py:703):

  • agent 超时不短路测试AGENT_TIMEOUT 只记下失败原因,仍然往下跑测试 (harness.py:752-757)——因为 agent 可能超时前已经把活干完了,值得验一下。
  • 失败原因不覆盖:测试超时要写进结果,前提是 failure_mode 还是 UNSETharness.py:800-806);agent 阶段已经贴过标签的,不被测试阶段盖掉。谁先出错记谁。

5. 结果模型:三张表

5.1 TrialResults —— 一次尝试的完整档案

TrialResultsmodels.py:43-59)是最小的落盘单位。除了 is_resolvedfailure_modeparser_results,它还记了:

  • token 计量total_input_tokens / total_output_tokens(从 agent 结果回填,harness.py:762-764)。
  • 六个时间戳:trial / agent / test 各自的 started_at、ended_at(models.py:54-59)—— 能拆出"agent 想了多久、测试跑了多久"。
  • recording_path:asciinema 录像的相对路径,用来回放 agent 到底敲了什么。

5.2 BenchmarkResults —— 把一堆 trial 聚合成分数

BenchmarkResultsmodels.py:62)就是 list[TrialResults] 加一组 @computed_field。最基础的 两个:

  • accuracymodels.py:134-139)= n_resolved / 总 trial 数。空结果时返回 0.0
  • n_resolvedmodels.py:114-117)= is_resolved 为真的 trial 数。

注意 accuracy 的分母是所有 trial,不是任务数。如果 n_attempts=3、10 个任务,那分母是 30。 所以当每个任务多跑几次时,accuracy 更像"平均单次成功率",而不是"解决了几个任务"——后者要看 pass@k。

5.3 RunMetadata —— 一次大跑的封面

RunMetadatamodels.py:14-40)记的是整场 run 的元信息:run_id、数据集名/版本、agent 名、 model 名、并发数、n_attempts、commit hash、用户名、起止时间,以及跑完回填的 accuracypass_at_k。它在开跑时写一次(_write_run_metadataharness.py:934),跑完再更新终值 (_update_metadata_on_endharness.py:971)。resumed_at 字段专门标记这是不是一次续跑。


6. pass@k:多跑几次,"至少中一次"的无偏估计

6.1 要解决的问题

Agent 有随机性。同一个任务跑 1 次可能失手,跑 10 次里中 3 次。pass@k 回答的是: "如果我从这些尝试里随机抽 k 次,至少有一次成功的概率是多少?"这比单次 accuracy 更能反映 "这个 agent 在预算 k 次时的真实能力"。

6.2 为什么不能"抽 k 次数数",而要用公式

最朴素的做法是真的去随机抽 k 次、看中没中。但那有采样噪声。terminal-bench 用的是 HumanEval 那套无偏估计:某任务总共跑了 n 次、其中 c 次成功,那么"抽 k 次全都落在失败堆里"的 概率是 C(n-c, k) / C(n, k),于是至少中一次 = 1 − C(n-c, k)/C(n, k)

真实实现 _pass_at_k_estimatormodels.py:74-78)没直接算组合数(会溢出),而是用连乘的 等价形式:

def _pass_at_k_estimator(self, n, c, k): # models.py:74
if n - c < k:
return 1.0 # 失败数还不够抽满 k 个 → 必中
return float(1.0 - np.prod(1.0 - k / np.arange(n - c + 1, n + 1)))

那个 if n - c < k: return 1.0 是边界:失败的次数比 k 还少,随便抽 k 个都不可能全是失败, 所以必中,概率 1。

6.3 k 取哪些值:2^i,外加 5、10

不是每个 k 都算,只算有意义的几档。pass_at_kmodels.py:90-112)先求所有任务里最少 跑了几次(min_attempts),k 的候选是 2, 4, 8, … 一路到 min_attemptsmodels.py:105{2**i for i in range(1, …)}),再补上 5 和 10(如果跑得够多)。

两个要点:

  • 从 2 起步rangei=1 开始,最小的 k 是 2^1=2,不算 pass@1(那基本就是 accuracy)。
  • len(success) < k 的任务被跳过_calculate_pass_at_kmodels.py:80-88):某任务跑的次数 不够 k,就不参与这一档 pass@k 的平均,避免拿不足样本硬算。

每一档 pass@k 是"各任务估计值的平均"(np.mean(passes)models.py:88)。


7. 落盘:每个 trial 的 results.json 是单一真相源

这是整个续跑机制的地基,值得单独强调。

harness 有两级 results.json

output/<run_id>/
├── tb.lock ← 配置锁(§8)
├── run_metadata.json ← 封面
├── results.json ← 全局聚合(可随时重建,不是真相源)
└── <task_id>/
└── <task>.<i>-of-<N>.<run_id>/ ← 一次 trial 的目录
└── results.json ← 【单一真相源】每 trial 一份

每次 trial 一跑完就立刻把自己的 TrialResults 写进自己目录_execute_single_trialharness.py:1013)。全局那份 results.json 只是把它们汇总 (_write_resultsharness.py:830),每完成一个 trial 就重写一遍。

关键设计(_load_previous_results 的文档串,harness.py:1039-1043 明说): "个别 trial 的 results.json 是单一真相源,全局那份只是聚合、可以重建"。 续跑时正是 遍历每个 trial 目录、把它们的 results.json 读回来重建 BenchmarkResultsharness.py:1063-1090)。所以哪怕全局文件坏了,只要 trial 级的还在,历史成绩就不丢。


8. 续跑与防漂移:锁文件把配置钉死

8.1 什么算"完成",续跑跳过谁

重开一个已存在的 output/<run_id>/ 目录就触发续跑(_is_resumingharness.py:154)。 判断一个任务"做完了"的标准很简单:它的每一次 attempt 目录里都有 results.json_filter_completed_and_cleanup_incomplete_tasksharness.py:296-353)。

  • 全 attempt 都有结果 → 完成,从待跑列表里剔除。
  • 有 attempt 缺结果 → 未完成;而且把那个任务的半成品目录整个删掉_clean_incomplete_task_artifactsharness.py:365),避免残留 pane/日志污染重跑。

8.2 tb.lock:五段配置指纹

开跑时 harness 写一份 tb.lock_create_run_lockharness.py:505)。它就是 RunLockrun_lock.py:185)序列化的 JSON,把这次跑的"身份"钉死成五段:

锁段记什么依据
HarnessLockterminal-bench 包版本、是否 editable 安装run_lock.py:52-57
AgentLockagent 名、module:class 导入路径、model 名、额外 kwargsrun_lock.py:127-145
RunConfigLock日志级别、并发数、n_attempts、超时倍率与各超时run_lock.py:163-175
DatasetLock数据集名/版本 或 本地路径、task_ids、registryrun_lock.py:60-124
LocalConfig输出路径、run_id、是否上传run_lock.py:177-183

8.3 续跑先比锁:配置漂移一律拒绝

续跑时不直接信任你这次的命令行参数,而是拿当前配置重新造一份 RunLock,跟磁盘上那份 比对_validate_resume_configurationharness.py:200-247)。不一致就 raise ValueError 拒绝续跑(harness.py:243-247)。这防的是"你上次用模型 A 跑了一半,这次手滑改成模型 B 想续" 这种会把成绩搅浑的操作。

比对逻辑有几处讲究(RunLock.__eq__run_lock.py:197-213):

  • 忽略时间戳和调用命令created_atinvocation(命令行原文)不参与比较——同一份配置 换个时间、换种敲法重跑,仍算"同一次配置"。
  • task_ids 按集合比DatasetLock.__eq__run_lock.py:105-124)把 task_ids 当集合比, 顺序不影响。
  • editable 安装也算指纹HarnessLock 里记了 is_editable_installationrun_lock.py:57,由 _is_editable_installationdirect_url.json 判定,run_lock.py:29-49)。 它进 RunLock.__eq__ 的比较——所以从"pip 装的版本"切到"本地 editable 开发版"再想续跑, 会被这一格拦下。

另外 RunLock.from_jsonrun_lock.py:218-226)读锁时还会顺手校验目录结构:run 目录名要等于 run_id、每个 trial 目录名要符合 <task>.<i>-of-<N>.<run_id> 命名、attempt 编号要从 1 连续 (_validate_directory_structurerun_lock.py:228-319)。结构对不上也拒绝——防的是被手工 挪乱过的目录。


9. 上传:S3 存全量、Postgres 存结构化

跑完且 upload_results=True 时走 _handle_results_uploadharness.py:1199),两条并行去处:

  • S3 存整目录_upload_results_to_s3harness.py:873)把 output/<run_id>/ 下所有文件 (含录像、pane、日志、每个 trial 的 results.json)逐个 upload_files3://<bucket>/<run_id>/, 单个文件失败不中断、只记进 failed_uploads。这是"全量原始产物",用于事后复盘。
  • Postgres 存结构化upload_results_to_dbdb.py:205-234)把 RunMetadata + 每个 TrialResults 转成 SQLAlchemy 行写库。它对每条 TrialResultsDBTaskResult.from_pydanticdb.py:107-131,调用点在 db.py:225)——这里才是实际跑的那个转换方法:把 parser_results 里的 UnitTestStatus 枚举转成字符串、failure_mode 转成它的 .value。这是"可查询的结构化成绩", 喂排行榜。(db.py 里另有一个 DBTrialResult.from_pydanticdb.py:158-185)逻辑一模一样, 但没被调用,属遗留并行实现,见 §11。)

两者失败都不炸主流程:S3 整体异常只 logharness.py:931-932),DB 异常也被兜住 (harness.py:1223-1224)——成绩已经落在本地 results.json 了,上传只是搬运


10. 巧妙之处(可借鉴)

  • 单一真相源在最细粒度:真相是每个 trial 自己的 results.json,全局聚合永远可重建 (harness.py:1039-1043)。这让"跑到一半崩了"变成小事——重建即可,不依赖任何全局状态没坏。
  • 状态折叠只发生在解析器内部:pytest 的 7 种状态在 to_test_statuspytest_parser.py:19) 就被压成两值,判定/聚合层永远只面对 PASSED/FAILED。加新 benchmark 不碰下游。
  • pass@k 用连乘避溢出1 − ∏(1 − k/i)models.py:78)等价于组合数比值,却不会算爆 大阶乘。这是 HumanEval 传下来的经典写法。
  • 续跑先比锁再动手:把"配置指纹"物化成 tb.lock、续跑前先 diff(harness.py:243), 用一个 __eq__ 就挡住了所有"半路改配置"的成绩污染。忽略时间戳/命令行、task_ids 按集合比, 是"比该比的、放过不该比的"的好范例。

11. 边界与局限(诚实)

  • parser_results 会被判成解决all([]) == True(§3)。解析器返回空字典而非 raise 时,会误判成功。防线在"抽不到就抛异常"的约定,而非判定层。
  • FailureMode.UNKNOWN 是死枚举:枚举里有,全代码库未见显式赋值 (inferred)。真正的兜底 用的是 UNKNOWN_AGENT_ERROR
  • pass@k 需要 n_attempts 够大:默认 n_attempts=1 时,min_attempts=1、k 候选从 2 起 (§6.3),pass_at_k 基本为空。要看 pass@k 必须显式多跑几次。
  • accuracy 的分母是 trial 不是任务(§5.2):多 attempt 时别把它当"解决了几个任务"读。
  • DB 模型有历史包袱db.py 里既有 DBTaskResultdb.py:85,写 task_results 表) 又有 DBTrialResultdb.py:133,写 trial_results 表),两者的 from_pydantic 转换逻辑 一字不差。但 upload_results_to_db 只调 DBTaskResult.from_pydanticdb.py:224-225), 实际写的是 task_results 表;DBTrialResult 那套是命名从 "task" 迁到 "trial" 时留下的 未被调用的并行实现(§9 已据此纠正为 DBTaskResult.from_pydantic)。

12. 代码地图(导航索引)

主题文件符号
解析器契约 + 两值状态terminal_bench/parsers/base_parser.pyBaseParser.parse · UnitTestStatus
pytest 解析主干terminal_bench/parsers/pytest_parser.pyPytestParser.parse · SHORT_TEST_SUMMARY_INFO_PATTERN
状态折叠(skip/xfail→PASS)terminal_bench/parsers/pytest_parser.pyPytestTestStatus.to_test_status
逐行抽取 / 清洗terminal_bench/parsers/pytest_parser.py_parse_result_line · _clean_line
解析器工厂与枚举terminal_bench/parsers/parser_factory.pyParserFactory.get_parser · ParserName
外部 benchmark 解析器terminal_bench/parsers/swebench_parser.pySWEBenchParser · MLEBenchParser · SWEPerfParser · SWELancerParser
任务声明用哪个解析器terminal_bench/handlers/trial_handler.pyparser_name(默认 PYTEST)· self.parser
判定:全 PASSED 才解决terminal_bench/harness/harness.py_is_resolved
失败原因枚举terminal_bench/agents/failure_mode.pyFailureMode
贴失败原因的位置terminal_bench/harness/harness.py_run_tests · _parse_results · _run_agent · _run_trial
trial 档案模型terminal_bench/harness/models.pyTrialResults
聚合与 pass@kterminal_bench/harness/models.pyBenchmarkResults · _pass_at_k_estimator · accuracy · n_resolved
run 封面terminal_bench/harness/models.pyRunMetadata
trial 落盘(单一真相源)terminal_bench/harness/harness.py_execute_single_trial · _write_results · _load_previous_results
配置锁terminal_bench/utils/run_lock.pyRunLock · DatasetLock · AgentLock · RunConfigLock · HarnessLock
写锁 / 续跑比锁terminal_bench/harness/harness.py_create_run_lock · _validate_resume_configuration
目录结构校验terminal_bench/utils/run_lock.py_validate_directory_structure
上传 S3 / DBterminal_bench/harness/harness.py · terminal_bench/db.py_upload_results_to_s3 · upload_results_to_db(调 DBTaskResult.from_pydantic

上一章 04-agents(被评测的主角:Agent 抽象与三大流派)· 下一章 06-datasets-adapters(数据集加载、版本注册与把外部 benchmark 移植进来)· index