DAY 58 · ENGINEERING

Durable Agent Execution

Replay Journal · Exactly-Once Side Effects · Determinism Boundary · Durable Suspend

2026-07-11 · BigCat

Agent 崩了不该从头再跑一遍——让它从崩溃的那一步接着走。

// WHY THIS MATTERS

你写的 agent 跑了 8 分钟、调了 12 次工具、烧了几万 token,第 13 步进程被 OOM 杀了——然后呢?多数人的 harness 答案是「整个从头再来」,包括那些已经成功、已经付过钱、已经发过邮件的步骤。这在 demo 里无所谓,在生产里是灾难。Agent 的执行时间从秒级涨到分钟甚至小时级,每一步都可能撞上 rate limit、网络抖动、worker 重启,「进程内的 while 循环」这种执行模型根本扛不住。答案叫 durable execution:把 agentic loop 从内存里的循环,变成一条可重放的日志。这一期讲四件事——durable execution 的机制、为什么 checkpoint 快照还不够、副作用怎么做到 exactly-once、以及 replay 确定性怎么和 LLM 的非确定性和解。这是把 Day 39(错误恢复)从「retry 单步」升级到「整条 agent 存活」的那一层。

// 01

Durable Execution:把 agentic loop 跑成可重放的日志

论断:durable execution 不是「更强的 retry」,是把整个 agent 的执行历史记成一条 journal,崩溃后 replay 回到断点——中间成功的步骤一步都不重跑。

背景与原理

普通 agent loop 的状态活在进程内存里:messages 数组、循环计数、局部变量。进程一死,全没了。Durable execution 引擎(Temporal / Restate / DBOS)换了个模型:你的 workflow 函数每执行一个「持久步骤」,引擎就把这一步的输入和输出写进一条 journal(event log)。进程崩溃后,引擎在新 worker 上重新运行同一个函数,但已经在 journal 里的步骤不再真正执行——直接把记录的返回值replay 回去,直到追上崩溃点,再继续往下跑。

对 agent 这个模型是量身定做:LLM 调用又慢又贵(1–30 秒、真金白银),工具调用可能有副作用,任务动辄跨分钟。Temporal 每个持久步骤只加约 10–50ms 开销——相对 LLM 调用可忽略,换来的是「worker 随便被杀,agent 都能从上一个成功步骤接着走」。DBOS 更轻,直接把 journal 存进你已有的 Postgres,一个 pip 包、不需要额外的 broker 或控制面。

普通 loop(内存态) Durable Execution(journal 态) ───────────────────────── ─────────────────────────────────── step1 ✓ (in RAM) step1 ✓ ─┐ step2 ✓ (in RAM) step2 ✓ ├─▶ JOURNAL (event log, 持久) step3 ✓ (in RAM) step3 ✓ ─┘ │ step4 ✗ CRASH step4 ✗ CRASH │ ───────────────── ───────────────── ▼ 重启 ⇒ step1 从头再跑 重启 ⇒ 新 worker 重放函数: ❌ 重复 LLM / 重复付款 step1→replay(记录值,不执行) ❌ 已花的 token 全废 step2→replay step3→replay step4→真正执行 ← 从这里继续 ✅ 前 3 步零重跑

实战示例

DBOS 的写法最能看清「哪些是持久步骤」——@DBOS.workflow 是可重放的编排,@DBOS.step 是被 journal 记录的原子步骤:

from dbos import DBOS

@DBOS.step()                       # 每个 step 的返回值被写进 Postgres journal
def call_llm(msgs): return client.messages.create(...)

@DBOS.step()
def run_tool(name, args): return TOOLS[name](args)

@DBOS.workflow()                   # 崩溃后从上一个已完成 step 重放续跑
def agent(task):
    msgs = [{"role":"user","content":task}]
    for _ in range(30):
        r = call_llm(msgs)               # 已成功的轮次 replay 时不重发
        if r.stop_reason == "end_turn": return r
        out = run_tool(...)              # 崩在这里,重启后前面全部跳过
        msgs += [...]

