Agent 跑到一半崩了怎么办?聊聊 Restate 的持久化执行
journal + replay,让 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(...) 步骤:
- Restate Server 把「要执行这一步」这件事先写进 journal(持久化到 Log,多副本,达到 quorum 才算写成功)
- 真正调用用户的函数
- 把返回结果也写进 journal
- 通知 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 Agents | AI 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 解决的是同一类问题,但在执行模型和运维形态上差别较大:
| 维度 | Temporal | Restate |
|---|---|---|
| 任务分发 | Pull:Worker 进程轮询 Task Queue | Push:Server 主动调用你的服务(HTTP) |
| Worker 模式 | 需要常驻 Worker 进程 | 不需要;服务可以是 FaaS(Lambda 等)或普通 HTTP 服务 |
| 编程模型 | Workflow + Activity 分离:Workflow 代码必须确定性,非确定性放 Activity | Service + 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 系统?
核心改动
-
把 Agent 主循环包进一个 Restate handler
- 整个 ReAct 循环变成一次 invocation
- 每轮 LLM + 每个工具用
ctx.run包装 - Restate 保证这次 invocation 跑完为止
-
Plan 执行里的每个步骤用
ctx.run包装for action in plan.actions: await ctx.run(action.name, ...)- Plan 执行到第 N 步崩溃 → 重试时前 N-1 步从 journal 回放,第 N 步继续
-
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 最值得用的场景。
参考资料: