跳到主要内容

数据截至 (上游 commit a675d6d61c41)

第 1 章 · 主循环:一步是怎么走完的

这章讲什么:Fara15Agent.run() 这个 150 行的循环体拆开,一步之内的七个阶段各干什么,以及它凭什么决定停下来。


1.1 先看整体形状

主循环在 src/fara/agents/fara/fara15_agent.py:200-354。剥掉细节,骨架只有这么点:

# 示意,非源码
for step in range(start_step + 1, max_rounds + 1):
save_screenshot(f"screenshot_{step}_pre.png") # 动作前的画面
action = call_model(screenshot, history) # 模型给出唯一一个动作
is_stop, observation = execute(action) # 动作落到浏览器
save_screenshot(f"screenshot_{step}_post.png") # 动作后的画面
run_context.checkpoint() # 整条轨迹写盘
if action == "ask_user_question": return # 停机①:交还给用户
if is_stop: break # 停机②:任务完成
# 停机③:for 循环自然结束 = 步数用完

重点看两件事:每步只执行一个动作,以及每步都存两张截图(pre / post)。后者不是为了调试好看 —— 第 5 章的打分器要靠这些帧来判断"这一步到底有没有产生预期效果"。


1.2 一步的七个阶段

怎么读这张图:从上到下是时间顺序;右侧是对应的源码位置。

step N 开始

├─ (a) 验证码闸门 ─────────────── fara15_agent.py:241-261
│ 挂起等 captcha 解完;超时按策略降级

├─ (b) 存 pre 截图 + 取当前 URL ── fara15_agent.py:263-267

├─ (c) 拼提示词 → 调模型 → 解析 ── fara15_agent.py:269-284
│ 得到 {action, coordinate, thoughts, ...}

├─ (d) 记录动作到轨迹 ─────────── fara15_agent.py:286-295

├─ (e) 执行动作 ──────────────── fara15_agent.py:297-302
│ 坐标换算 → env.<method>() → 生成一句文字观察

├─ (f) 存 post 截图 + 落盘 ────── fara15_agent.py:304-328

└─ (g) 判停 ─────────────────── fara15_agent.py:330-346
ask_user_question ─> return(WAITING_FOR_USER)
is_stop_action ─> break (COMPLETE)
其它 ─> step N+1

下面挑四个有内容的阶段展开。


1.3 (a) 验证码闸门:一个会自己关掉的开关

要解决的小问题

跑在云端浏览器(BrowserBase)上时,平台会自动帮你解验证码。解的过程中页面是半死的,这时候截屏喂给模型只会让它做出蠢动作。所以每步开头得先确认"没在解验证码"。

思路

环境层维护一个 asyncio.Event,验证码开始解就 clear()、解完就 set()(src/fara/environments/playwright/environment.py:267-288)。agent 每步开头等这个事件。

关键是降级策略:等超时了怎么办?硬等下去整个 run 就废了。代码给了三档(src/fara/agents/fara/fara15_agent.py:241-261):

配置行为
raise_on_captcha_timeout=True(默认)第一次超时直接抛异常,这条轨迹作废
raise_on_captcha_timeout=False超时计数 +1,带着没解开的验证码继续往下走
累计超时 ≥ captcha_timeout_limit(默认 2)永久关掉本次 run 的闸门,后面每步不再等

最后一档是这个设计里最像"工程经验"的一处:如果一个 run 已经连着两次等不到验证码,那多半是这个环境根本不会发验证码信号,继续每步等 60 秒纯属浪费。

本地跑为什么完全不受影响

wait_for_captcha 会先看事件是不是已经 set 了,是就立刻返回 True(src/fara/agents/captcha.py:16-17)。本地 Playwright 环境的事件在构造时就 set 了(environment.py:103-104),所以本地永远直接放行。


1.4 (c) 调模型:输入怎么拼,输出怎么解

输入侧:每一步都重建系统提示词

_generate_model_call(fara15_agent.py:623-676)的组装顺序是:

[system message] + [裁剪过的历史] + [本轮 (新截图, 文字提示)]

本轮那条文字提示是拼出来的,三选一(fara15_agent.py:637-649):

情形文字内容
用户刚回了话Current URL: ... + 用户原话
上一步产生了文字观察(如读页面的答案)Current URL: ... + 观察正文 + 固定催促语
普通情况Current URL: ... + 固定催促语

固定催促语是一个类常量:USER_MESSAGE = "Here is the next screenshot. Think about what to do next."(fara15_agent.py:127)。