你几乎没改 agent 逻辑——只是给「昂贵/有副作用」的操作套上 step,给循环套上 workflow。执行语义就从「崩了全丢」变成「崩了续跑」。

失败模式:把所有东西都塞进一个巨型 step(比如整个 loop),journal 粒度太粗——崩溃只能回到 loop 起点,等于没有 durable。粒度反过来太细(每个字符串拼接都 step)则 journal 爆炸、replay 变慢。正确粒度:每个昂贵或有副作用的边界(一次 LLM 调用、一次 tool 调用)一个 step。
进阶资源 · Restate What is Durable Execution, restate.dev/what-is-durable-execution · DBOS Why DBOS, docs.dbos.dev/why-dbos · Temporal Durable Execution meets AI, temporal.io/blog
// 02

Checkpoint ≠ Durable Execution:快照救不了「节点跑一半崩」

论断:LangGraph 的 checkpointer 存的是「节点之间」的状态快照,而不是「节点之内」的执行进度——一个节点跑到一半崩,整个节点会重头再跑一遍。

背景与原理

很多人以为「我用了 LangGraph checkpointer / 存了 state 到 DB」就等于 durable。不是。Checkpoint 是在每个 super-step(节点边界)之后存一张状态快照;它给你的是记忆、human-in-the-loop、和节点级的容错。但崩溃的粒度是残酷的:如果一个节点内部先调了 LLM、又发了邮件,然后在第三行崩了——重启时整个节点从第一行重放,LLM 会重调、邮件会重发。Diagrid 那篇 Checkpoints Are Not Durable Execution 把这点说透了:快照记的是「到过哪些格子」,不是「格子里做过哪些不可撤销的动作」。

LangGraph 自己暴露了三档 durability 来让你选权衡:exit(只在退出时落盘,最快,崩了不可恢复)、async(异步落盘,有小概率丢最后一步)、sync(每步同步落盘再继续,最慢但最稳)。选 sync 也只把恢复粒度做到节点边界,节点内的原子性仍然要你自己保证。这就是它和 Temporal/Restate 这类步骤级 journal 引擎的本质差距。

实战示例

# ❌ 危险:一个节点里塞了两个有副作用的动作
def node_book_trip(state):
    flight = charge_card(state)      # 副作用 1:扣款成功
    email  = send_confirm(flight)    # 这里崩 → 重放时 charge_card 再扣一次!
    return {"booking": email}

# ✅ 修法 A:拆成单副作用节点,让 checkpoint 边界=副作用边界
graph.add_node("charge", charge_node)   # 每个节点最多一个不可逆动作
graph.add_node("email",  email_node)
# 编译时选最稳一档,并绑定 thread_id 作为续跑句柄
app = graph.compile(checkpointer=saver, durability="sync")
app.invoke(inp, config={"configurable":{"thread_id":uid}})

核心纪律:checkpoint 引擎里,「一个节点 = 一个可安全重放的单元」。把节点切到「最多含一个不可逆副作用」,快照边界才真正对齐了危险边界。要么就升级到步骤级 journal 的引擎(§03)。

失败模式:用 durability="exit" 图省事,结果长任务中途崩溃后 thread 里啥都没有——因为它只在正常退出时才落盘。长时/高风险的 agent 至少要 async,涉及扣款/发信/写外部系统的一律 sync + 单副作用节点。
进阶资源 · LangGraph Persistence 文档, docs.langchain.com/.../persistence · Diagrid Checkpoints Are Not Durable Execution, diagrid.io/blog
// 03

副作用的 Exactly-Once:LLM 和工具调用必须 journal 化 + 幂等

论断:replay 的正确性建立在一条铁律上——已记录的步骤只重放数据,绝不重新执行;而引擎管不到的外部世界(付款 API、发邮件),得靠你自己加幂等键。

背景与原理

durable execution 的「exactly-once」有个容易被误读的边界:引擎保证的是step 的返回值在 journal 里只被物化一次——重放时不会二次调用你的函数体。Restate 的 ctx.run()、Temporal 的 activity、DBOS 的 @DBOS.step 都是这个语义。但引擎无法阻止被包裹的那次调用本身对外部世界产生重复效果——如果你在 step 第一次执行时就同时扣了两次款,journal 只会忠实记下这个结果。

