WorkBuddy Bench源码深度解析:如何构建可复现、可审计、可扩展的 Coding Agent 评测系统

从四层配置、Harbor 沙箱、Harness split-mount、协议代理、分片调度到 CompositeVerifier,拆解真实工作型 Agent Benchmark 的工程内核

Posted by iceyao on Tuesday, July 28, 2026

源码基线:本文基于 workbuddy-bench 提交 b516950(2026-07-23)分析。项目版本为 0.1.0,依赖 Harbor v0.18.0。文中的源码链接均固定到该提交,避免后续实现变化造成语义漂移。

实验边界:本文完成了依赖安装、全量 Python 字节码编译、配置/调度/协议转换/评分微基准和 synthetic 指标聚合验证。当前工作区未下载四个数据集,Docker daemon 也未启动,因此没有把模拟数据包装成 Code/Web/Office/Security 的真实模型成绩。真实全量评测步骤和结果读取方法在实践章节完整给出。

引言:Agent Benchmark 真正难测的不是“答案”,而是一次完整工作

传统 LLM Benchmark 往往把输入和输出压缩成一个函数:

prompt → model → answer → exact match / judge score

Coding Agent 的真实执行却更像一次受约束的软件交付:

角色化任务
  → 读取仓库或办公文件
  → 多轮模型推理
  → 调用 shell / 编辑器 / 浏览器等工具
  → 修改工作区
  → 运行测试
  → 产出 patch、文件或报告
  → 收集轨迹、token、耗时与错误
  → 判定是否真正解决问题

只要其中任一层不受控,评测就可能失真:

  • Agent CLI 版本不同,工具行为和默认提示词就不同;
  • 任务镜像偷偷包含某个 Harness,换 Agent 时必须重建全部镜像;
  • Anthropic Harness 与 OpenAI 后端协议不匹配,模型能力尚未比较,接入差异已经成为混杂变量;
  • 多任务并发时,简单平均分片会让 Hard 任务扎堆;
  • 某次尝试少输出几个失败测试,5/5 可能比诚实的 5/10 看起来更高;
  • 只写一个总分,无法区分模型错误、工具错误、Verifier 崩溃和上游服务故障。

WorkBuddy Bench 的定位正是解决这些工程问题。它面向真实角色化工作,提供 Code 80 题、Web 70 题、Office 50 题、Security 60 题四个子集;本仓库则是建立在 Harbor 之上的评测框架:把 Agent CLI 放进 Docker 沙箱运行,记录轨迹和资源消耗,再通过数据集自己的验证逻辑产出可聚合分数。

WorkBuddy Bench 源码全景

从源码看,它的核心不是某个评分公式,而是五个相互约束的平面:

平面 关键实现 解决的问题
配置平面 config_loaders.pyresolve_manifest.py 一次运行究竟用了什么模型、Harness、任务和覆盖项
编排平面 scripts/run.shprepare_job.pysharded_eval.py 运行顺序、失败前置、并发、清理与恢复
执行平面 Harbor、WorkBuddyDockerEnvironmentCbcAgentCcAgent 可复现沙箱、CLI 交付、非交互运行与轨迹采集
连接平面 FastAPI Pipeline、A2O 转换、UpstreamSender 协议桥接、参数注入、限流、重试与观测
评价平面 CompositeVerifier、数据集插件、scorer.metrics 数据集差异、错误归因、总分和诊断分离

本文沿一次 Job 的真实数据流逐层拆解,并回答三个问题:

  1. 为什么配置解析结果要先固化成 Manifest,而不是直接生成 Harbor YAML?
  2. 为什么 Harness、Proxy 和 Judge 都被设计成可替换边界?
  3. 评分系统如何避免“没跑的任务消失”“测试分母缩水”等看似细小、实际致命的统计偏差?

一、源码地图:薄入口、厚运行时、数据集插件化

仓库的 Python 代码约 1.59 万行,主体位于 src/workbuddy_bench/

workbuddy_bench/
├── configs/
│   ├── bench/          # 数据集级运行不变量
│   ├── harnesses/      # Agent CLI family、版本、preset、mount 镜像
│   ├── models/         # 模型身份、协议、后端 env 名、采样参数
│   └── jobs/           # model + harness + dataset 的一次组合
├── scripts/
│   ├── run.sh          # 本地统一入口
│   ├── dataset/        # 下载与校验数据集
│   ├── harness/        # 构建 Harness mount image
│   ├── judge/          # Judge 辅助入口
│   └── proxy/          # 独占或共享 Proxy 生命周期
└── src/workbuddy_bench/
    ├── agents/         # CbcAgent、CcAgent
    ├── runner/         # Manifest、Job、任务准备、分片、路由
    ├── proxy/          # FastAPI、Pipeline、A2O、Sender、Interceptor
    ├── judge/          # CompositeVerifier 内核与插件 Registry
    └── scorer/         # task scorer、run metrics、host-side LLM judge

这个结构表达了一个重要判断:Benchmark Framework 不应该内置某一个 Agent 的工作方式,也不应该让某一个数据集的评分细节污染全局 Runner。

  • Runner 只负责把声明解析成一次确定的运行;
  • Agent adapter 只负责安装、配置、调用和解析某个 CLI;
  • Proxy 只负责连接与协议层;
  • CompositeVerifier engine 只拥有 collect → judge → score 生命周期;
  • Code、Web、Office 的具体 evidence、judge 和 scoring policy 来自数据集插件;
  • Security 甚至可以完全绕过 CompositeVerifier,使用任务自带的 tests/scoring.py

这种“内核持有顺序、插件持有策略”的拆分,贯穿整个项目。


