数据截至 (上游 commit a675d6d61c41)
第 4 章 · 轨迹格式与"问完用户再续跑"
这章讲什么: Fara 跑完一个任务会在磁盘上留下一堆文件。这堆东西的结构不是随手设计的 —— 它同时要当调试日志、训练数据和评测输入,还要支持中途停下来问用户。这章讲它为什么长这样。
4.1 磁盘上长什么样
跑一个任务后的目录(结构依据 src/fara/core/data_point_io.py:1-12 的模块文档 + fara15_agent.py 的截图命名):
fara_runs/task_1_9f3a2b7c/
├── task.json # 任务定义,写一次就不动
├── metadata.json # run_id、创建时间、统计
├── solver_log/
│ ├── events.jsonl # ★ 核心:一行一个事件,只追加
│ └── status.json # 当前状态 + 最终答案,状态变就重写
├── verification.jsonl # 打分结果,只追加(在线跑时为空)
├── data_point.json # 完整快照(checkpoint 时整份重写)
├── run_state.json # 续跑用的杂项状态
└── screenshot_0_pre.png ... screenshot_N_post.png
注意有两套并存的持久化:
| 机制 | 特点 | 谁写 |
|---|---|---|
事件流 events.jsonl | 追加写、每次 add_event 立刻 flush | DataPointWriter.add_event |
整份快照 data_point.json | 每步 checkpoint 重写整个文件 | RunContext.save |
为什么要两套?追加写保证进程被 kill 也不丢已发生的事件;整份快照保证下游读起来简单(一个 DataPoint.load() 搞定)。冗余,但各有各的用处。
4.2 核心设计:事件流,不是步骤数组
直觉先行
最自然的设计是这样:
# 示意,非源码 —— Fara 没有这么做
steps = [
{"screenshot": "1.png", "action": "click", "result": "ok"},
{"screenshot": "2.png", "action": "type", "result": "ok"},
]
问题在于:一个 step 里的东西是分几次产生的。截图在动作前,动作在中间,观察在动作后。要么等一步全做完再写(崩了就丢一整步),要么写一半再回头改(追加写就不成立了)。
Fara 的做法
把一切拍平成一维事件流,每个事件一产生就写(src/fara/core/data_point.py:194-202):
events.jsonl:
{"type":"observation", "observation_type":"user_message", "content":"订张票"}
{"type":"observation", "observation_type":"environment", "screenshot_path":"screenshot_1_pre.png"}
{"type":"action", "id":"a1", "action_name":"left_click"}
{"type":"observation", "observation_type":"tool_output", "action_id":"a1", "output":"I clicked..."}
{"type":"observation", "observation_type":"environment", "action_id":"a1", "screenshot_path":"screenshot_1_post.png"}
{"type":"observation", "observation_type":"environment", "screenshot_path":"screenshot_2_pre.png"}
...
关键就一个字段:action_id。
action_id 的值 | 含义 |
|---|---|
None | 这是动作前的观察(pre 截图、用户消息) |
| 某个动作的 id | 这是那个动作之后的观察(tool 输出、post 截图) |
于是"哪些事件属于哪一步"这个结构不需要在写的时候确定,读的时候现算就行。
读的时候怎么重建 step
SolverLog.steps()(data_point.py:313-379)是这个设计的还账处。算法分两趟:
第一趟:顺序扫事件
遇到 Action ──> 开一个新 Step,把攒着的 pre 观察挂上去
遇到 action_id=None ──> 攒进"下一步的 pre"
遇到 action_id=X ──> 先记着,等第二趟
第二趟:把 post 观察挂回它的 Step
按 action_id 查表,挂到对应 Step 的 observations_post
如果这个观察出现的位置比它的动作晚了不止一步 ──> 标记为 "late"
"late 观察"是个很实际的考虑:异步环境里,一个动作的结果可能拖到两三步之后才回来。它会被同时挂到"它属于的那一步"和"它实际出现的那一步"(data_point.py:360-370),下游按需选择。
引用不到的 action_id 直接抛 RuntimeError(data_point.py:356-359)—— 宁可炸也不静默丢数据。
顺便解决的两个约束
SolverLog.add_action 有一条硬规则:连续两个动作之间必须至少有一个观察(data_point.py:384-387)。这保证了轨迹一定是"观察-动作-观察-动作"的交替形态,不会出现两步之间没有任何环境反馈的畸形数据。
Observation 和 Action 都用 pydantic 的 model_validator(mode="before") 自动补 id(uuid)和 timestamp(data_point.py:112-120、183-191)。调用方不用操心。
4.3 数据结构总览
DataPoint
├─ task: Task 任务定义(id、instruction、环境配置、验证配置)
├─ solver_log: SolverLog
│ ├─ events: [Event] ★ 事件流
│ ├─ status: SolverStatus running / complete / waiting_for_user / ...
│ └─ outcome: Outcome 最终答案
├─ verification: {name -> VerificationResult} 打分结果,按 verifier 名索引
└─ metadata: DataPointMetadata run_id、created_at、stats
三种观察事件类型(pydantic 用 observation_type 做判别联合,data_point.py:194-197):
| 类型 | 记录什么 | 关键字段 |
|---|---|---|
ComputerObservation | 环境状态 | screenshot_path、url、page_info |
ToolOutput | 动作的文字结果 | output、error |
UserMessage | 用户说的话 | content、message_type |
UserMessageType 有四种(data_point.py:140-146):TASK(初始任务)、FOLLOWUP_TASK(追加任务)、CRITICAL_POINT_RESPONSE(回答 agent 的提问)、RETRY_FEEDBACK(重试反馈)。CLI 在用户回话时用的是第三种(src/fara/run_fara.py:124-129)。
Action 里塞了什么
一个 Action(data_point.py:166-191)不只是"点了哪",还带着决策上下文:
| 字段 | 内容 |
|---|---|
action_name | 动作名,如 left_click |
content | {"action": ..., "arguments": {...}} 完整参数(含 thoughts) |
action_nl_description | 人类可读的一句话:思考 + 动作名(参数) |
llm_conversation | 模型的原始响应文本 + 推理,一字不改 |
agent_state | 决策时刻的 agent 快照(名字、工具、计划、facts) |
tool_sub_calls | 工具内部又调模型的记录(如 read_page_answer_question) |
保留 raw_response 是为训练数据准备的 —— 想拿轨迹做 SFT,你需要模型当时逐字说了什么,而不是解析后的结构。构造在 Fara15Agent._log_action(fara15_agent.py:372-393)。
给分析用的扁平视图
get_step_summaries()(data_point.py:433-489)把重建出来的 step 再压成一维的 StepSummary 列表,每项含 index、动作名、参数、URL、截图路径、工具输出、这步之前的用户消息。做失败分析、画表格用这个比自己遍历事件流方便得多。
4.4 写盘:三条不同的持久化路径
DataPointWriter(src/fara/core/data_point_io.py:60-151)按数据的更新特征分了三种写法:
| 数据 | 更新特征 | 写法 | 文件 |
|---|---|---|---|
| 事件 | 只增不改 | 追加 + flush() | solver_log/events.jsonl |
| 验证结果 | 只增不改 | 追加 + flush() | verification.jsonl |
| 任务/元数据/状态 | 会变但很小 | 写 tmp 再 os.replace 原子替换 | task.json 等 |
原子替换那个 _write_json(data_point_io.py:52-57)是防止进程在写文件中途被杀导致 JSON 截断 —— 半个 JSON 文件比没有文件更糟,因为它会让下次读取报解析错误。
RunContext.create 会自动判断新建还是恢复:目录里有 task.json 就走 resume,否则走 create(src/fara/core/run_context.py:52-57)。DataPointWriter.create 遇到已存在的 task.json 直接 FileExistsError,逼调用方明确表态(data_point_io.py:87-92)。
一个小但周到的去重
同名 verifier 跑两遍会怎样?RunContext.add_verification_result(run_context.py:78-89)会检测重名,自动改成 name_1、name_2,避免覆盖。做多次打分对比时很有用。
4.5 "问完用户再续跑"完整走一遍
这是把前面所有东西串起来的一条真实路径。
① agent.run() 跑到第 5 步,模型调 ask_user_question
│
├─ 动作和观察照常写进 events.jsonl
├─ _state.current_step = 5
├─ solver_log.status = WAITING_FOR_USER
├─ run_context.checkpoint() ← 整份状态落盘
└─ return(问题文本, ...)
v
② CLI 打印问题,读到用户输入 "从旧金山出发"
│
└─ run_context.add_observation(UserMessage(
content="从旧金山出发",
message_type=CRITICAL_POINT_RESPONSE)) ← 追加进事件流
v
③ agent.run(run_context) 再次调用(同一个 agent 对象)
│
├─ _state.chat_history 非空 ──> 判定为"续跑"
├─ status 改回 RUNNING
├─ pending_user_response = solver_log.get_last_user_message()
├─ start_step = 5 ← 从第 6 步接着数
└─ 跳过"新任务初始化"(不重发任务描述、不存 screenshot_0)
v
④ 第 6 步的文字提示变成:"Current URL: ...\n从旧金山出发"
这条消息带 metadata {"is_user_response": True, "user_response": "从旧金山出发"}
──> 后续截图裁剪时,图可以删,这句话必须留
关键代码位置:续跑判定 fara15_agent.py:214-218,用户回话的提示词拼接 fara15_agent.py:644-655,裁剪时的保护 fara15_agent.py:555-576。
状态活在哪儿
这里有个容易混淆的点:续跑靠的是内存里的 _state,不是磁盘。
| 状态 | 存在哪 | 跨进程活不活 |
|---|---|---|
_state.chat_history(完整对话,含图) | agent 对象内存 | ✗ |
_state.current_step | agent 对象内存 | ✗ |
_state.facts | agent 对象内存 | ✗ |
| 事件流、状态、截图 | 磁盘 | ✓ |
所以 CLI 那个多轮循环必须在同一个进程里复用同一个 agent 对象。agent.close() 会把 _state 置 None(fara15_agent.py:356-362),所以它只在一个任务彻底结束后才调(run_fara.py:137-138)。
Agent 基类上留了 save_state / load_state 两个钩子(src/fara/core/agent.py:144-164),但 Fara15Agent 没有覆盖它们。也就是说跨进程续跑目前不成立 —— 磁盘上有事件流,但重建不出模型的对话历史。这是一个明确的能力缺口。
4.6 关键细节与坑
第 0 步的截图被复制了一份。 _log_initial_observations(src/fara/core/agent.py:126-142)把 screenshot_0_pre.png 复制成 screenshot_0_post.png。同理,停机动作(terminate / ask_user_question)不会改变页面,所以 post 截图直接 copy pre(fara15_agent.py:305-312)。
为什么要凑齐 pre/post 对? 因为下游一律假设"每步都有前后两帧"。第 5 章会看到,打分器就是靠成对的帧判断"这一步有没有产生预期变化"。少一张就得写特判。
ComputerObservation 被记了两次,但内容不同。 动作前记的那条带 url 和 page_info(fara15_agent.py:286-292),动作后那条通过基类 _log_observation 记(agent.py:102-124),带 action_id。这不是重复,是"动作前后各拍一次环境状态"。
checkpoint() 每步重写整个 data_point.json。 一条百步轨迹会重写 100 次完整 JSON。轨迹长了会有可见开销 —— 但换来的是任何时刻 kill 掉进程,盘上都有一份完整可读的快照。
run_state.json 基本是空的。 RunContext._state 有 set_state/get_state 接口,但 Fara15Agent 一次都没调过。这套机制是给更复杂的编排场景预留的。
step_budget_scores 的存在暗示了一种评测方式。 评测侧会算"如果只允许 N 步,这条轨迹得几分"(webeval/src/webeval/metric_helpers.py 的 calc_step_budget_scores)。这要求轨迹必须完整保留每一步,不能中途裁剪 —— 又一个事件流设计的受益者。
4.7 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 顶层数据结构 | src/fara/core/data_point.py | DataPoint、SolverLog、Task |
| 事件类型 | src/fara/core/data_point.py | ComputerObservation、ToolOutput、UserMessage、Action |
| step 重建算法 | src/fara/core/data_point.py | SolverLog.steps |
| 扁平分析视图 | src/fara/core/data_point.py | SolverLog.get_step_summaries、StepSummary |
| 状态枚举 | src/fara/core/data_point.py | SolverStatus |