《AI Agent 高级编程》6.1 为什么需要持久化执行:崩溃/重启/长任务改造

Agent 一次任务要跑几十步、跨小时甚至跨天,进程一崩内存里的进度就全没了。本节用一个 append-only 日志把多步任务改造成可持久化执行:真跑一个在第 3 步硬崩溃的任务,第二次运行从日志跳过已完成步骤、只重放未完成部分,并用对照实验证明「有日志」比「从头重跑」少花模型调用。

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 确定性重放与副作用:模型调用变可重放步骤 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距