Agent 跑到一半崩了怎么办?聊聊 Restate 的持久化执行

journal + replay,让 ReAct 循环和多步编排真正「跑完为止」

March 16, 2026·5 min read·Yimin
#AI#Agent#Restate#Orchestration#Durable Execution#ReAct

Agent 循环跑到第 3 步时进程被 OOM kill 了,重启后从头来过——LLM 调了两遍,工具执行了两遍,用户收到重复通知。这种事在 AI Agent 的 ReAct 循环里不是偶发,是迟早会发生。

🎯 问题是什么?

一次典型的多步 Agent 任务大概长这样:

用户:"帮我分析 sales.csv,写一份报告,发邮件给团队"

第 1 轮:LLM 决定先读文件 → 调用 read_file 工具
第 2 轮:LLM 分析数据 → 调用 bash_exec 跑分析脚本
第 3 轮:LLM 生成报告 → 调用 write_file 写入
第 4 轮:LLM 准备发邮件 → 调用 send_email 工具

如果第 3 轮结束、第 4 轮执行到一半时进程崩溃了:

问题现象
进度丢失重启后不知道跑到第几步,只能从头
重复调用 LLM第 1-3 轮的 LLM 请求再跑一遍,花钱又耗时
工具重复执行write_file 可能重复写,send_email 可能发两封
结果不一致两次 LLM 调用返回不同结果,上下文混乱

这不是理论问题。一次跨越几十个步骤、多个 subagent、持续数分钟的复杂任务,进程崩溃、连接超时、OOM 的概率不低。

典型的「没有解法」:大多数 Agent 框架只把用户消息持久化到 DB,但 Agent 循环本身的执行进度、每一步的结果,都是内存状态——进程死了就没了。


🧠 Restate 的思路:把每一步「记下来」

Restate 是一个持久化执行平台,核心思想很朴素:

让每一个「有副作用的步骤」在执行前先写进一个 journal,失败后从 journal 重放,跳过已完成的步骤。

它不需要你改写执行逻辑,只需要把有副作用的操作用 ctx.run(...) 包起来:

// 没有 Restate:直接执行,崩了不知道有没有跑过
const result = await callLLM(messages, tools);

// 有 Restate:先记 journal,再执行;重试时读 journal 跳过
const result = await ctx.run("LLM call", () => callLLM(messages, tools));

这一行包装的代价很小,但带来的保障是:同一次 invocation 里,这个步骤只会真正执行一次


⚙️ 原理:Journal + Replay

整个机制分三层:

1. Journal:记录每一步的「做了什么 + 得到什么」

每当 handler 执行到一个 ctx.run(...) 步骤:

  1. Restate Server 把「要执行这一步」这件事先写进 journal(持久化到 Log,多副本,达到 quorum 才算写成功)
  2. 真正调用用户的函数
  3. 把返回结果也写进 journal
  4. 通知 handler 继续往下跑

从这一刻起,这个步骤就被「定格」了——无论进程怎么崩,结果都在 journal 里。

2. 崩溃后 Replay

进程崩了,Restate Server 检测到连接断开,发起重试:

时间轴:

                第3步执行中  ← 崩溃
第1步 ─── 第2步 ─── 第3步 ─✗

重试:

第1步(j) ─── 第2步(j) ─── 第3步(j) ─── 第4步 → 继续
   ↑               ↑               ↑
从 journal 读        从 journal 读        从 journal 读
不重新执行            不重新执行            不重新执行

(j) = 从 journal 回放,不会真正调用下游。

Handler 代码从第 1 步开始跑,但每次遇到 ctx.run(...),SDK 先查 journal——有记录就直接注入结果,没有才真正执行。

对代码来说,感觉就像没有崩溃过一样。

3. Restate Server 的架构

Restate Server 是一个用 Rust 写的单二进制,内部关键组件:

┌─────────────────────────────────────────────────────────┐
│                     Restate Server                       │
│                                                          │
│  Ingress  →  Durable Log (Bifrost)  →  Partition        │
│  (接收请求)   (持久化、多副本、quorum)   Processor        │
│                                         (驱动 handler)   │
│                        ↕                    ↕            │
│                    RocksDB              Journal/State    │
│                   (物化状态)            (快速读取)        │
└─────────────────────────────────────────────────────────┘
          ↕ HTTP/2 双向流
┌───────────────────────────────────────────┐
│           你的 Agent 进程                  │
│  handler(ctx) {                            │
│    ctx.run("LLM", ...)                     │
│    ctx.run("tool", ...)                    │
│  }                                         │
└───────────────────────────────────────────┘
  • Log(Bifrost)是唯一真相来源:所有步骤、状态更新、RPC 都先变成 log 里的事件,写进去才算「发生了」。
  • Partition Processor 是执行驱动器:读 log、调 handler、把 journal 发过去。
  • RocksDB 是缓存:把 log 里的内容物化成可快速访问的 journal + K/V,不是第二份真相,崩了可以从 log 重建。

🏛️ 三种用法

Restate 提供三种不同的抽象,对应不同场景:

抽象典型场景关键能力
Service + Handler多服务调用编排、Saga 补偿ctx.run、服务间 RPC、Awakeable(外部事件)、并行步骤
Workflow按 key 的长期流程(注册、审批)key 隔离、Durable Promise(ctx.promise)、可信号/可查询、定时器
Durable AgentsAI Agent ReAct 循环LLM 调用 + 工具执行全部进 journal,断点恢复,不重复调用

对 AI Agent 来说,最直接有用的是第三种——Durable Agents

// Agent handler 里的 ReAct 循环,每一步进 journal
while (true) {
  const result = await ctx.run("LLM call", () => callLLM(messages, tools));
  if (result.done) return result.text;

  for (const tc of result.toolCalls) {
    const output = await ctx.run(`tool:${tc.name}`, () => runTool(tc));
    messages.push(toolResult(tc.id, tc.name, output));
  }
}

每一轮 LLM、每一个工具执行都进了 journal。崩溃后,Restate 把 handler 在新进程里重新调起,带上 journal——前面的轮次从 journal 注入结果,从第一个未完成的步骤继续。


🔍 和 Temporal 有什么区别?

Restate 和 Temporal 解决的是同一类问题,但在执行模型和运维形态上差别较大:

维度TemporalRestate
任务分发Pull:Worker 进程轮询 Task QueuePush:Server 主动调用你的服务(HTTP)
Worker 模式需要常驻 Worker 进程不需要;服务可以是 FaaS(Lambda 等)或普通 HTTP 服务
编程模型Workflow + Activity 分离:Workflow 代码必须确定性,非确定性放 ActivityService + Handler 一体:通过 ctx.run 包装副作用,不强制确定性
部署依赖需要 Temporal Server 集群 + 外部 DB(PostgreSQL/Cassandra)单二进制,内嵌 Log + 存储,无外部 DB 也能跑
内置状态状态通常放外部 DB,由 Activity 读写内置 K/V(Virtual Object),与 journal 一起持久化

选 Temporal:已有常驻 Worker 架构、需要丰富生态和最佳实践文档。
选 Restate:想快速上手、偏 Serverless/FaaS 部署、要内置 K/V、不想管外部 DB。

Restate 的「单二进制、无外部依赖」特性对轻量化 Agent 服务更友好——本地开发直接 restate-server,生产可以 Docker 跑,不需要配 PostgreSQL。


🚀 怎么接进 Agent 系统?

核心改动

  1. 把 Agent 主循环包进一个 Restate handler

    • 整个 ReAct 循环变成一次 invocation
    • 每轮 LLM + 每个工具用 ctx.run 包装
    • Restate 保证这次 invocation 跑完为止
  2. Plan 执行里的每个步骤用 ctx.run 包装

    • for action in plan.actions: await ctx.run(action.name, ...)
    • Plan 执行到第 N 步崩溃 → 重试时前 N-1 步从 journal 回放,第 N 步继续
  3. Subagent 调用用 ctx.serviceClient 做服务间调用

    • 父 agent 调子 agent,本身也是一个 durable 调用
    • 子 agent 挂了,父 agent 的这步会被重试,不会丢

不需要改动的部分

  • 业务逻辑不用动,只是「包一层」
  • 数据库、Channel 层不受影响
  • 可以先只对「长时间 Job」接 Restate,对话型 ReAct 保持原来的方式

重放是自动的,不用写额外代码

「重放」不是你实现的功能——是 Restate SDK 在 replay 阶段替你做的。你只需要:

  • 所有副作用都通过 ctx.run(LLM 调用、工具执行)
  • Handler 逻辑不依赖进程级全局状态(每次重入都从 messages 数组重建上下文)

满足这两点,Restate 就能帮你透明地做断点续跑。


⚠️ 需要注意的地方

ctx.run 里的函数要写对

如果把同一个步骤起了两个不同的名字,Restate 会认为是两个步骤。步骤的名字(第一个参数)要和执行内容对应,崩溃重试时才能正确匹配 journal。

Handler 代码本身不能有隐藏的副作用

ctx.run 外面调 LLM、写文件这种事不能做——Restate 不知道这些操作发生过,重试时会重复执行。「所有副作用都进 ctx.run」是前提条件。

Restate Server 也要高可用

进程挂了 journal 还在,但如果 Restate Server 本身是单点,它挂了也没有。生产上要么多副本部署,要么用 Restate Cloud——它帮你管。


📝 总结

问题Restate 的解法
Agent ReAct 中途崩溃Journal 记录每步,重试时从断点继续
LLM 调用重复ctx.run("LLM call", ...) 进 journal,重试时回放结果
工具执行重复ctx.run("tool:name", ...) 进 journal,重试时回放结果
多步编排进度丢失Invocation 级 journal,跨步骤可恢复
部署复杂单二进制,无外部 DB,restate-server 即可

Restate 的核心思想其实并不复杂:先写日志,再做事;重试时读日志,跳过已完成的

这和 WAL(Write-Ahead Log)的思想一脉相承,只是把粒度从数据库的事务提升到了应用代码的函数调用级别。

一个普通的 Agent Loop 是「尽力而为」——崩了就崩了,重来就重来。Restate 能把它变成「承诺跑完」——不管中间发生什么,这次任务一定会执行到完成。

对于跨多个 subagent、执行几十步、持续数分钟的复杂编排任务,「跑到一半崩掉重来」是不可接受的——这正是 Restate 最值得用的场景。


参考资料: