Learning AI Quality 返回 KuthorX Blog II博客首页

第 25 章

第 5 周:Agent 跑到一半被 kill -9,从哪接着跑?

断点续跑的三层:检查点粒度、事件日志重放与快照、被杀那一刻的副作用和写到一半的检查点。用 BugHunt 测试 Agent 在 51~54 个崩溃点上各 kill -9 一次再续跑(合成数据),对照 Codex rollout 的先记录后执行、补 aborted 输出、坏行跳过与补换行、flush 不 fsync。一段讲解视频,一个可以选崩溃点的互动演示,一套能本地跑的实验代码。

BugHunt 的测试 Agent 要探索 12 个页面,跑一次几十分钟。跑到第 7 页,进程被杀了:OOM、发布重启、有人顺手 kill -9。重启后从头跑,前 6 页的模型调用白花一遍钱,第 3 页的 Bug 报告还会再提交一次。前一个是成本问题,后一个是正确性问题。这一章做一个能被 kill -9 的 worker,在每一个崩溃点上杀它一次、再让它续跑,看哪种写法会重复提交、哪种会把检查点写坏,最后对照 Codex 的 rollout 是怎么处理这两个瞬间的。

讲解视频

互动演示

选一种检查点方式和一种副作用协议,拖动崩溃点(每页的"模型回复后 / 访问后 / 记意图后 / 提交后 / 写检查点到一半 / 写完检查点"),页面会在那个位置"kill -9",再续跑到结束:时间线上标出哪些页从检查点恢复、哪些重做;下面是被杀那一刻的日志或快照文件(坏掉的半行标红),以及续跑结束后跟踪系统里的报告(重复的标红)。底部的表把 8 种组合在全部崩溃点上穷举一遍,数字和本地实验脚本的输出一致。逻辑和 bughunt_worker.py 相同,数据是合成的,浏览器里没有真的进程。

互动演示:选崩溃点,kill -9,再续跑 在新标签页打开

断点续跑要回答的四个问题

问题对应的概念
多久存一次、存到多细检查点粒度
重启后怎么把状态拼回来重放(事件日志)vs 快照
被杀那一刻正在做的副作用,做了没有先记意图 + 幂等键
检查点写到一半被杀,文件还能不能读原子替换、坏行跳过、flush 与 fsync

本周前两讲《长任务跑到一半,它现在到底是什么状态?》和《超时了,再发一次安全吗?》讲了任务状态机和重试幂等。状态机回答"任务现在处在哪个状态",这一讲回答"进程死了以后,这个状态还在不在、对不对"。

本章的"检查点"指持久化的恢复点(进程死后从哪里接着跑),不是状态机那一讲里 worker 检查取消请求的位置。

实验设置

代码在 week05_长任务可靠性/code/checkpoint_resume/,只用 Python 标准库,不调任何模型 API:

  • bughunt_worker.py:测试 Agent。依次处理第 1~12 页,每页 mock 模型决策 → 访问页面 → 有 Bug 时提交报告 → 写检查点。第 3、7、10 页有注入的 Bug(合成数据)。Bug 跟踪系统是一个 SQLite 文件,代表外部系统。--crash-at p07:after_submit 让它在指定位置用 SIGKILL 杀掉自己(和 kill -9 是同一个信号);不带这个参数就是续跑。
  • crash_sweep.py:对每种组合,在每一个崩溃点各杀一次、续跑到结束,然后判定;再做 40 次外部随机时刻的 kill -9,最后测每次写检查点的耗时。
  • test_checkpoint_resume.py:unittest,6 个用例。

判定看三样东西是否一致:跟踪系统里的报告(不重、不漏)、续跑结束时内存里的结果、再从磁盘重新加载一次的结果。

先手动杀一次看看。每步 sleep 50ms,0.75 秒后从外面 kill -9:

$ python3 bughunt_worker.py /tmp/bh --checkpoint log --effects idempotent --delay 0.05 &
$ sleep 0.75; kill -9 $!
exit=137
$ wc -l < /tmp/bh/events.jsonl; tail -2 /tmp/bh/events.jsonl
       7
{"finding": false, "page": 5, "seq": 6, "t": "page_done", "tokens": 1185}
{"finding": false, "page": 6, "seq": 7, "t": "page_done", "tokens": 1222}
$ python3 bughunt_worker.py /tmp/bh --checkpoint log --effects idempotent
{"status": "done", "next_page": 13, "findings": [3, 7, 10], "aborted": [], "tokens": 14886, "resume": {"source": "log", "replayed": 7, "bad_lines": 0}}
$ sqlite3 /tmp/bh/tracker.db 'select page,key from reports'
3|bughunt-task-001:p03
7|bughunt-task-001:p07
10|bughunt-task-001:p10

退出码 137 = 128 + 9,被 SIGKILL 杀掉。日志里有 7 条事件(第 1~6 页完成,加第 3 页的一条"意图"),续跑重放这 7 条,从第 7 页接着跑,三条报告不重不漏。token 合计 14,886 = Σ(1000 + 37p),p 从 1 到 12。

检查点粒度:存多细

粒度什么时候写崩溃后要重做的写入代价
任务级(不存)不写全部重做0
步骤级每完成一页写一次当前这一页每页一次;快照要写整份状态
事件级每个模型回复、每个工具结果都追加一行正在进行的那一次调用最频繁,但每次只追加一行

Agent 有天然的检查点边界:一次完整的模型回复、一次完整的工具结果。边界中间的状态存不下来:流式输出到一半,另一半在服务端;工具执行到一半,进度在外部系统里。所以粒度再细,“正在进行的那一次调用"也可能要重做,或者要单独处理(下面的危险窗口)。

实验里不存检查点,平均每次崩溃要重做 6.51 次模型调用;每页一次快照,平均 0.76 次(都是 51 个崩溃点上的平均)。

重放 vs 快照:怎么拼回来

方式存什么恢复时做什么优点缺点
事件日志只追加,一个事件一行从空状态开始,按顺序把每条事件应用一遍每次只写一行;完整历史可审计、可回放评测恢复时间和日志长度成正比
快照完整状态读一个文件恢复最快每次写整份;看不到中间过程;写一半就坏
快照 + 日志后缀两者都有,定期打快照读最新快照,只重放它之后的事件恢复量有上限两份数据要保持一致

**“重放"重放的是记录,不是动作。**重放时不重新调模型、不重新执行工具,只把记录下来的结果按顺序应用到状态上。这要求"应用事件"是一个确定性的纯函数:同一串事件,应用几遍都得到同一个状态。实验里的 apply_event 每次返回新状态、不改原对象,单测里专门断言了这一点。

实验里 12 页、3 个 Bug 页一共 15 条事件(12 条"本页完成” + 3 条"意图”)。纯日志恢复平均重放 7.26 条、最多 15 条;每 4 页打一次快照后,平均 1.98 条、最多 4 条。

对照 Codex:rollout JSONL 是事件日志;压缩时写进去的 compacted 条目带着 replacement_history,相当于快照;codex resume 从最新的检查点开始,只重放它之后的条目。这些在《上下文快满了,Codex 怎么办?》讲过,这里不重复。补一个上次没展开的细节:恢复分两步。第一步 select_input_compaction( core/src/session/rollout_reconstruction.rs:58-88 )只看最新的 compacted:它的字段齐全(有 replacement_history、窗口编号和恢复元数据等),才能用来把扫描范围截到它之后;不齐全就返回 None,扫描全部条目:

// Only the newest compaction can bound replay. If it is incomplete, an older compaction
// cannot replace the history or window state that the newer one may have changed.
let (index, compacted) = rollout_items
    .iter()
    .enumerate()
    .rev()                                   // 倒着找
    .find_map(|(index, item)| match item {
        RolloutItem::Compacted(compacted) => Some((index, compacted)),
        _ => None,
    })?;                                     // 找到的第一个就是最新的 compacted
// ...
if compacted.replacement_history.is_none()
    || compacted.window_number.is_none()
    // ...
{
    return None;                             // 最新的不完整:不用它截短输入,扫描全部条目
}

注释的理由是:更新的那次压缩可能改过历史和窗口状态,不能拿更早的压缩去截短输入。第二步在选好的条目里倒着扫( :230-257 、 :138-144 ),仍然取最新的、没被回滚的、带 replacement_history 的那个 compacted 当历史起点,只追加它之后的条目。所以"第一步返回 None"不等于"历史全量重放":最新的是没有 replacement_history 的旧格式时,会退回更早的检查点,再按旧格式现场重建( :434-458 )。这和"快照 + 日志后缀"的一致性要求是一回事:起点是最新的、仍然有效的完整快照。

危险窗口:副作用做完了,检查点还没写

第 3 页的顺序是:访问页面 → 提交报告 → 写检查点。如果在提交之后、检查点写完之前被杀,检查点里第 3 页还是"没做完",续跑会把第 3 页重做一遍,报告就提交了两次。进程内部永远没法知道被杀前那一下副作用到底做没做,只有三种选择:

协议做法实验结果(每页一次快照)
先做后记(naive)提交,再写检查点,不带幂等键51 个崩溃点里 6 个重复报告:3 个 Bug 页 ×(提交后、写检查点中)
至多一次(at_most_once)提交前先记"意图";恢复时发现有意图、没结果,标成 aborted,不重做54 个点里 3 个漏报(意图记了、报告没交),6 个状态与外部不一致(报告交了、自己记成 aborted)
至少一次 + 幂等键提交前先记意图,键 = 任务 id + 页号;恢复时用同一个键重做,跟踪系统按键去重54 个点全部正确

至少一次加幂等键,效果上就是恰好一次:重做是安全的,因为对方会去重。跟踪系统那边是一条唯一约束:

db.execute("CREATE TABLE IF NOT EXISTS reports (id INTEGER PRIMARY KEY, page INT, key TEXT UNIQUE)")
db.execute("INSERT OR IGNORE INTO reports (page, key) VALUES (?, ?)", (page, key))  # key 为 NULL 时不去重

幂等键必须在重启后保持不变:要么从确定的输入派生(任务 id + 步骤号,本实验的做法),要么生成一次(比如 Stripe 建议的 V4 UUID)、随"意图"一起落盘,恢复时读回来用。错误的是每次启动重新生成,那样重启后键变了,去重就失效。这和《超时了,再发一次安全吗?》讲的重试幂等是同一件事:续跑本质上就是进程级别的重试。《模型怎么’调用’一个函数?》那一章的幂等测试行里也说过:模型重新发起的调用 id 不同,要靠业务幂等键。

Codex 怎么处理这个窗口

**先记录,再执行(尽力而为)。**模型发出工具调用时, core/src/stream_events_utils.rs:324-357 先把调用条目写进 rollout,再创建执行工具的 future:

// The model emitted a tool call; log it, persist the item immediately, and queue the tool execution.
Ok(Some(call)) => {
    // ...
    record_completed_response_item(ctx.sess.as_ref(), ctx.step_context.as_ref(), &item)
        .await;                                  // 先等调用条目记录完
    let cancellation_token = ctx.cancellation_token.child_token();
    let tool_future: InFlightFuture<'static> = Box::pin(
        ctx.tool_runtime
            .clone()
            .handle_tool_call(call, cancellation_token),   // 再排队执行工具
    );
    // ...
}

记录会一路走到本地存储的 durable_write:追加条目后再 flush().await,等写入任务确认才返回( thread-store/src/local/live_writer.rs:372-384 )。这一步相当于"记意图",但只保证顺序:落盘失败时 persist_rollout_items 只打一条 error 日志、返回 false( core/src/session/mod.rs:4496-4503 ),工具照样执行。也就是"尽力先记",不是"记不上就不做"。旁边还有一句注释值得记:SQLite 是 JSONL 的派生视图,“can lag JSONL after failure, but can never get ahead of canonical history”( live_writer.rs:353-354 )。两份数据时,规定好谁是唯一的事实来源,另一份只能落后、不能超前。