二、四层配置:从可读 YAML 到可审计 Manifest

2.1 合并顺序不是普通参数覆盖,而是评测实验定义

一次运行由五份输入按顺序合并:

bench/_default.yaml
  → bench/<dataset_id>.yaml
    → harnesses/<family>/_defaults.yaml + versions/<version>.yaml
      → models/<provider>/<slug>.yaml
        → jobs/<slug>.yaml

后者覆盖前者。底层函数 deep_merge() 只有十行左右,但语义很严格:字典递归合并,标量和列表整体替换,所有值都 deepcopy,不让一次解析回写污染缓存对象。

def deep_merge(base: dict, override: dict) -> dict:
    out = copy.deepcopy(base) if isinstance(base, dict) else {}
    for key, value in (override or {}).items():
        if isinstance(value, dict) and isinstance(out.get(key), dict):
            out[key] = deep_merge(out[key], value)
        else:
            out[key] = copy.deepcopy(value)
    return out

不同层分别回答不同问题:

  • Bench:这个数据集跑几次、并发多少、超时倍率、上下文窗口多大、是否启用 LLM Judge;
  • Harness:使用哪个 Agent CLI 类、什么协议、哪一版 CLI、哪份 settings preset、挂载到哪里;
  • Model:发给后端的模型 ID、后端支持的协议、URL/KEY 来自哪些环境变量、采样参数是什么;
  • Job:把三者组合起来,并为本次实验添加 task selection 或局部 override。

如果把所有字段塞进一个大 YAML,复制 Job 时很容易让某个隐含参数悄悄漂移。分层配置让“数据集不变量”和“实验变量”保持不同生命周期。

四层配置解析与双产物

2.2 Manifest 与 Harbor Runtime YAML 为什么必须分开

很多框架会把合并后的 YAML 直接交给执行器。WorkBuddy Bench 多做了一步:resolve_manifest() 先生成:

scripts/logs/instances/<instance-id>/manifest.json

之后 prepare_job.compose_job_config() 才把它投影成 Harbor 能消费的:

.workspace/data/generated/jobs/<job>.yaml

两者服务对象不同:

产物 面向谁 包含什么
manifest.json 人、验证器、Proxy、后处理 配置来源及 SHA-256、Git provenance、解析时间、task selection、协议需求、route、sanitized Harness 最终配置
Harbor runtime YAML Harbor environmentagentsdatasets、并发、attempt、timeout、verifier 等执行字段

Manifest 的价值可以概括为“把决策结果冻结”。例如 CBC 不同版本的上下文压缩机制不同:

  • < 2.103.4:窗口写进 models.json.maxInputTokens,百分比写入 CODEBUDDY_AUTOCOMPACT_PCT_OVERRIDE
  • >= 2.103.4:省略 maxInputTokens,改用绝对 token 的 CODEBUDDY_AUTO_COMPACT_WINDOW,并限制在 [100000, 1000000]

Manifest 中的 harness_runtime_config 会提前构造出最终 settings_jsonmodels_json 和翻译后的环境变量,但 API Key 只保留 env 名或 <redacted>。因此评测结束后,即使 preset 已经被改过,也能回答“当时容器里实际写入了什么”。

2.3 Fail-fast:不要让错误配置变成低分样本

resolve_manifestrun.sh 主动拒绝几类模糊状态:

  • model_connection: direct 时,Harness 协议必须被模型后端原生支持;
  • params.extra_body 包含 Harness 无法原生表达的字段时,必须使用 local_proxy
  • context_window 不能超过模型声明的物理上限;
  • Harness 必须使用 <family>/<version> canonical slug;
  • task selection 的 index、name、count 必须合法;
  • Dataset 声明需要 split-mount 时,Harness mount 配置必须完整。

这不是一般的输入校验。Benchmark 中“配置错误后继续跑”会把基础设施故障伪装成模型能力不足;fail-fast 是实验有效性的一部分。


三、一次 Job 的控制流:先冻结、再暂存、后执行

统一入口是 scripts/run.sh。它不是简单调用 harbor run,而是一条带资源生命周期的事务式管线。

3.1 主流程

解析 --job / --dry-run 与并发参数
  → 加载 .env
  → 读取 model + harness adapter
  → 校验 direct / local_proxy 兼容性
  → 检查 dataset 是否存在
  → 创建 INSTANCE_ID 与实例目录
  → resolve_manifest
  → Harness mount preflight
  → validate_model
  → 启动或热加载 Proxy
  → prepare_tasks
  → prepare_job
  → harbor run 或 sharded_eval
  → 可选 host-side LLM judge
  → split_proxy_log
  → trap 清理实例 Proxy 与 staged dataset

脚本有两个容易被忽略的可靠性细节。

第一,cleanup_instance() 不会按端口或进程名粗暴杀 Proxy。它记录本次启动的 PID,再检查进程参数是否仍包含本实例的 --config,确认身份后才停止,避免并行 Job 互相误杀。

第二,信号处理覆盖 EXITINTTERM,staging 目录按 INSTANCE_ID 精确删除;资源清理范围与运行实例一一对应。

3.2 为什么复制整个 Dataset root

_stage_dataset() 会把数据集 root 复制到:

.workspace/tmp/staged/<instance-id>/<dataset-id>/

而不是直接在 datasets/ 上执行 prepare_tasks。原因是后者会进行三类原地注入:

  1. task.toml[agent][verifier] 写入执行用户;
  2. 确保 CompositeVerifier import path;
  3. 根据网络模式修改 environment/docker-compose.yaml,增加 host.docker.internal:host-gateway

