Agent 跑到一半被 kill -9,从哪接着跑?

断点续跑 = 存什么 + 怎么拼回来 + 正在做的那一步怎么办。状态好存,难的是"副作用做完了、检查点还没写"的那一瞬间,和"检查点写到一半"的那一瞬间。

用 BugHunt 测试 Agent 做实验(合成数据,不调模型):在 51~54 个崩溃点上各 kill -9 一次再续跑,看哪种写法会重复提交 Bug 报告、哪种会把检查点写坏。最后对照 Codex 的 rollout 是怎么处理这两个瞬间的。

1问题:跑到第 7 页,进程没了

BugHunt 的测试 Agent 要依次探索 12 个页面,每页做三件事:问模型下一步、访问页面、发现 Bug 就向跟踪系统提交报告。第 3、7、10 页藏着注入的 Bug(合成数据)。跑到第 7 页,进程被杀了:机器内存不够被 OOM killer 杀掉、发布时滚动重启、有人顺手 kill -9。

重启后从头跑,有两个损失:前 6 页的模型调用重新花一遍钱;第 3 页的 Bug 报告又提交一次,跟踪系统里出现两条一样的报告。前一个是成本,后一个是正确性问题。断点续跑要回答四个问题:

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

本周前面两讲《长任务跑到一半,它现在到底是什么状态?》《超时了,再发一次安全吗?》讲了任务状态机和重试幂等。状态机回答"任务现在处在哪个状态",这一讲回答"进程死了以后,这个状态还在不在、对不对"。这里的"检查点"指持久化的恢复点,不是状态机那一讲里 worker 检查取消请求的位置。

2检查点粒度:存多细

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

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

粒度越细,丢的越少,写得越多。实验里步骤级快照的平均重做是 0.76 次模型调用(51 个崩溃点平均),不存检查点是 6.51 次。

3重放 vs 快照:怎么拼回来

方式存什么恢复时做什么优点缺点
事件日志只追加,一个事件一行从空状态开始,按顺序把每条事件应用一遍每次只写一行;完整历史可审计、可回放评测恢复时间和日志长度成正比
快照完整状态读一个文件恢复最快每次写整份;看不到中间过程;写一半就坏(见第 5 节)
快照 + 日志后缀两者都有,定期打快照读最新快照,只重放它之后的事件恢复量有上限两份数据要保持一致
"重放"重放的是记录,不是动作。重放时不重新调模型、不重新执行工具,只把记录下来的结果按顺序应用到状态上。这要求"应用事件"是一个确定性的纯函数:同一串事件,应用几遍都得到同一个状态。

实验里 12 页、3 个 Bug 页一共 15 条事件。纯日志恢复平均要重放 7.26 条、最多 15 条;每 4 页打一次快照后,平均 1.98 条、最多 4 条。

对照 Codex:rollout JSONL 是事件日志,压缩时写进去的 compacted 条目带着 replacement_history,相当于快照;codex resume 从最新的检查点开始、只重放它之后的条目。这些在《上下文快满了,Codex 怎么办?》讲过。补一个上次没展开的细节:恢复分两步。第一步 select_input_compaction(rollout_reconstruction.rs:58-88)只看最新的 compacted:字段齐全,才用它把扫描范围截到它之后;不齐全就返回 None,扫描全部条目(注释的理由是更新的压缩可能改过历史和窗口状态,不能拿更早的压缩去截短输入)。第二步倒着扫(:230-257、:138-144),仍然取最新的、没被回滚的、带 replacement_history 的那个当历史起点。所以"第一步返回 None"不等于"历史全量重放"。

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

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

协议做法实验结果(快照,每页一次)
先做后记(naive)提交,再写检查点,不带幂等键51 个崩溃点里 6 个重复报告:3 个 Bug 页 ×(提交后、写检查点中)
至多一次(at_most_once)提交前先记"意图";恢复时发现有意图、没结果,标成 aborted,不重做54 个点里 3 个漏报(意图记了、报告没交),6 个状态与外部不一致(报告交了、自己记成 aborted)
至少一次 + 幂等键提交前先记意图,键 = 任务 id + 页号;恢复时用同一个键重做,跟踪系统按键去重54 个点全部正确
幂等键必须在重启后保持不变:要么从确定的输入派生(任务 id + 步骤号,本实验的做法),要么生成一次(比如 V4 UUID)、随"意图"一起落盘,恢复时读回来用。错误的是每次启动重新生成,重启后键变了,去重就失效。这和上一讲的重试幂等是同一件事:续跑本质上就是"整个进程级别的重试"。

Codex 怎么处理这个窗口

先记录,再执行(尽力而为)。模型发出工具调用时,stream_events_utils.rs:324-357 的注释写的是 "persist the item immediately, and queue the tool execution":先 await 把调用条目写进 rollout,再创建执行工具的 future。本地存储的写入路径会等写入任务确认 flush 完才返回(live_writer.rs:372-384)。这一步相当于"记意图",但只保证顺序:落盘失败时 persist_rollout_items 只打 error 日志、返回 false(session/mod.rs:4496-4503),工具照样执行。是"尽力先记",不是"记不上就不做"。

恢复后:有调用、没输出,就补一个 "aborted"。如果工具跑到一半进程被杀,rollout 里只有 function_call,没有对应的 function_call_output。每次组装发给模型的历史时,ensure_call_outputs_present 会给这种调用补一条内容为 "aborted" 的输出,插在调用后面(normalize.rs:52-67、:134-137)。这条输出只放进 prompt,不写回 rollout(:141-145 的注释:"Prompt normalization can run repeatedly without persisting its synthetic outputs")。

也就是说,Codex 不会自动重新执行被打断的工具,而是告诉模型"这次调用中止了",由模型决定要不要再调一次。按我的理解,这在 harness 这一层是"至多一次";模型如果重新发起同一个调用,又变成了"至少一次"。所以那条 shell 命令是不是幂等的,最后还是要由工具自己负责。

5写到一半被杀:撕裂写

检查点自己也是一次写操作,也可能写到一半。实验里用"写一半就 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 时,解析不了的行跳过并计数(recorder.rs:1105-1112);打开 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(())
}

这个分支有测试:recorder_tests.rs:146-156 的 append_repair_terminates_nonempty_rollout_tail 写一个没有结尾换行的文件,连续打开两次,断言末尾恰好一个换行(修补是幂等的,第二次不会多补)。

kill -9 和断电不是一回事

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

反过来,Python 的 open() 有用户态缓冲区:f.write() 之后没 flush() 就被 kill -9,这行数据就没了(本机实测:没 flush 的文件 0 字节,flush 过的 12 字节)。

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

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

▶互动演示:选一个崩溃点,kill -9,再续跑

合成数据:12 个页面,第 3、7、10 页有 Bug(橙色下划线)。页面里的逻辑和本地实验脚本 bughunt_worker.py 一样:mock 模型 → 访问页面 → 有 Bug 时(记意图 →)提交报告 → 写检查点。浏览器里没有真的进程,"kill -9"是在选定位置中断执行;持久化文件、跟踪系统、续跑和判定都由 JS 实时算出来。

检查点
副作用

快速选择
判定
跟踪系统里的报告
重做的模型调用
恢复来源
重放事件 / 跳过坏行
检查点写入次数(两次运行合计)
执行 被杀 从检查点恢复,跳过 重做 标为 aborted
跟踪系统 reports 表(续跑结束后)

当前组合:穷举全部崩溃点

✎练习

题 1(危险窗口):每页结束写一次快照,副作用"先做后记"、不带幂等键。第 3 页报告提交成功,快照还没写,进程被 kill -9。续跑后跟踪系统里会怎样?
报告在被杀之前已经提交并提交了事务,跟踪系统里有一条;快照没写,续跑从第 3 页重新开始,又插入一条。SQLite 的事务只保证这一次插入完整,管不了"同一件事做两次",要靠幂等键(唯一约束)去重。
题 2(Codex):Codex 执行一个 shell 工具调用时进程被杀,rollout 里只有 function_call,没有输出。codex resume 之后,下一次请求模型时会怎样?
ensure_call_outputs_present 在组装 prompt 时给缺输出的调用补一条 "aborted" 输出,历史里不会留下没配对的调用;它也不会自动重跑工具。C 不对:正因为补了输出,请求里每个调用都有配对。这条合成输出只进 prompt,不写回 rollout。
题 3(算):不存检查点(重启从第 1 页开始),在第 7 页提交报告之后被杀。续跑完成时,一共多做了几次模型调用(和不崩溃相比)?
次
第一次运行已经做了第 1~7 页的模型调用,重启后从第 1 页做到第 12 页。总共 7 + 12 = 19 次,比不崩溃的 12 次多 7 次。演示里选"不存 + 先做后记"、拖到"第 7 页:提交后"可以验证。
题 4(算):快照 + 日志,每 4 页一份快照,副作用用"至少一次 + 幂等键"(Bug 页会多一条"意图"事件,Bug 在第 3、7、10 页)。在第 12 页写检查点写到一半时被杀,续跑要重放几条事件?
条
最新的快照在第 8 页结束时。之后的事件:第 9 页完成、第 10 页意图、第 10 页完成、第 11 页完成,共 4 条;第 12 页那一行只写了一半,被跳过(坏行 1)。
题 5(flush 与 fsync):日志每一行都 write + flush 交给了操作系统,但从不 fsync。下面哪种情况可能丢掉最后几行?
write 返回后数据已经在内核页缓存里,进程怎么死都不影响,内核会写回磁盘;B 和 C 都只是进程死了。断电(或内核崩溃)时页缓存里还没写回的数据会丢,要防这个才需要 fsync。Codex 的 rollout 就是这个取舍:每行 flush,不 fsync。

6面试要点与代码

一句话讲清楚

断点续跑分三层:状态怎么存(事件日志重放、快照、快照加日志后缀,粒度越细丢得越少、写得越多);副作用怎么办(先记意图再执行,恢复时用确定的幂等键重做,让外部系统去重,效果上恰好一次);检查点自己写坏了怎么办(快照写临时文件再原子 rename,日志读时跳过坏行、打开时补换行;扛 kill -9 只要交给内核,扛断电要 fsync)。测的时候不靠随机 kill,而是穷举每个崩溃点,用"外部系统、内存结果、从磁盘重建"三方一致做判定。

追问准备

  1. 为什么不随机 kill 几十次就算了?危险窗口很窄。一次实测里随机时刻 kill -9 40 次(实际杀到 39 次),只撞上 1 次重复报告(随机,每次运行可能不同);穷举 51 个崩溃点,确定地找出全部 6 个。按 rule of three,39 次随机 kill 零失败也只能说明触发率上界约 3/39 ≈ 7.7%。
  2. 恢复以后怎么判断"对"?三方对比:跟踪系统里的报告(不重、不漏)、续跑结束时内存里的结果、从磁盘重新加载一次的结果。最后一项专抓"这次对、下次恢复才错"的 bug,比如日志没补换行。
  3. Codex 恢复后会重跑被打断的工具吗?不会。缺输出的调用会补一条 "aborted" 的输出给模型,由模型决定。模型重新调用时,工具本身的幂等性就成了关键。
  4. 还有什么没覆盖?续跑过程中又被杀(连环崩溃)、两个进程同时续跑同一个任务(要加锁,Codex 的 rollout crate 里有 writer_lock.rs)、磁盘满。这个实验每次只杀一次,这些都没测。

核心代码(Python,标准库)

# 事件 → 新状态:确定性的纯函数,重放几遍结果都一样
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

# 快照:写临时文件 + fsync + 原子替换
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)

# 一页的主循环:Bug 页先记意图(带确定的幂等键),再提交,最后记"本页完成"
for page in range(state["next_page"], N_PAGES + 1):
    ...
    if page in BUG_PAGES:
        key = f"{TASK_ID}:p{page:02d}"
        record({"t": "intent", "page": page, "key": key})
        submit_report(root, page, key)          # INSERT OR IGNORE,key 有唯一约束
    record({"t": "page_done", "page": page, "tokens": tokens, "finding": page in BUG_PAGES})

完整代码在 week05_长任务可靠性/code/checkpoint_resume/:bughunt_worker.py(会被 kill -9 的 worker)、crash_sweep.py(穷举崩溃点 + 外部随机 kill + 写入耗时)、test_checkpoint_resume.py(unittest)。

常见错误说法

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

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