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 个点全部正确 |
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 实时算出来。
当前组合:穷举全部崩溃点
✎练习
function_call,没有输出。codex resume 之后,下一次请求模型时会怎样?ensure_call_outputs_present 在组装 prompt 时给缺输出的调用补一条 "aborted" 输出,历史里不会留下没配对的调用;它也不会自动重跑工具。C 不对:正因为补了输出,请求里每个调用都有配对。这条合成输出只进 prompt,不写回 rollout。write + flush 交给了操作系统,但从不 fsync。下面哪种情况可能丢掉最后几行?6面试要点与代码
一句话讲清楚
断点续跑分三层:状态怎么存(事件日志重放、快照、快照加日志后缀,粒度越细丢得越少、写得越多);副作用怎么办(先记意图再执行,恢复时用确定的幂等键重做,让外部系统去重,效果上恰好一次);检查点自己写坏了怎么办(快照写临时文件再原子 rename,日志读时跳过坏行、打开时补换行;扛 kill -9 只要交给内核,扛断电要 fsync)。测的时候不靠随机 kill,而是穷举每个崩溃点,用"外部系统、内存结果、从磁盘重建"三方一致做判定。
追问准备
- 为什么不随机 kill 几十次就算了?危险窗口很窄。一次实测里随机时刻 kill -9 40 次(实际杀到 39 次),只撞上 1 次重复报告(随机,每次运行可能不同);穷举 51 个崩溃点,确定地找出全部 6 个。按 rule of three,39 次随机 kill 零失败也只能说明触发率上界约 3/39 ≈ 7.7%。
- 恢复以后怎么判断"对"?三方对比:跟踪系统里的报告(不重、不漏)、续跑结束时内存里的结果、从磁盘重新加载一次的结果。最后一项专抓"这次对、下次恢复才错"的 bug,比如日志没补换行。
- Codex 恢复后会重跑被打断的工具吗?不会。缺输出的调用会补一条 "aborted" 的输出给模型,由模型决定。模型重新调用时,工具本身的幂等性就成了关键。
- 还有什么没覆盖?续跑过程中又被杀(连环崩溃)、两个进程同时续跑同一个任务(要加锁,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 扛不扛得住。