Skip to content

从断点恢复到轨迹重放: ageval 的长程评测日志

· 34 min · agent / eval / ageval

最近在关注长程 Agent 评测时, 我开始考虑运行中断后的恢复问题. 一道题可能运行数小时, 评测框架需要保存过程中产生的进度.

我们在做的 ageval 是一个 Agent 评测框架. 它让任务脚本负责描述题目、交互和评分, 再用插件选择模型、Agent 运行程序和执行环境. 这样, 同一道题可以在相同的评分条件下, 更换模型或运行程序做对照.

ZJU-REAL/ageval76repoA composable framework for reproducible evaluation of long-running agents.Python

这些组合能力已经让我们能够运行不同的 benchmark. 随着 Agent 能处理更长的任务, 运行中断的成本也随之增加. Terminal-Bench 4.0 把单题的 Agent 超时上限设为八小时. 长时间的评测会产生大量模型调用、文件修改和工具输出.

假设 Agent 已经读完代码、写了一半程序, 主进程却因为断线或机器重启退出. 下次启动评测时, 已经完成的题目可以跳过, 未完成的题目则需要恢复进度. 如果重新执行, 就需要承担额外的时间与 token 成本.

我们希望保存下来的进度能支持三种操作:

  1. Resume: 中断后接着跑, 复用上一轮的思考和工具调用等进度;
  2. Fork: 从同一进度换个模型继续, 观察两者后续的差异;
  3. Replay: 把已记录的终端操作放进新环境里重放, 检查结果.

下图展示评测进度的保存, 以及续跑、分支和重放时环境与运行的变化:

Loading

从 v0.10.0 开始, ageval 陆续引入活动日志、检查点、续跑、分支和动作重放. 下面记录这些设计的来龙去脉.

从 Pi Durable 开始调研#

十月初, 我看到了 Pi Durable. 它把模型调用、工具执行等工作组织为任务, 并在执行过程中保存进度. 进程重启后, 它会找到尚未完成的任务继续处理. 模型请求如果中途被截断, 可以重新发送; 工具调用则需要区分副作用. 工具只有声明可以安全重跑, 才会自动重跑; 否则模型会收到中断信息, 再决定下一步.

earendil.comPi Durable一个关注长时间运行与进程重启的 Agent 运行框架.

Pi Durable 将模型请求、工具调用和上下文压缩都视为任务. 每个任务保存执行进度; 新进程打开同一份存储后, 就能找到尚未完成的任务. 下面的伪代码展示了它如何处理被截断的模型请求和工具调用, 恢复规则来自 Pi Durable 原文:

def recover_task(task):
if task.is_interrupted_model_request():
save_partial_answer_as_aborted(task)
return resend_model_request(task)
if task.is_interrupted_tool_call():
# 只有声明可以安全重跑的工具才自动重试
if task.tool.replay_safe:
return rerun_tool(task)
return report_interruption_to_model(task.partial_output)
return continue_from_checkpoint(task)

例如搜索工具声明可以安全重跑, 中断后可以再次搜索; 部署工具如果没有这个声明, Pi Durable 会把中断前收集到的输出交给模型, 让模型判断下一步. 这里保存的是任务和会话状态. 工具所在的文件系统由执行环境提供, 环境丢失后, 还需要其他机制恢复文件.

我开始考虑把这种恢复机制引入 ageval. 评测还需要保存 Agent 工作时的环境: 除了对话, 仓库里已经修改的文件、执行命令产生的结果也需要恢复. 围绕环境状态和运行记录, 我接着看了几种方案:

项目或方案保存的主要内容中断后怎样处理环境
Pi Durable会话和任务进度环境由外部接口提供, 文件状态需要另行处理
InspectAgent 状态、事件和指定的沙箱文件新建沙箱, 恢复检查点中保存的路径
Harbor 的恢复 RFC环境句柄、运行阶段等恢复信息提议重新连接仍然存活的环境
SWE-agent 的动作重放轨迹中记录的动作在新环境里重新执行动作, 得到新的输出

Inspect: 在回合边界保存检查点#

Inspect 会保存 Agent 状态、事件和沙箱中指定路径的文件. 它在回合边界检查是否需要写检查点: 即使按时间触发, 也会等当前回合和工具调用结束. 下面的伪代码展示了 Inspect 如何在回合之间保存状态:

while not finished(state):
if checkpoint_trigger_fired():
agent_state = capture_agent_state()
files = capture_sandbox_paths()
# 写入检查点记录后,这次保存才可用于恢复
save_checkpoint_record(agent_state, files, events, store)
state = await run_one_turn(state)