只复制 tasks/ 也不够,因为 dataset.toml 位于其父目录,Verifier contract 和 split-mount 需求都要从祖先目录解析。因此这里复制整个 dataset root,是为了同时保持:

  • 原始数据集只读;
  • 数据集级 contract 仍然可发现;
  • 并行 Job 各自有独立改写空间。

3.3 Egress control 下 Host Gateway 放在哪里

ensure_host_gateway_compose() 没有无条件把 extra_hosts 写给主容器:

  • 所有 phase 都是 public:写入 services.main
  • 任一 phase 启用非 public 网络模式:Harbor 会让 main 使用 egress sidecar 的 network namespace,此时 Docker 不允许 main 同时带 extra_hosts,所以 host gateway 被写到 harbor-docker-egress-control-sidecar

这是一处典型的“理解下游运行时语义后再扩展”设计:它没有修改 Harbor 全局 Compose,而是使用 task-local override,并保留任务原有的 allowlist/no-network 隔离。


四、Harness 适配:为什么 Agent CLI 不应该烤进任务镜像

4.1 Split-mount:把任务环境与被测对象解耦

CbcAgentCcAgent 都是从头实现的 Harbor BaseInstalledAgent 子类,而不是薄封装现有 CLI。二者遵守相同生命周期:

install(environment)
  → run(instruction, environment, context)
    → populate_context_post_run(context)

每一个 Harness 版本先构建成独立只读 OCI 镜像,运行时再通过 Harbor 原生 ServiceVolumeConfig(type="image") 挂载:

type: image
source: workbuddy-bench/harness/codebuddy-code:2.109.3
target: /opt/codebuddy-code
read_only: true

Agent 的 install() 在容器启动后把 /opt/<harness>/bin 中实际存在的 launcher 软链接到 /usr/local/bin。只有挂载中没有 CLI 时,才回退到网络安装。

Harness split-mount 交付机制

这样设计的收益不只是节省构建时间:

  • 控制变量:同一个 task image 可以测试不同 Harness 和版本;
  • 缓存复用:切换 Agent 不需要重建 260 个任务环境;
  • 可审计:版本来自 versions/<version>.yaml,镜像 tag 可被 Manifest 记录;
  • 最小侵入:任务数据集不需要认识 CodeBuddy Code 或 Claude Code;
  • 安全边界:Harness 镜像只读,运行期配置写入 Agent 用户自己的 HOME。

代价也很明确:Docker image mount 需要 Engine API >= 1.48WorkBuddyDockerEnvironment 只在 mounts 中真实出现 type: image 时协商 API;普通 bind/volume 运行不受影响。

4.2 CbcAgent.run():配置文件型 Harness

CbcAgent.run() 的核心工作是构造两个确定性文件:

  • ~/.codebuddy/models.json:model id、URL、API Key、token/temperature 等;
  • ~/.codebuddy/settings.json:permissions deny、thinking 等行为配置。

为了避免 shell 转义和任意任务指令破坏 JSON,代码在 Host Python 中完成序列化,再 Base64 编码,通过容器命令解码写入。

随后以 headless 模式运行:

cbc -p --output-format stream-json -y \
  --model <model> --max-turns <N> -- '<instruction>' \
  2>&1 </dev/null | tee /logs/agent/cbc-output.txt

Proxy 模式下,Bearer Token 不是普通 Dummy Key,而是:

<trial_id>::<route>

Proxy 用 route 选择后端,用 trial id 将请求日志归属到唯一 Trial。

4.3 CcAgent.run():环境变量型 Harness

CcAgent.run() 不写 models.json,而是设置:

ANTHROPIC_BASE_URL
ANTHROPIC_API_KEY
ANTHROPIC_MODEL
CLAUDE_CODE_MAX_OUTPUT_TOKENS
CLAUDE_CODE_AUTO_COMPACT_WINDOW
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE

如果任务镜像的 ENV HOME 指向另一个用户,docker exec -u dev 仍可能继承错误 HOME。两个 Agent 都会从 /etc/passwd 重新解析当前 UID 的真实 home:

export HOME="$(getent passwd "$(id -u)" | cut -d: -f6)"

这看似是容器兼容小修补,实际避免了跨任务镜像时大量“模型没问题、CLI 因权限无法写配置”的假失败。

4.4 Stream JSON 到 ATIF:效率指标从哪里来

populate_context_post_run() 解析 assistant 和最终 result 事件,生成 trajectory.json,并回填 Harbor AgentContext

n_input_tokens
n_output_tokens
n_cache_tokens
cost_usd

CBC 还显式区分:

  • raw assistant events;
  • 真正带 token 的 LLM calls;
  • CBC 自己报告的 num_turns

因为一次 LLM message 可能被拆成多个 stream event,把三者混为“step 数”会让效率比较失真。


五、Proxy:接入层既是协议桥,也是实验观测面

5.1 directlocal_proxy 的边界

Job 必须显式声明:

model_connection: direct      # 或 local_proxy

direct 的优点是链路短,但只适用于:

  • Harness protocol 被模型后端原生支持;
  • 没有 top_kmin_pchat_template_kwargs 等 Harness 无法表达的 extra_body

local_proxy 则负责:

  • Anthropic client → OpenAI backend 的 A2O 转换;
  • model slug → backend model ID 重写;
  • 将 model params 和 extra_body 统一注入请求;
  • 每模型并发限制;
  • 固定间隔重试;
  • 请求、响应、usage、reasoning、tool call 的 JSONL 观测;
  • Trial 级日志归属。

5.2 一次 Claude Code → OpenAI 后端的完整时序

Anthropic 到 OpenAI 的 Proxy 时序