**恢复后:有调用、没输出,就补一个 “aborted”。**工具跑到一半进程被杀,rollout 里只有 function_call,没有对应的输出。每次组装发给模型的历史时,ensure_call_outputs_present 会给这种调用补一条输出( core/src/context_manager/normalize.rs:52-67 ,由 history.rs:933-937 的 normalize_history 调用):

ResponseItem::FunctionCall { id, call_id, .. }
    if !function_output_ids.contains(call_id.as_str()) =>   // 这个调用找不到输出
{
    info!("Function call output is missing for call id: {call_id}");
    missing_outputs_to_insert.push((
        idx,                                                 // 记下位置,之后插在调用后面
        ResponseItemEnvelope::new(ResponseItem::FunctionCallOutput {
            id: synthetic_output_id("fco", id.as_deref()),
            call_id: Some(call_id.clone()),                  // 和调用配对
            // ...
            output: FunctionCallOutputPayload::from_text("aborted".to_string()),  // 内容就是 "aborted"
            // ...
        }),
    ));
}

这条输出只放进 prompt,不写回 rollout( normalize.rs:141-145 的注释:“Prompt normalization can run repeatedly without persisting its synthetic outputs”)。所以 Codex 不会自动重跑被打断的工具,而是告诉模型"这次调用中止了",由模型决定下一步。这也保证了发给模型的历史里每个调用都有配对的输出,和《Agent 到底是什么?一个 while 循环》里 tool_use / tool_result 必须配对是同一类不变量。

按我的理解,这在 harness 这一层是"至多一次";模型如果重新发起同一个调用,又变成了"至少一次"。shell 命令执行到一半,可能已经改了文件、发了请求,所以那条命令本身是否幂等,最后还是要由工具负责。

写到一半被杀:撕裂写

检查点自己也是一次写操作,也可能写到一半。实验里用"写一半就 SIGKILL"来模拟;真实环境里,大条目被缓冲区分几次写出、断电、磁盘满,都会留下半截数据。

写法写一半被杀会怎样实验(54 个崩溃点)
直接覆盖快照(先截断再写)文件只剩半个 JSON,旧的也没了12 个"写检查点中"的点全部恢复失败
写临时文件 + fsync + os.replace临时文件坏了,正式文件还是上一版54 个点全部正确
日志追加,读时跳过坏行,打开时补换行最后一行是半行,读的时候跳过54 个点全部正确
日志追加,读时跳过坏行,但不补换行续跑写的第一条事件粘在半行后面,一起变成坏行9 个点"重建不一致"

os.replace 的原子性来自 POSIX,Python 文档的原话是 “If successful, the renaming will be an atomic operation (this is a POSIX requirement)":读的人要么看到旧文件,要么看到新文件。

“不补换行"那一组最值得注意。续跑本身是对的:内存里的状态没问题,报告也没重复。坏的是磁盘上的日志少了一条事件,下一次恢复才会出错。3 个 Bug 页恰好没事,因为续跑后写的第一条事件是"意图”,被粘坏的是它,紧跟着的"本页完成"完好无损。这类 bug 只有在续跑结束后从磁盘再重建一次、和内存里的结果对比,才抓得到。

Codex 的两道防线

读 rollout 时,解析不了的行跳过并计数( rollout/src/recorder.rs:1105-1112 ):

let mut value: Value = match serde_json::from_str(&line) {
    Ok(value) => value,
    Err(e) => {
        warn!("failed to parse line as JSON: {line:?}, error: {e}");
        parse_errors = parse_errors.saturating_add(1);   // 计数,不中断
        continue;                                         // 跳过这一行
    }
};

打开 rollout 准备追加时,先检查最后一个字节是不是换行,不是就补一个( recorder.rs:2043-2056 ,在 :1765 和 :2034 两处打开文件时调用):

fn ensure_rollout_is_newline_terminated(file: &mut File) -> std::io::Result<()> {
    if file.metadata()?.len() == 0 {          // 空文件,不用管
        return Ok(());
    }
    file.seek(SeekFrom::End(-1))?;            // 跳到最后一个字节
    let mut final_byte = [0];
    file.read_exact(&mut final_byte)?;        // 读出来
    if final_byte[0] != b'\n' {               // 不是换行:上次写到一半
        file.write_all(b"\n")?;               // 补一个,把半行隔离成单独的一行
        file.flush()?;
    }
    Ok(())
}

实验里的 LogStore._open 做的是同一件事,log_norepair 就是去掉这一步的对照组。Codex 给这个分支写了测试( rollout/src/recorder_tests.rs:146-156 ):写一个没有结尾换行的文件,连续两次 open_log_file,断言文件末尾恰好多了一个 \n。第二次打开不再补,说明修补是幂等的:

#[test]
fn append_repair_terminates_nonempty_rollout_tail() -> std::io::Result<()> {
    let home = TempDir::new().expect("temp dir");
    let rollout_path = home.path().join("rollout.jsonl");
    fs::write(&rollout_path, b"{\"type\":\"event_msg\"}")?;   // 最后一行没有换行
    drop(open_log_file(&rollout_path)?);                           // 打开一次:补换行
    drop(open_log_file(&rollout_path)?);                           // 再打开一次:不应再补

    assert_eq!(fs::read(&rollout_path)?, b"{\"type\":\"event_msg\"}\n");  // 恰好一个换行
    Ok(())
}

kill -9 和断电不是一回事

write() 返回以后,数据在内核的页缓存里。进程被杀,内核照样会把它写回磁盘,所以扛 kill -9 只需要把数据交给内核;扛断电和内核崩溃,才需要 fsync。实验里日志模式从不 fsync,54 个 kill -9 全部恢复正确。

反过来,Python 的 open() 有用户态缓冲区,write() 了没 flush() 就被 kill -9,这行就丢了:

$ python3 -c "
import os, signal
f = open('/tmp/laq_buf.txt', 'w'); f.write('page_done 7\n')
g = open('/tmp/laq_buf2.txt', 'w'); g.write('page_done 7\n'); g.flush()
os.kill(os.getpid(), signal.SIGKILL)"
$ wc -c /tmp/laq_buf.txt /tmp/laq_buf2.txt
       0 /tmp/laq_buf.txt
      12 /tmp/laq_buf2.txt

Codex 的 rollout 每写一行都 write_all + flush,没有 fsync( recorder.rs:2091-2097 )。tokio 文档写明 flush 不保证数据写到物理磁盘,要用 sync_all。所以 rollout 扛得住 kill -9,断电时最后几行不保证。而迁移 rollout 时,先写 staged 文件并 sync_all( thread-store/src/local/rollout_migration.rs:740-746 ),再 rename 成正式的 rollout,然后 fsync 父目录( rollout_migration.rs:806-819 );子 Agent 迁移改写首行,同样是临时文件 + sync_all + rename( rollout_migration/publish.rs:200-213 )。迁移标记文件(journal,一个空文件)创建后也 fsync 文件和父目录( publish.rs:225-262 )。热路径每条都写,不 fsync;冷路径很少发生,做足。

本机(macOS)每次写入的中位耗时(一次运行):快照(临时文件 + fsync + rename)0.228 ms,日志追加一行(只 write)0.0020 ms,追加 + fsync 0.033 ms;复跑一次是 0.300 / 0.0025 / 0.037 ms,量级相同。macOS 的 fsync 不保证写穿磁盘自带的缓存,man 2 fsync 说要更强的保证得用 F_FULLFSYNC,所以这里 fsync 的数字偏乐观,换机器、换文件系统会差很多。

实验结果:穷举崩溃点

python3 crash_sweep.py 跑了两次。第一部分(穷举)是确定性的,两次逐字相同;第二、三部分依赖机器调度和磁盘,两次不同。下面是第一次的完整输出(合成数据):

