Agent Harness Lab 复盘
前言
最近在学一个 Agentic AI 的课程,其中有一讲为 Post-Training Verifiable Agents,学完之后我对 agent / tool / harness / verifier / reward 等概念有了大概认识,也浅显了解了 SFT / RL / Best-of-N / Rejection Sampling 等相关方法,但没有亲手把整套系统跑起来过。
因此,我决定做一个 Lab 来巩固一下这些新学的内容。我主要想搞清楚两件事:同一个 LLM 做 One-shot(一次性推理)和 Agent Loop 会有多大差别;以及对 Agent Loop 做 Test-time Sampling,增加 rollout 后的效果有多大提升。其中,Benchmark 我采用的是 SWE-bench Lite。
1. Pilot:实验前的准备工作
1.1 实验框架及设计
实验调用 Deepseek-V4-Flash Thinking 模型。
A 是 One-shot 基线。模型收到 issue 和 BM25 检索出的约 13K token 静态仓库上下文,只发起一次 API 请求,不使用 tool,也没有环境反馈,直接输出 Patch。这里测的是同一个模型只看固定上下文时的一次性修复能力。
B 是可以使用 tool 的 Agent Loop。模型可以调用 list_tree、read_file、search_code、edit_file、write_file、run_shell 和 finish。harness 把每次执行结果作为 Observation 交回模型,模型再继续推理、修改或测试,直到主动 finish 或触及 step limit。
每个 SWE-bench Lite 任务都给出一个真实 issue 和对应仓库的 Base Commit。A 和 B 最终都会产生一个 Candidate Patch,再由官方 verifier 在隔离的评测环境中判定是否解决任务。B 的 Agent Loop 本身运行在独立 Docker 容器中。
图 1:A 和 B 共享任务集;Pass@k 来自 B 的多次 rollout,Rejection Sampling 只处理成功 trajectory。
A 和 B 是并列的方法对比,C 评估 B 的多次 rollout,D 只处理已经完成的 trajectory。四部分共享任务和产物,不会把同一条 trajectory 从 A 依次传到 D。
C 对同一任务运行 8 个相互独立的 B rollout,再计算 Pass@1、Pass@2、Pass@4 和 Pass@8。不同 rollout 不共享对话或工作区,测的是增加采样后,候选集合能覆盖多少任务。
D 是离线 Rejection Sampling。它保留 B 中 reward=1 的 trajectory,拒绝 reward=0,再按任务对 Patch 去重,用来生成后续可用的数据。
1.2 Agent Loop 与 Test-time Sampling
Agent Loop 在没有网络和主机目录挂载的独立容器中运行。每个 rollout 都从同一个 Base Commit 开始,工作区不会继承上一条 Patch,也看不到其他 rollout 的 trajectory;官方评测器只在 agent 结束后运行。这样可以隔开 Gold Patch(用来确认 benchmark 和评测环境能够正常工作,可以理解为官方的答案,它不会放进 prompt、工作区或 tool 返回的信息)、之前的采样结果和评测细节。
图 2:本地测试结果会作为 Observation 返回;循环结束后由最终 verifier 给出 reward。
run_shell 中的本地测试通过,不等于任务已经得到 reward=1。本地测试可能覆盖不全,后续修改也可能破坏已经通过的行为,实验只认最终 Candidate Patch 的 verifier 结果。
1.3 Pilot v1
Pilot v1 先跑了四个任务,结果是 A=0/4、B=1/4。我检查完 trajectory 和日志后,发现这两个数字没法直接比较。
A 当时把 max_tokens 设为 8192,推理内容和最终回答共用输出预算,四次 One-shot 中有 3/4 达到 token 上限,4/4 的最终 Patch 都是空的,说明 token 上限设得太低了。
B 的问题更直接:15 次 apply_patch 调用全部失败,4/4 的 B trajectory 也都触及 30-step limit。模型即使找到了修改位置,也没有可靠的编辑接口。
A 的 Patch 全空,B 的编辑接口又坏了,继续比较 0/4 和 1/4 没有意义。所以我紧接着跑了 Pilot v2。
1.4 Pilot v2:修正 harness 并验证协议
Pilot v2 先修 Pilot v1 暴露的问题。我把 One-shot 的 max_tokens 从 8192 提高到 65536;B 不再使用 apply_patch,改为 edit_file 和 write_file;step 上限也从 30 提高到 60。这一轮先确认 A 和 B 能正常运行,不追求更高分数。
接口修好后,我又做了 B 的多样性检查。我对同一个 Astroid 任务运行了 4 个独立 B rollout,step 分别是 39、26、40、34,得到 4 个不同的 Patch 哈希值。它们使用相同的 Base Commit、prompt、模型和 harness 配置,容器彼此隔离。这只是一次多样性检查,不计算 Pass@k;结果确认同一模型面对同一任务会产生不同 trajectory。
Pilot v2 的四次 One-shot 都产生了非空 Patch,没有再出现输出截断。四条主要 B trajectory 都主动调用 finish,没有触及 60-step limit,edit_file 也没有报错。到这里,harness 基本稳定,可以进入 Formal。
1.5 冻结实验协议
Formal 使用 20 个任务,来自 10 个仓库,每个仓库两个任务。A 对每个任务运行一次,B 运行 8 次,共得到 20 条 A trajectory 和 160 条 B trajectory,总计 180 条正式 trajectory。
图 3:Pilot v1 暴露 harness 问题,Pilot v2 验证修正后的协议,Formal 使用冻结配置运行;三个阶段的分数不合并。
| 项目 | One-shot | Agent Loop |
|---|---|---|
| 每个任务的运行数 | 1 | 8 个独立 rollout |
| 仓库信息 | 约 13K token 的 BM25 静态仓库上下文 | 通过 tool 主动检索工作区 |
| 环境交互 | 无 | 读写代码、运行命令、接收 Observation |
| 编辑方式 | 直接输出最终 Patch | edit_file / write_file |
| 终止条件 | 一次 API 请求结束 | finish 或 60-step limit |
| 最终判定 | verifier | 同一个 verifier |
进入 Formal 前,我冻结了模型、prompt、tool、max_steps、token 预算、verifier、reward 分类规则和任务集合。如果看到任务失败就改 prompt、加 step 或补 tool,正式评测就会变成针对 benchmark 的持续调参。因此 Formal 中即使出现 0/8,或者 trajectory 到第 60 step 仍未完成,我也保留原始结果,把改动留到下一轮实验。
值得一提的是,Formal 跑到 20/180 时,任务因 Windows 写入 checkpoint 失败而中断。我进行了只涉及 checkpoint 状态保存的修复,没有改模型、prompt、tool、step、verifier 或 reward,已经完成的 trajectory 也没有重跑。修复后继续沿用同一次 Formal 运行,protocol_changed=false。
2. 实验结果
2.1 One-shot 与 Agent Loop:20% vs 55%
Formal 的实验结果很清楚:One-shot 解决 4/20 个任务,比例为 20%;Agent Loop 的 160 条 rollout 中有 88 条成功,按任务平均得到 Pass@1 55%。两者相差 35 个百分点。
图 4:相同 20 个任务上,One-shot 为 20%,Agent Loop Pass@1 为 55%。
One-shot 只看静态仓库上下文,Agent Loop 则能自己查代码、修改、测试,再根据 Observation 调整后续动作。35 个百分点来自这套交互方式的整体变化,不能单独算到某个 tool 或某次测试反馈上;比较范围也就是这组冻结的 20 个任务。
每个任务都有 8 个 B rollout,20 个任务一共 160 条,其中 88 条 reward=1。对每个任务的 c/8 取平均,同样得到 88/160=55%。这个数碰巧也等于全部 trajectory 的成功比例,但 Pass@1 仍按任务定义,后面的 Pass@2、Pass@4、Pass@8 继续使用同一套算法。
2.2 Pass@k
Formal 对每个任务固定采样 8 个 rollout。记 n=8,c 为其中成功的 rollout 数,k 为从已观察样本中抽取的候选数。当 k=n=8 时,只要 c>0,该任务的 Pass@8 就是 1。
对 B 增加独立 rollout 后,Pass@1、Pass@2、Pass@4、Pass@8 依次为 55.00%、61.7857%、64.6429%、65.00%。从 1 个候选增加到 2 个,覆盖率提高 6.79 个百分点;2 到 4 个增加 2.86 个百分点;4 到 8 个只增加 0.36 个百分点。
特别地,当 k=n=8 时,只要 c>0,该任务的 Pass@8 就是 1。本实验里有 13/20 个任务至少成功过一次,所以平均 Pass@8 与 Solved@8 都是 65%。
| 指标 | 结果 | 这项数字表示什么 |
|---|---|---|
| One-shot | 20.00%(4/20) | 每个任务一次静态推理的解决比例 |
| Agent Loop Pass@1 | 55.00% | 从每个任务 8 次实测 rollout 推得的单候选覆盖估计 |
| Agent Loop Pass@2 | 61.7857% | 两个候选中至少包含一个成功结果的覆盖估计 |
| Agent Loop Pass@4 | 64.6429% | 四个候选中至少包含一个成功结果的覆盖估计 |
| Agent Loop Pass@8 | 65.00% | 本次完整 8-rollout 候选集合的任务覆盖率 |
| Solved@8 | 65.00%(13/20) | 8 个实测 rollout 中至少成功过一次的任务比例 |
图 5:Test-time Sampling 提高了任务覆盖率,但 P@4 到 P@8 只增加 0.36 个百分点。
Pass@k、Best-of-N 和 Rejection Sampling 用途不同:Pass@k 看候选中是否存在成功结果,Best-of-N 还要靠选择器从 N 个候选中选出一个,Rejection Sampling 则用 verifier 过滤 trajectory 来生成数据。而本实验没有选择器,所以 Pass@8 不能直接当成部署成功率。
2.3 任务级 c/8 与采样收益
把汇总曲线拆到任务级,20 个任务里有 7 个是 0/8,7 个是 8/8,只有 6 个落在中间。这 6 个任务分别成功 3、5、5、6、6、7 次,没有任务处于 1/8 或 2/8。
图 6:每个任务实测的 c/8;两端各有 7 个任务,中间有 6 个任务。
这也解释了 Pass@k 为什么很快趋于饱和。7 个 8/8 的任务继续采样不会增加任务覆盖,而 7 个 0/8 的任务在当前 8 次采样中一次都没有成功,因此额外收益主要来自中间 6 个任务。这里的 0/8 只描述本次观测结果,并不代表继续增加 rollout 一定无效。
2.4 成功/失败 trajectory 的 step 与成本
160 条 B trajectory 中,88 条成功,72 条失败。成功组平均执行 35.26 step,中位数为 32;失败组平均 45.5 step,中位数为 52.5。
图 7:失败 trajectory 的 step 中位数为 52.5,成功组为 32,相差 20.5 step。
图 7 的 ECDF(经验累积分布函数)使用全部 trajectory,没有平滑,也没有删除极端点;在任意一个 step 位置,都能读出已有多少 trajectory 在此之前结束。失败曲线整体右移,差异覆盖整组分布,并非只由少量 60-step 样本拉高均值。
成功 trajectory 的平均 API 成本为 0.06984 CNY,中位数为 0.05082 CNY;失败 trajectory 的平均成本为 0.13955 CNY,中位数为 0.11717 CNY。**失败组的平均成本约为成功组两倍,中位数超过两倍。**全部样本的成本范围是 0.01028 到 0.39885 CNY,ECDF 保留了高成本尾部。
图 8:失败 trajectory 的成本均值和中位数都高于成功组。
图 13:step 越多时成本通常越高,同一 step 数下仍有明显价差。
图 13 则补上了 step 与成本的关系:高 step 区域里失败点更多,但相同步数的成本仍会因输入、输出 token 和缓存命中而分散。这里看到的是相关性,不能据此认定执行变长会导致失败;step 用来描述交互长度,CNY 记录实际 API 消耗。
2.5 tool 使用差异
按每条 trajectory 的平均调用数计算,失败组的 read_file 为 15.03,成功组为 9.64;search_code 为 10.15 对 6.09;edit_file 为 4.43 对 2.93;run_shell 为 22.92 对 19.40。失败组使用 list_tree 也更多,成功组的 write_file 和 finish 略多。
图 10:失败 trajectory 在多数 tool 上的平均调用次数更高。
可以看出,失败 trajectory 往往花更多调用在搜索、编辑和测试上,其中既有必要的排查,也有重复读取和 Patch 反复修改。write_file 与 finish 没有跟着上升,也提醒我不能只看总量:finish 表示 agent 主动结束,一次 read_file 可能取得关键信息,也可能只是重复读同一段代码。调用顺序、参数和随后返回的 Observation 比单纯计数更有用。
2.6 step limit 与结果
Formal B 中有 46 条 trajectory 触及 60-step limit,其中 15 条成功、31 条失败,成功率为 32.61%。未触及上限的 114 条中有 73 条成功、41 条失败,成功率为 64.04%。
图 9:触及 step limit 的 trajectory 成功率为 32.6%,未触及组为 64.0%。
触及上限的 46 条里仍有 15 条成功,未触及的 114 条里也有 41 条失败。step limit 与结果虽然有关联,但也不能直接替代对 trajectory 过程的判断。
失败 trajectory 跑得更久、成本更高,又有 46 条碰到上限,这让我有点怀疑是不是 60 step 卡得太死了。汇总数据看不出这些 trajectory 到边界时是还在推进还是已经陷入重复搜索,所以我继续检查了 7 个 c=0 的任务。
3. c=0 任务的失败分析
3.1 56 条失败 trajectory 的分类
7 个 c=0 任务各跑了 8 个 rollout,一次都没有成功,共有 56 条 reward=0 trajectory。我在 Formal 完成后逐条做失败分析,主要看最终 Patch 是否落在相关代码上、后期还有没有新修改或新的测试反馈,以及是否出现重复搜索、Patch 来回修改和范围漂移。这个子集覆盖的是“8 次采样都没解决”的任务,不代表全部 72 条失败 trajectory。
这套分类是在事后做的启发式、定性判断,不是 verifier 给出的真实标签;分析也没有随机改变 max_steps 或重新规划策略。我把 56 条 trajectory 分为:能力 / 语义类(likely_capability_failure)35 条,占 62.5%;策略类(likely_strategy_failure)11 条,占 19.6%;执行长度受限类(likely_horizon_limited)7 条,占 12.5%;混合 / 不明确(mixed_or_unclear)3 条,占 5.4%。
图 11:56 条 c=0 trajectory 中,能力 / 语义类最多,执行长度受限类有 7 条。
56 条 trajectory 中有 22 条触及 step limit,只有 7 条被归入执行长度受限类。后一个判断还要求结束前的修改保持局部化,Patch 非空且与问题相关,并且测试仍有新反馈或改善,不能已经被停滞信号主导。触及 step limit 本身不能说明失败就是执行长度受限。
能力 / 语义类记录的是 agent 找到了相关代码,也做了相关修改,却没有满足完整的软件行为约束。这个分类针对当前 trajectory,不表示模型能力已经到顶。
3.2 三个代表性任务
同为 0/8,七个任务的失败过程并不一样。astropy-7746 和 flask-4992 的 8 条 trajectory 全部属于能力 / 语义类;xarray-3364 有 3 条策略类、3 条执行长度受限类和 2 条混合类。其余任务也有不同组合。
图 12:按任务展开 56 个分类标签,每行包含 8 条 rollout。
flask-4992 的 8 个 rollout 很快找到了 Config.from_file、相关文档和测试,也都主动 finish,没有一条触及 60-step limit。它们倾向于接受 r、rb 这类完整的文件打开模式,但 issue 中的实际调用是 mode='b'。修改位置没有错,本地也有测试通过,漏掉的是文件打开模式的组合规则和公开 API 语义;agent 在 17 到 23 step 就结束了,增加上限解决不了这类遗漏。
xarray-3364 的 8 个 rollout 全部跑满 60 step,修改集中在 concat.py,反复处理变量发现、合并与拼接的选择、维度、dtype 提升和填充值构造。其中 3 条结束前仍在做局部修改并获得新的或改善中的反馈,归为执行长度受限;另有 3 条策略类、2 条混合类。其余过程里能看到重复搜索、重新读取和反复测试,有的后期还在检查已安装包缓存和外部参考,Patch 最终仍不完整或内部不一致。8/8 触及上限,并不等于 8/8 都缺执行步数。
astropy-7746 的 8 个 rollout 都定位到 WCS 数组转换,并加了空输入的提前返回。多数 trajectory 在主动 finish 前拿到过本地测试通过的反馈,没有一条碰到 step limit。最终遗漏落在输出容器、形状语义、轴数量和 ra_dec_order 等约束上:代码位置找对了,局部测试也过了,完整行为仍没有通过 verifier。
3.3 从增加 step 到重新规划
做失败分析之前,我看到失败 trajectory 平均跑得更久,碰到 step limit 的也多,所以最初以为是 60 step 不够。再看 7 个 c=0 任务时,22 条 trajectory 虽然触及上限,只有 7 条更像执行长度受限。我才意识到很多失败卡在语义、跨路径一致性或搜索策略上,这不是单纯把运行时间拉长就能解决的问题。
更长的执行预算对少数 trajectory 可能有用,xarray-3364 的 3 条执行长度受限样本就值得单独测试。但如果把所有运行从 60 step 统一提高到 100,那些已经在重复搜索、没有新证据或出现范围漂移的 trajectory 也会继续消耗预算,reward 是否改善还是未知数。
如果有下一轮的实验,我会尝试进行自适应计算:对于后期修改仍集中、测试反馈还在变化的情况,选择性给其增加预算;而对于搜索和编辑开始重复的情况,则触发重新规划或提前停止。实验保持模型、prompt 和 tool 不变,只调整分配额外 step 的条件,变化来源会更清楚。
3.4 Rejection Sampling
Formal B 的 160 条正式 trajectory 中,verifier 给出 88 条 reward=1 和 72 条 reward=0。Rejection Sampling 拒绝 72 条失败 trajectory,保留 88 条成功 trajectory,再按任务对 Patch 去重,得到 82 条示范样本。
图 14:Generate → Verify → Filter;160 条 trajectory 过滤为 88 条成功记录,Patch 去重后得到 82 条示范样本。
筛选只看 verifier 结果,没有人工挑选。到这里,Generate → Verify → Filter 的数据生成链已经跑通。我没有继续做 SFT,因为这个 Lab 要验证的 harness、Agent Loop、Test-time Sampling、失败分析和 Rejection Sampling 都已有实际结果,再加一个训练阶段不会回答最初的三个问题。
4. 总结
回看整个 Lab,Pilot 阶段花了不少时间和 token,但这些开销是必要的。Pilot 先把输出预算、编辑接口和 step limit 等 harness 问题排掉,避免 Formal 最后测到的其实是实验装置本身的问题。
Formal 最直接的结论,是 Agent Loop 相比 One-shot 有明显提升。在同一个模型、同一组 20 个 SWE-bench Lite 任务和冻结实验协议下,One-shot 的解决率是 20%,Agent Loop 的 Pass@1 是 55%。也就是说,仅把静态的一次性推理换成可以主动检索代码、使用 tool、运行测试并根据 Observation 继续调整的 Agent Loop,单次解决能力就提高了 35 个百分点。这是这次实验里最明显的一项增益。
第二个结论是,Test-time Sampling 有用,但收益很快递减。Pass@1 从 55% 提高到 Pass@8 的 65%,说明多跑几个独立 rollout 确实能覆盖掉一部分单次运行的不稳定性;但从 4 个 rollout 增加到 8 个,只多了 0.36 个百分点。任务级结果也能解释这一点:20 个任务里有 7 个是 8/8,7 个是 0/8,真正贡献额外采样收益的主要是中间那 6 个任务。对于 0/8 的任务,我只能说在这 8 次采样里没有观察到成功,不能据此判断继续增加 rollout 一定没有用。
第三个结论是,在这批实验里,更多计算量并不会自动变成更好的结果。失败 trajectory 平均跑得更久,成本也更高;56 条 c=0 失败里有 22 条触及 step limit,但只有 7 条更像是单纯受执行长度限制。很多 trajectory 已经找到了相关代码,甚至通过了一部分本地测试,最后还是漏掉了 API 行为、边界情况、输出语义或不同代码路径之间的一致性。至少从这批结果来看,把 max_steps 统一从 60 往上加并不是最值得优先尝试的改动。
所以如果继续做下一轮实验,我会在算力分配上进行一些优化:trajectory 还在产生新反馈时继续给 step,已经开始重复搜索或反复修改时就触发重新规划,必要时提前停止。相比单纯增加 rollout 或 step,我现在更想知道的是:什么时候值得继续算,什么时候应该换一个方向。