所以真正的工程纪律是两层:(1)把每个外部调用都包成一个 step/activity,让引擎负责「不重放执行」;(2)对非幂等的外部 API,在 step 内部再带上 idempotency key,让下游服务负责「重复请求去重」。Restate 甚至支持请求级 idempotency key 自动去重。LLM 调用虽然「无外部副作用」,也必须包成 step——否则 replay 时会重新烧一次 token,贵得毫无必要。

实战示例

# 幂等键 = workflow 内稳定可复现的 ID(不能用随机数/时间戳!)
@DBOS.step()
def charge(user, amount, idem_key):
    return stripe.PaymentIntent.create(
        amount=amount, customer=user,
        idempotency_key=idem_key)      # ← 下游 Stripe 去重,重放/重试都只扣一次

@DBOS.workflow()
def checkout(user, cart):
    # key 由 workflow 身份派生,replay 时值不变 → 天然幂等
    key = f"pay-{DBOS.workflow_id}"
    charge(user, cart.total, key)      # 崩溃重放:step 已记录 → 不再执行
    send_receipt(user)                 # 若在此崩,charge 不会二次触发

关键在幂等键的来源:它必须从 workflow 的持久身份(workflow_id + 步骤序号)派生,这样每次 replay 算出来的 key 都一样。用 uuid4() 或时间戳当 key 是最经典的坑——每次重放生成新 key,下游认不出是同一笔,去重失效。

失败模式:给 tool 结果做缓存却忘了 LLM 输出不确定——把「读文件」这种确定性 tool 和「问模型」这种非确定性调用用同一套 memoization 逻辑处理。读文件重放拿旧值没问题;但如果业务依赖「每次都问最新模型」,就不该无脑 replay 缓存值,得显式标记该 step 为可重算。分清哪些 step 要 replay 记录值、哪些要重新执行,是设计时就要定的。
进阶资源 · Restate Durable AI Loops, restate.dev/blog/durable-ai-loops · Restate AI examples(agents/A2A/MCP), github.com/restatedev/ai-examples
// 04

Replay 确定性 vs LLM 非确定性:把不确定赶进 activity

论断:durable execution 要求 workflow 代码确定性可重放,而 LLM、随机、时钟、网络天生不确定——解法是把所有不确定性挤出 workflow 主体,关进被 journal 记录的 step 里。

背景与原理

replay 能工作的前提是:重新运行 workflow 函数时,它的控制流走得和第一次逐字节一致——否则重放到一半,代码走到了 journal 里不存在的分支,引擎就懵了。这要求 workflow 主体里不能直接出现:随机数、now()/时钟、直接的网络/文件 IO、以及……直接 LLM 调用。这些都是「不确定源」。这正好和 Day 56 讲的采样确定性是两种不同的确定性:那期讲「同 prompt 同输出」,这里讲「同函数重放走同一条路」。

解法统一而优雅:把每一个不确定源都包进一个 step/activity。第一次执行时它真跑、结果进 journal;replay 时它不跑、直接吐 journal 里的旧值——于是从 workflow 的视角看,连「掷骰子」都变成了确定的。LLM 的非确定输出被这一层「冻结」进日志,workflow 主体就重新变回一个纯粹、可重放的状态机。这也顺带解锁了 durable execution 最杀的一个能力:长时暂停——用 durable timer + signal,agent 可以「睡」几天等人审批,期间不占任何常驻进程,审批信号一到就从睡点唤醒续跑。

WORKFLOW 主体(必须确定、可重放) ┌───────────────────────────────────────────┐ │ plan → decide → loop → ... │ ← 只有纯逻辑/控制流 │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────┐ ┌──────┐ ┌──────────────┐ │ │ │ STEP │ │ STEP │ │ durable timer│ │ │ │ LLM │ │ tool │ │ + signal(等审批) │ │ └──┬───┘ └──┬───┘ └──────┬───────┘ │ └─────┼────────┼───────────┼─────────────────┘ ▼ ▼ ▼ [不确定性全部被关进 step / 由 journal 冻结] replay 时:step 吐旧值,timer 立即到期 → 主体走同一条路