入口 _process_request() 先从 Authorization、x-api-keyanthropic-auth-token 提取 Token,再拆分 <trial_id>::<route>

路由解析采用严格语义:

if model in self.config.routes:
    return self.config.routes[model]
if model:
    return None
if not self.config.shared and len(self.config.routes) == 1:
    return next(iter(self.config.routes.values()))

非空 model 没有精确命中就返回 404,绝不“反正只有一条 route 就随便转发”。只有私有单路由 Proxy 且请求完全没有寻址信息时才 fallback;共享 Proxy 永不 fallback。这样能把拼写错误暴露为配置失败,而不是把请求悄悄送到错误模型。

Pipeline._prepare_upstream() 在 A2O 分支执行:

  1. convert_request_a2o() 转换 system、message、thinking、tool use/result、image;
  2. 长工具名截断到 OpenAI 64 字符限制,并保留反向映射;
  3. 清洗 JSON Schema 2020-12 中许多 OpenAI-compatible backend 不接受的关键字;
  4. 去除 Anthropic cache control 与 header;
  5. body["model"] = route.effective_model
  6. body.update(route.extra_body),让实验配置覆盖 Harness 原请求。

响应则由 convert_response_o2a()StreamingA2OConverter 转回 Anthropic message/SSE,并把 cache read/creation token 从 OpenAI usage 形状映射回来。

5.3 并发为什么按 Backend 限制,而不是全局 Semaphore

UpstreamSenderbackend URL + API Key digest 作为 client 和 semaphore key:

backend A → AsyncClient A + Semaphore A
backend B → AsyncClient B + Semaphore B

没有配置 max_concurrent 的模型不额外限流,由 Harbor trial concurrency 控制。这样共享 Proxy 中某个拥塞模型不会占满全局 semaphore,阻塞其他后端。

连接池的 max_connections=None 也不是“无限制就更快”。真正的上限在 per-backend semaphore;连接池不能比它更紧,否则请求会在 HTTP pool 中排队,绕过统一可见的限流点。

5.4 为什么重试是固定间隔,而不是指数退避

Sender 只重试 429/502/503/504httpx.TransportError,默认窗口为:

6 retries × 5 seconds + 0~20% jitter ≈ 30~36 seconds

源码注释记录了一次很具体的工程教训:Proxy 指数退避若超过 CBC 的 600 秒 first-token/stream timeout,CBC 会开始自己的重试,而 Proxy 还在重试旧请求,形成嵌套重试,最终产生二十多分钟的空 Patch Trial。

因此固定窗口的目标不是最大化单请求成功概率,而是保证:

Proxy 短重试窗口 < Harness 客户端超时窗口

更关键的是,streaming 请求一旦已经向客户端 yield 第一个 chunk,后续传输错误不能重放 POST,否则前半段内容和 tool call 会重复。代码用 yielded 标记在 mid-stream failure 时直接失败,只允许在尚未输出任何 chunk 时重试。

5.5 日志是观测面,也可能是敏感面

LogInterceptor 会记录:

  • client body 与 upstream body;
  • route、协议、duration;
  • usage、reasoning、tool schema 和 tool call;
  • trial_id
  • streaming 聚合结果。

它有意不记录 Authorization/API Key header,但 full request/response 仍可能包含代码、文档或业务数据。因此 record_full_io 应作为诊断开关,而不是所有生产评测的无条件默认值;结果目录也需要访问控制与保留周期。


六、分片与并发:调度的目标是缩短尾部,而不是平均任务数

6.1 Task selection 先固化,分片只消费结果

Job 可以声明:

task_selection:
  mode: random
  count: 10
  seed: 42

支持 all / first / last / index / random / nameresolve_task_selection() 会把具体 task name 排序后写入 manifest.selected_tasks

随机选择使用局部 random.Random(seed),不会依赖全局 RNG 状态。同一 Manifest 的集合因此可以复现;sharded_eval 不再重新解释 selection 规则,只消费已经冻结的 task list。

6.2 build_shards():一个小型 LPT 近似调度器

任务的 metadata.difficulty 被映射成:

DIFFICULTY_WEIGHT = {"easy": 1, "medium": 2, "hard": 3}

build_shards() 先按权重降序,再将任务放入当前累计权重最小的 shard:

ordered = sorted(tasks.items(), key=lambda item: (-item[1]["weight"], item[0]))
for task_name, task_info in ordered:
    index = min(
        range(shard_count),
        key=lambda i: (shard_weights[i], len(shards[i]), i),
    )
    shards[index].append(task_name)
    shard_weights[index] += task_info["weight"]

它接近经典 LPT(Longest Processing Time first)启发式。难度不是精确耗时预测,但比 round-robin 更能避免 Hard 任务集中到一个 shard,拖长整个 Job 的尾部。

难度感知分片与恢复

6.3 两级并发与 1.1 秒启动间隔

这里有两级并发:

  1. sharded_eval 为每个 shard 启动独立 subprocess.Popen
  2. 每个进程内部 harbor run -n <per_shard_concurrency> 并发 Trial。

总理论并发约为:

active shards × per_shard_concurrency

但还会受 model max_concurrent、Docker 资源和任务网络约束。

LAUNCH_STAGGER_SEC = 1.1 看起来像性能损失,实际是兼容 Harbor Job 目录按秒命名的约束:多个 shard 同一秒启动会争用相同目录,产生 FileExistsError。牺牲几秒启动时间,换取确定性目录隔离。

6.4 Resume 为什么同时检查 checksum 和 attempt 数

Resume 不是“发现同名任务就跳过”。load_completed_tasks() 只接受:

result.task_checksum == 当前 task dirhash
且匹配 Trial 数 >= n_attempts

