6.1 为什么需要持久化执行:崩溃/重启/长任务改造
卷一 7.3 检查点与快照
已经让 minagent 能「崩溃续跑」:把 Session 序列化成 JSON 写进临时文件,恢复后从断点接着走。但那一节解决的是一次 run_turn 内部的续跑——状态还在同一个进程的内存里绕了一圈才落盘。真实的长任务比这苛刻得多:进程可能被 OOM Killer 杀掉、容器可能被调度到另一台机器、一次审批可能跨过整个周末。这些场景里,「进程」这个载体根本不保证活着,唯一能指望的是写在外部存储上的执行进度。本节就把 minagent 的检查点思路升级成 Durable Execution(持久化执行):让「进度」本身成为一等公民,进程只是重放这份进度的临时躯壳。
本节把「崩溃续跑」从「进程内一次循环」推进到「任意次重启、任意时长」:用一个 append-only 日志记录每一步的输入输出,真跑一个在第 3 步硬崩溃的多步任务,证明第二次运行会跳过已完成步骤、只重放未完成部分;再用一组对照实验量化「有日志」比「从头重跑」省下的模型调用次数。
6.1.1 Agent 任务的三副面孔:长、贵、有副作用
为什么普通函数调用不需要 Durable Execution,而 Agent 任务需要?因为 Agent 任务同时具备三个特征,每一个都让「进程内存」这个默认状态容器变得不可靠:
| 特征 | 具体表现 | 对执行模型的压力 |
|---|---|---|
| 长 | 一次任务几十步,耗时从几分钟到几小时,甚至跨天(等人工审批、等外部回调) | 单进程的存活期不够覆盖整个任务 |
| 贵 | 每步几乎都要一次模型调用,token 成本随步数线性累积 | 从头重跑 = 把已经付过的钱再付一遍 |
| 有副作用 | 发邮件、写数据库、下单、调用外部 API、写文件 | 重跑 = 重复执行,可能造成真实损害 |
三个特征叠加,结论就很硬:崩溃后从零重跑既浪费钱、又可能闯祸。所以需要一个机制,能记住「哪些步已经做完」,崩溃后只补做没做完的。这正是 Durable Execution 要做的事。
6.1.2 内存里的状态为什么靠不住
有人会说:加个 try/except 兜住异常、重试不就行了?问题是大部分崩溃根本不给 Python 抛异常的机会。看这张表:
| 事件 | 内存里的 messages/steps | 后果 |
|---|---|---|
os._exit / kill -9 / 段错误 | 全丢,无异常 | try/except 完全无效 |
| OOM Killer 杀进程 | 全丢 | 同上 |
| 机器重启 / 容器被重调度 | 全丢 | 同上 |
| 滚动发布换掉旧实例 | 全丢 | 同上 |
| 主动等待(审批、回调) | 进程不能一直挂着 | 无法表达「暂停几小时」 |
注意第一行:os._exit() 和 kill -9 不会触发 finally、不会走异常栈、不会 flush 缓冲。这一点值得亲手验一下,因为它直接决定了「补救式」写法为什么行不通:
import os, atexit
def work():
try:
print("进入 try: 开始干活", flush=True)
os._exit(1) # 硬崩溃
except Exception as e:
print("except 捕获:", e) # 捕获不到
finally:
print("finally 执行了") # 不会执行
open("/tmp/ab_dur/finally_ran.txt", "w").write("ran")
atexit.register(lambda: print("atexit 钩子执行了")) # 不会执行
work()
本机实测:
进入 try: 开始干活
退出码=1
finally_ran.txt 存在? 否
except 没打印、finally 没打印、atexit 钩子也没打印,连「落一份临时状态」的机会都没有。这就宣判了所有「先干活、崩溃了再补救」的写法:在硬崩溃面前,补救代码根本没有运行的机会。唯一可靠的做法是反过来——先把手头这一步的结果落到可靠存储上,落盘成功了才算「这一步完成」;进程随时可以死,死了以后照着存储里的记录接着走。
这就是 Durable Execution 的核心不变量:一个步骤只有在它的输出被持久化之后,才算完成。这条不变量把「任务进度」从易失的内存搬到了可重放的日志上,进程从此变成无状态的执行器。
6.1.3 最小可运行:一个在第 3 步崩溃的多步任务
光说概念没用,直接上代码。下面这个脚本实现了一个 20 行的 Durable 引擎类,外加一个 5 步的研究任务,并在第 3 步注入一次硬崩溃(os._exit(1),模拟被强杀):
"""持久化执行最小实现:append-only 日志 + 可重放步骤。
第一次运行在第 3 步硬崩溃,第二次运行从日志恢复。"""
import json, os, sys
RUN = "/tmp/ab_dur/c1"
JOURNAL, CRASH, MLOG = RUN + "/journal.jsonl", RUN + "/crashed", RUN + "/model.log"
def fake_model(prompt):
with open(MLOG, "a") as f:
f.write(prompt + "\n") # 记一次真实"模型调用"
return {"plan": "拆成 Q1/Q2 两个子问题", "summarize": "结论: 跳过已完成步骤"}[prompt]
def fake_search(q):
return {"durable execution": "Temporal/DBOS 用事件溯源日志驱动工作流",
"deterministic replay": "重放要求相同输入产生相同输出"}[q]
class Durable:
def __init__(self):
self.done = {}
if os.path.exists(JOURNAL):
for line in open(JOURNAL, encoding="utf-8"):
if line.strip():
r = json.loads(line)
self.done[r["step"]] = r["output"]
self.replayed, self.executed = [], []
def step(self, name, fn):
if name in self.done: # 命中日志 -> 重放,不真执行
self.replayed.append(name)
return self.done[name]
out = fn()
self.executed.append(name)
with open(JOURNAL, "a", encoding="utf-8") as f:
f.write(json.dumps({"step": name, "output": out}, ensure_ascii=False) + "\n")
f.flush(); os.fsync(f.fileno()) # 落盘后才算"完成"
return out
def search_q2():
if not os.path.exists(CRASH): # 首次运行:第 3 步硬崩溃
open(CRASH, "w").write("x")
print("!! 第 3 步 [search:q2] 执行中硬崩溃 os._exit(1)")
sys.stdout.flush(); os._exit(1)
return fake_search("deterministic replay")
def run_task():
d = Durable()
d.step("plan", lambda: fake_model("plan"))
d.step("search:q1", lambda: fake_search("durable execution"))
d.step("search:q2", search_q2) # <-- 崩溃点
d.step("summarize", lambda: fake_model("summarize"))
d.step("write_report", lambda: "report.md 已写入")
return d
if __name__ == "__main__":
d = run_task()
n = sum(1 for _ in open(MLOG)) if os.path.exists(MLOG) else 0
print("已重放(来自日志):", d.replayed)
print("本次真执行: ", d.executed)
print("累计真实模型调用:", n, "次")
Durable.step 就是整个引擎的心脏:先查日志,命中就返回旧结果(重放),没命中才真执行、写日志、fsync 落盘。fsync 那一行不能省——不刷盘的话,进程被强杀时这一步的写入可能还在 OS 页缓存里,等于没写。用假模型驱动、本机实测(Python 3.14.6),第一次运行:
!! 第 3 步 [search:q2] 执行中硬崩溃 os._exit(1)
退出码=1
第二次运行(同一个脚本、同一个日志文件,模拟重启后重跑):
已重放(来自日志): ['plan', 'search:q1']
本次真执行: ['search:q2', 'summarize', 'write_report']
累计真实模型调用: 2 次
退出码=0
逐行读这份输出,持久化执行的效果一目了然:
已重放是['plan', 'search:q1']:崩溃前完成的两步没有真执行,直接从日志里取回了上次的输出。这就是「不重做已完成动作」的直接证据。本次真执行是['search:q2', 'summarize', 'write_report']:从崩溃点那一步接着走,一共补做了 3 步。累计真实模型调用: 2 次:plan与summarize各一次。注意plan只在第一次运行时真调过一次,第二次是重放,没有产生新的调用。
再看一眼这份日志文件长什么样——它就是「任务进度」的全部真相:
{"step": "plan", "output": "拆成 Q1/Q2 两个子问题"}
{"step": "search:q1", "output": "Temporal/DBOS 用事件溯源日志驱动工作流"}
{"step": "search:q2", "output": "重放要求相同输入产生相同输出"}
{"step": "summarize", "output": "结论: 跳过已完成步骤"}
{"step": "write_report", "output": "report.md 已写入"}
每一行是「一步的结论」,顺序即执行顺序。崩溃点 search:q2 那一步在第一次运行时没有留下任何记录(因为它在落盘之前就崩了),所以第二次运行才会把它当作「未完成」补做——这恰好验证了 6.1.2 那条不变量:没落盘 = 没完成。整个恢复逻辑不依赖任何进程内状态,只看这个文件,这就是它能跨进程、跨机器重放的原因。
6.1.4 对照:没有日志只能从头重跑
为了量化「日志」到底省了什么,我们把同一个任务去掉日志再跑一遍——状态只存在内存里,崩溃后重启只能从头来:
# naive.py 的核心:没有日志,每次调用都真执行
def run_task():
plan = fake_model("plan") # 每次运行都重新调用模型
q1 = fake_search("durable execution")
q2 = search_q2() # 第 3 步崩溃
summ = fake_model("summarize")
return plan, q1, q2, summ
同样跑两次(第一次崩溃、第二次重跑),实测累计模型调用:
| 方案 | 第 1 次运行 | 第 2 次运行 | 累计模型调用 | 副作用重做 |
|---|---|---|---|---|
| 有日志(Durable) | 崩溃 | 重放 2 步 + 补做 3 步 | 2 次 | 无 |
| 无日志(naive) | 崩溃 | 从头重跑 5 步 | 3 次 | 有风险 |
差别在这组小任务里只是「多一次模型调用」,但把规模放大就刺眼了:如果任务有 40 步、在第 39 步崩溃,naive 方案要把前 38 步全部重做——38 次模型调用、38 次工具调用、以及 38 次潜在副作用的重复执行。步数越多、单步越贵,日志方案的优势越是指数级拉开。这也是为什么 Durable Execution 在「长任务」上是必需品而非优化项。
6.1.5 四种典型的长任务场景
「长任务」不是抽象概念,它对应几类具体的产品形态。对照下面这张表,可以判断自己的 Agent 是否真的需要持久化执行:
| 场景 | 为什么长 | 崩溃/重启的代价 | 需要的能力 |
|---|---|---|---|
| 深度研究报告 | 拆题 → 多次检索 → 交叉验证 → 成文,几十步 | 已检索的几十次调用全废 | 步骤级重放 |
| 代码库批量重构 | 逐文件分析 + 逐文件改写,跨小时 | 已改写的文件状态不明 | 副作用幂等 + 进度记录 |
| 人工审批工作流 | 生成草稿后等主管审批,可能跨天 | 进程无法挂一天 | 可暂停/可恢复的执行 |
| 定时/周期性任务 | 每天定时跑,单次可能超时被中断 | 下次触发时状态未知 | 断点续跑 + 幂等 |
四类场景的共性是:任务生命周期远长于「一次进程能稳定存活的时长」。把状态放在内存里,等于把任务的生命押在进程不死上;一旦押错,代价是重跑整段。持久化执行就是把这份押注取消掉——状态在外部存储里,进程死活与任务进度解耦。
6.1.6 把长任务改造成可持久化的形状
Durable Execution 不是给现有代码套一层壳就能生效的,它要求你把任务改造成「可寻址、可重放」的形状。这份清单是从 6.1.3 那段代码里提炼出来的:
| 改造点 | 做法 | 为什么 |
|---|---|---|
| 把流程切成具名步骤 | 每步有唯一 name(如 search:q1) | 日志要靠名字寻址,匿名步骤无法定位 |
| 每步输出落盘 | 完成即 write + fsync | 崩溃后能从这一步之后接着走 |
| 副作用步骤单独命名 | 发通知、写库等单独成步 | 便于给它们加幂等保护(见 6.2) |
| 步骤输入必须确定 | 同一步的输入跨运行必须一致 | 否则重放对不上(见 6.2) |
| 恢复时跳过已完成 | 命中日志即重放 | 这是「不重跑」的机制保证 |
| 日志格式带版本号 | 加 version 字段 | 将来改结构时能识别旧数据 |
其中最容易被忽略的是**「步骤输入必须确定」**。6.1.3 的假模型之所以能重放,是因为它的输出只由输入决定;真实模型如果每次都换答案,重放就没意义了。这个问题——确定性重放与副作用隔离——是 6.2 的主题,也是 Durable Execution 里最容易翻车的一环。
6.1.7 这套机制替你做了什么、你又让渡了什么
自研的 Durable 引擎替你做的:把「进度」从易失内存搬到可重放的日志上,于是进程崩溃不再是灾难,重启只是「换一个躯壳继续跑」;已完成的步骤自动跳过,模型调用与副作用都不会重做。
你让渡的:每走一步都要序列化 + 写盘 + fsync,单步延迟增加;任务必须写成「具名步骤」的形状,不能是任意嵌套的循环;步骤的输入必须确定、副作用必须幂等,否则重放会出错。这些约束不是免费的,但它们换来的是「任务可以跨进程、跨机器、跨天存活」。
需要强调的是,本节这套 20 行引擎只是把机制讲清楚的最小实现——它没有并发保护、没有日志压缩、没有把存储换成数据库或对象存储。现成的框架(LangGraph 的检查点、Temporal 类模型)把这些工程细节都替你做了,代价是锁定到它的执行模型上。这两条路的边界在哪里,是 6.3 与自研检查点对照 要回答的问题。想先看框架源码里检查点是怎么实现的,可以翻 5.1 读 LangGraph ;想回看卷一那版单进程检查点,见 7.3 检查点与快照 。
下一节 6.2 确定性重放与副作用 会把「模型调用」变成真正可重放的步骤:录下输入输出,重放时命中记录就不再真调模型。
阅读导航:上一节:5.3 读 Claude Agent SDK:subagent 与 hook 调度(含 TS SDK 对照) · 下一节:6.2 确定性重放与副作用:模型调用变可重放步骤 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。