实战示例

# Temporal 风格:workflow 确定,不确定性走 activity
@workflow.defn
class ApprovalAgent:
    def __init__(self): self._approved = None
    @workflow.signal                       # 外部审批用 signal 打进来
    def approve(self, ok): self._approved = ok
    @workflow.run
    async def run(self, task):
        plan = await workflow.execute_activity(  # LLM 调用 = activity
            call_llm, task, start_to_close_timeout=TIMEOUT)
        # 睡到「有人 approve」或 3 天超时——零常驻进程
        await workflow.wait_condition(
            lambda: self._approved is not None,
            timeout=timedelta(days=3))
        if self._approved:
            await workflow.execute_activity(apply_plan, plan, ...)

注意 call_llm 从不在 workflow 体里直接调——它永远是个 activity。wait_condition + signal 让 agent 挂起数天而不烧一分钱算力,这是「异步人审批队列」(Day 34)最干净的落地方式。

失败模式:在 workflow 主体里写 if random() > 0.5datetime.now() 做分支——replay 时值变了,控制流分叉,引擎报 non-determinism error 直接崩。同理:workflow 里 import requests 直接发请求、或用会随版本变化的库做核心分支,都会破坏可重放性。铁律:workflow 体只碰纯逻辑,一切副作用与不确定性下沉到 step/activity。
进阶资源 · Jack Vanlightly Demystifying Determinism in Durable Execution, jack-vanlightly.com · Temporal Of course you can build dynamic AI agents, temporal.io/blog

// 综合实战 · 给你的长时 agent 装上「断点续跑」

把这一期串成一个可验证的小工程:拿你现有的任意 agent loop,给它加上 durable 执行,并亲手 kill 进程验证它真能续跑

  1. 选引擎:已有 Postgres → DBOS(最轻,pip 装);要跨语言/复杂编排 → Temporal;要贴着现有框架 → Restate。别一上来就上最重的。
  2. 划 step 边界(§01):每次 LLM 调用、每次 tool 调用各一个 step;循环体是 workflow。别塞巨型 step。
  3. 切副作用节点(§02):任何「扣款/发信/写外部系统」的动作,单独成一个 step,且一个 step 最多一个不可逆动作
  4. 加幂等键(§03):每个外部写操作带上从 workflow_id 派生的 idempotency key。绝不用 uuid4()/时间戳。
  5. 清不确定性(§04):把 workflow 体里的 random/now()/直接 IO 全部下沉到 step。
  6. 混沌验证:跑到一半 kill -9 worker,重启,确认——(a)已完成的 LLM 调用没重发(看 token 账单)、(b)扣款只发生一次、(c)agent 从断点继续而非从头。这一步是全部工程的唯一 ground truth:没亲手 kill 过,就不算 durable。

做完你会得到一个反直觉的体感:durable execution 几乎没改你的 agent 逻辑,却把「demo 级可靠性」变成了「生产级可靠性」——差别全在执行底座,不在 prompt。

// ENGLISH GLOSSARY

Durable Execution
把程序执行历史记成可持久 journal、崩溃后 replay 续跑的编程范式。Temporal/Restate/DBOS 的核心。
Journal / Event Log
记录每个持久步骤输入输出的日志;replay 的数据源。
Replay
崩溃后重新运行 workflow 函数,已记录步骤直接吐旧值不重执行,追上断点后继续。
Workflow vs Activity/Step
workflow=确定可重放的编排;activity/step=被 journal 记录、可含不确定性与副作用的原子单元。
Checkpointer
LangGraph 在节点边界存状态快照的机制;是节点级容错,非步骤级 durable execution。
Durability Mode
LangGraph 的 exit/async/sync 三档落盘时机权衡。
Exactly-Once
step 返回值在 journal 中只物化一次;外部副作用的 exactly-once 仍需幂等键配合。
Idempotency Key
从 workflow 持久身份派生的稳定键,让下游对重复请求去重。
Determinism (replay)
workflow 重放时控制流逐字节一致的要求;区别于 Day 56 的采样确定性。
Durable Timer / Signal
让 agent 挂起数天等外部事件、零常驻进程唤醒续跑的原语。

// 深入思考

replay 会重放旧的 LLM 输出。但如果我就是想让 agent 崩溃后「用最新模型重新想一遍」,而不是重放旧答案,该怎么办?
这触到了 durable execution 的一个设计张力:replay 默认冻结历史,而你有时想要「fresh」。解法是显式区分 step 的语义。多数引擎允许你把某个调用标记为「不进 journal / 每次重算」(non-durable / local),或者用版本化 activity:旧 journal 的 step 保持旧值,只有新触发的才走新模型。更常见的做法是「不在同一 workflow 内换脑」——重来一遍想法应该是一个新 workflow 实例,而不是同一实例的 replay。混淆「恢复执行」和「重新决策」是设计事故的根源:前者要一致,后者要新鲜,不该共用一条路径。
durable execution 要求 workflow 确定可重放,可 agent 的本质就是让 LLM 动态决定下一步。这两者不矛盾吗?
不矛盾,因为「谁不确定」被精确切开了。LLM 的决策确实不确定——但那次决策一旦发生,它的输出就被 activity 冻进 journal,成了一个确定的值。workflow 主体只是「拿着这个已确定的决策值走 if/else」,而 if/else 本身是确定的。换句话说:非确定性发生在 activity 边界,workflow 看到的永远是已经坍缩过的结果。Temporal 那篇《Of course you can build dynamic AI agents》就是在反驳「durable = 只能静态 DAG」的误解:动态 agent 完全能跑在 durable 引擎上,只要不确定性都下沉到 activity。
什么时候「不」该上 durable execution?它的成本在哪?
三种情况别上:(1)任务是秒级、无副作用、可无脑重试的——加 journal 只是徒增复杂度和一个数据库依赖;(2)团队还没把 agent 逻辑跑通,过早上 Temporal 会让调试更难(多了 worker/journal 心智负担);(3)副作用本身天然幂等或可无损重放(纯读)。成本主要是:每步的持久化开销与延迟、一套要运维的引擎/DB、以及「workflow 必须确定」带来的写码约束(不能随手 now())。判据和 Day 03 的 workflow-vs-agent 一样:能用简单的就别上重的,直到「崩溃丢进度」真的开始疼。
Checkpoint(LangGraph)和步骤级 journal(Temporal)之间,有没有中间地带?怎么选?
有,而且实践里常混用。选择轴是「副作用密度 × 任务时长 × 团队运维能力」。纯对话/短 RAG agent:LangGraph checkpointer 足够,还自带 time-travel 调试和 human-in-the-loop。一旦出现跨系统写、扣款、多天挂起、严格 exactly-once,就该上步骤级 journal 引擎。中间地带:用 LangGraph 写 agent 逻辑,但把它整个作为一个 activity 跑在 Temporal 里(近来 ZenML/AppScale 都在推这种组合)——LangGraph 管图内编排,Temporal 管跨图的持久与重试。不必二选一。
幂等键必须从 workflow 身份派生。可如果 agent 动态决定「同一个工具调用 10 次」,每次的 key 怎么保证既稳定又互不相同?
关键洞察:幂等键要绑的不是「调用内容」而是「调用在 journal 中的位置」。引擎给每个 step 一个确定递增的序号(step_id),replay 时序号复现一致。所以 key = workflow_id + step_id(或 + 一个稳定的循环下标),天然满足「同一次调用 replay 时 key 不变、不同次调用 key 不同」。千万别用调用内容做 key——内容相同的两次故意重复调用(比如轮询)会被错误去重成一次。这也是为什么幂等键该由引擎/框架派生,而不是你手写:手写极易在「稳定」和「唯一」之间顾此失彼。

// 延伸阅读