原因是 Harbor 对一个 included task 会重新运行全部 attempts。若旧结果只有 1 次,而新配置要求 3 次,跳过任务会让样本欠采样。

被接受的旧 Trial 通过 Path.symlink_to() 链接到当前 Job 的 resumed-trials/,让后续 metrics 和 post judge 使用同一个结果根扫描,无需复制数据或修改历史结果。


七、CompositeVerifier:固定生命周期,开放数据集策略

7.1 统一值对象:先消除 Judge 输出方言

judge/core/models.py 定义了一组 dataset-neutral dataclass:

  • EvaluationContext:workspace、tests、verifier、env、metadata;
  • EvaluationItem:最小评分单元、权重、criteria、category;
  • JudgeSpec:judge 类型、family、目标 item、配置;
  • EvidenceRecord / EvidenceBundle:上游证据;
  • JudgeVerdict / JudgeResult:归一化状态、score、confidence、错误;
  • ScoreResult:最终 reward、测试计数、诊断与元数据。

状态词表被压缩为:

PASS / FAIL / REVIEW / ABSTAIN / BUILD_ERROR / JUDGE_ERROR

这让 pytest、native script、LLM、VLM、Agent Judge 的输出先进入同一中间表示,再交给 scoring policy;聚合层不需要理解每种 Judge 的私有 JSON。

7.2 Dataset contract 与 Plugin Registry

数据集在 dataset.toml 声明:

[dataset]
id = "..."
version = "..."

[verifier]
schema = "workbuddy.verifier.v1"
engine = "composite"

默认插件路径是:

shared/verifier/plugin.py::build_registry

load_verifier_registry() 动态加载后,插件返回 VerifierRegistry

VerifierRegistry(
    plan_builder=...,
    evidence_collectors=[...],
    judge_runners={...},
    scoring_policy=...,
    prepare=...,          # optional
    finalize_score=...,   # optional
    custom_verify=...,    # optional
)

Code 可以注册 pytest/native script,Office 可以注册文件结构规则与可选 LLM judge,Web 可以用 custom_verify 接管复杂的 screenshot/VLM/penalty pipeline。Security v1.0 则不声明 composite contract,由 Harbor 原生 Verifier 执行任务自带 scorer。

CompositeVerifier 插件架构

7.3 Engine 不是并发调度器,而是确定性生命周期

CompositeVerifierEngine.run() 的顺序非常克制:

collect evidence
  → for judge in plan.judges: 顺序执行
    → scoring_policy.score(...)

Judge 没有 gather() 并行执行。这里的优先级是确定性、共享工作区安全和可解释顺序;高并发由 Harbor Trial 和 shard 层承担。若未来要并行 Judge,必须先给 Judge 声明只读证据、资源预算和依赖 DAG,否则两个 Agent Judge 同时修改工作区会破坏评分有效性。

错误处理也不是统一 except: reward=0

  • evidence collector 异常:整个尝试是 BUILD_ERROR
  • 没有 runner、runner family 与 JudgeSpec.family 不匹配、runner 异常:生成覆盖目标 item 的 JUDGE_ERROR verdict;
  • scoring policy 可选择 fatal_judge_error=True,把 Judge 基础设施错误升级为 0 分。

这样 score.json 能解释零分来自“被测产物失败”还是“评判系统失败”。

7.4 reward.jsonscore.json:机器门禁和人类诊断分离

ScoreResult 输出两份文件:

  • reward.json:只允许有限数值,供 Harbor gate 和 Host metrics 读取;
  • score.json:包含 verdict、judge result、evidence、plan、context、cap/gate 调整等富诊断。

ArtifactWriter 先在同目录创建临时文件,再 os.replace() 原子替换目标路径。这样既避免消费者读取半截 JSON,也能处理容器 rule runner 留下 root-owned 旧文件的情况——替换目录项不需要以 truncate 方式重新打开旧 inode。


八、评分与聚合:Reward、Pass Rate 和诊断指标不能混为一谈

8.1 Trial 内评分:保守合并、门禁与上限

默认 PassRateScoringPolicy 对同一 item 的多个 scorable verdict 取最小值:

item_score = min(judge scores for this item)

设计理由是:一个 Judge 的 FAIL 不应该被另一个 Judge 的 PASS 平均稀释。随后按 item weight 计算:

raw_reward =
  Σ(item_score × item_weight)
  ─────────────────────────
       Σ(item_weight)

未被任何 Judge 覆盖的 plan item 仍在分母中并计 0;否则只评了容易子集也可能得到满分。

最后应用两类只减不增的 safeguard:

  • pass_gate / required:必需项没有 PASS,reward 强制为 0;
  • hard_fail_cap / score_cap:指定失败发生时,reward 不得超过 cap。

Aggregate-only 的部分分 0 < score < 1 虽然状态可能编码成 FAIL,但默认不会触发 hard-fail cap;数据集必须显式声明 cap_on_partial,避免把“部分完成”误判为硬失败。

8.2 Run 级评分:先平均尝试,再平均任务

compute_job_metrics() 读取每个 Trial 的 verifier/score.json,分数字段优先级是:

reward > overall > test_pass_rate > tests_passed/tests_total

聚合分两层:

task_reward = mean(attempt rewards of this task)
job_reward  = mean(task_reward across tasks)

当每个任务 attempts 相同时,它等价于 Trial 平均;当 attempts 不齐时,先按任务平均可以避免“尝试次数多的任务权重更大”。

pass_rate 则是满分 Trial 的比例:

full_pass = 1 if reward >= 1.0 else 0