检查点记录写好后, 这份状态才能用于恢复. 假如工具运行了十分钟, 期间进程退出, Inspect 会回到上一个已提交的检查点; 它不会保存正在运行的工具进程. 重试时, Inspect 创建新的沙箱、执行环境初始化, 再把捕获的文件路径和 Agent 状态恢复进去.

Harbor RFC: 接回仍在运行的环境#

Harbor 的恢复 RFC提出另一种路径: 在运行开始后保存环境句柄和当前阶段, 中断后尝试通过句柄接回原来的容器或远程沙箱. 下面的伪代码展示了恢复标记、环境重连和后续阶段之间的关系:

marker = read_recovery_marker(trial)
if marker is None:
return restart_trial(trial)
env = reconnect(marker.environment_handle)
if env is None:
return restart_trial(trial)
if marker.phase == "verifier":
return verify(env) # Agent 已结束,继续评分
if agent_supports_resume(marker):
return resume_agent(env)
return restart_trial(trial)

恢复标记记录的是环境地址和运行阶段, 本身不包含文件快照. 这个流程依赖原环境仍然存活: 环境还在时, 文件和正在运行的进程可能都在; 句柄失效时, 提案退回重新执行. RFC 中还有主动断开后等待原进程完成的分支, 上面的代码展示了崩溃恢复的主要路径.

轨迹重放: 执行记录中的动作#

SWE-agent 的轨迹文件保存了每一步的模型响应(response)、从响应中解析出的思考(thought)和动作(action), 以及执行后得到的观察(observation)和环境状态(state). 其中的 query 还记录了当时发给模型的消息, 供后续检查.

重放实现会从历史消息中取出 assistant 的内容; 使用函数调用时, 还会取出对应的 tool_calls. 它将这些记录交给一个重放模型, 按顺序返回给 Agent 循环. Agent 再解析其中的动作, 在环境里执行, 得到新的观察. 下面的伪代码展示这个过程:

recorded_responses = []
for message in trajectory.history:
if message.role == "assistant":
recorded_responses.append({
"content": message.content,
"tool_calls": message.tool_calls,
})
for response in recorded_responses:
action = parse_recorded_response(response)
observation = environment.execute(action)
new_trajectory.append(response, action, observation)

这里的”重放模型”依次返回记录中的 assistant 消息, 不会向待测模型发起新的请求. 原来的模型输入和响应仍可用于查看当时的决策; 这次执行产生的观察来自当前环境. SWE-agent 的这条路径主要用于生成演示轨迹和调试工具, 但是不能确保重放的结果保持一致. 因为初始文件、依赖或外部服务发生变化, 都可能让同一系列动作产生不同结果.


这些方案涉及三部分状态: 模型看到的上下文、环境里的文件, 以及可能还在运行的进程. 恢复时, Agent 需要接着之前的对话工作, 看到的文件也应当处于那段对话结束时的状态. 如果原环境里还有命令正在运行, 恢复过程还需要处理这条命令.

把问题写成 RFC#

ageval 把一道题的一次执行称为 Attempt. 为了方便阅读, 后面也会叫它”一次运行”. 这次运行会依次准备环境、执行任务、评分和记录结果.

下图是我们技术报告里的框架图: 左边是任务和评测结果, 中间是任务与 Agent 的交互, 右边是执行环境. 底部列出了各阶段; 右下角的活动记录和检查点保存运行进度.

ageval 框架图: 左侧为数据集与任务, 中间为交互和插件组合, 右侧为执行环境和持久化状态, 底部为运行阶段.

ageval 框架: 任务定义、Agent 交互、插件组合和执行环境. 图片来自项目技术报告的 Figure 2.

在增加恢复能力之前, ageval 会在运行结束后的记录阶段整理出完整轨迹. 查看已经结束的任务时, 这份轨迹很有用; 但如果父进程在任务执行途中突然退出, 记录阶段可能还没有开始, 轨迹也就来不及写完整.

另一个问题藏在 Agent 的一次调用里. 任务脚本可能只向 Agent 发出一次”完成这道题”的请求; Agent 接下来却会多次询问模型、读取文件、运行命令. 如果只记录任务脚本的那一次请求, 运行到第二十步时退出, 前面十九步仍然无法准确恢复.

于是我们先写了 RFC 0001, 把恢复所需的记录、环境状态和失败处理写下来, 再和 Agent 沟通设计细节. 讨论集中在步骤的完成边界、对话和文件状态的对应关系, 以及中断工具的重复执行可能产生的副作用.

接着, 我们把工作拆成 恢复功能的总 issue 和几项实现: 先处理日志、检查点和本地环境, 再接入不同的 Agent 运行程序与远程环境. 续跑完成后, 又分别实现了从检查点创建分支和终端动作重放.

一边运行, 一边保存进度#

一次运行的可恢复状态由执行记录和环境文件共同构成. ageval 分别用活动日志和文件检查点保存它们.

活动日志: 记下每一步#

运行时, ageval 会持续向 activity.jsonl 追加记录. 里面有模型调用、工具调用、会话和运行阶段等事件. 外部操作开始前, 系统先记下这一步正在进行; 等它结束, 再写下结果.

举个例子: Agent 准备执行 python test.py. 日志先记录这次工具调用开始, 然后命令运行; 命令结束后, ageval 保存文件检查点, 再写入包含输出和检查点编号的完成记录. 下面是对应顺序的伪代码, 实际实现见 ActivityLog.append:

log.append(request_id, status="in_flight", command=command)
result = environment.execute(command)
seq = log.reserve_next_sequence()
checkpoint = save_files(environment, seq)
log.append(request_id, status="completed", seq=seq,
output=result.output, checkpoint_seq=checkpoint.seq)

reserve_next_sequence() 在这里用来说明顺序, 实际代码由日志写入过程分配编号, 并在写完成记录前调用检查点准备函数. 如果保存检查点时进程退出, 日志里还没有这一步的完成记录; 恢复时会把它当作尚未完成的操作处理. 完成记录中的编号则告诉恢复过程应该使用哪份文件状态.

日志也要考虑模型上下文的变化. 长会话可能经过压缩: Agent 把较早的对话总结成一段较短的内容, 随后继续工作. 恢复时需要让模型看到压缩后的摘要和后续消息; 如果把被摘要替代的旧消息也重新加回来, 上下文就会发生变化. 下一节会介绍 ageval 如何通过日志或会话文件恢复上下文.

检查点: 保存对应的文件#

日志可以告诉我们 Agent 修改过文件, 但续跑还需要拿到文件的内容. ageval 在步骤完成时保存工作目录、Agent 的 home 目录和相关产物的检查点. Agent 自己管理的会话文件可能放在 home 目录里, 保存它们有助于恢复原来的会话, 包括其中已经做过的上下文压缩.

每个检查点有一份文件清单, 记录文件路径、权限、修改时间以及文件内容的 SHA-256 值. SHA-256 会根据文件内容计算出一个固定长度的标识; ageval 用它查找保存的内容. 如果两个检查点中的文件内容相同, 它们的清单就可以引用同一份数据.

这与 Git 按内容标识和复用文件对象的思路相似. Git 用树对象和提交对象组织目录与历史; ageval 在每个检查点保存一份路径到内容标识的清单, 用于恢复当时的文件. 关于直接用 Git 保存检查点的取舍, 见附录.

下图展示两次检查点如何共用未修改的 app.py, 同时保留 test.py 的两个版本. 图中的 A、B、C 代表不同文件内容的摘要:

Loading

恢复时, ageval 读取选定检查点的清单, 按其中的摘要找到文件内容; 两次检查点中 app.py 都引用 A, test.py 则分别引用 B 和 C.

下面的伪代码展示了文件内容如何保存和复用, 以及恢复时如何按清单重建目录. 具体实现见 CheckpointStore:

def capture(sequence, directory):
entries = {}
for path in managed_files(directory):
data = read_and_filter(path)
if data is None: # 排除不应保存的文件
continue
digest = sha256(data)
if not blob_exists(digest):
save_blob(digest, data) # 相同内容只保存一份
entries[path.relative] = (digest, path.mode, path.mtime)
write_manifest_atomically(sequence, entries)
def restore(sequence, directory):
entries = read_manifest(sequence)
for path, (digest, mode, mtime) in entries.items():
write_file(directory / path, read_blob(digest))
set_metadata(directory / path, mode, mtime)
# 清除受管理目录中不属于该检查点的文件
remove_unlisted_managed_files(directory, entries)

不同路径的文件只要内容相同, 也可以引用同一份数据. 恢复时如果所需数据不存在, 系统会报错, 不会继续把这次恢复当作成功.

检查点的文件范围

read_and_filter 合并了源码中的读取和过滤步骤. 检查点只遍历受管理目录中的普通文件, 不跟随符号链接; 某些凭据路径会被排除, home 目录中的文本还会经过敏感信息遮盖. 因此检查点保存的是经过这些规则处理的文件状态.

不同 Agent 的会话管理方式也不同:

无论采用哪种方式, 恢复后的工作目录都需要反映会话中已完成的操作.

Resume: 中断后接着跑#

先看最直接的场景: 评测还没写出最终结果, 父进程就退出了. 重新启动时, ageval 先检查这次运行的配置是否仍然匹配, 再判断原环境的状态.

下面的流程图展示配置检查与环境恢复的路径:

Loading

判断能否接回环境时, ageval 不只检查容器是否还在, 还会核对环境身份和环境内记录的进度. 下面的伪代码展示了环境探测、接回和重建的判断过程, 具体实现见 classify_environment:

probe = environment.probe(saved_handle)
if probe.is_busy:
probe = wait_until_idle_or_deadline(probe)
if probe.is_busy:
return "busy"
if same_environment(probe, log):
# 工具在父进程退出后完成,收回它的结果
if probe.last_exit_matches_inflight_tool(log):
attach(probe)
record_finished_tool_and_checkpoint(probe.last_exit)
return "attached"
if probe.cursor == log.last_completed_checkpoint:
return attach(probe)
return rebuild_and_restore_latest_checkpoint(log)

其中有一个容易遗漏的情况: 父进程退出时, 工具命令还在原环境里运行; 等恢复时检查, 它已经结束了. 如果环境保存的命令结果能和日志中未完成的调用对上, ageval 可以接回环境, 补记输出和检查点. 否则, 环境身份或进度不一致时, 就重新创建环境并恢复最近的检查点. 重建还会重做必要的环境准备操作, 再恢复文件. 已经完成的调用会使用记录中的结果.

如果环境里的操作仍在运行, 恢复过程会先等待; 到达适用的截止时间后仍然忙碌时, 会退出并保留未完成状态, 避免同时启动另一份执行. 没有可用截止时间时, 会等待环境空闲. 对于未能收回结果的中断步骤, ageval 会根据调用类型和工具的重试许可决定下一步. 下面的伪代码展示了这些分支:

if interrupted_step.kind == "model_call":
mark_aborted(interrupted_step)
resend_model_call()
elif interrupted_step.replay_safe:
rerun_tool()
else:
return_interrupted_result(interrupted_step.partial_output)

只有明确标记为可安全重复的工具才会自动重试; 其他工具会把中断信息和已有输出交回 Agent. 文件检查点无法撤销已经发生在环境外部的副作用, 例如已经发出的网络请求.

续跑要求原运行尚未结束, 且当前配置与原配置匹配. 整套评测恢复时, 已经结束的题目保持原有结果. 恢复后的运行仍会计入此前消耗的时间和调用次数, 以免反复中断改变原有的资源限制.

Fork: 从同一进度比较不同模型#

续跑实现后, 保存的对话和文件还提供了一个实验机会: 让不同模型从同一进度继续执行.

假设模型 A 已经读过题目、浏览过仓库并写出初稿. 我们可以选一个合适的检查点, 让模型 B 从同样的文件和会话进度出发, 再比较它们各自如何完成剩下的任务. 这个操作叫 fork, 即从已有进度创建一条新的运行分支.

Fork 会创建新的运行和新的环境, 保留原运行的来源信息. 下面的伪代码展示了从检查点创建新运行的过程; 配置校验的实现见 prepare_fork:

cut = find_checkpoint(parent, sequence)
require(cut.is_forkable)
require(only_solver_model_changed(parent.config, child.config))
child = create_attempt()
child.copy_history_until(parent, cut)
environment = create_fresh_environment(child)
restore_files(environment, cut.checkpoint)
continue_with_model(child, environment, new_model)

选定位置必须是可分支的完成状态. 新运行继承该位置之前的活动记录和检查点, 再在新环境中恢复文件; 它不会接回原运行的环境. 当前实现支持固定 Agent 运行程序下的单会话分支, 多角色分支还需要进一步实现. 模型不同, 也可能导致之后的行为和成本不同, 因此共同的前半段用量会单独记录.

分支结果记录了它来自哪次运行、哪个检查点, 以及模型配置发生了什么变化. 默认的从头运行统计会排除这类分支, 避免把共享前半段的结果当成独立完成整题的样本.

Replay: 重做记录过的终端动作#

Fork 还会让模型继续做决定. 另一种需求是: 不再询问模型, 只把已经记录的命令重新执行一遍, 看看新环境会出现什么结果.

这就是 replay. ageval 根据保留下来的动作记录创建一个干净环境, 按顺序执行终端动作, 保存这次的输出, 并和原记录比较. 下面的伪代码展示了执行动作、比较输出和发现差异后停止的过程, 具体实现见 replay_exec.py:

for action in recorded_actions:
actual = execute_terminal_action(environment, action)
save_new_observation(action, actual)
difference = compare(action.recorded_result, actual)
if difference:
# 保存首次差异并停止执行后续动作
record_first_difference(difference)
break
compare_collected_artifacts_if_actions_match()

每一步使用新环境产生的输出, 与原记录中可用的退出码和输出比较; 发现第一个有意义的差异后, 动作重放停止. 输出比较会先做规范化处理; 如果动作都匹配, 收集产物后还会检查产物差异. 重放也可以对新产物评分.

重放能帮助排查某个命令或产物, 也能检查一段已记录的执行过程在新环境里是否得到相同结果. 它依赖任务初态和动作记录: 网络响应、时间、依赖版本或外部状态发生变化, 都可能让结果不同. 终端命令如果会访问外部服务或修改环境之外的数据, 重放时也会再次产生相应操作.

当前实现覆盖 miniswe 的终端动作; 其他 Agent 的终端适配和任务自定义工具的重放需要另外支持. 重放结果同样记录来源, 也不会默认混入从头运行的模型评测统计.

总结与后续工作#

三种操作使用同一套执行记录, 各自处理不同的需求:

操作起点下一步
Resume未完成运行的可恢复进度继续同一次运行
Fork保留下来的可分支检查点创建新运行, 让选定模型继续
Replay保留下来的终端动作和任务初态在新环境中重做动作、比较结果

这套机制的恢复效果和运行开销还需要通过实验测量. 我在技术报告中计划了父进程中断、环境丢失和工具执行中断等故障注入实验, 用来衡量恢复成功率、恢复耗时等指标. 这些实验还待完成. 能否复用现成的 benchmark, 以及专门构建一套测试的成本, 也需要进一步评估.

多角色 fork、更多 Agent 的动作重放, 以及任务自定义工具的重放, 也留给后续工作. 对于任务自定义工具, 我还想参考 agentenv-framework, 把目前由 SDK 管理的工具改为通过 Python 编写的 MCP (Model Context Protocol) 服务提供.


附录#

为什么不用 Git 保存检查点#

Git 可以保存文件在不同时刻的状态. 平时我们用 git add 把选定的修改放进暂存区, 再用 git commit 创建一次提交. 每个提交指向当时的目录和文件内容, 也记录它的父提交; 之后可以用 git diff 查看变化, 或从某个提交取出文件.

沿着这个思路, ageval 可以在每次工具调用结束后保存一个 Git 提交, 再把提交 ID 写入活动日志. 恢复时, 日志给出进度, Git 提供对应的文件版本. 下图展示这一方案中提交与活动日志的关系:

Loading

Git 能提供文件版本和差异查看能力. 但是要用于评测恢复, 在处理任务仓库、会话文件持久化、文件权限变更检查和敏感信息过滤等方面还有所不足.

  1. 检查点不能随意写进任务自己的 Git 历史. 代码题的工作目录可能已经是 Git 仓库, Agent 也可能用 git status、git log 和 git commit 完成任务. 如果把每一步检查点提交到这个仓库, Agent 看到的历史和状态就会改变. 可以为快照另建仓库, 但普通 git add 遇到内层仓库时, 会记录指向内层提交的条目, 内层未提交的修改不会自动进入外层快照; 被忽略的文件和内层 .git 数据也需要明确处理.

  2. 恢复进度还需要工作目录之外的文件和活动日志. 有些 Agent 把完整的会话记录写在 home 目录里, 恢复上下文时需要这些文件; 相关产物也可能位于另一目录. 使用独立 Git 仓库时, 需要把这些文件纳入快照, 并把提交 ID 写入对应的日志记录. 如果文件提交已完成而日志还没写完, 恢复过程还要判断这个提交是否属于已完成的工具调用.

  3. Git 保存的普通文件模式无法覆盖评测可能检查的所有权限变化. 它能记录可执行位的变化, 却无法区分 0640 和 0600 这样的权限; 如果题目检查这些权限, 就需要额外保存信息. ageval 的检查点记录普通文件的权限位和修改时间, 恢复时重新设置它们. 当前检查点也有自己的范围: 不跟随符号链接, 也不保存所有操作系统元数据.

  4. 保存 home 目录时, 凭据和敏感信息需要在写入存储前处理. 当前实现会排除部分凭据路径, 并对 home 文本做敏感信息遮盖. 使用 Git 存储时同样需要先过滤文件, 因为提交后再删除文件, 旧内容仍可能留在历史对象里.

因此, Git 可以作为文件快照的存储后端, 日志关联、快照范围、权限和过滤规则仍需要由评测系统处理. 当前检查点把这些需求放进同一套实现; 两种方案的性能和维护成本还需要通过实验比较.