跟着跑一遍 Harbor:从 harbor run 到读懂 result.json
标签:#Harbor #Agent开发 #AI评测 #LLM #终端
前面四篇把概念、词汇、架构、命令都铺完了。这篇不再讲道理,我带你亲手跑一遍,再把跑出来的东西读明白。三个例子全部来自我自己机器上的真实 Job:一个过了(reward 1.0)、一个没过(reward 0.0)、还有一个直接 RuntimeError 报错。命令和每一行数字都来自 result.json,一个没编。
选这三个,不是要比谁强谁弱。terminal-bench 这类评测本身就是带随机性的采样,单次跑分说明不了模型好坏。我拿它们当「三种最常见的运行结果」来拆:你会过、你会挂、你会直接炸。学会读这三种结果,你才算真正会用 Harbor,而不是只会敲命令。
0. 跑之前准备什么
技术文章最怕不能复现。我这次的环境就两样东西:
- 一个 Agent:
terminus-2(Harbor 内置的 agent 框架),下面两趟都用它 - 一个任务:本地
./my-first-task里一个 hello 冒烟任务(让 agent 在沙箱里生成一个文件)
想跟我跑一样的,把这两样备好就行。Agent 和任务锁死,剩下的变量才有意义。
第一步:跑一个任务
命令就一行。我本地两趟的真实跑法(只换 --model):
bash
# 跑通的那趟:terminus-2 agent + 本地 hello 冒烟任务
harbor run --dataset ./my-first-task \
--agent terminus-2 \
--model openai/doubao-seed-2-0-lite-260428 \
--jobs-dir ./jobs
# 报错的那趟(同一个 agent,换了个模型):
harbor run --dataset ./my-first-task \
--agent terminus-2 \
--model openai/glm-5-2-260617 \
--jobs-dir ./jobs
跑完 Harbor 不会在终端里甩给你一个「正确率」。它做的是在 ./jobs 下建一个以时间戳命名的目录,把这场 Trial、判卷结果、ATIF 轨迹全塞进去。想精确单跑某道题调试,用 harbor trial start -p <本地task目录>(注意是 trial 单数,-p 接路径不是 task-id)。具体 flag 以 harbor run --help 为准。
第二步:跑完去哪看
进 ./jobs,你会看到类似这样的目录:
text
jobs/
├── 2026-08-07__19-42-53/ # doubao run1,reward 0.0
├── 2026-08-07__20-03-58/ # doubao run2,reward 1.0
└── 2026-08-14__11-51-07/ # glm 那趟,RuntimeError
每个目录里都有 result.json。一个 Job 的全部真相都在这里。最省事的是直接 harbor view ./jobs 看 Web UI 汇总,但想写脚本、想 diff,还是得会读这个 json。
其中一个result.json文件内容如下:
json
{
"id": "e37a0d6d-7d47-4862-bc17-5ab81915f9ab",
"task_name": "harbor/my-first-task",
"trial_name": "hello__m2uKEis",
"trial_uri": "file://XXX/my-first-task/jobs/2026-08-07__19-42-53/hello__m2uKEis",
"task_id": {
"path": "XXX/my-first-task/hello"
},
"source": "my-first-task",
"task_checksum": "c88a7941b8aab4ae7ee250fda4b9be691179c84acf46119bd290db76e623189e",
"config": {
"task": {
"path": "XXX/my-first-task/hello",
"git_url": null,
"git_commit_id": null,
"name": null,
"ref": null,
"overwrite": false,
"download_dir": null,
"source": "my-first-task"
},
"trial_name": "hello__m2uKEis",
"trials_dir": "jobs/2026-08-07__19-42-53",
"install_only": false,
"timeout_multiplier": 1.0,
"agent_timeout_multiplier": null,
"verifier_timeout_multiplier": null,
"agent_setup_timeout_multiplier": null,
"environment_build_timeout_multiplier": null,
"agent": {
"name": "terminus-2",
"import_path": null,
"model_name": "openai/doubao-seed-2-0-lite-260428",
"n_concurrent": null,
"concurrency_group": null,
"skills": [],
"override_timeout_sec": null,
"override_setup_timeout_sec": null,
"max_timeout_sec": null,
"resume_trajectory": false,
"load_trajectory": null,
"extra_allowed_hosts": [],
"kwargs": {},
"mcp_servers": []
},
"environment": {
"type": "docker",
"import_path": null,
"force_build": false,
"delete": true,
"cpu_enforcement_policy": "auto",
"memory_enforcement_policy": "auto",
"override_cpus": null,
"override_memory_mb": null,
"override_storage_mb": null,
"override_gpus": null,
"override_tpu": null,
"mounts": null,
"extra_docker_compose": [],
"kwargs": {},
"extra_allowed_hosts": []
},
"verifier": {
"override_timeout_sec": null,
"max_timeout_sec": null,
"disable": false
},
"artifacts": [],
"extra_instruction_paths": [],
"job_id": "a18c5c53-1be1-4865-9340-b7d1051986d7"
},
"agent_info": {
"name": "terminus-2",
"version": "2.0.0",
"model_info": {
"name": "doubao-seed-2-0-lite-260428",
"provider": "openai"
}
},
"agent_result": {
"n_input_tokens": 3846,
"n_cache_tokens": 0,
"n_output_tokens": 1961,
"cost_usd": null,
"rollout_details": [],
"metadata": {
"n_episodes": 3,
"api_request_times_msec": [
10138.22078704834,
25633.883953094482,
9491.698026657104
],
"summarization_count": 0
}
},
"verifier_result": {
"rewards": {
"reward": 0.0
}
},
"exception_info": null,
"started_at": "2026-08-07T11:43:02.409215Z",
"finished_at": "2026-08-07T11:44:16.883833Z",
"environment_setup": {
"started_at": "2026-08-07T11:43:02.441041Z",
"finished_at": "2026-08-07T11:43:05.140353Z"
},
"agent_setup": {
"started_at": "2026-08-07T11:43:05.140594Z",
"finished_at": "2026-08-07T11:43:26.123958Z"
},
"agent_execution": {
"started_at": "2026-08-07T11:43:26.124529Z",
"finished_at": "2026-08-07T11:44:13.335000Z"
},
"verifier": {
"started_at": "2026-08-07T11:44:14.307660Z",
"finished_at": "2026-08-07T11:44:14.631308Z"
},
"step_results": null
}
小提示:我本机这三次没接计费(后面成本那节解释),所以 result.json 里
cost_usd全是 None。这不是 bug,是本地跑的常态。
第三步:读分数层
result.json 的 stats 里,Job 级汇总直接给你 n_total_trials / n_completed_trials / n_errored_trials,每个 Trial 带一个 reward(0.0--1.0,judge 给的)。
我三个 Job 的真实数字(时间戳以目录名为准):
- doubao run1 (
2026-08-07__19-42-53):任务hello__m2uKEis,reward = 0.0(没过),输入 3846 / 输出 1961 token,约 82 秒。 - doubao run2 (
2026-08-07__20-03-58):任务hello__dQHdvZb,reward = 1.0(过了),输入 3655 / 输出 725 token,约 79 秒。 - glm 那趟 (
2026-08-14__11-51-07):任务hello__zwnesAT,n_errored_trials = 1,reward_stats为空,cost_usd = None,约 74 秒。报错类型RuntimeError,写在exception_stats里。
有个细节很多教程会写错:success_rate 和 mean_reward 不是 harbor view 直接吐的字段,JobSummary 里压根没有这两个名字,得你自己从 result.json 把每个 trial 的 reward 抽出来算。派生方法:
success_rate = (reward == 1.0 的 trial 数) ÷ n_completed_trialsmean_reward = 所有 trial reward 的均值
一段 Python 就够(键名和路径都来自真实 result.json):
python
import json
js = json.load(open("jobs/2026-08-07__20-03-58/result.json"))
evals = js["stats"]["evals"]
rk = next(iter(evals)) # 本次 run 的 eval 键
reward_stats = evals[rk]["reward_stats"]["reward"] # {"1.0": [...], "0.0": [...]}
rewards = [float(r) for r in reward_stats] # 每个键是一个 trial 的 reward
passed = sum(1 for r in rewards if r == 1.0)
success_rate = passed / len(rewards) # doubao run2: 1.0 / 1 = 1.0
mean_reward = sum(rewards) / len(rewards) # run1 则是 0.0
evals 里的 reward_stats 还直接列了每个 reward 值对应的 task-id(doubao 的 hello__m2uKEis 是 0.0、hello__dQHdvZb 是 1.0),exception_stats 则列了报错类型(glm 的 hello__zwnesAT 是 RuntimeError)。这比 success_rate 一个百分比信息量大得多.
第四步:结果是 0.0,怎么排查
reward = 0.0 不等于「agent 没动」。doubao run1 的 token 记到了(输入 3846 / 输出 1961),说明 agent 完整跑完了流程,只是没做对。这种最该拉轨迹看:
bash
harbor view ./jobs # Web UI 里点开某条 Trial 看 ATIF 回放
harbor traces export ./jobs --output traces/ # 导出 ATIF 做离线分析
ATIF(Agent Trajectory Interchange Format)是 Harbor 把各家 agent 的轨迹统一成同一套 schema 的标准,本质是一条 Agent → Tool → If → Feedback 的循环录像带。run1 和 run2 是同一个 hello、同一个模型,一个 0.0 一个 1.0。把两条轨迹并排,你看到的就是「姿势不同」:run1 走完了但路径不对,run2 路径对了一次过。这层结论,光看 reward 是看不出来的。
轨迹文件实在agent文件夹下面的trajectory文件中,其中一个内容如下:
json
{
"schema_version": "ATIF-v1.7",
"session_id": "587a8169-961c-4179-b979-023c1df145fa",
"agent": {
"name": "terminus-2",
"version": "2.0.0",
"model_name": "openai/doubao-seed-2-0-lite-260428",
"extra": {
"parser": "json"
}
},
"steps": [
{
"step_id": 1,
"timestamp": "2026-08-07T11:43:26.406252+00:00",
"source": "user",
"message": 问题分析
},
{
"step_id": 2,
"timestamp": "2026-08-07T11:43:37.440717+00:00",
"source": "agent",
"model_name": "doubao-seed-2-0-lite-260428",
"message": 问题分析,
"reasoning_content": 操作理由,
"tool_calls": [
{
"tool_call_id": "call_0_1",
"function_name": "bash_command",
"arguments": {
"keystrokes": "ls -la\n",
"duration": 0.1
}
},
{
"tool_call_id": "call_0_2",
"function_name": "bash_command",
"arguments": {
"keystrokes": "cat numbers.txt\n",
"duration": 0.1
}
},
{
"tool_call_id": "call_0_3",
"function_name": "bash_command",
"arguments": {
"keystrokes": "sort -n numbers.txt > sorted.txt\n",
"duration": 0.1
}
},
{
"tool_call_id": "call_0_4",
"function_name": "bash_command",
"arguments": {
"keystrokes": "cat sorted.txt\n",
"duration": 0.1
}
}
],
"observation": {
"results": [
{
"content":操作结果
}
]
},
"metrics": {
"prompt_tokens": 850,
"completion_tokens": 609
}
},
{
"step_id": 3,
"timestamp": "2026-08-07T11:44:03.547794+00:00",
"source": "agent",
"model_name": "doubao-seed-2-0-lite-260428",
"message": 问题分析,
"reasoning_content": 操作理由,
"tool_calls": [
{
"tool_call_id": "call_1_task_complete",
"function_name": "mark_task_complete",
"arguments": {}
}
],
"observation": {
"results": [
{
"content": 操作结果
}
]
},
"metrics": {
"prompt_tokens": 1380,
"completion_tokens": 985
}
},
{
"step_id": 4,
"timestamp": "2026-08-07T11:44:13.331744+00:00",
"source": "agent",
"model_name": "doubao-seed-2-0-lite-260428",
"message": 问题分析,
"reasoning_content": 操作理由,
"tool_calls": [
{
"tool_call_id": "call_2_task_complete",
"function_name": "mark_task_complete",
"arguments": {}
}
],
"observation": {
"results": [
{
"content": 操作结果
]
},
"metrics": {
"prompt_tokens": 1616,
"completion_tokens": 367
}
}
],
"final_metrics": {
"total_prompt_tokens": 3846,
"total_completion_tokens": 1961,
"total_cached_tokens": 0
}
}
第五步:直接 RuntimeError,怎么读报错
glm 那趟是另一种常见问题:reward_stats 空、n_errored_trials = 1、exception_stats = {'RuntimeError': ['hello__zwnesAT']}。关键信号在 token:n_input_tokens = None。
token 是 None,说明 agent 在启动阶段就抛了错,连一次输入都没产生。这不代表它「不会做这道题」,是它压根没上场。这类 RuntimeError 通常是环境或初始化问题(比如 adhoc 模板跟 terminus-2 的握手没对上),能救:查 exception_stats 里的报错类型、去 ./jobs/2026-08-14__11-51-07 里看原始日志、把 agent 或任务初始化修一下再跑。
重点来了:同样是「没拿到 reward」,run1 是「动了没做对」(能力或提示问题,看轨迹),glm 这趟是「启动就炸」(环境或流程问题,看异常)。分数都不会告诉你区别,只有把分数、轨迹、异常三层合起来才分得清。这也是为什么我不拿单次跑分下结论,同一个 doubao 跑同一个 hello,run1 和 run2 就翻了盘。
第六步:把成本算上
回到账单。我本机这三次 cost_usd 全是 None(没接计费),所以美元数先空着;但 token 数给了真实口径:
text
glm 那趟:n_input_tokens = None → 启动报错,未产生消耗
doubao run1:3846 in / 1961 out → 动了,没过
doubao run2:3655 in / 725 out → 动了,过了,还比 run1 省了 ~2/3 输出
一个扎心的观察:run2 比 run1 省了快三分之二的输出 token,却从 0.0 翻到 1.0,便宜还更准。很多团队拿「平均 cost」当优化指标,结果换便宜模型后翻车姿势更野。所以别只看账单总数,要把 token 和 reward 放一起看:同样的钱,哪次真的换到了对题。
第七步:把这次跑固化成 baseline
最实用的收尾:把题和参数记进一个自定义 Dataset,下次模型升级或 Agent 改了 prompt,原样再跑一遍,两次 Job 的 result.json 放一起 diff,就知道这周是不是比上周强了。因为你知道单次有方差,diff 时也会多看几次、少被一次波动骗到。
bash
harbor init --dataset my-baseline # 生成 dataset.toml 骨架
harbor add ./my-first-task/hello \
--to my-baseline/dataset.toml # 把跑过的 hello 任务加进去
# 同题换模型,才是真正的「同一起跑线」
harbor run --dataset my-baseline --agent terminus-2 \
--model openai/doubao-seed-2-0-lite-260428 --jobs-dir ./runs/modelA
harbor run --dataset my-baseline --agent terminus-2 \
--model openai/glm-5-2-260617 --jobs-dir ./runs/modelB
# 想放大成真实大考(--dataset terminal-bench@2.0 --n-tasks 20),命令同构
这才是 Harbor 在团队里最该待的位置:发版前的守门员,而不是一次性的排行榜。
总结一下
跑评测不难,难的是跑完之后会读。我这篇用自己机器上三个真实 Job 带你过了一遍:一个过了(doubao run2,reward 1.0)、一个没过(doubao run1,0.0,但 agent 动了)、一个直接炸(glm 那趟 RuntimeError,token 是 None,agent 没上场)。三个结果教会你三件事:reward 0.0 要看轨迹才知道是「没做对」还是「没上场」;success_rate 得自己从 reward_stats 算;单次跑分带随机性,同题两次能翻盘,至少多跑几次看方差。把分数、轨迹、异常三层合起来,才是能拿去汇报的结论;只甩一个百分比,那是 demo,不是评测。下轮该做的,是把这套子集固化成 baseline,让每次发版都有把公平的尺。
来都来了,顺手三件事
- 点赞:免费的奶茶,给这篇续命,也让更多被 demo 骗过的同学刷到。
- 收藏:给你的 Agent 留条后路。哪天真翻车了,回来照着跑两遍、看三层就清醒了。
- 关注:这个 Harbor 系列一共 8 篇,每篇都源码说话、不编数字。下一篇聊它为什么这么设计(Provider 抽象 / 沙箱隔离 / ATIF 标准,三个取舍各自放弃了什么),关注了不迷路。
评论区聊聊(任选)
- 你跑 Harbor / terminal-bench 时,踩过「启动就 RuntimeError、agent 没上场」的坑吗?怎么救的?聊聊你的排查故事。
- 你最想看我用 Harbor 把哪套任务跑成大考(terminal-bench 同题 20 道那种)?呼声高的,我下篇就照这套流程讲清楚怎么跑、怎么读 result.json。命令都在这篇里,你抄去自己跑也行。
系列连载 · 上一篇《Harbor 长什么样:一张图看懂分层架构》(架构)· 下一篇《Harbor的设计哲学:三个关键取舍》