因此 reward=0.8pass_rate=0 完全可能同时成立:前者衡量平均完成度,后者衡量完美解决比例。

评分语义与防膨胀机制

8.3 三个防分数膨胀机制

机制一:Missing task 补零。

如果传入 Manifest 的 selected_tasks,预期任务未产生任何 Trial 时会合成 <task>__never_ran build error,进入所有分母。崩溃的 shard 不会因为“没有结果文件”而从平均值中消失。

机制二:Build error 补零。

score.json 缺失、不可解析、没有可用分数或 test_status=build_error 的 Trial 都计 0,而不是跳过。

机制三:防测试分母收缩。

对纯 test-count score,同一任务的多次尝试先寻找最大 tests_total,再统一重算:

rebased_score = tests_passed / max(tests_total across attempts)

假设两次结果分别是 5/105/5,第二次不能因为漏掉五个失败测试就得到 1.0;两次都按最大分母 10 重算为 0.5。

只有纯计数分才重算。若 overall 是 LLM+Rule 混合分,tests_total 只是其中一个组件,强行 rebase 会破坏原公式,因此源码通过 llm_judge_component_scoreoverall != test_pass_rate 标记将其排除。

8.4 Patch 相似度为什么只做诊断

scorer.py 会计算:

  • file_hit_rate
  • diff_coverage(行归一化后的 fuzzy overlap);
  • diff_coverage_exact
  • Agent/Gold 修改文件数和新增行数。

overall = test_pass_rate,Patch 相似度不参与主分。因为同一个需求往往有多种正确实现,强迫 Agent 模仿 Gold Patch 会奖励“像参考答案”,而不是奖励行为正确。


九、实践验证:从零配置到可复现运行

9.1 环境准备

要求:

  • Python >= 3.12
  • uv
  • Docker Engine,使用 split-mount 时 API >= 1.48
  • 模型后端 URL 和 Key;
  • 足够的磁盘空间:四个压缩数据集约 707 MB,任务镜像与解包后的 Docker layer 会更大。
git clone https://github.com/Tencent/workbuddy-bench.git
cd workbuddy-bench

uv sync --group dev
cp .env.example .env

下载子集时脚本会校验 SHA-256:

./scripts/dataset/fetch-dataset.sh code
# 或:
./scripts/dataset/fetch-dataset.sh code web office sec

不要在 Host 上手动解包任务内的 environment/workspace.tar.gz;Task Dockerfile 会在容器构建时解压到 /workspace

9.2 定义模型

创建 configs/models/<provider>/<slug>.yaml

model:
  name: your-backend-model-id
  protocols: [openai]
  backend_url_env: MY_MODEL_BASE_URL
  backend_key_env: MY_MODEL_API_KEY
  context_window: 200000
  params:
    max_output_tokens: 32768
    temperature: 0.2
    extra_body:
      top_p: 0.95
      chat_template_kwargs:
        enable_thinking: true

然后在 .env 只写真实值:

MY_MODEL_BASE_URL=https://example.com/v1
MY_MODEL_API_KEY=...

配置文件保存的是环境变量名,不保存密钥。含 extra_body 时 Job 必须选择 local_proxy

9.3 定义 Job

创建 configs/jobs/my-code-eval.yaml

model: my-provider/my-model
harness: codebuddy-code/2.109.3
dataset: datasets/wb-bench-code-v1.0/tasks
harness_backend: local
model_connection: local_proxy

task_selection:
  mode: random
  count: 10
  seed: 42

orchestrator_override:
  n_concurrent_trials: 4

record_full_io: false

先检查 Manifest:

uv run ./scripts/run.sh --job my-code-eval --dry-run

Dry-run 不 staging 数据集、不启动 Proxy、不构建容器,只解析并打印 Manifest;这是检查 model route、protocol、context window、mount image 和 task selection 的最低成本方式。

9.4 构建 Harness mount 与运行

scripts/harness/build-harness-mounts.sh \
  --harness codebuddy-code/2.109.3

uv run ./scripts/run.sh --job my-code-eval

也可以允许入口自动构建缺失 Harness 镜像:

AUTO_BUILD_HARNESS_MOUNT=1 \
uv run ./scripts/run.sh --job my-code-eval

分片运行:

SHARDS=4 \
SHARD_CONCURRENCY=2 \
NO_FORCE_BUILD=1 \
uv run ./scripts/run.sh --job my-code-eval

此时理论最多有 4 × 2 = 8 个 Harbor Trial 并发;若 model YAML 的 max_concurrent 更小,Proxy 会按该后端进一步限流。

仅验证 Agent rollout、不运行 Verifier:

DISABLE_VERIFICATION=1 \
uv run ./scripts/run.sh --job my-code-eval

该模式不会产生可用于质量结论的完整 reward,适合调试 Harness 安装、Proxy 连接和工具权限。

9.5 读取结果

典型 Trial:

results/<job>/<run>/<task>__<trial-id>/
├── agent/
│   ├── cbc-output.txt 或 claude-output.txt
│   ├── trajectory.json
│   └── requests.jsonl       # 开启 full I/O 后
├── verifier/
│   ├── reward.json
│   └── score.json
├── config.json
└── result.json

Run 级指标:

uv run python -m workbuddy_bench.scorer.metrics \
  results/<job>/<run>

若是 task selection,传入本次 Manifest,确保未运行任务补零:

uv run python -m workbuddy_bench.scorer.metrics \
  results/<job>/<run> \
  --manifest scripts/logs/instances/<instance-id>/manifest.json \
  --json

9.6 添加新的 Harness