URL 会先截掉 query 串再限长 100 字符(get_trimmed_url,src/fara/agents/utils.py:142-146)—— 长 URL 里的追踪参数对模型没用还占 token。

输出侧:文本里挖出一个 JSON

模型不用 OpenAI 的原生 function calling,而是吐纯文本,格式来自 Qwen 的 Nous 风格模板:

I need to click the search box first.
<tool_call>
{"name": "computer_use", "arguments": {"action": "left_click", "coordinate": [500, 120]}}
</tool_call>

_parse_thoughts_and_action(fara15_agent.py:440-467)用最朴素的字符串切分把它拆开:<tool_call>\n 前面全是思考,后面到 \n</tool_call> 之间是 JSON。

有意思的是两级容错:

# 示意,非源码
try:
action = json.loads(action_text) # 先按标准 JSON 解
except json.JSONDecodeError:
action = ast.literal_eval(action_text) # 退化成 Python 字面量:能吃单引号

第二级是给"模型输出了 {'action': 'left_click'} 这种单引号 JSON"准备的。重点看这里:小模型在格式上偶尔出错是常态,与其重试一次不如先便宜地救一把

再往外还有第三级:terminate_on_parse_error=True 时,连 ast 都解不动就不抛异常了,而是把模型的原始输出当成 terminate 的答案交上去,让这条轨迹"体面地失败"(fara15_agent.py:455-466)。这在批量跑评测时很重要 —— 一条轨迹崩了不该让整个进程挂掉。

网络层的重试

模型调用本身裹了 tenacity 的指数退避:最多 5 次、5~60 秒(fara15_agent.py:505-511)。但 openai.BadRequestError 被显式排除 —— 400 是请求本身有问题,重试没有意义。


1.5 上下文怎么裁:两道独立的闸

截图是最占 token 的东西。Fara 用了两道互相独立的裁剪机制。

第一道:只保留最近 N 张图

maybe_remove_old_screenshots(fara15_agent.py:537-587)从后往前扫历史,数到第 max_n_images 张(默认 3,fara15_agent.py:89)之后,把更老消息里的图片对象删掉。

巧妙点在于文字不是一起删的。带这两种 metadata 标记的消息,即使图被删了,文字也要留下(fara15_agent.py:555-558):

metadata 标记含义为什么必须留
is_original第一条消息,带原始任务描述删了模型就忘了自己在干嘛
is_user_response用户中途回的话删了就丢了用户的授权/补充信息

而且对 is_user_response 那条,回填的不是当时拼出来的完整文字(那里面掺了 URL 前缀和催促语),而是 metadata 里存的用户原话 user_response(fara15_agent.py:573-575)。这是个容易忽略但很对的细节。

第二道:按 token 预算再砍一轮

_fit_images_to_budget(fara15_agent.py:607-621)在第一道之后跑:粗估整个 prompt 的 token 数,超了就从最老的带图消息开始一张张扔,直到进预算或者只剩最后一张。

估算方式极其粗糙(fara15_agent.py:589-598):一张图算 image_token_estimate(默认 1500)个 token,文字按 len // 4 + 1 算。这是刻意的 —— 精确计数要额外调 tokenizer,而这里只需要一个"别爆上下文"的保险丝。

默认这道闸是关的:image_budget_token_cap 默认 0,cap <= 0 直接返回原历史(fara15_agent.py:610-612)。


1.6 (g) 三种停机,状态各不相同

停机方式触发SolverStatus最终答案是什么
模型主动结束动作是 terminateCOMPLETEarguments.answer
交还用户动作是 ask_user_questionWAITING_FOR_USER那句问题的描述文本
步数耗尽for 循环走完COMPLETE最后一步的观察文本

第三行要划重点:步数用完也被标成 COMPLETE(fara15_agent.py:349-352),枚举里明明有 SolverStatus.MAX_ROUNDS(src/fara/core/data_point.py:51)却没被用上。

这不是无害的:下游要区分"真的做完了"和"跑超时了",只能靠最后一个动作是不是 terminate 来判断。评测侧确实是这么补的 —— WebTailBench 的 evaluator 第一件事就是检查最后一个动作,不是 terminate 直接判 0 分(webeval/src/webeval/benchmarks/webtailbench/webtailbench.py:406-413)。

ask_user_question 的两种模式

auto_user_reply 开关决定问用户之后怎么办(fara15_agent.py:330-340):

ask_user_question