合成数据:12 个页面,注入 Bug 的页面 [3, 7, 10],hybrid 每 4 页一份快照

== 1. 穷举崩溃点:每个点 kill -9 一次,再续跑到结束 ==
检查点 | 副作用 | 点数 | 正确 | 恢复失败 | 重复报告 | 漏报 | 状态不一致 | 重建不一致 | 平均重做模型调用 | 平均重放事件 | 最多重放事件
none | naive | 51 | 10 | 0 | 41 | 0 | 0 | 0 | 6.51 | 0 | 0
snapshot | naive | 51 | 45 | 0 | 6 | 0 | 0 | 0 | 0.76 | 0 | 0
snapshot | at_most_once | 54 | 45 | 0 | 0 | 3 | 6 | 0 | 0.61 | 0 | 0
snapshot | idempotent | 54 | 54 | 0 | 0 | 0 | 0 | 0 | 0.78 | 0 | 0
snapshot_inplace | idempotent | 54 | 42 | 12 | 0 | 0 | 0 | 0 | 0.71 | 0 | 0
log | idempotent | 54 | 54 | 0 | 0 | 0 | 0 | 0 | 0.78 | 7.26 | 15
log_norepair | idempotent | 54 | 45 | 0 | 0 | 0 | 0 | 9 | 0.78 | 7.26 | 15
hybrid | idempotent | 54 | 54 | 0 | 0 | 0 | 0 | 0 | 0.78 | 1.98 | 4

snapshot/naive 出错的崩溃点:p03:after_submit→重复报告,p03:mid_checkpoint→重复报告,p07:after_submit→重复报告,p07:mid_checkpoint→重复报告,p10:after_submit→重复报告,p10:mid_checkpoint→重复报告
snapshot/at_most_once 出错的崩溃点:p03:after_intent→漏报,p03:after_submit→状态与外部不一致,p03:mid_checkpoint→状态与外部不一致,p07:after_intent→漏报,p07:after_submit→状态与外部不一致,p07:mid_checkpoint→状态与外部不一致,p10:after_intent→漏报,p10:after_submit→状态与外部不一致,p10:mid_checkpoint→状态与外部不一致
snapshot_inplace/idempotent 出错的崩溃点:p01:mid_checkpoint→恢复失败,p02:mid_checkpoint→恢复失败,p03:mid_checkpoint→恢复失败,p04:mid_checkpoint→恢复失败,p05:mid_checkpoint→恢复失败,p06:mid_checkpoint→恢复失败,p07:mid_checkpoint→恢复失败,p08:mid_checkpoint→恢复失败,p09:mid_checkpoint→恢复失败,p10:mid_checkpoint→恢复失败,p11:mid_checkpoint→恢复失败,p12:mid_checkpoint→恢复失败
log_norepair/idempotent 出错的崩溃点:p01:mid_checkpoint→重建不一致,p02:mid_checkpoint→重建不一致,p04:mid_checkpoint→重建不一致,p05:mid_checkpoint→重建不一致,p06:mid_checkpoint→重建不一致,p08:mid_checkpoint→重建不一致,p09:mid_checkpoint→重建不一致,p11:mid_checkpoint→重建不一致,p12:mid_checkpoint→重建不一致

== 2. 外部 kill -9:每页两步各 sleep 10ms,随机时刻 SIGKILL,40 次 / 组,seed=2026 ==
snapshot/naive: {'正确': 39, '重复报告': 1}(其中 1 次进程已先跑完、没杀到)
snapshot/idempotent: {'正确': 40}(其中 1 次进程已先跑完、没杀到)
log/idempotent: {'正确': 40}(其中 1 次进程已先跑完、没杀到)

== 3. 每次写检查点的耗时(本机,200 次取中位数)==
快照(写临时文件 + fsync + rename):0.228 ms
日志追加一行(只 write,不 fsync):0.0020 ms
日志追加一行 + fsync:0.033 ms

第二次运行(第一部分与上面相同,省略):