扩展点不是只加一个 Dockerfile,而是五步:

  1. 实现 BaseInstalledAgent 子类的 install()run(),最好实现 populate_context_post_run()
  2. configs/harnesses/<family>/_defaults.yaml 声明 name/import_path/protocol/mount
  3. 为每个版本新增 versions/<version>.yaml 和确定性 settings preset;
  4. runner/harness_adapters.py::HARNESS_ADAPTERS 注册 URL/Key env 和协议;
  5. 若 Manifest 需要展示最终动态配置,在 _build_harness_runtime_config() 增加该 Harness 的 sanitized audit builder。

只实现 Agent 类但不补 audit builder,运行仍可能成功,但 Manifest 只能显示 static fallback,审计完整性会下降。


十、实验评估:框架开销、调度平衡与指标正确性

10.1 实验环境与方法

本地环境:

项目
机器 Apple M2,16 GB RAM
系统 macOS 14.7.5 arm64
Python 3.14.2
workbuddy-bench b516950
Harbor v0.18.0,锁定提交 527d50d
计时方法 timeit.repeat(repeat=7),报告每次操作中位数

先执行:

uv sync --group dev
uv run python -m compileall -q src
uv run pytest -q

结果:

compileall: success
pytest: no tests ran in 0.03s

这说明当前公开快照的源码可导入、可编译,但仓库没有随源码交付 pytest 测试文件;pytest 返回码 5 代表“未收集到测试”,不能写成“测试全部通过”。项目大量注释提到内部测试名,但它们不在这个快照中,这是当前可验证性的一个局限。

10.2 纯框架微基准

以下用固定内存 fixture 测量 Python 控制面的单操作开销,不包含模型网络、Docker 构建、容器启动和任务测试时间:

操作 Fixture 中位耗时 吞吐
deep_merge 3 层嵌套配置 20.020 μs 49,951 ops/s
resolve_task_selection 从 1,000 题 seeded random 选 100 题 55.084 μs 18,154 ops/s
build_shards 260 题分到 8 shards 418.337 μs 2,390 ops/s
convert_request_a2o 21 条 message、30 个 tool schema 102.837 μs 9,724 ops/s
PassRateScoringPolicy.score 100 个 EvaluationItem 645.206 μs 1,550 ops/s

结论不能解读成“Proxy 每秒能跑 9,724 个真实请求”,因为真实链路受 JSON 编解码、FastAPI、SSE、网络和模型延迟支配。它只说明:对分钟级 Agent Trial 而言,配置合并、分片规划、协议对象转换和本地评分的 CPU 开销不是主要瓶颈。

10.3 分片平衡实验

构造 260 个任务,权重按 1,2,3 循环,使用源码 build_shards(tasks, 8),得到:

shard total weights: [65, 65, 65, 65, 65, 65, 65, 64]
shard task counts : [32, 32, 32, 33, 33, 33, 33, 32]

最大与最小累计权重只差 1,说明在这个规则分布 fixture 中 greedy 策略几乎完美平衡。真实任务耗时不一定与 easy/medium/hard 线性对应,所以它不是最优 makespan 保证;但作为无需历史运行数据的冷启动调度器,性价比很高。

10.4 指标防膨胀实验

构造三个预期任务:

  • Task A 两次尝试:5/10 与缩水后的 5/5
  • Task B 两次尝试:均为 10/10
  • Task C 未产生任何 Trial。

调用:

compute_job_metrics(
    run_dir,
    expected_tasks=["task-a", "task-b", "task-c"],
)

得到:

{
  "reward": 0.5,
  "pass_rate": 0.3333,
  "n_tasks": 3,
  "n_trials": 5,
  "missing_tasks": ["task-c"],
  "score_sources": {
    "reward": 3,
    "tests_passed_over_max_tests_total": 1,
    "missing_or_build_error": 1
  }
}

Task A 的第二次 5/5 被重算为 5/10=0.5;Task C 作为 never_ran 进入分母。于是:

Task A reward = mean(0.5, 0.5) = 0.5
Task B reward = mean(1.0, 1.0) = 1.0
Task C reward = 0.0

Job reward = mean(0.5, 1.0, 0.0) = 0.5
Pass rate = 1 个满分任务样本 / 3 个任务 = 0.3333

这个实验验证了两点:分母收缩无法抬高纯计数分,完全没跑的任务也不会从 Job 统计中消失。

10.5 Scorer 输出示例

对一个 synthetic patch 输入 tests_passed=7tests_total=8wall_time=42.31,实际输出:

{
  "test_pass_rate": 0.875,
  "test_status": "partial_pass",
  "overall": 0.875,
  "file_hit_rate": 1.0,
  "diff_coverage": 0.5,
  "diff_coverage_exact": 0.5,
  "agent_files_changed": 2,
  "agent_lines_added": 4,
  "gold_files_changed": 2,
  "gold_lines_added": 4,
  "tests_passed": 7,
  "tests_total": 8,
  "wall_time_sec": 42.31
}

file_hit_rate=1.0diff_coverage=0.5,说明 Agent 改对了文件,却只覆盖了一半参考新增行;主分仍严格等于测试通过率 0.875

10.6 真正的 Agent Benchmark 应报告哪些维度

全量运行后,不应只贴一个 reward。至少要分四组:

维度 指标 来源
正确性 reward、pass_rate、tests_passed/total、heldout pass score.jsonscorer.metrics
稳定性 各 attempt reward/pass_rate、方差、build/judge error 比例 n_attempts=3 的 Trial 集合
效率 wall time、input/output/cache tokens、cost、LLM calls score.jsontrajectory.json
诊断 file hit、diff coverage、tool calls、Proxy duration/upstream error score.jsonrequests.jsonl

