跳到主要内容

数据截至 (上游 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 REWARDSteps 0–7连续分 → 过阈值转二元中间步骤做对了多少
OUTCOME REWARDStep 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结果分为真
processrubric 分过阈值(默认 0.8)
both两个都要

打完分会写回轨迹

评完之后,rubric 会被写回轨迹目录的 task_data.jsonprecomputed_rubric 字段(webtailbench.py:310-345),下次评同一条轨迹就能跳过 Step 0a 的 rubric 生成。中间过程(intermediate_mm_rubric_steps)也会存下来,方便事后审查裁判自己的推理链。


5.6 轨迹从哪来:两种格式并存

这里有个历史包袱要说清楚。第 4 章讲的 DataPointFara-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.1pyproject.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.pyBenchmarkexec_hasheval_hash
应试者抽象webeval/src/webeval/basesystem.pyBaseSystem
执行/评测编排webeval/src/webeval/core.pyrun_single_taskevaluate_single_example
轨迹读取webeval/src/webeval/trajectory.pyTrajectoryparse_text_based_eventFinalAnswer
Universal Verifierwebeval/src/webeval/rubric_agent/mm_rubric_agent.pyMMRubricAgent
九步流水线webeval/src/webeval/rubric_agent/mm_rubric_agent.py_generate_reply
裁判配置webeval/src/webeval/rubric_agent/mm_rubric_agent.pyMMRubricAgentConfig
双客户端构建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.pyclassify_critical_point_for_rubric
CP 类型定义webeval/src/webeval/rubric_agent/critical_point_types.yaml(YAML definition / types)
裁判提示词webeval/src/webeval/rubric_agent/prompts.pyACTION_ONLY_RUBRIC_SCORER_PROMPT
失败分析 / 任务校验webeval/src/webeval/rubric_agent/verifier_agent.pyVerifierAgent.verify
WebTailBenchwebeval/src/webeval/benchmarks/webtailbench/webtailbench.pyWebTailBenchBenchmark.evaluator
轨迹→DataPointwebeval/src/webeval/benchmarks/webtailbench/shared_data_adapter.pycreate_datapoint
复现 CLIwebeval/scripts/webtailbench.py(脚本入口)
独立重打分webeval/scripts/verify_trajectories.py(脚本入口)