├── auto_user_reply=False(默认)──> 设 WAITING_FOR_USER,return 回 CLI
│ CLI 打印问题,读一行输入,再 run() 续跑

└── auto_user_reply=True ─────────> 注入固定假答复,continue 下一步
(只给没有用户模拟器的评测场景用)

那句固定假答复写死在代码里:"keep going so far as you don't make up information, you have my approval"(fara15_agent.py:333-335)。


1.7 CLI 侧的多轮对话

主循环 return 之后,CLI 有一个套在外面的 while 把控制权来回传(src/fara/run_fara.py:117-130):

# 示意,非源码
final_answer, _, _ = await agent.run(run_context)
while run_context.solver_log.status == SolverStatus.WAITING_FOR_USER:
print(f"\nFara asks: {final_answer}")
reply = input("Your response (Enter to abandon): ").strip()
if not reply:
break # 空回车 = 放弃这个任务
run_context.add_observation(UserMessage( # 用户的话先进轨迹
content=reply,
message_type=UserMessageType.CRITICAL_POINT_RESPONSE,
))
final_answer, _, _ = await agent.run(run_context) # 同一个 agent,续跑

重点看 agent.run重复调用同一个对象。续跑靠的是 _state.chat_history 非空这个判断(fara15_agent.py:214-218):非空 = 这是续跑,跳过"新任务初始化",从 _state.current_step 接着数步数,并从轨迹里捞出最新的用户消息喂进去。

第 4 章会把这条续跑路径和轨迹格式一起讲透。


1.8 关键细节与坑

观察文本会被截断,但截法有点特别。 只有 read_page_answer_question 的输出会被当成"文字观察"喂给下一轮(_TEXT_OBSERVATION_ACTIONS,fara15_agent.py:130),截断保留头尾各一半、中间插一句 ... [truncated N chars] ...(src/fara/agents/utils.py:77-90)。保头是因为答案常在开头,保尾是因为总结常在结尾。默认上限 1000 字符。

visit_url 失败不算致命错误。 导航抛异常时不往上抛,而是把错误信息塞进 _pending_observation,下一轮模型会看到"我试图访问 X 但失败了"(fara15_agent.py:795-800)。这让模型有机会自己换个 URL 重试。

动作白名单是从提示词里正则抠出来的。 extract_allowed_actions(fara15_agent.py:61-66)用 "enum": [...] 这个正则从已经渲染好的系统提示词文本里提取合法动作名,而不是直接读 schema 常量。这保证了"模型被告知能用的动作"和"运行时允许执行的动作"永远是同一份来源,不可能漂移。

save_screenshots 配置项是死的。 Fara15AgentConfig.save_screenshots(fara15_agent.py:95)被 CLI 赋值(run_fara.py:80),但在整个 Fara-1.5 路径里从未被读取 —— 截图实际只看 run_context.output_dir 是否存在(fara15_agent.py:395-399),而 CLI 总会设置它(run_fara.py:59)。所以不加 --save_screenshots 也照样存图。

续跑时有一处靠短路求值兜住的隐患。 变量 scaled_screenshot 只在"全新开始"分支里赋值(fara15_agent.py:220),续跑分支不赋值;但第 272 行 scaled_screenshot if is_first_round else None 会用到它。续跑时 start_step >= 1,所以 step 从 2 起、is_first_round 恒为 False,短路避免了 NameError正确但脆弱,改动这段循环边界时要小心。


1.9 代码地图

主题文件路径符号名
主循环本体src/fara/agents/fara/fara15_agent.pyFara15Agent.run
提示词组装 + 调模型src/fara/agents/fara/fara15_agent.py_generate_model_call
模型输出解析src/fara/agents/fara/fara15_agent.py_parse_thoughts_and_action
动作校验与分发src/fara/agents/fara/fara15_agent.py_execute_action_dispatch_action
动作白名单提取src/fara/agents/fara/fara15_agent.pyextract_allowed_actions
截图裁剪(按张数)src/fara/agents/fara/fara15_agent.pymaybe_remove_old_screenshots
截图裁剪(按预算)src/fara/agents/fara/fara15_agent.py_fit_images_to_budget_estimate_prompt_tokens
可变状态容器src/fara/agents/fara/fara15_agent.pyFara15AgentState
验证码闸门src/fara/agents/captcha.pywait_for_captcha
观察文本截断src/fara/agents/utils.pytruncate_observationformat_text_observation
模型客户端src/fara/clients/wrapper.pyChatCompletionClient.create
CLI 多轮循环src/fara/run_fara.pyrun_fara15_agent