数据截至 (上游 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 | 最终答案是什么 |
|---|---|---|---|
| 模型主动结束 | 动作是 terminate | COMPLETE | arguments.answer |
| 交还用户 | 动作是 ask_user_question | WAITING_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.py | Fara15Agent.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.py | extract_allowed_actions |
| 截图裁剪(按张数) | src/fara/agents/fara/fara15_agent.py | maybe_remove_old_screenshots |
| 截图裁剪(按预算) | src/fara/agents/fara/fara15_agent.py | _fit_images_to_budget、_estimate_prompt_tokens |
| 可变状态容器 | src/fara/agents/fara/fara15_agent.py | Fara15AgentState |
| 验证码闸门 | src/fara/agents/captcha.py | wait_for_captcha |
| 观察文本截断 | src/fara/agents/utils.py | truncate_observation、format_text_observation |
| 模型客户端 | src/fara/clients/wrapper.py | ChatCompletionClient.create |
| CLI 多轮循环 | src/fara/run_fara.py | run_fara15_agent |