== 2. 外部 kill -9:每页两步各 sleep 10ms,随机时刻 SIGKILL,40 次 / 组,seed=2026 ==
snapshot/naive: {'正确': 40}(其中 0 次进程已先跑完、没杀到)
snapshot/idempotent: {'正确': 40}(其中 0 次进程已先跑完、没杀到)
log/idempotent: {'正确': 40}(其中 0 次进程已先跑完、没杀到)

== 3. 每次写检查点的耗时(本机,200 次取中位数)==
快照(写临时文件 + fsync + rename):0.300 ms
日志追加一行(只 write,不 fsync):0.0025 ms
日志追加一行 + fsync:0.037 ms

几个读法:

  • 不存检查点最糟:51 个点里只有 10 个正确,就是第 3 页报告提交之前的那些点(第 1、2 页各 4 个,第 3 页 2 个);之后任何时候被杀,从头跑都会把已提交的报告再交一遍。
  • 崩溃点数:先做后记每页 4 个点(模型后、访问后、写检查点中、写完检查点),Bug 页多一个"提交后”,12 × 4 + 3 = 51;带意图的协议在 Bug 页再多一个"记意图后",54 个。
  • 随机 kill 很难撞上窗口:快照 + 先做后记,第一次运行随机 kill -9 40 次(实际杀到 39 次)只撞上 1 次重复报告,第二次运行 40 次全部"正确",一次都没撞上;穷举 51 个点,每次都确定地找出全部 6 个。按《30 次全过,能说明什么?》的 rule of three,39 次随机 kill 零失败,也只能说明触发率的 95% 上界约 3/39 ≈ 7.7%。
  • 重做 0.78 vs 0.76:带意图的协议在 Bug 页多了一个"记意图后"的崩溃点,那里被杀也要重做当前页,所以平均略高。至多一次是 0.61,因为它遇到中断的那页直接放弃、不重做,省下的成本换来的是漏报。

测试用例:

$ python3 -m unittest -v test_checkpoint_resume
test_hybrid_replay_is_bounded_by_snapshot_interval (test_checkpoint_resume.CrashSweepTest) ... ok
test_log_idempotent_survives_every_crash_point (test_checkpoint_resume.CrashSweepTest) ... ok
test_snapshot_naive_duplicates_only_in_submit_window (test_checkpoint_resume.CrashSweepTest) ... ok
test_apply_event_returns_new_state (test_checkpoint_resume.ReplayUnitTest) ... ok
test_inplace_snapshot_torn_write_is_detected (test_checkpoint_resume.ReplayUnitTest) ... ok
test_torn_last_line_is_skipped_and_newline_repaired (test_checkpoint_resume.ReplayUnitTest) ... ok

----------------------------------------------------------------------
Ran 6 tests in 17.184s

OK

test_snapshot_naive_duplicates_only_in_submit_window 断言的不只是"有重复",而是"出错的点恰好是 3 个 Bug 页的提交后和写检查点中":多一个、少一个都算失败。这比"跑一下看有没有报错"强得多。

核心代码

事件应用、原子快照、主循环(摘自 bughunt_worker.py):

def apply_event(state, ev):
    """把一条事件应用到状态上,返回新状态(不改原对象)。"""
    s = {**state, "findings": list(state["findings"]), "aborted": list(state["aborted"]), "seq": ev["seq"]}
    if ev["t"] == "intent":
        s["pending"] = {"page": ev["page"], "key": ev["key"]}
    elif ev["t"] == "page_done":
        s["next_page"] = ev["page"] + 1
        s["tokens"] += ev["tokens"]
        s["pending"] = None
        if ev["finding"]:
            s["findings"].append(ev["page"])
    elif ev["t"] == "aborted":
        s["next_page"] = ev["page"] + 1
        s["pending"] = None
        s["aborted"].append(ev["page"])
    return s


def atomic_write(path, data, torn):
    tmp = path.with_suffix(".tmp")
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
    try:
        write_all_or_torn(fd, data, torn)
        os.fsync(fd)
    finally:
        os.close(fd)
    os.replace(tmp, path)  # POSIX 保证 rename 原子:要么旧文件,要么新文件