吞吐量可以按:

completed trials / run wall-clock time

从 Job 时间和完成 Trial 数派生,但当前 scorer.metrics 不直接输出统一 throughput 字段。比较不同模型时还必须固定:

  • Harness family 与版本;
  • task selection 和 seed;
  • n_attempts
  • context window 与 compact 策略;
  • max turns;
  • tool deny preset;
  • shard/concurrency;
  • model params;
  • 是否启用 Proxy full I/O 和 LLM Judge。

否则“模型更快”可能只是 max turns 更小,“模型更准”可能只是工具权限更多。


十一、设计评价:核心优势、技术局限与演进方向

11.1 核心优势

1. 可复现性不是一句口号,而是多层实现。

固定 Harbor tag、版本化 Harness、只读 mount、实例级 dataset staging、config hash、Git provenance、task checksum、seeded selection 共同形成复现链。

2. 被测对象与基础设施边界清晰。

Task image、Harness image、Model backend、Proxy、Verifier 都能独立替换。换一个 Agent CLI 不要求改任务数据集,换一个数据集也不要求重写 Runner。

3. 失败被结构化归因。

Harness result error、Proxy upstream error、build error、judge error、missing task、partial reward 都有不同记录位置,不必把所有零分归咎于模型。

4. 评分有防作弊意识。

未覆盖 item 计 0、Missing task 补零、Build error 补零、纯计数分母重算、Patch 相似度只做诊断,这些都直接针对 Benchmark 常见的分数膨胀路径。

5. 控制面开销足够小。

本机微基准显示 260 题分片规划只需约 0.42 ms,100 item 评分约 0.65 ms。相比模型和容器运行,这些确定性检查几乎是“免费”的,值得保留。

11.2 当前局限

1. 公开源码快照没有测试套件。

pytest 未收集到任何测试。对于包含复杂协议转换、SSE 状态机、文件原子替换和分母重算的项目,仅靠注释和真实评测回归仍不够。

2. Judge engine 内部完全顺序。

这是安全而清晰的默认值,但 screenshot VLM、静态规则和只读 LLM rubric 可能具备并行空间。当前无法声明 Judge 依赖与资源预算。

3. 难度权重是粗粒度代理。

easy=1 / medium=2 / hard=3 不知道真实 Docker build time、模型轮数或 verifier time。任务难度与运行耗时相关但并不等价。

4. Shell 入口承担较多编排。

run.sh 同时处理配置、Proxy 生命周期、环境变量桥接、进程清理和 Harbor 调用。它实践上可靠,但跨平台性、单元测试和结构化错误处理弱于纯 Python CLI。

5. O2A 仍未实现。

Proxy 枚举了 O2A,但入口明确拒绝 OpenAI client → Anthropic backend 的协议不匹配。当前主要覆盖 Anthropic Harness 到 OpenAI backend 和同协议透传。

6. Full I/O 观测有数据治理成本。

JSONL 对调试极有价值,也可能保存完整代码、文档、reasoning 和工具参数。框架层尚未提供字段级脱敏、加密或 retention policy。

11.3 建议的演进路线

短期:补公开契约测试。

优先覆盖:

  • Manifest 与 Agent runtime config 一致性;
  • A2O request/stream round-trip;
  • route strict match;
  • mid-stream no-replay;
  • split-mount API negotiation;
  • missing task、shrunken denominator 与 composite score exemption。

中期:让调度使用历史耗时。

在保留 difficulty 冷启动权重的同时,从历史 Trial 学习:

estimated_cost =
  image_build_time
  + agent_wall_time
  + verifier_wall_time

再做 LPT 分配,并把 model backend max_concurrent 纳入全局资源规划。

中期:为 Judge 引入声明式 DAG。

给 Judge Spec 增加:

depends_on
read_only_workspace
resource_class
timeout
fatality

只并行互不依赖、只读证据的 Judge;涉及工作区修改的 Agent Judge 继续串行。

长期:形成可比较的效率前沿。

不仅按 reward 排名,而是报告:

quality × latency × token × cost × stability

以 Pareto frontier 展示不同模型/Harness 组合,避免用更多 token、更多 turns 和更高并发换来的小幅分数被误解为全面领先。


总结

WorkBuddy Bench 的源码给出了一套很务实的 Agent 评测观:

一个可信的 Benchmark,不只是把题交给 Agent 再运行测试;它必须同时控制执行环境、被测 CLI、模型连接、并发调度、错误归因和统计分母。

它最值得借鉴的不是某个类,而是几个组合起来的设计原则:

  1. 配置先解析成可审计 Manifest,再投影到执行器格式;
  2. 任务镜像与 Harness 镜像分离,被测对象只读挂载;
  3. 协议和采样参数差异压到 Proxy 边界,路由严格失败;
  4. 并发放在 Trial/Shard 层,评分内核保持确定性;
  5. 数据集通过 Contract + Plugin 注入策略,Engine 只持有生命周期;
  6. 总分与诊断分离,未运行、构建错误和缩水分母都不能从统计中消失。

对于准备自建 Coding Agent Eval 的团队,最小可行方案不应从“再写一个评分脚本”开始。更可靠的顺序是:

先固定实验声明
  → 再隔离任务与 Harness
  → 再建立可观测连接层
  → 再统一 Verdict 中间表示
  → 最后讨论如何聚合分数

模型能力会快速变化,但这条工程顺序相对稳定。WorkBuddy Bench 的价值,正在于把这套顺序落实成了可运行、可检查、也可继续演进的代码。

「真诚赞赏,手留余香」

爱折腾的工程师

真诚赞赏,手留余香

使用微信扫描二维码完成支付