数据截至 (上游 commit a675d6d61c41)
第 6 章 · 巧妙之处、边界与横向对比
这章讲什么: 前五章把机器拆完了。这章只回答一个问题 —— 读完这个项目,你应该带走什么。
6.1 巧妙之处(可 借鉴的技术)
① 把提示词当接口锁死,用测试守着
妙在哪: 对一个在特定提示词上做过 SFT 的模型,提示词改一个字符就可能掉点。但提示词是散文,天然容易被"顺手优化"。Fara 的做法是写一个测试,从三个源常量重新拼出完整提示词,和运行时产出做字节级相等断言(tests/test_fara15.py:85-114)。
怎么抄: 任何依赖精确提示词的系统,都应该有这么一个测试。它把"别乱改"这个口头约定变成了 CI 里的红灯。
② 固定归一坐标空间,把换算留在运行时
妙在哪: 上一代 Fara-7B 告诉模型的分辨率是截图缩放后的实际尺寸 —— 它把 smart_resize 算出来的 resized_width/height 直接填进工具配置(src/fara/fara_7b/_prompts.py:197-203),视口一改模型就要重新适配。Fara-1.5 把它钉成固定的 1000×1000(src/fara/agents/coord_spaces.py:15),换算全部交给运行时 的 _proc(src/fara/agents/fara/fara15_agent.py:495-503)。
怎么抄: 模型输出的任何"物理量",都应该在一个和部署环境解耦的抽象单位里表达。一个常量换来的是模型和环境的完全解耦。
③ 动作白名单从渲染后的提示词里反向提取
妙在哪: extract_allowed_actions(fara15_agent.py:61-66)用正则从已经拼好的系统提示词文本里抠出 "enum": [...],而不是直接读 schema 常量。
这样一来,"模型被告知能用什么"和"运行时允许执行什么"物理上是同一份数据,结构上不可能漂移。改了 schema 忘了改校验列表这种经典 bug 被根除了。
怎么抄: 当两个地方必须保持一致时,让其中一个从另一个派生,而不是靠人维护两份。
④ 把不可见的事件伪造成可见的画面
这是 Fara 里最有"设计品味"的一条,而且出现了三次:
| 场景 | 做法 | 位置 |
|---|---|---|
| Ctrl+F 查找 | 浏览器原生查找栏够 不着 → 在页内 JS 画一个假的,自动聚焦 | src/fara/environments/playwright/find_overlay.js |
| 文件下载完成 | 下载是不可见事件 → 跳转到一个 data URL 页面显示"已保存到 X" | playwright_controller.py:412-417 |
| 滚动位置 | 截图看不出"下面还有多少" → 文字化成 "scrolled to 34% of page" | environment.py:677-692 |
统一的思路: agent 的世界只有截图。任何它需要知道但截图里没有的信息,要么画进截图里,要么写进文字观察里 —— 而不是新增一个它得学会调用的 API。
怎么抄: 给感知受限的 agent 加能力时,优先扩展它已有的感知通道,而不是扩展它的动作空间。前者不需要模型重新学习。
⑤ 三级解析容错,最后一级是"体面地失败"
模型输出解析的三级降级(fara15_agent.py:440-467):
json.loads ──失败──> ast.literal_eval ──失败──> 按配置二选一
(标准 JSON) (吃单引号等变体) ├─ 抛异常
└─ 当成 terminate,
原文当答案
妙在第三级。 批量评测时,一条轨迹解析失败不该炸掉整个进程。把它转成"一次失败但格式合法的终止",整批任务能继续跑,这条也会被如实记成失败。
⑥ 便宜的探测放在昂贵的恢复前面
页面恢复的第一步不是 reload,而是 page.evaluate("1")(playwright_controller.py:111-115)。一次几毫秒的表达式求值就能分辨"页面真死"和"刚才那个错误是假警报",避免了没必要的整页重载。
怎么抄: 任何自动恢复流程,第一步都应该是最便宜的状态确认。
⑦ 执行与打分彻底分离
exec_hash() / eval_hash() 双 hash 路由(webeval/src/webeval/benchmark.py:131-143)让"跑一次轨迹、用 N 种裁判配置反复打 分"成为默认能力,而不是需要额外搭脚手架的特殊操作。
怎么抄: 昂贵不可复现的步骤(跑真实网站)和便宜可复现的步骤(打分)之间,应该有一个持久化的边界。
⑧ 不是所有 LLM 判断都值得投票
裁判的 Step 8 里,"结果对不对"跑 N 次多数投票,"有没有越界"只跑 1 次。源码注释直说:后者是有明确依据的确定性判断,投票买不到多少东西(webeval/src/webeval/rubric_agent/mm_rubric_agent.py:3501-3505)。
怎么抄: self-consistency 不是免费的。只在模型确实容易分歧的判断上用。
6.2 边界与局限(诚实清单)
刻意不做的
| 不做什么 | 依据 |
|---|---|
| 多标签页 | single_tab_mode 默认 True,新页接管后关掉旧页;动作空间里没有"切换标签页" |
| 浏览器之外的桌面 | computer_use_mode 只接受 fara_next_browser,windows 模式直接 NotImplementedError(_prompts.py:268-272) |
| 一步多动作 | 每轮只解析一个 <tool_call>,没有并行工具调用 |
| 可访问性树 / DOM 定位 | 这是整个项目的立身之本 —— 全靠视觉 |
| 沙箱 | README 明确建议在沙箱环境里跑、要盯着执行(README.md:225),沙箱本身要靠 Magentic-UI 提供 |
能力缺口
跨进程续跑不成立。 Agent.save_state / load_state 钩子在基类里(src/fara/core/agent.py:144-164),Fara15Agent 没有覆盖。磁盘上有完整事件流,但重建不出模型的 chat_history。所以"停下来问用户"只能在同一个进程内完成。
步数耗尽被标成 COMPLETE。 SolverStatus.MAX_ROUNDS 存在但从未被赋值(fara15_agent.py:349-352)。下游只能靠"最后一个动作是不是 terminate"来区分,评测侧确实这么补了(webtailbench.py:406-413),但任何直接读轨迹状态的人都会被误导。
facts 只写不读。 pause_and_memorize_fact 把事实存进 _state.facts(fara15_agent.py:829-832),但没有任何地方把它拼回提示词。记忆实际是靠观察文本留在对话历史里生效的 —— 也就是说,一旦这条观察被截图裁剪逻辑挤出上下文,这个"记忆"就没了。
save_screenshots 配置项无效。 声明了、CLI 赋值了、从未被读(见 §1.8)。截图实际只看 output_dir。
get_visible_text 必然抛错。 调 WebSurfer.getVisibleText(),而页内脚本的全局名是 MultimodalWebSurfer、且没导出这个函数(见 §3.7)。死代码,但埋着雷。
结构性风险
视觉定位没有兜底。 模型报的坐标错了(点在按钮边缘、页面在截图后发生了重排),没有任何验证机制。对比之下,DOM 系的 agent 至少能报"选择器没匹配到"。这是纯视觉路线的固有代价。
时序全靠固定 sleep。 每个非停机动作之后统一调一次 env.wait_for_load()(fara15_agent.py:724-726),它在等到 load state 之后再无条件睡 extra_sleep_time(environment.py:641-642;这个字段默认 3.0 秒,定义在 BrowserEnvironmentConfig,src/fara/environments/computer.py:157)。同类固定等待还有两处:弹窗捕获等 1 秒(playwright_controller.py:513)、验证码解完再等 3 秒(environment.py:279)。慢网站会截到加载中的图,快网站白等。
两套 agent 实现并存。 src/fara/agents/fara/(1.5)和 src/fara/fara_7b/(7B)是两份平行代码,各有一套 message 类型、prompt 构造、控制器。共享的只有 qwen_helpers/。维护成本翻倍。
webeval 落后于运行时。 见 §5.7,README 自己承认了。
6.3 横向对比
同为浏览器 agent:感知路线的分野
| 维度 | Fara | browser-use | Stagehand |
|---|---|---|---|
| 感知 | 纯截图 | DOM 树 + 可交互元素索引 | DOM + 视觉辅助 |
| 定位方式 | 模型直接报像素坐标 | 模型选元素编号 | 自然语言 → 选择器 |
| 对模型的要求 | 必须是视觉模型,且要专门训过定位 | 通用模型即可 | 通用模型即可 |
| 定位失败表现 | 静默点错地方 | 报"索引不存在" | 报"选择器没匹配" |
| token 成本 | 图片 token(固定) | DOM 文本(随页面复杂度爆炸) | 中间 |
| 换视口/换设备 | 归一坐标,零成本 | 不受影响 | 不受影响 |
取舍一句话: Fara 用"必须专门训模型"换来"不受页面结构复杂度影响";DOM 系用"页面越复杂上下文越贵"换来"任何通用模型都能用"。
同为 computer-use:范围的分野
| 维度 | Fara | OSWorld |
|---|---|---|
| 操作对象 | 只有浏览器 | 整个 Linux 桌面(含终端、文件管理器、办公软件) |
| 环境实现 | Playwright 直连 | 虚拟机 + 截图/输入注入 |
| 定位 | 归一坐标 | 依实现而定 |
| 定位是仓库重点吗 | 是,主体就是运行时 | 否,主体是任务集与验证脚本 |
Fara 的动作空间里 visit_url / history_back / web_search 这类浏览器专属动作占了很大比重 —— 这不是简化版的桌面 agent,是为浏览器专门设计的动作空间。
同为 agent 评测:裁判形态的分野
| 维度 | Fara / webeval | inspect-ai | openai-evals |
|---|---|---|---|
| 打分对象 | 多模态轨迹(截图序列) | 文本 TaskState | 文本补全 |
| 裁判复杂度 | 九步流水线、双模型、多数投票 | 可组合的 scorer | 模板化的 model-graded eval |
| 输出信号 | 过程分 + 结果分两个独立信号 | 单一 score(可自定义) | 单一 score |
| 打分可复跑 | 双 hash 路由,跑一次评多次 | 有 log 复用 | 有缓存 |
Fara 独有的那一条是过程/结果分离。 文本任务里"过程"没什么好评的, 但对 agent 来说"步步正确但最后一步点错"和"瞎点碰对了"是完全不同的两种失败/成功,必须分开报。
代际对比:Fara-7B → Fara-1.5
| 维度 | Fara-7B(src/fara/fara_7b/) | Fara-1.5(src/fara/agents/fara/) |
|---|---|---|
| 动作数 | 11 | 18 |
| 坐标空间 | 随截图尺寸浮动 | 固定 1000×1000 |
| 停下来问用户 | 不支持(没有 ask_user_question) | 支持,且写进了提示词 |
| critical points | 只在通用 fn-call 模板里有一段泛泛的话(qwen_helpers/fncall_prompt.py:145-160) | 三 Case 结构化定义,是提示词第二段 |
terminate 参数 | status: success|failure | answer: <文本> |
| 读页面回答问题 | 无 | 有(read_page_answer_question) |
| 轨迹记录 | 自己管截图和日志 | 统一走 RunContext / DataPoint |
| 环境抽象 | 直接持有 BrowserBB | 走 Environment 抽象基类 |
最有信息量的一行是**「停下来问用户」**:上一代根本没有这个动作,所以"critical point"在 7B 上只能表现为"停下不做";1.5 代把它升级成了"停下来问",这才让 README 里说的"多轮 rollout + 用户模拟器训练"(README.md:135、README.md:143)有落点。
6.4 全局代码地图
按"我想干什么"组织,便于直接跳源码。
想看主流程
| 我想…… | 打开 | 找 |
|---|---|---|
| 看 CLI 怎么起 | src/fara/run_fara.py | main、run_fara15_agent |
| 看一步怎么走完 | src/fara/agents/fara/fara15_agent.py | Fara15Agent.run |
| 看提示词怎么拼 | src/fara/agents/fara/_prompts.py | get_computer_use_system_prompt |
| 看动作怎么执行 | src/fara/agents/fara/fara15_agent.py | _dispatch_action |
| 看动作怎么落到浏览器 | src/fara/environments/playwright/environment.py | PlaywrightEnvironment |
想改东西
| 我想…… | 打开 | 注意 |
|---|---|---|
| 加一个动作 | _prompts.py 的 FaraBrowserComputerUse.parameters + fara15_agent.py 的 _dispatch_action | 模型没训过新动作,加了也不会用 |
| 换视 口尺寸 | run_fara.py 的 PlaywrightEnvironment(...) 参数 | 归一坐标会自动跟着换算 |
| 改上下文里保留几张图 | Fara15AgentConfig.max_n_images | 默认 3 |
| 开 token 预算保险丝 | Fara15AgentConfig.image_budget_token_cap | 默认 0 = 关闭 |
| 让 agent 别停下来问 | Fara15AgentConfig.auto_user_reply=True | 只适合评测,会注入假授权 |
| 让解析失败不炸 | Fara15AgentConfig.terminate_on_parse_error=True | 批量跑时应该开 |
| 调动作后的固定等待 | BrowserEnvironmentConfig.extra_sleep_time(computer.py:157) | 默认 3.0 秒;调小会更容易截到加载中的画面 |
想读数据
| 我想…… | 打开 | 找 |
|---|---|---|
| 理解轨迹 JSON | src/fara/core/data_point.py | DataPoint、SolverLog |
| 把事件流还原成步骤 | src/fara/core/data_point.py | SolverLog.steps、get_step_summaries |
| 读磁盘上的轨迹 | src/fara/core/data_point_io.py | DataPointReader.read |
想做评测
| 我想…… | 打开 | 找 |
|---|---|---|
| 理解打分流程 | webeval/src/webeval/rubric_agent/mm_rubric_agent.py | _generate_reply |
| 接一个新 benchmark | webeval/src/webeval/benchmark.py | Benchmark |
| 复现 WebTailBench | webeval/scripts/webtailbench.py | (脚本) |
| 只重新打分 | webeval/scripts/verify_trajectories.py | (脚本) |
| 看复现说明 | docs/eval_reproducibility.md | —— |
想验证理解
| 我想…… | 打开 | 找 |
|---|---|---|
| 确认提示词长什么样 | tests/test_fara15.py | test_system_prompt_byte_identical_to_training |
| 确认坐标换算 | tests/test_fara15.py | test_proc_coords_scales_display_space_to_viewport |
| 确认动作全集 | tests/test_fara15.py | BROWSER_ACTIONS |
| 看一条真实轨迹 | webeval/data/example_trajectory/ | 一个完整的 AllTrails 任务 |
6.5 一页纸总结
Fara 是什么: 一个纯视觉的浏览器 computer-use agent 运行时,加一套给这类 agent 打分的评测框架。
它赌的是什么: 赌"小模型 + 专门训练的视觉定位能力"能打赢"大模型 + DOM 文本"。README 报的数字(9B 在 Online-Mind2Web 上 63.4%,README.md:178)是这个赌注的成绩单。
代码里最值得学的: 不是 agent 循环本身(那部分很朴素),而是围绕"感知受限的 agent"做的一系列观察设计 —— 归一坐标、伪造的查找栏、文字化的滚动位置、data URL 形式的下载回执。这些设计的共同点是:不给 agent 加需要学习的新能力,而是把世界改造成它已经看得懂的样子。
用之前要知道的: 这是 research preview(README.md:225),要沙箱、要盯着、别碰敏感数据;webeval 那半边还停在上一代。