deepagents 评测体系拆解:Harbor 集成与自定义 dataset
写 Agent 最容易上瘾的部分是加工具,最容易被跳过的部分是回答「它到底变好还是变差了」。
传统单元测试对 Agent 几乎失效:同样的输入,模型可能走五步也可能走二十步,可能调用三次搜索也可能一次不调,输出措辞每次不同。你要是只断言最终字符串相等,测试会天天红;你要是只做人工抽查,回归就藏在抽查不到的那 5% 里。
LangChain 的 deepagents 把这个问题的答案明明白白摆在仓库里:libs/evals 是一个独立的评测包,MIT 协议,自己就能跑。它不是我见过最庞大的评测框架,但分层思路很干净,值得逐层拆。这篇文章只讲实现,不讲结论。
两条腿,各管一件事
打开 libs/evals 的 README,第一句话就把定位写死了:End-to-end behavioral evaluation suite for the Deep Agents SDK。然后在第二段提到 Harbor 集成,用来跑 Terminal Bench 2.0 这类沙箱 benchmark。
这两句话对应两条完全不同的技术路线,而且在代码里是彻底分开的两套东西:
| 路线 | 目录 | 测什么 | 信号性质 |
|---|---|---|---|
| pytest 行为套件 | tests/evals/ |
你写的场景下,Agent 的具体行为对不对 | 诊断性:错在哪一步 |
| Harbor 集成 | harbor_adapters/ + deepagents_harbor/ |
外部 benchmark 上,Agent 端到端拿多少分 | 可比性:跨模型、跨版本排序 |
这个分工不是随手分的,因为两种评测回答的是不同问题。
pytest 那套的价值在定位。它断言的是「这一步应该调用 edit_file 而不是 write_file」「最后一段文本里应该出现这个文件名」「这个文件应该被删掉」。失败了你能看到完整轨迹:第几步、调了什么工具、参数是什么、返回了什么。代价是这些场景是你自己编的,覆盖不到真实世界的长尾。
Harbor 那套的价值在外部锚点。Terminal Bench、DRBench、Context-Bench 都是别人维护的 benchmark,题目和答案不在你手里,评分逻辑也是别人定的。它测不出「第几步错了」,但它给出的分数可以和别人的模型、别的版本直接比。
真正做产品的时候两个都要:用 Harbor 看有没有退步,用 pytest 看退步在哪。
先看 pytest 那套:两层断言
tests/evals/utils.py 是这个套件的地基,一千五百多行,核心是一个叫 TrajectoryScorer 的容器和两类断言基类。
scorer = (
TrajectoryScorer()
.success(file_equals("/app/answer.txt", "Tammy Roberts"))
.expect(agent_steps=4, tool_call_requests=3)
)
trajectory = run_agent(agent, query="...", model=model, scorer=scorer).success() 装的是 SuccessAssertion,违反就 pytest.fail,测试直接红。.expect() 装的是 EfficiencyAssertion,只往 LangSmith 打 feedback,永不失败。
为什么要刻意分成两层,而不是全用硬断言?
因为 Agent 的工程现实是:正确性可以要求确定性,效率不能。同一个任务,用四步做完和用六步做完,只要答案对,就不该判失败,否则每换一个模型都要重写一遍期望值。但步数偏离又确实是信号:模型可能开始反复重试同一个工具,也可能把简单任务过度规划。这类偏离应该被记录、被画在趋势图上,但不该被当成 bug。
具体有这些断言:
硬失败类(SuccessAssertion)
FinalTextContains/FinalTextExcludes/FinalTextContainsAny/FinalTextMinLength:对最后一条 AI 消息做文本断言FileEquals/FileContains/FileExcludes/FileAbsent:对 Agent 状态里的文件系统做断言ToolCalled/ToolNotCalled:断言某个工具被或没被调用,可以按步号、按参数子集、按参数全等来收窄llm_judge:包了一层openevals,用于没法用字符串断言的语义判断
只记录类(EfficiencyAssertion)
AgentSteps:步数精确匹配ToolCallRequests:工具调用总数精确匹配MaxToolCallRequests:工具调用数上限ToolCall:某个工具调用发生过
几个细节值得单独说。
ToolNotCalled 是硬失败,但它的匹配逻辑和 ToolCall 共用同一个 _find_tool_call_matches。原因是反向断言的自由度更容易出事:如果一个路径拼错了工具名,ToolNotCalled("updat_goal") 会永远通过,看起来覆盖了,实际什么都没测。代码里专门有一条注释提醒:用字面量工具名的地方,要把它钉到工具注册表上,否则改名就能让断言空转。
FileAbsent 和 FileExcludes 的区别也容易踩。前者断言路径不在 trajectory.files 里,后者只断言内容不含某个子串。删文件成功后,LangGraph 的 files channel reducer 会把值为 None 的 key 丢掉,所以路径会消失。如果你用 FileExcludes 测删除,文件还在但内容变了也会通过。
还有一个很妙的补丁:_strip_common_zero_width。
return text.translate({
ord("\u200b"): None, # zero-width space
ord("\u200c"): None, # zero-width non-joiner
ord("\u200d"): None, # zero-width joiner
ord("\ufeff"): None, # BOM
})写这个函数的原因是:不少模型会在路径和 URL 里插入零宽字符,目的是防止下游 UI 把它们自动变成链接。这个行为对渲染是好的,对 needle in haystack 是灾难。断言写的是 /app/answer.txt,模型输出的是 /app/\u200banswer.txt,视觉上一模一样,字符串比对失败。这类失败最难查,因为你在日志里看不出区别。所以所有文本断言前都先剥一遍零宽字符。
run_agent 本身做的事不多:拼输入、建 thread、agent.invoke、把结果翻成 AgentTrajectory、跑断言、往 LangSmith 打点。
def run_agent(agent, *, query, model, initial_files=None, scorer=None,
thread_id=None, eval_metadata=None, extra_state=None):
invoke_inputs = _build_invoke_inputs(query, initial_files, extra_state)
...
result = agent.invoke(invoke_inputs, config)
trajectory = _trajectory_from_result(result)
if scorer is not None:
_assert_expectations(trajectory, scorer)
return trajectoryextra_state 这个参数是为中间件留的:如果被测 Agent 挂了 RubricMiddleware,它需要从 state 里读 rubric 字段,这里浅合并一下就能塞进去,不会覆盖 messages 和 files。
评测按类别打标,类别定义在 deepagents_evals/categories.json,是唯一真相源:
{
"categories": ["file_operations", "retrieval", "tool_use", "memory",
"conversation", "summarization", "unit_test", "langchain/middleware"],
"radar_categories": ["file_operations", "retrieval", "tool_use",
"memory", "conversation", "summarization"]
}前六个上雷达图,unit_test 和 langchain/middleware 不上,因为后者测的是上游中间件的兼容性,跟 Agent 能力不是一个维度。
Harbor 是什么,为什么值得接
Harbor 是 laude-institute 的评测框架。它解决的事情很具体:把 benchmark 的每一道题跑在一个干净的容器里,装好 agent,等它干完活,跑题目的验证脚本,拿到一个 0 到 1 的 reward,顺手把整条轨迹按 ATIF 格式存下来。
换来的能力是「换环境不改题」:同一份 dataset,你可以让它跑在本地 Docker、Modal、Daytona、Runloop,或者 LangSmith 的托管沙箱上,命令只差一个 --env。
代价是它有自己的约定。要接进来,你的 Agent 得符合 Harbor 的 agent 接口,你的任务得符合 Harbor 的 task 目录格式。
deepagents 对这两件事的处理方式,正好是这篇文章的主体。
一次完整的 Harbor 运行长这样:
graph TB
subgraph "Harbor Runner"
A[harbor run] --> B[Task Dataset]
B --> C[Sandbox Container]
end
subgraph "Inside Sandbox"
C --> D[LangGraph Server]
D --> E[dcode graph]
D --> F[bare graph]
D --> G[tau3 graph]
end
C -->|after agent stops| H[tests/test.sh]
H --> I[judge.py]
I -->|reads| J[answer.txt]
I -->|writes| K[reward.txt]
K --> L[ATIF Trajectory Log]
classDef red fill:#FF6B6B,color:#000000
classDef teal fill:#4ECDC4,color:#000000
classDef blue fill:#45B7D1,color:#000000
classDef sage fill:#96CEB4,color:#000000
classDef yellow fill:#FFEAA7,color:#000000
classDef plum fill:#DDA0DD,color:#000000
class A red
class B teal
class C blue
class E sage
class F yellow
class G plumHarbor 只关心三件事:容器起没起来、agent 停没停下、reward.txt 里有几分。中间怎么走,它不管。
三个 graph,一个 LangGraph 工程
deepagents_harbor/langgraph_project/langgraph.json 是整个集成的入口。它不是普通配置文件,而是 LangGraph Server 的工程声明:
{
"dependencies": [
"./.local_deps/deepagents",
"./.local_deps/deepagents-code",
"langchain[mcp]>=1.4.1,<2.0.0",
"langchain-anthropic>=1.5.4,<2.0.0",
...
],
"graphs": {
"dcode": "./langgraph_agent.py:make_graph",
"bare": "./langgraph_agent.py:make_bare_graph",
"tau3": "./langgraph_agent.py:make_tau3_graph"
}
}三个 graph,对应三种被测对象:
dcode 是 Deep Agents Code 这个产品级 Agent。make_graph 调的是 create_cli_agent,把 interactive=False、auto_approve=True、enable_ask_user=False、enable_memory=False、enable_skills=False、enable_shell=True 一个个摆明。也就是说,评测跑的是产品,不是简化版。它刻意不传 system_prompt,因为要验证的是 dcode 自己的生产提示词,不是评测作者重写的版本。
bare 是裸 SDK。make_bare_graph 只有几行:
backend = LocalShellBackend(root_dir=_workdir(configurable),
inherit_env=False,
virtual_mode=False)
return create_deep_agent(model, backend=backend, tools=tools,
skills=_skills(configurable))virtual_mode=False 是刻意的:隔离交给容器,不靠路径虚拟化。inherit_env=False 让沙箱里的 shell 拿不到宿主环境变量。这样 bare 测的才是「一个刚装了 deepagents 的开发者会得到什么」。
tau3 是对话场景。τ³-bench 的题目要跑在真实业务系统的 MCP server 上,所以 make_tau3_graph 从 configurable["mcp_servers"] 里读 Harbor 转发的连接信息,用 MCPAdapter 列出工具,再丢给 create_deep_agent。
这三个 graph 共用同一份依赖清单,而依赖里的前两项指向 ./.local_deps/deepagents 和 ./.local_deps/deepagents-code。这两个目录不在 git 里,由 Makefile 用 rsync 从工作区同步进去:
stage-harbor-local-deps:
rsync -a --delete ... ../deepagents/ $(HARBOR_LOCAL_DEPS_DIR)/deepagents/
rsync -a --delete ... ../code/ $(HARBOR_LOCAL_DEPS_DIR)/deepagents-code/
rsync -a --delete ... ../acp/ $(HARBOR_LOCAL_DEPS_DIR)/acp/
rsync -a --delete ... ../partners/quickjs/ $(HARBOR_LOCAL_DEPS_DIR)/partners/quickjs/意义在于:你在本地改了 SDK 的一行代码,make stage-harbor-local-deps && harbor run ... 之后,沙箱里跑的就是你改过的版本,不需要先发一个包。CI 里这个环节是自动的,本地调试靠它省掉整条发布链路。
Harbor 侧的适配器只有三十行
deepagents_harbor/agent.py 整个文件就一个类:
class DeepAgentsLangGraph(LangGraph):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.skills_dir:
self.configurable.setdefault("skills_dir", self.skills_dir)它继承 Harbor 自带的 LangGraph agent,只补了一件事:Harbor 会把任务的 [environment] skills_dir 交给每个 agent,但 Harbor 的 langgraph agent 没把它往下传。runner 传进 graph 的唯一通道是 configurable,所以这里塞进去。
这就是「接 Harbor」的全部代码量。Harbor 把 LangGraph 当成一等公民,你只要让它能起一个 LangGraph Server,剩下的事它自己做:装 venv、拉起服务、把任务环境里的东西通过 configurable 递进去、等 agent 停下、跑验证器。
三处安全设计
这三处不是随手写的,每一处都对应一个具体的攻击面。
第一处是剥环境变量。
_SHELL_ENV_DENYLIST = (..., "ANTHROPIC_API_KEY", "OPENAI_API_KEY", ...) # 18 项
@contextmanager
def _scrub_shell_env():
saved = {k: os.environ.pop(k) for k in _SHELL_ENV_DENYLIST if k in os.environ}
try:
yield
finally:
os.environ.update(saved)make_graph 整体包在 _scrub_shell_env() 里。理由是 dcode 有 shell 工具,沙箱里的 shell 继承宿主进程环境的话,模型一条 env 就能打印出你所有厂商的 key,然后可能写进某个文件、发到某个 URL。构建 graph 的瞬间把这些 key 从 os.environ 里摘掉,工具的进程就看不到。
第二处是拒绝 stdio 型 MCP。
_mcp_connections 只接受 http 和 sse,stdio 明确拒绝。注释里的理由值得抄下来:dataset 可以通过工作流的 dataset_override 控制配置,如果允许 stdio,一个不可信的 dataset 就能在 agent 的沙箱里执行任意命令。MCP 的 stdio transport 本质就是「你在本地起一个子进程」,把它开放给外部配置等于开放了 RCE。
第三处是不传 sandbox_type。
create_cli_agent 有个 sandbox_type 参数,看起来应该填 "harbor"。实际上不能填:填了之后 dcode 的 get_default_working_dir 会对这个值抛 ValueError。所以这里传的是 sandbox=None 加显式 cwd,让 dcode 以为自己就在本地跑。
还有两个小机制值得一提。_apply_glm_5_2_reasoning_default 只对 GLM-5.2 生效,给它默认 reasoning_effort=high,显式传值优先。_build_model 遇到 openai: 前缀会自动加 use_responses_api=True,因为这条路径绕过了 Deep Agents 的 OpenAI profile,不补的话会走错 API。_harbor_assistant_id 则把 Harbor 的 session id 规范化成 dcode 认的文件系统 ID:非 [A-Za-z0-9_-] 一律换成 -,超 64 字符加 sha256 前 12 位后缀。
解剖一个自定义 dataset
Harbor 的任务目录格式,context-retrieval-evals 里三十个任务是最好的样本。随便挑一个 cb-cloud-1:
cb-cloud-1/
├── environment/
│ ├── .dockerignore
│ └── Dockerfile
├── solution/
│ └── solve.sh
├── tests/
│ ├── case.json
│ └── test.sh
├── instruction.md
└── task.toml七个文件,各管一件事。少一个就跑不起来。
task.toml 是元数据和运行策略:
version = "1.3"
[metadata]
source = "contextbench"
suite = "cloud"
difficulty = "easy"
source_difficulty = "easy"
question_type = "comparison_tiebreak"
[environment]
network_mode = "allowlist"
allowed_hosts = ["astral.sh", "*.astral.sh", "github.com", "*.githubusercontent.com",
"pypi.org", "*.pythonhosted.org", "api.smith.langchain.com",
"api.anthropic.com", "api.openai.com", ...]network_mode = "allowlist" 加十七个域名,是个很克制的选择。评测要装包、要调模型 API,但不需要访问任意互联网,所以白名单把出网面收窄到「包源 + 各家模型端点」。这比 public 安全,比 none 可用。
difficulty 和 source_difficulty 分开存也有讲究。前者是校准后的分层,后者是上游原始标签,保留下来做溯源。
instruction.md 只有三句话:
Among all people who live in the same state as the owner of the pet named 'Dawn',
who owns the most vehicles? If there's a tie, who among them is the oldest?
Use only the files under `/app/files` to answer.
Write your final answer (and nothing else) to `/app/answer.txt`.第二句限定了信息边界,第三句定义了交付契约。这两句是让自动评分可行的前提:如果 agent 把答案写在聊天回复里,验证器就没法稳定取到。
tests/case.json 是题目和答案:
{"input": "Among all people ...", "ground_truth": "Tammy Roberts"}environment/Dockerfile 定义沙箱:
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl ...
COPY files/ /app/files/注释里解释了为什么构建期装 curl:构建期有网络,运行时只有 HTTPS 白名单,apt 走不通。这是个很小但很容易踩的点,自己写 dataset 时第一版几乎都会忘。
solution/solve.sh 是参考解:
#!/bin/sh
set -eu
printf '%s\n' 'Tammy Roberts' > /app/answer.txt它的作用是验证「题目是可解的」。Harbor 的 OracleAgent 会直接跑这个脚本,如果连它都拿不到满分,说明题目或验证器写错了,跟模型无关。这是写 dataset 时最该先跑通的东西。
tests/test.sh 是验证入口:
#!/bin/sh
set -eu
python3 /tests/judge.pytests/judge.py 是评分逻辑,下一节单独讲。
整个结构里,真正让这套东西可维护的是「哪些文件该提交、哪些不该」。
单源化:只提交差异
context-retrieval-evals 的三十个任务,语料完全相同,都是同一批十个 txt 文件。验证器也逐字节相同。如果全量提交,仓库里会有三十份重复的语料和三十份重复的 judge.py,改一个 prompt 要改三十处。
所以 adapter 的设计是:
- 语料单源在
harbor_adapters/contextbench/vendor/files/ - 验证器单源在
harbor_adapters/contextbench/templates/加vendor/rubric.txt - 每个任务只提交
tests/case.json
生成的 environment/files/ 和 tests/{test.sh,judge.py,rubric.txt} 全部 git-ignore。harbor_adapters/contextbench/adapter.py(347 行)负责按需铺开:
def populate_corpus(dataset_dir: Path) -> int:
...
def stamp_calibrated_tiers(dataset_dir: Path, calibration_path: Path) -> int:
...
def _write_task_files(task_dir, question, answer, extra) -> None:
..._write_task_files 把 Dockerfile、.dockerignore、instruction.md、solve.sh、task.toml 的内容全部内联生成。stamp_calibrated_tiers 用正则 ^difficulty = ".*"$ 覆盖难度行,source_difficulty 保持原值。
这里有个必须记住的前置条件:本地跑之前,先 populate。
uv run python -m harbor_adapters.contextbench.main --populate datasets/context-retrieval-evals不跑这一步,harbor run --path 会看到一个只有 case.json 的任务目录,既没有语料也没有验证器,构建和评分都会崩。这不是 bug,是单源化的必然代价:仓库里存的是配方,磁盘上才是成品。
对着 --populate 还有两个参数:--task-ids 和 --limit 用来只生成一部分任务,--stamp-tiers 配 --calibration 用来重新校准难度分层。三者互斥,因为一个是「铺语料」、一个是「改标签」,语义完全不同。
安全上也有小心思。parse_task_id 用正则 ^cb-(?P<suite>[a-z0-9]+)-(?P<index>\d+)$ 解析,并且校验 Path(task_id).name == task_id,防止 ../../etc 这种目录穿越。populate_corpus 只处理 task_dir.resolve().parent == dataset_root 的直接子目录,防止跟着软链接跑到仓库外面。
验证器怎么写
judge.py 复刻的是 Letta 那套 Context-Bench 的 RubricGrader,一百六十多行,做的事情比看起来多。
评分用的是 LLM 裁判,不是字符串相等。因为答案是 Tammy Roberts 这种人名,字符串比对会因为大小写、空格、全名和简称的区别全线失败。所以 rubric.txt 里写的是评分规则,judge.py 把三个占位符填进去:
prompt = string.Formatter().vformat(
rubric, (), {"input": question, "ground_truth": truth, "submission": submission}
)三种输入分别从 /tests/case.json、/tests/rubric.txt、/app/answer.txt 读。裁判模型调 Chat Completions,response_format 用 json_schema 约束成 {score, rationale},所以返回值可以直接解析,不用处理模型自由发挥的格式。
有个细节只能靠踩坑得到:temperature。
def _temperature(model: str) -> float:
if model.startswith(("o1", "o3")) or "gpt-5" in model.lower():
return 1.0
return 0.0推理模型拒绝 temperature=0.0,直接返回 400。所以对 o1、o3、gpt-5 系列必须给 1.0,其他模型给 0.0 求确定性。代码里把这条写成了函数而不是常量,就是为了让这个反直觉的分支有个名字。
分数落盘前会 clamp 到 [0, 1]。/app/answer.txt 不存在直接 0.0,不重试,因为那是 agent 没交付。其他异常重试五次,全失败返回 0.0 并在日志里留痕。最终结果写 /logs/verifier/reward.txt,Harbor 从这里读 reward。
两处和上游的刻意偏离,注释都写明了理由:裁判模型来自 JUDGE_MODELS(默认 gpt-5.6-luna),不是上游的 gpt-5-mini;submission 读 /app/answer.txt,不是读最后一条 assistant 消息。后者是因为 agent 可能把答案同时写在文件和回复里,读文件更稳定。
context-retrieval-evals 的验证器是「简单版」:语料在 agent 容器里,答案在 case.json 里,裁判只需要一次 LLM 调用。
DRBench 那套是困难模式
datasets/drbench-evals 目录在 git 里只有三个文件:README.md、dataset.toml、.gitignore。一个任务目录都没有。
一百个任务全部在运行时从上游生成。harbor_adapters/drbench/adapter.py 把上游 ServiceNow/drbench 钉在一个 commit 上:
UPSTREAM_SHA = "0d699ecf6aa96b1de378595b432e9b16a82f0ed9"用 blobless、depth-1、sparse 的方式拉:
_SPARSE_PATTERNS = (
"/drbench/data/tasks/*/config",
"/drbench/data/tasks/*/*.json",
"/drbench/data/subsets",
)每任务五个配置文件约 2.4 MiB,跳过的是 drbench/data/tasks/*/files/ 那 69 MiB 语料,因为 app 模式下文档由上游的镜像自己提供。整个生成大约两秒,缓存在 harbor_adapters/drbench/.upstream/。
不提交任务目录有两个理由,都写在 README 里:solution/solve.sh 是答案键(它包含 gold insights),不该进公开仓库;一千多个文件是一个 commit hash 的纯函数,放进 diff 没有意义。
代价是没法在 PR 里 review 生成结果,所以 CI 用 make dataset-check 连跑两次再 diff,证明生成是确定性的。
DRBench 的运行时结构也比 Context-Bench 复杂得多。每个任务两个 compose service:
main:Harbor 装 agent 的地方,故意不放任何任务数据drbench:上游的每任务镜像,按 digest 钉死,自带 supervisord 和该任务的文档
为什么 agent 容器要空着?因为上游镜像里有 /drbench/task/env.json,它按文档记录 qa_type,也就是每个文件到底是「金句素材」还是「干扰项」的显式标签。agent 只要 cat 一下这个文件,整个研究任务就变成了开卷抄答案。ground truth(eval.json)不在镜像里,所以不算完全泄题,但干扰项设计直接作废。用两个 service 在结构上隔离,比删文件更可靠。
评分也比 Context-Bench 复杂:tests/judge.py 直接装上游 drbench 包,调 drbench.score_report.score_report,四个指标全部用上游代码算:
| 指标 | 上游类 | 含义 |
|---|---|---|
insights_recall |
QASimilarityV2 |
报告能推出多少比例的金句 |
distractor_recall |
DistractorRecall |
吃进了多少干扰项,越高越差 |
factuality |
CitationFactuality |
每条引用能否解析到源、是否支撑结论 |
report_quality |
ReportQuality |
五项各 1 到 10 分,归一化 |
头号 reward 是前四者的调和平均(1 - distractor_recall 参与),这是论文 arXiv 2510.00172 表 2 自己的聚合方式。上游开源代码算了四个指标但没算平均,所以组合发生在 judge.py 里。唯一偏离论文的地方是给每个分量加了 0.01 下限,否则任意一个零分会把总分打成零,排序信息全丢。
这个验证器还必须跑在独立环境里:[verifier].environment_mode = "separate",Harbor 会在 agent 环境拆掉之后,单独从 tests/ 构建第二个镜像。因为 drbench 包本身同时带着 gold eval.json 和整份文档语料,这些在 agent 运行时一个都不能存在。
还有个最容易忽略的坑:引用必须可解析。
评分要把每条引用还原到源文件,还原不了就算不支持,不管论断多正确。所以 instruction.md 规定了精确格式:文档用文件名、网页用完整 URL、邮件用 RoundCube-<发件人地址>-<收件人地址>-<主题>、聊天用 MatterMost-<频道>-<团队>-<用户>。发件人地址和主题是逐字符匹配的,写显示名解析不出来,上游 normalize_email_citation 里每个模式都要求有 @。
怎么跑
有三层入口,从粗到细。
第一层:CLI
装好之后有 deepagents-evals 这个命令,七个子命令:
deepagents-evals list categories # 有哪些类别
deepagents-evals list evals --category memory
deepagents-evals list models --group set0
deepagents-evals run --model gpt-5.6-luna
deepagents-evals run --model ... --eval-category memory --eval-tier hard
deepagents-evals run --model ... --report results.json
deepagents-evals trials --model ... --trials 3 # 跑三轮再聚合
deepagents-evals trials --model ... --trials 3 --retry-failed
deepagents-evals aggregate --summary-out trials_summary.json
deepagents-evals radar --summary trials_summary.json
deepagents-evals catalog --check # EVAL_CATALOG.md 是否最新
deepagents-evals model-groups --check--model 可以用环境变量 DEEPAGENTS_EVALS_MODEL 代替。所有子命令都支持 --json 和 --dry-run,后者只打印要执行的命令,不真跑。
退出码是给 CI 用的:0 成功,1 有 eval 失败,2 配置错误,3 没有可用报告。
这里有个坑必须单独提醒。pytest_reporter.py 会把每个 trial 的 pytest 退出码改写成 0,目的是让「有测试失败」和「框架崩了」在 CI 层面可区分。所以判断失败不能看 pytest_returncode,要看 trials_summary.json 里的 counts.failed.mean。
另外,这套件强制要求 LANGSMITH_TRACING=true 和 LANGSMITH_API_KEY,缺一个就拒绝启动。不是可选遥测,是硬依赖:所有 t.log_feedback 都走这里,没它评测结果无处落地。
第二层:Makefile
CI 实际调的是 Makefile,参数更贴近真实用法:
make evals MODEL=anthropic:claude-sonnet-4-6
make evals-trials MODEL=... TRIALS=3
make stage-harbor-local-deps
make run-hello-world
make run-terminal-bench-modal # -n 4
make run-terminal-bench-daytona # -n 40
make run-terminal-bench-docker # -n 1
make run-terminal-bench-runloop # -n 10
make run-terminal-bench-langsmith # -n 1 + --plugin langsmith注释里专门澄清了一件事:-n 是并发 trial 数(沙箱槽位),不是任务数;限制任务数要用 -l。并发上限差这么多,是因为各家的沙箱配额不同:Daytona 能给到 40,本地 Docker 只能 1,Modal 居中。
第三层:直接调 Harbor
最灵活的入口还是 Harbor 本身:
uv run harbor run \
--path libs/evals/datasets/context-retrieval-evals \
--agent deepagents_harbor.agent:DeepAgentsLangGraph \
--agent-kwarg graph=bare \
--model anthropic:claude-sonnet-4-6 \
--env docker \
--verifier-env JUDGE_MODELS=gpt-5.6-luna--agent-kwarg graph=bare 就是选 graph,dcode、bare、tau3 三选一。--env 选运行环境,支持 docker、langsmith、daytona、modal、runloop。
环境变量模板有个坑:${VAR} 形式的占位符,宿主没设这个变量时 Harbor 抛 ValueError,而不是留空。要写成 ${VAR:-default} 才是可选的。
统一评测:四个类别
libs/evals 不是一堆散装 benchmark,它有一个统一评测工作流(.github/workflows/unified_evals.yml),把四个类别编在一起,一处触发、一张报表。
| 类别 | Dataset | 任务数 | 测什么 | 默认跑 |
|---|---|---|---|---|
autonomous |
harbor-index |
90+ | 终端里自主完成任务 | 是 |
conversation |
tau3-subset |
30 | 多轮对话 + 工具调用 | 是 |
research |
drbench-evals |
100 | 企业深度研究 | 是 |
context |
context-retrieval-evals |
30 | 长上下文检索 | 否 |
context 默认不跑,因为它的信号和 research 有重叠,而且每个任务要额外起一套语料容器,成本不划算。
pass@K 和 avg@K
多 trial 之后要聚合,两个指标的定义必须说清楚,否则同一个数字两个人算出两个结果。
pass@K:K 次 rollout 里至少通过一次的任务占比。衡量「这模型有没有可能做对」。
avg@K:通过的 trial 数(每任务封顶 K)除以期望 trial 数(任务数乘 K),缺失的 rollout 算失败。衡量「平均能做对多少」。
区别在尾部:一个模型可能 3 次里对 1 次,pass@3 是 100%,avg@3 是 33%。
research 类别只报 avg@K,不报 pass@K。原因很硬:DRBench 的 reward 是连续分,不是 0/1,pass@K 需要一个二值阈值,这里结构性不存在。硬算出来会是一串毫无意义的 0。
research 的五个特殊点
这个类别在流水线里是最别扭的一个,五个地方都和别人不一样:
- 自己钉
sandbox_env: docker和runner_label: ubuntu-24.04-arm。上游只发 arm64 镜像(amd64 说 coming soon),而 runner 架构只在容器跑在 runner 上时才起作用,所以必须 docker 沙箱。 network_mode = "public"。613 条 gold insight 里有 45 条是external_fact,只存在于开放网络。它是唯一被授予TAVILY_API_KEY的类别。- 五个连续分加调和平均,前面已经说过。
- 抑制 pass@K,只报 avg@K。
concurrency: 1。每个 trial 要跑 Nextcloud、Mattermost、Postgres、Roundcube 一整套,单个镜像 1.22 GiB 压缩、3 到 4 GiB 解压,而 runner 只有 14 GB 空闲磁盘。Harbor 不会自动清理镜像,down --rmi local也会留下已拉取的层。
第五条不是保守,是物理限制。README 里写得很直白:磁盘是硬约束,宁可分片宽一点,不要堆并发深。
边界与成本
拆完这套东西,有几件事值得在下手前想清楚。
它不便宜。每个 pytest eval 都是一次真实 LLM 调用,多 trial 就是倍增。Harbor 那边更贵:每个任务一个容器,DRBench 还要额外起四个应用加一个独立验证器。CI 里 unified_evals 的预算按六小时算,这也是为什么四个类别都只跑子集:tau3-subset 是 30 个而不是全部,harbor-index 是按 Opus 4.8 实测通过率分层筛过的。
它的数字不完全可比。research 跑在 arm64 Docker 上,其他类别跑在 amd64 LangSmith 沙箱上。README 自己承认这不是硬件可比的,只能当绝对分看。
它是自建评测,有自证倾向。pytest 那套的题目和断言都是 deepagents 团队写的,天然偏向自己实现的能力。这也是为什么 Harbor 那部分必须存在:外部 benchmark 是唯一能对冲这种偏向的东西。
它假设你会读源码。文档覆盖了用法,但很多行为只在注释里解释,比如为什么 sandbox_type 不能传、为什么 FileAbsent 比 FileExcludes 严格、为什么 --populate 是前置条件。照着 README 跑很容易,改它就得读。
参考
- deepagents 仓库
- libs/evals README
- libs/evals CONTRIBUTING
- libs/evals UNIFIED_EVALS
- Harbor
- Terminal Bench 2.0
- DRBench
- DRBench 论文 arXiv:2510.00172
- Context-Bench / Letta evals
- Deep Agents 文档
作者:TheAIEra 来源:https://www.theaiera.cn 网站:AI 人工智能时代