返回博客列表

deepagents 评测体系拆解:Harbor 集成与自定义 dataset

2026-10-29T01:20:00+08:00
deepagentsHarborAgent 评测LangChaindataset

写 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 trajectory

extra_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 plum

Harbor 只关心三件事:容器起没起来、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.py

tests/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 的五个特殊点

这个类别在流水线里是最别扭的一个,五个地方都和别人不一样:

  1. 自己钉 sandbox_env: docker 和 runner_label: ubuntu-24.04-arm。上游只发 arm64 镜像(amd64 说 coming soon),而 runner 架构只在容器跑在 runner 上时才起作用,所以必须 docker 沙箱。
  2. network_mode = "public"。613 条 gold insight 里有 45 条是 external_fact,只存在于开放网络。它是唯一被授予 TAVILY_API_KEY 的类别。
  3. 五个连续分加调和平均,前面已经说过。
  4. 抑制 pass@K,只报 avg@K。
  5. 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 跑很容易,改它就得读。

参考


作者:TheAIEra 来源:https://www.theaiera.cn 网站:AI 人工智能时代

分享给朋友