数据截至 (上游 commit a675d6d61c41)
第 5 章 · 评测框架与 Universal Verifier
这章讲什么: 怎么判断一个 agent 到底把事办成了没有?这个问题比想象中难 —— 没有标准答案可对,只有一堆截图和一句最终回复。这章讲 Fara 的答案:一个九步的多模态 LLM 裁判。
5.1 为什么打分这件事是个真问题
先看清难点。传统 QA 评测有标准答案,字符串一比就完事。agent 评测没有:
| 难点 | 具体表现 |
|---|---|
| 没有唯一正确答案 | "找一家评分 4 分以上的川菜馆" —— 合格答案有几十个 |
| 只看最终回复不够 | agent 可能嘴上说订好了,实际没点提交 |
| 只看截图也不够 | 关键状态可能出现在某一帧,得先知道该看哪一帧 |
| 网站天天变 | 昨天能做的任务今天可能做不了 |
所以 Fara 的裁判要同时做两件事:看过程(rubric 逐条打分)和看结果(二元成败判定)。
5.2 评测框架骨架:两根轴
webeval 的抽象只有两个类:
Benchmark(题库) System(应试者)
webeval/src/webeval/benchmark.py webeval/src/webeval/basesystem.py
│ │
├─ download_dataset() ├─ get_answer(task) ──> 跑一条轨迹
├─ load_dataset() ──> examples └─ load_answer_from_disk() ──> 读已有轨迹
├─ evaluator(task, traj) ──> 分数
├─ compute_aggregate_metrics()
├─ exec_hash() ──┐
└─ eval_hash() ──┴──> 结果目录的路由键
两个 hash 方法是这个设计里最实用的一处:
| 方法 | 编码什么 | 作用 |
|---|---|---|
exec_hash() | 执行配置 | 不同执行参数的轨迹存到不同目录 |
eval_hash() | 打分配置 | 同一条轨迹用不同裁判配置打的分,存在不同文件 |
WebTailBench 的 eval_hash 长这样:mmrubric_0.8-5-3(webeval/src/webeval/benchmarks/webtailbench/webtailbench.py:450-455),编码了分数阈值、最大图数、关键点阈值。
为什么这个设计值钱: 跑一次 agent 很贵(几分钟 + 一堆 LLM 调用),但重新打分很便宜。分开之后,你可以跑一次轨迹、用五种裁判配置反复打分,而不用重跑。分数按 scores/<eval_hash>.json 存,已有就直接读(webeval/src/webeval/core.py:267-292)。
执行也是可续的
run_single_task(core.py:36-101)开头先尝试读盘上已有的答案,只有在"没有答案"或"答案标记为 aborted"时才重跑;重跑前会清空目录但保留 core.log(core.py:73-79),日志不能丢。
5.3 Universal Verifier:MMRubricAgent
这是整个评测体系的核心,3600 行(webeval/src/webeval/rubric_agent/mm_rubric_agent.py)。类文档写明它产出两个互相独立的信号(mm_rubric_agent.py:892-910):
| 信号 | 步骤 | 形态 | 含义 |
|---|---|---|---|
| PROCESS REWARD | Steps 0–7 | 连续分 → 过阈值转二元 | 中间步骤做对了多少 |
| OUTCOME REWARD | Step 8 | 二元 | 最终状态对不对 |
对应 README 里 WebTailBench 报的两列 Process / Outcome(README.md:161-182)。分开报是有道理的:一个 agent 可能步步正确但最后一步点错,也可能瞎点乱撞碰巧到了正确页面。
九步流水线
怎么读这张图:从上到下顺序执行;右边标注这一步用哪个模型(分工表见 §5.4)。
Step -1 分类关键节点(这任务属于哪种 critical point) gpt5
│ 结果会渗进后面每一个提示词
v
Step 0a 生成 rubric(把任务拆成可打分的条目) gpt5
│ 数据集自带 precomputed_rubric 就跳过
v
Step 0c 只看动作日志先打一遍分 o4-mini
│
v
┌────────────────── 多模态部分(以下都要看截图) ──────────────────┐
│ Step 1 加载截图 │
│ Step 2 每张截图对每条 rubric 打相关性分 gpt5 │
│ Step 3 按条目把截图分组(每条只看最相关的几张) │
│ Step 4 证据分析:这几张图能否证明这条做到了 gpt5 │
│ Step 4.5 条件性条目消歧(条件到底成不成立) │
│ Step 5 现实性检查:这条 rubric 在真实网站上合理吗 gpt5 │
│ Step 6-7 带图重新打分 + 冗余动作惩罚,跑 N 次取中位数 gpt5 │
│ Step 8 结果判定 + critical point 越界检查,并行 gpt5 │
└────────────────────────────────────────────────────────────────┘
实现在 _generate_reply(mm_rubric_agent.py:3167 起),关键节点分类在开头就跑完(mm_rubric_agent.py:3192-3209)。
为什么要分这么多步
每一步都在解决一个具体的失败模式:
| 步骤 | 不做会怎样 |
|---|---|
| Step 0c 先看动作日志 | 直接上多模态又贵又容易被无关画面带偏;先有个文本基线 |
| Step 2–3 相关性分组 | 100 张截图全塞给裁判,它会淹死;每条只需要最相关的几张 |
| Step 4.5 条件消歧 | rubric 里"如果需要登录则……"这类条目,得先判断条件成不成立才能计入总分 |
| Step 5 现实性检查 | rubric 是 LLM 凭任务描述编的,可能要求网站上根本不存在的东西 |
| Step 6–7 中位数投票 | 单次 LLM 打分方差大 |
| Step 7 冗余惩罚 | 绕了 50 步碰巧做对,不该和 5 步做对拿一样的分 |
关于"条件性条目"的处理
这是个容易忽略但设计得很干净的地方。rubric 条目可以带 condition 字段;_compute_final_scores(mm_rubric_agent.py:2951-2972)在汇总时:
# 示意,非源码
for c in items:
if "condition" in c:
if c.get("is_condition_met", False):
total_max += c["max_points"] # 条件成立才计入分母
total_earned += c.get(earned_field, 0)
else:
total_max += c["max_points"]
total_earned += c.get(earned_field, 0)
重点看:条件不成立的条目,分子分母同时不计。而不是给 0 分 —— 那会冤枉 agent。
投票机制:两种
| 场景 | 投票方式 | 为什么 |
|---|---|---|
| Steps 6–7 过程分 | 跑 N 个实例,取中位数那个实例的完整结果 | 分数是连续的,中位数抗离群 |
| Step 8 结果判定 | 跑 N 个实例,多数票 | 结果是二元的,只能数票 |
majority_vote_instances 必须是正奇数,构造时就断言(mm_rubric_agent.py:929-932)—— 偶数会出现平票。
Step 8 的多数票还有一层严谨:先滤掉投 None 的实例,有效票数不足半数以上就整体判 None(而不是硬凑一个结论,mm_rubric_agent.py:3543-3548)。
critical point 越界是单独一次调用
Step 8 里,"结果对不对"跑 N 次投票,"有没有越过 critical point"只跑 1 次。源码注释解释得很好(mm_rubric_agent.py:3499-3503):这是一个有明确依据的 确定性判断,多数投票买不到多少东西,而且这次调用很便宜。
这体现了一个成本意识:不是所有 LLM 判断都值得投票,分歧大的才值得。
5.4 两个模型:谁在干重活
MMRubricAgentConfig 收两个客户端实例(mm_rubric_agent.py:793-800)。分工不用猜 —— 模块文档里有一张现成的对照表(mm_rubric_agent.py:630-655):
| 客户端 | 负责哪些步骤 | 这些活的性质 |
|---|---|---|
gpt5_client(gpt-5.2) | Step 0a 生成 rubric、0b 依赖检查、2 相关性打分、4 证据分析、5 现实性检查、6 整份重打分(默认路径)、7 副作用惩罚、8 结果判定、9a 失败点分析 | 全部多模态打分,外加需要世界知识的判断 |
o4mini_client(o4-mini) | Step 0c 纯文本基线打分、6 逐条重打分(legacy 路径,默认关)、9b 轨迹辅助的任务校验、10 任务合法性校验 | 不用看图就能做的边角活 |
调用点数量印证这张表:源码里 self._gpt5_client 出现 13 处,self._o4mini_client 只有 2 处(Step 0c 与那条默认关闭的 legacy 路径)。
结论和直觉是反的
一般人的预期是"贵模型只在需要判断力的地方点一两下,便宜模型干重 复劳动"。这里恰恰相反:量最大、最重复的那部分 —— 每张截图 × 每条 rubric 的相关性打分和证据分析 —— 全部走 gpt5,便宜模型只承担一次纯文本基线和几个收尾校验。
原因不难理解:o4-mini 在这条流水线上不承担多模态,能分给它的只有"不看图也能做的判断"。所以省钱的手段不是换模型,而是减少送进去的图:Step 2–3 的相关性筛选把每条 rubric 要看的截图从"全部"压到 max_images_per_criterion(默认 5,mm_rubric_agent.py:803);grounding_image_quality 则是另一个可调旋钮(见 §5.8)。省的是图的张数和字节数,不是模型单价。
还有一个历史包袱:配置其实收得下第三个客户端 model_client,但它已经不在任何流水线步骤里使用,只为向后兼容留着(mm_rubric_agent.py:653-655)。
客户端所有权
_ensure_clients(mm_rubric_agent.py:934-964)写得很仔细:自己从 *_client_config 建的客户端标记 _owns_*,close() 时才关;调用方传进来的实例一律不动。重试流程会反复 initialize → run → close,所以它被设计成幂等可重入。
5.5 WebTailBench 怎么用这个裁判
README 说 WebTailBench 有 609 个任务、11 个真实场景类 别(README.md:186)。代码侧的接入在 WebTailBenchBenchmark(webeval/src/webeval/benchmarks/webtailbench/webtailbench.py)。
打分前的三道硬门槛
evaluator(webtailbench.py:396-441)在花钱调 LLM 之前,先用三条便宜的规则筛掉明显无效的轨迹:
轨迹进来
│
├─ 一个动作都没有? ──> 0 分
├─ 最后一个动作不是 terminate? ──> 0 分 ← 这条补上了主循环的状态缺口
├─ |动作数 - 截图数| ≥ 2? ──> 0 分 ← 数据完整性检查
v
才调 MMRubricAgent
第二条尤其重要:第 1 章讲过,步数耗尽的轨迹也被标成 COMPLETE,分不出来。这里靠"最后一个动作是不是 terminate"把它筛掉。评测侧在补运行时的账。
三种成功口径
打完分之后,success_criterion 决定怎么算成功(webtailbench.py:431-441):
| 口径 | 判定 | 默认 |
|---|---|---|
outcome | 结果分为真 | ✓ |
process | rubric 分过阈值(默认 0.8) | |
both | 两个都要 |
打完分会写回轨迹
评完之后,rubric 会被写回轨迹目录的 task_data.json 的 precomputed_rubric 字段(webtailbench.py:310-345),下次评同一条轨迹就能跳过 Step 0a 的 rubric 生成。中间过程(intermediate_mm_rubric_steps)也会存下来,方便事后审查裁判自己的推理链。
5.6 轨迹从哪来:两种格式并存
这里有个历史包袱要说清楚。第 4 章讲的 DataPoint 是 Fara-1.5 运行时的格式。而 webeval 读的是另一种格式:web_surfer.log(一行一个 JSON 日志事件)+ <task_id>_final_answer.json + 编号截图(webeval/README.md 的 "Trajectory format" 一节)。
Trajectory 类(webeval/src/webeval/trajectory.py:121-189)负责读它,并且能吃两种日志形态:
读 web_surfer.log 的每一行
│
├─ 有事件带 action 字段? ──> 直接用(结构化的 WebSurferEvent)
│
└─ 全是纯文本事件? ──> parse_text_based_event 从
"Thought #N: ... Action #N: executing tool 'x' with arguments {...}"
这种字符串里反解出动作和参数
第二条路是为更老的轨迹准备的 —— 那时候 agent 只用 logger.debug(...) 打纯字符串。这种"从日志文本里反解结构"的兼容层不优雅,但对一个要长期比对历史结果的评测系统是必要的。
最后,create_datapoint(webeval/src/webeval/benchmarks/webtailbench/shared_data_adapter.py)把 Trajectory 转成裁判要的 DataPoint。
5.7 诚实的现状:webeval 落后于 Fara-1.5
这一点必须说清楚,因为它影响你怎么用这部分代码。
根目录 README 明说(README.md:329):webeval 这条流水线产出的是上一代 Fara-7B 的数字,正在为 Fara-1.5 的评测栈更新。docs/eval_reproducibility.md:3-7 重复了同样的免责。
代码里能看到具体的滞后痕迹:
| 痕迹 | 位置 |
|---|---|
webeval/README.md 引用的路径是 src/fara/fara_agent.py | 该文件现在在 src/fara/fara_7b/fara_agent.py |
WebTailBench 用 FARA_ACTION_DEFINITIONS 作为动作定义 | 那是 Fara-7B 的 11 个动作(src/fara/fara_7b/fara_agent.py:32),不含 1.5 新增的 7 个 |
环境依赖钉在 vllm==0.10.0 + torch==2.7.1 | 根 pyproject.toml:53 的 Fara-1.5 栈是 vllm==0.19.1 |
结论: 想理解 Fara-1.5 的 agent,读 §1–4;想理解"怎么给 CUA 轨迹打分"这个方法论,读这一章 —— 裁判本身的设计是可以直接迁移的,和被评的是哪代模型无关。
5.8 关键细节与坑
打分失败不会让整个评测崩。 evaluate_by_rubric_evaluator 重试 3 次,全失败就返回一个记录了错误的 0 分结果(webtailbench.py:376-380),整批任务继续跑。
但 Step 0c 打分失败会硬抛。 动作日志打分连试 max_iters(默认 5)次仍失败时,代码选择 raise 而不是带着一份空 rubric 往下走,异常信息把理由写清楚了(mm_rubric_agent.py:3313-3320):畸形的空 rubric 会让下游的 check_feasibility / _generate_retry_feedback 崩在别处、报出误导性的错误信息,不如在这里暴露真因。
每一步的中间产物都被留下来了。 intermediate 字典累积了九步的全部中间结果(相关性分、证据分析、消歧理由、每个投票实例的分数),最后整个塞进输出。裁判本身是个复杂 LLM 系统,不留下推理链根本没法 debug。
JPEG 质量默认 95。 送给裁判的截图默认近乎无损(grounding_image_quality,mm_rubric_agent.py:814-818),注释说是为了保住亚像素级的 UI 细节,并点名这是留给成本消融的旋钮 —— 配合 §5.4 说的"省图不省单价",这里是第二个可调项。
用户模拟器开关会改变 rubric 的评判标准。 user_simulator_enabled=False(默认)时,rubric 不能奖励"提问"、必须接受"在 critical point 停下"这种行为;为 True 时反过来要奖励提问(mm_rubric_agent.py:820-830)。这是因为同一个行为在有/没有用户模拟器的两种设定下,对错是相反的。
评测框架本身写得比较粗。 webeval/src/webeval/core.py 用模块级全局变量传多进程状态(_GLOBAL_SYSTEM / _GLOBAL_BENCHMARK),代码里自带 # TODO: remove global object(core.py:19)。日志 basicConfig 强制写死到 stdout.txt(core.py:20-26),对当库用的人不友好。
5.9 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 题库抽象 | webeval/src/webeval/benchmark.py | Benchmark、exec_hash、eval_hash |
| 应试者抽象 | webeval/src/webeval/basesystem.py | BaseSystem |
| 执行/评测编排 | webeval/src/webeval/core.py | run_single_task、evaluate_single_example |
| 轨迹读取 | webeval/src/webeval/trajectory.py | Trajectory、parse_text_based_event、FinalAnswer |
| Universal Verifier | webeval/src/webeval/rubric_agent/mm_rubric_agent.py | MMRubricAgent |
| 九步流水线 | webeval/src/webeval/rubric_agent/mm_rubric_agent.py | _generate_reply |
| 裁判配置 | webeval/src/webeval/rubric_agent/mm_rubric_agent.py | MMRubricAgentConfig |
| 双客户端构建 | webeval/src/webeval/rubric_agent/mm_rubric_agent.py | _ensure_clients、_build_client_from_endpoint_config |
| 条件性条目汇总 | webeval/src/webeval/rubric_agent/mm_rubric_agent.py | _compute_final_scores |
| 中位数投票 | webeval/src/webeval/rubric_agent/mm_rubric_agent.py | _run_steps_6_7_single_instance、_select_median_instance |
| 结果判定 | webeval/src/webeval/rubric_agent/mm_rubric_agent.py | _outcome_verification、_check_cp_violation |
| CP 分类 | webeval/src/webeval/rubric_agent/critical_point_classifier.py | classify_critical_point_for_rubric |
| CP 类型定义 | webeval/src/webeval/rubric_agent/critical_point_types.yaml | (YAML definition / types) |
| 裁判提示词 | webeval/src/webeval/rubric_agent/prompts.py | ACTION_ONLY_RUBRIC_SCORER_PROMPT 等 |
| 失败分析 / 任务校验 | webeval/src/webeval/rubric_agent/verifier_agent.py | VerifierAgent.verify |
| WebTailBench | webeval/src/webeval/benchmarks/webtailbench/webtailbench.py | WebTailBenchBenchmark.evaluator |
| 轨迹→DataPoint | webeval/src/webeval/benchmarks/webtailbench/shared_data_adapter.py | create_datapoint |
| 复现 CLI | webeval/scripts/webtailbench.py | (脚本入口) |
| 独立重打分 | webeval/scripts/verify_trajectories.py | (脚本入口) |