goose 如何把基准测试变成 bug 报告:读懂 evals/harbor 里那个"必须有人类参与"的自我改进闭环
基准测试(benchmark)分数只能说明 Agent"在哪里会卡住"——goose 团队围绕 Terminal-bench 设计了一套"评测 + 对比 + 归因 + 人工裁决 + 落地修复"的人机协同改进闭环,并用仓库中 evals/harbor 下的 Python 工具链把整个流程固化成可复制的命令。读完本文,你将掌握这套闭环的工具用法(cmd.py 的 run/list/show/task/compare/pull 子命令、两条分析 recipe 的编排逻辑),以及它如何把"一次失败"升级为"一类能力的缺失",并从源码层理解 goose 借助 Harbor 在 Docker 容器中跑基准的底层机制。
基准的诅咒:当指标变成目标,它就不再是好指标
"当一项度量变成目标,它就不再是好的度量",这正是 Goodhart 定律对编码 Agent 基准的诅咒。评测任务公开、结果收敛为一个数字,榜单上不可避免地挤满了——哪怕无意——对着基准过拟合的 harness。这并不意味着业界通行的 Terminal-bench 标准毫无价值,而是改变了 goose 团队使用它的方式:
- 榜单是一个嘈杂的"通用 Agent 能力"信号,不适合用来比较大小;
- 真正的信号是失败的模式——goose 反复卡住的地方、goose 失败而别的 harness 成功的地方。
这也是为什么 goose 团队平时用 Sonnet 而非当下最强模型跑基准:目标不是刷出最大数字,而是故意在桌上留下足够多的失败样本,好看清 Agent 到底缺什么支撑。换句话说,评测要回答的不是"谁更强",而是"接下来该修什么"。
自我改进闭环:AI 跑分、人类裁决、AI 落地
时下"自我改进 Agent"很流行,但 goose 团队真正信任的版本,目前必须有人类参与。整个闭环分四步:
- 运行基准,得到一批逐任务的 JSON 与日志;
- 让 goose 对比"一个 harness 成功、另一个失败"的任务,请它用具体语言解释差异(例如"A 看到了那张图,B 从头到尾没打开过它"或"A 在正确文件产生后就停了,B 一直改写直到改坏");
- 人类横跨若干这样的失败,归纳出"更一般的教训",决定改进方向;
- 让 goose 去实现这个更宽泛的改进,而不是修单个任务。
最后这个人类步骤,恰恰防止闭环坍缩成"刷分技巧":没有它,自我改进 Agent 很可能偷懒到"一个任务写一个 Skill"——Agent 跟人类一样会偷懒。有了它,一次任务上的失败才有机会沉淀成对尚未见过的新任务也有帮助的能力。
工具链落点:evals/harbor 与一个 PEP 723 的 uv 脚本
这套工具就放在 goose 仓库的 evals/harbor 目录下,核心入口是 cmd.py:
evals/harbor/
├── agent.py # GooseBinaryAgent:把指定 goose 二进制跑进任务容器
├── cmd.py # CLI 入口(run/list/show/task/compare/rm/pull)
├── config_template.yaml # 容器内 goose config.yaml 模板(含 6 个可开关扩展)
├── reporter.py # 加载 Harbor 0.8 的 Job/TrialResult 并渲染报告
├── runner.py # 生成 Harbor 配置并启动评测任务
├── recipes/
│ ├── analyze_bench_failure.yaml # 单任务失败归因 recipe
│ └── compare_bench_run.yaml # 找出"一成一败"任务并批量下发的 recipe
└── README.md
cmd.py 是一个 PEP 723 内联依赖的 uv 脚本,依赖声明就写在文件头:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["harbor==0.8.0", "PyYAML>=6.0"]
# ///
(见 cmd.py)这意味着没有打包仪式、不用建新仓库、也不用记住"哪次激活过哪个 virtualenv"——首次运行 uv 会按文件头自动装好 harbor 与 PyYAML。正如团队博客所说,这正是 vibe coding 最能发光的地方:小工具把"要做的事"和"实验说明"写在一起,即便写得不够优雅也没关系。由于脚本同时充当控制面和实验文档,你可以让 Agent 直接给它加子命令、插桩某个指标、或让输出表格不那么难看,改完立刻生效。
各子命令的职责一目了然(cmd.py):
| 子命令 | 作用 |
|---|---|
run |
运行一次评测任务(针对某个 goose 二进制、模型与扩展集合) |
list |
列出 runs/ 下所有评测任务及汇总统计 |
show |
查看某个任务的全部逐任务结果,可按状态过滤 |
task |
深挖某个任务在某个评测中的完整细节 |
compare |
逐任务对比两个评测 |
rm |
删除一个或多个评测结果 |
pull |
把远端机器上的评测结果 rsync 回本地 |
用 Harbor 包一层:远端跑分、容器隔离、逐任务留痕
完整跑一次基准是"慢、可并行、且 Docker 重度"的活,所以真正的评测跑在远端 Linux 机器上;但分析不必留在那里。run 子命令做的事,是把 Harbor 包在一个特定 goose 二进制、模型和扩展集之外,然后留下一个装满逐任务 JSON 与日志的目录。
一次典型运行(runner.py 中定义的默认值):
# 固定某个二进制,其余全用默认
./evals/harbor/cmd.py run /path/to/goose --job-name my-run
# 换模型(anthropic 或 openrouter 等 provider/model 形式)
./evals/harbor/cmd.py run /path/to/goose \
--model anthropic/claude-opus-4-5 --job-name opus-run
./evals/harbor/cmd.py run /path/to/goose \
--model openrouter/nvidia/nemotron-3-nano-30b-a3b \
--job-name nemotron-smoke
# 只跑任务子集(注意 Harbor 要求带限定前缀的形式)
./evals/harbor/cmd.py run /path/to/goose \
--tasks terminal-bench/fix-git,terminal-bench/extract-elf \
--job-name smoke
# 开关扩展,生成对应 config.yaml
./evals/harbor/cmd.py run /path/to/goose \
--extensions developer,todo,codemode --job-name codemode-run
# 加倍单个任务超时(适合重跑 AgentTimeoutError 的样本)
./evals/harbor/cmd.py run /path/to/goose \
--timeout-multiplier 2.0 \
--tasks terminal-bench/oom,terminal-bench/compile-vim \
--job-name oom-retry-2x
- 数据集:
terminal-bench/terminal-bench-2(README 注明全量含 89 个任务) - 模型:
anthropic/claude-sonnet-4-6 - 扩展:
developer,todo - 并发:4
- 最大回合:100
- trials:1
- 默认在每个任务容器内
apt-get install libgomp1(可用--no-install-goose-runtime-deps关闭)
想先看生成的 Harbor 配置而不真正启动,加 --dry-run 即可。
底层发生了什么:runner + agent 的两次协作
从源码看,run 的链路相当清晰:
- runner.py 的
build_harbor_config校验参数、读.env、用 render_goose_config 按 config_template.yaml 渲染 config(把developer/todo/computercontroller/memory/summon/codemode六个扩展按需置为enabled: true),校验模型必须是provider/model形式、job 名必须匹配^[A-Za-z0-9][A-Za-z0-9._-]*$,然后写一份_generated_config.json到runs/<job_name>/下; - 生成配置中挂载的是
agent:GooseBinaryAgent这个自定义 adapter(agent.py),它与 Harbor 内置 goose harness 的区别是:用调用方提供的预构建二进制(上传进容器,而非 curl 安装),并从模板生成config.yaml、从宿主机环境读取 provider 密钥; - 每个任务容器内,adapter 会写一个临时 recipe(agent.py),用
goose run --recipe ... --output-format stream-json执行,stdout 通过tee落到/logs/agent/goose.txt,逐条流式 JSON 因此被完整保留。
值得注意的细节:为了评测的公平与干净,容器内会设置 GOOSE_TELEMETRY_ENABLED=false、GOOSE_TELEMETRY_OFF=true、CONFIGURE=false、GOOSE_DISABLE_KEYRING=true(agent.py),避免遥测、向导配置与系统钥匙串干扰基准行为;如果 goose 在日志中留下 creditsExhausted(额度耗尽)这类致命通知,adapter 会把整个 trial 判为失败并抛出异常(agent.py),防止"根本没干活却算跑完"。
密钥从哪来
run 会先做一次"缺什么密钥"的前置校验(runner.py),缺失即报错并列出应设的变量。密钥可放在 .env(当前目录或脚本目录,runner.py 会依次查找),也可直接在当前会话 export。各 provider 需要的键由 agent.py 的 PROVIDER_SECRETS 定义:
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
DATABRICKS_HOST=https://...
DATABRICKS_TOKEN=...
OPENAI_API_KEY=sk-...
GOOGLE_API_KEY=... # google / gemini 等 provider 按需设置
评测"别人家"的 harness:直接在 Harbor 配置里写
Stock harness(Harbor 自带的 opencode、pi、aider、claude-code 等)不需要 goose 的 adapter——它们会在容器内自行安装、从环境变量读密钥。此时直接写一份 Harbor YAML 并调用 harbor run 即可:
# opencode-sonnet46-full.yaml
job_name: opencode-sonnet46-full
jobs_dir: /path/to/goose/evals/harbor/runs # 让 cmd.py 也能看到它
n_attempts: 1
n_concurrent_trials: 4
environment:
type: docker
force_build: false
delete: true
env:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
agents:
- import_path: harbor.agents.installed.opencode:OpenCode
model_name: anthropic/claude-sonnet-4-6
datasets:
- name: terminal-bench/terminal-bench-2
export ANTHROPIC_API_KEY=...
uv tool install harbor
harbor run -c opencode-sonnet46-full.yaml
输出同样落在 evals/harbor/runs/<job>/ 下。关键设计是:goose 跑与非 goose 跑在格式上完全同构——底层都是 Harbor 的 TrialResult JSON,因此 cmd.py 的 list/show/compare 一视同仁地处理它们,跨 harness 的"谁成功谁失败"对比才能成立(见 README)。
把失败翻出来:list / show / task / compare 四板斧
分析全部在本地完成。pull 先带回结果(下一节),然后用:
# 每个评测一行汇总
./evals/harbor/cmd.py list
# 看某个评测的全部任务;或按状态过滤
./evals/harbor/cmd.py show <job_name>
./evals/harbor/cmd.py show <job_name> --status error
./evals/harbor/cmd.py show <job_name> --status timeout
# 钻到单评测单任务
./evals/harbor/cmd.py task <job_name> <task_name>
./evals/harbor/cmd.py task <job_name> <task_name> --tail 50 # tail agent 日志
# 两个评测逐任务对比
./evals/harbor/cmd.py compare <job_a> <job_b> # 汇总
./evals/harbor/cmd.py compare <job_a> <job_b> -v # 外加逐任务 diff
# 删除
./evals/harbor/cmd.py rm <job_name> [<job_name> ...]
list 的输出是精心设计过的:compute 列是所有 trial 时长的总和(把并行摊平),README 明确说明这样比墙钟时间更稳——墙钟时间把"评测本身花了多久"和"宿主机开了多少并发"混为一谈,跨 run 对比会失真;turns 列则是所有 trial 的 Agent 回合总数(每个 assistant 消息/harness 步计一次)。README 中的一次真实快照(摘要示意):
job_name model rate compute in out turns cost pass/fail/err/tout
-----------------------------------------------------------------------------------------------------------------------------------
goose-sonnet46-full-code-mode claude-sonnet-4-6 57.3% 22.0h 63.3M 1.1M 3k $206.43 51/20/2/16
sonnet46-full claude-sonnet-4-6 50.6% 22.5h 62.4M - 3k - 45/21/3/20
opencode-sonnet46-full claude-sonnet-4-6 52.8% 22.2h 111.5M 1.6M 3k $70.30 47/23/0/19
pi-sonnet46-full claude-sonnet-4-6 47.2% 24.4h 114.4M 1.8M 3k $74.82 42/25/1/21
(完整表格与逐行解读见 evals/harbor/README.md。)这组对照还顺带解释了为何要跑"非最强"配置:claude-sonnet46-full 一行是 Harbor 原版 Goose harness(curl 安装版)的结果,它用来做 sanity check——确认仓库自带的 GooseBinaryAgent adapter 没有白白丢分。
状态分类背后的规则
list/show/compare 里每个 trial 归属 pass / partial / fail / timeout / error / no-reward 六类,判定逻辑在 reporter.py,有一条值得记住的原则:reward 优先于异常——Harbor 可能记录一个 AgentTimeoutError,即便 verifier 已经给该 trial 打了分(例如 Agent 干完了活、harness 在收尾时崩了,或它超时前已经写对答案)。规则如下:
- 拿到 reward 且
>= 1.0→pass;0 < reward < 1→partial; - 无 reward 但有异常:异常类型含
timeout→timeout,否则error; - 无 reward 也无异常 →
no-reward;其余一律fail。
至于"turns"是怎么数的,reporter.py 的注释把口径写得很清楚:首选 agent/trajectory.json(Harbor 标准格式,每个 Agent 步一条);没有时回退解析 harness 日志——goose 的 stream-json 会为每个流式块发一条 message(同一 assistant 回合共享一个 message.id),所以要按 id 去重,否则"一个回合流式吐出 2000 token"会被数成 2000 次。
compare 的核心价值在"状态转移"视图:把两个评测共有的任务按 (A 状态, B 状态) 分组,得到 both pass / both not-pass / only A passes / only B passes 四个桶(reporter.py)。-v 时进一步打印"只有 A 解出的任务"清单——这正是后面 recipe 要找的素材。
pull:远端跑分,本地读报告
如果评测跑在远端机器,分析不该被迫留在那里。pull 用 rsync 把 run 目录拉回本地,然后你就能在本地 list/show/compare——这比听起来重要得多:如果每个问题都得 SSH 进评测机、徒手翻日志,你会懒得提问(博客原文)。
# 全量拉回
./evals/harbor/cmd.py pull tbench@host:/path/to/goose
# 只拉特定 job
./evals/harbor/cmd.py pull tbench@host:/path/to/goose \
--jobs sonnet46-full pi-sonnet46-full
# 完全镜像远端(删除本地没有对应远端的 run)
./evals/harbor/cmd.py pull tbench@host:/path/to/goose --delete
远端参数格式是 user@host:/path/to/goose——reporter.py 会为它自动补上 /evals/harbor/runs/ 再 rsync 进本地 runs/。典型的异地工作流(README 推荐用 screen/mosh/tmux 挂在远端):
# 远端:跑两个配置做对照
ssh tbench@host
cd /path/to/goose
./evals/harbor/cmd.py run ./target/release/goose --job-name baseline
./evals/harbor/cmd.py run ./target/release/goose \
--extensions developer,todo,codemode --job-name codemode
# 本地:拉回来、逐任务 diff
./evals/harbor/cmd.py pull tbench@host:/path/to/goose --jobs baseline codemode
./evals/harbor/cmd.py compare baseline codemode -v
用 goose 自己读失败:两条 recipe 组成"诊断流水线"
工具链的最后一公里,是让 goose 亲自去读 benchmark 结果、产出机制层面的解释。这一步由两条 recipe 承担,都放在 recipes 下。
compare_bench_run.yaml:先圈定"一成一败"的任务
compare_bench_run.yaml 接受两个参数:target(我们要改进的 run,通常是 goose)和 reference(做得更好的那个 run),随后:
- 确认两个 run 目录存在(
ls evals/harbor/runs/<job>/),缺一个就停下告知用户; - 用
./evals/harbor/cmd.py compare <reference> <target> -v拿到逐任务分解。注意参数顺序:把reference放 A、target放 B,"Only A solved" 一栏就恰好是我们想要的任务清单——reference 通过而 target 没通过的任务; - 不要过滤 timeout。recipe 给的理由很尖锐:超时常常掩盖真实失败——Agent 沿错误路径一路狂奔直到时间耗尽;真正"思路对、只是没跑完"的超时,和"穿着超时外衣的逻辑失败"要留给单任务分析器去分辨;
- 展示清单与数量、征得用户明确同意后,每个任务开一个 iTerm 标签页运行
analyze_bench_failure.yaml(中间sleep 1防止按键串台,任务名用裸名如extract-elf而非terminal-bench/extract-elf)。
analyze_bench_failure.yaml:单任务"解剖报告"
analyze_bench_failure.yaml 的目标是:对"reference 成功、target(通常是 goose)失败"的单任务,形成关于"为什么失败"的理论,并提出 goose 可以改什么。它明确限定只分析、不改代码、不开 PR,按七个步骤走:
- 第 1 步 头条事实:对两个 run 分别跑
cmd.py task,拿到 status/reward/duration/tokens/turns/cost/error 与 verifier 输出尾部; - 第 2 步 找 trial 目录:Harbor 0.8 的 trial 目录形如
<task>__<随机后缀>,必须用ls -d runs/<job>/<task>__*/从磁盘发现,不许猜后缀; - 第 3 步 读任务规格:任务定义缓存在
~/.cache/harbor/tasks/packages/.../<digest>/,重点读instruction.md、tests/test_outputs.py或run-tests.sh、solution/solution.sh;描述失败时直接引用失败的那条断言而非转述——"转述正是错误结论溜进来的地方"; - 第 4 步 读轨迹:优先
agent/trajectory.json(Harbor 的 ATIF 格式),可用这条 jq 快速浏览:jq '.steps[] | {step_id, source, message, tool_calls: [.tool_calls[]?.function_name]}';否则读agent/goose.txt之类原始日志。对两边都要识别:采取的方法、容器里留下的最终产物,以及 target 的失败模式(误读规格 / 思路对但浅层 bug / 超时——但要分辨是"真在推进"还是"抖动式空耗",后者实为逻辑失败 / 钻进无产出分支 / verifier 期待了规格没明说的东西); - 第 5 步 读 verifier 输出:
$TRIAL_DIR/verifier/test-stdout.txt通常最具诊断性,tail -80直击失败的断言; - 第 6 步 到 goose 源码找理论:recipe 直接给了一张"故障模式 → 源码位置"地图——agents(Agent 主循环、工具调用处理、上下文管理)、providers(provider 特有问题)、developer 扩展(大部分 shell/文件工具所在)、prompts(系统提示词);用
rg精准搜索。如果 reference 是别的 harness,思考它对 prompt、工具形状、超时或重试策略做了哪些不同处理; - 第 7 步 成文:产出含 task / outcome(引用失败断言)/ what reference did / what target did(点名失败模式)/ theory / what we might change in goose 六个部分的 Markdown。recipe 对 theory 的要求是"机制层面的解释,而不是感觉"——"developer 扩展的 text_editor 截断了超过 2MB 的文件,而任务输出有 3MB",胜过"goose 糊涂了"。
这套流水线设计的价值在于:cmd.py compare 只告诉你"哪里分叉了",而 recipe 让 goose 替你读完两侧轨迹、任务规格与 verifier 输出,把分叉归因到机制,人类再跨多个任务的结论做裁决。正如 compare_bench_run.yaml 里那句说明:错误不能只当 leaderboard 上的一个减号来对待。
两个真实案例:失败如何变成普遍能力的补丁
闭环的产出最终要落到 goose 自身行为上。团队最近通过这条流水线找到并修复了两个真实弱点(详见 博客原文)。
案例一:信息够了还不停手。 goose 偶尔会在已经掌握足够信息时继续探索:答案近在咫尺却不收束,而是继续翻找,最终把回合耗尽、评测失败。同样的现象在普通对话里也会发生——如果 goose 不知道自己已进行到第几轮,它就没有真正的理由停下探索、转向收束。修复方向是让 goose 感知到回合数,知道自己"该收手了"。从源码结构看,goose 的 Agent 主循环与回合上限控制位于 agents/state_machine,其中 ops_maxturns 负责处理最大回合——这与"回合数感知"的修复方向一致,也解释了为什么 benchmark 评测要把"跑满 100 回合"当作一种独立的失败态(timeout)来统计。这个修复不只是刷高了分:它在正常使用中同样降低 goose"该结束时跑题漂移"的概率。
案例二:悄悄弄丢的"从磁盘读图片"能力。 goose 一度支持把图片拖进会话后读取磁盘上的图片文件;后来为了支持更顺滑的会话内图片加载而重写了这部分,过程中不小心把磁盘图片工具一起删了。在基准里,这表现为 goose 用 PIL 库去解析图片,而不是动用模型自身的视觉能力。为什么对比 recipe 一眼就能看穿它?因为成功的 run 直接"看了图",失败的 run 在"绕路处理图"——两相对照,机制立刻显形。
两个案例的共同点在于:修复带来的收益远不止一个评测分数——前者让 goose 少漂移、后者让 goose 能再次读取磁盘上的图片,二者都让 goose 在日常使用中变得更好。这正是闭环的回报:评测分数变好只是副产品。
结语:让基准从"榜单"变成"bug 报告"
回到开头的 Goodhart 定律——goose 团队给出的解法不是拒绝基准,而是改变看待基准的方式:
- 榜单数字是嘈杂的整体能力信号,失败的模式才是可操作的情报;
- 评测要故意留下失败样本,用
evals/harbor的compare与两条 recipe 把"一个 run 失败"翻译成"goose 缺了某类支撑"; - 每一轮都有人类在"过拟合基准"与"泛化能力"之间把关,把单个任务的失败提炼成对未来任务有效的补丁;
- 评测场景与真实使用场景共享同一套行为缺陷,因此修好基准失败往往也修好了日常对话中的同类问题。
当基准不再是一块榜单、而是变成一份 bug 报告时,它才真正开始有用。如果你也想复现这套流程,仓库中的 evals/harbor/README.md 是完整的操作手册——从 run、报告渲染、容器 adapter 到两条 分析 recipe 都可直接查阅与运行。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00