# run() 里的主循环
    pending = state["pending"]
    if pending and effects == "at_most_once":
        record({"t": "aborted", "page": pending["page"]})  # 不知道提交过没有,宁可不做

    for page in range(state["next_page"], N_PAGES + 1):
        time.sleep(delay)
        ledger(root, f"model {page}")
        tokens = model_tokens(page)
        crash.point(page, "after_model")
        time.sleep(delay)
        ledger(root, f"visit {page}")
        crash.point(page, "after_visit")
        found = page in BUG_PAGES
        if found:
            key = None
            if effects != "naive":
                key = f"{TASK_ID}:p{page:02d}"
                record({"t": "intent", "page": page, "key": key})
                crash.point(page, "after_intent")
            submit_report(root, page, key)
            crash.point(page, "after_submit")
        record({"t": "page_done", "page": page, "tokens": tokens, "finding": found}, torn=crash.torn(page))
        crash.point(page, "after_checkpoint")

注意 idempotent 模式恢复时不需要特别处理 pending:意图记了但"本页完成"没记,next_page 还停在这一页,主循环自然从这一页重做,用的是同一个键。

测开视角:怎么测断点续跑

  1. **穷举崩溃点,而不是随机 kill。**把"会写盘或有副作用的每一步前后"都列成崩溃点,每个点杀一次。危险窗口很窄,一次实测里随机 kill 40 次只撞上 1 次(随机,每次运行可能不同)。
  2. **三方一致做判定。**外部系统(不重、不漏)、续跑结束时的内存结果、从磁盘重新加载的结果。最后一项专抓"这次对、下次恢复才错"的 bug。
  3. **断言出错的位置,而不只是出错的数量。**对已知有缺陷的写法(先做后记),断言出错的点恰好是哪几个,这样改动以后窗口变大变小都能发现。
  4. **撕裂写单独造。**直接写半行、半个 JSON 当输入,测读的一方能不能跳过、写的一方能不能隔离,并且重复打开不会多补(Codex 的 append_repair_terminates_nonempty_rollout_tail 就是这么写的)。
  5. 这次没覆盖的:续跑过程中又被杀(连环崩溃,可以把崩溃点扩成"第一次杀在 A、第二次杀在 B"的二元组);两个进程同时续跑同一个任务(要加锁,Codex 的 rollout crate 里有 writer_lock.rs,这次没读);磁盘满;断电(kill -9 测不出 fsync 的问题,要在虚拟机或用能丢页缓存的文件系统模拟)。

常见错误说法

  • “断点续跑就是定期把状态存下来”:还要处理被杀时正在做的副作用,和写到一半的检查点。
  • “恢复就是把日志里的工具调用重新执行一遍”:重放的是记录下来的结果,不重新调模型、不重新执行工具。
  • “flush 了就落盘了”:flush 只是交给操作系统,扛得住 kill -9;扛断电要 fsync,rename 之后还要 fsync 所在目录。
  • “随机 kill -9 几十次都没出问题,续跑就可靠”:窗口很窄,一次实测里 40 次只撞上 1 次(随机,每次运行可能不同);要穷举崩溃点。
  • “幂等键每次启动生成一个 uuid”:重启后键变了,去重失效;要么从任务 id 和步骤号派生,要么生成一次、随意图落盘,恢复时读回来。
  • “Codex 恢复时会把被打断的工具调用重跑一遍”:它在 prompt 里给缺输出的调用补一条 “aborted”,由模型决定;这条输出不写回 rollout。
  • “快照越频繁越好”:快照每次写整份状态,状态大了很贵;日志加定期快照,把恢复量限制在一个快照间隔以内,写入又只是追加一行。

下一章:《环境坏了,测试 Agent 会把它报成 Bug 吗?》,讲故障注入。用第 3 周的 mock 模型服务器制造流中断、429、超时和畸形 SSE,看 Agent 扛不扛得住。