Skip to main content

04 - DBOS 与 Restate

前置03 篇的确定性约束。两个方案都在回应 Temporal 的接入成本问题。

本篇回答:不额外运维一套分布式系统,能不能拿到持久化执行?代价是什么?

本篇会用到的词

意思
系统库DBOS 在你自己的 Postgres 里建的那套表,用来存工作流状态和每步的输出。「只要一个 Postgres」指的就是不用再额外部署别的东西
PENDING工作流的一种状态,表示「开始了但还没跑完」。DBOS 的恢复就是从扫描这个状态开始的
ConductorDBOS 的协调组件。多实例部署时靠它仲裁,保证一个中断的任务只被一个实例捡起来
写放大一次业务操作在数据库里引发的实际写入次数。DBOS 每个 step 一次写、每条工作流再加两次,步数多时这个量不可忽略
run()Restate 的语句级标注:把不确定的调用包进去,框架就会记住它第一次的返回值。漏包一处不会报错,只在崩溃恢复时才出问题
AwakeableRestate 提供的一个可以被外部唤醒的挂起点,带唯一 ID。人工审批、等待回调都用它

一、DBOS:用一个 Postgres 承载状态

DBOS 的路线是取消独立的工作流服务,状态直接写进 Postgres。官方描述系统数据库里存的是"所有工作流检查点、步骤输出,以及调度和队列状态",且"每一个工作流输入和步骤输出都被持久化存储在系统数据库里"。

1.1 三阶段恢复

这正好补上 02 篇 5.1 节指出的 LangGraph 缺口 —— 谁来把中断的任务捡起来。

三阶段恢复 —— 正好补上 LangGraph 那个「没人拉起」的缺口① 检测扫描状态为 PENDING的工作流进程启动时自动做② 重放用检查点里的输入重新调用工作流每步先查是否已有结果③ 续跑遇到第一个没有检查点的步骤,正常执行下去从这里开始才是新工作这三步里只有 ① 是 DBOS 比 LangGraph 多出来的。② ③ 两步的机制,本质上和 Temporal 的重放是同一套东西。
「谁来发现中断的任务」听起来是个小问题,但它决定了这套机制在无人值守场景下能不能用 —— 而无人值守恰恰是长任务最常见的形态。

官方对二、三阶段的描述:

DBOS restarts each interrupted workflow by calling it with its checkpointed inputs. As the workflow re-executes, it checks before each step if that step's output is checkpointed in Postgres.

When reaching an uncheckpointed step, the recovered workflow executes that step normally and proceeds from there, thus resuming from the last completed step.

1.2 它与 Temporal 是同一个模型

恢复机制完全一致 —— 从头重跑,已完成的步骤直接返回记录值。 区别只在历史存在哪里:Temporal 存在自己的集群,DBOS 存在你的 Postgres。

因此 03 篇的全部确定性约束在这里同样成立。官方也明确要求工作流确定性、步骤幂等。

这一点容易被"只要一个 Postgres"的轻量宣传掩盖:接入成本降低了,认知成本没有降低。你依然不能在工作流函数里直接调模型,依然要担心改代码破坏重放。

1.3 检测阶段的两种模式

模式机制适用
单节点进程启动时自动扫描 PENDING单实例部署
分布式通过 Conductor 协调多实例部署

第二种是必需品而非增强项:

无协调 —— 多实例部署时的错误做法有协调实例 A 启动扫到 PENDING 任务 T实例 B 同时启动也扫到任务 T任务 T 被执行两次副作用全部翻倍实例 A / B都扫到 TConductor 仲裁只发一份执行权T 执行一次所以在多实例部署下,协调组件是必需品而不是增强项 —— 只跑一个实例时看不出区别,一旦扩容到两个,恢复逻辑立刻变成一个分布式互斥问题。这也是 01 篇那六个问题里的第 ③ 个:自己手搓检查点时,这一条最容易漏。
值得注意的是,这个问题只在「崩溃恢复」这条路径上出现,正常运行时完全看不到 —— 而崩溃恢复恰恰是最少被测试的路径。

自行实现恢复逻辑时,这一点是最常见的遗漏 —— 恢复机制本身会变成故障放大器。

1.4 写放大:这个方案的主要代价

官方给出的开销:

one database write per step (to checkpoint the step's outcome) plus two additional database writes per workflow (one at the beginning to checkpoint workflow inputs, one at the end to checkpoint the workflow outcome)

量化到 Agent 场景:

数值
单个 30 步任务的数据库写入32 次
平台每天 10 万个任务320 万次写入 / 天
单步模型输出约 20 KB,30 步600 KB / 任务
每天 10 万任务的存储增量约 60 GB / 天

官方也提示了后半个问题:检查点大小取决于数据量,小的步骤输出开销可忽略,大的输出需要仔细的架构考量

Agent 场景恰好落在"大输出 + 多步骤"的最坏组合上。 务实做法是把大的中间结果(长文本、抓取的网页、生成的报告)存到对象存储,检查点里只放引用:

@DBOS.step()
def fetch_and_summarize(url: str) -> dict:
"""步骤的返回值会被完整写进 Postgres。
因此这里返回的必须是「引用 + 摘要」,而不是整个网页正文 ——
否则每一步都会往数据库里塞几十 KB。"""
html = fetch(url) # 可能有几百 KB
key = object_store.put(html) # 大对象存到 S3 / OSS,拿一个 key
summary = call_model(f"总结:{html[:8000]}")
return {"blob_key": key, "summary": summary} # 只有这个进检查点

持久化执行的瓶颈从来不是 CPU,是存储写入量。 这与 02 篇 1.3 节里 LangGraph 做 DeltaChannel 要解决的是同一个问题。

二、Restate:把不确定性变成显式标注

Restate 用 Rust 实现,星数高于 DBOS(4,308 对 1,532)。

2.1 与 Temporal 的粒度差异

03 篇里 Temporal 要求把不确定操作放进 Activity —— 这是函数级的结构拆分。Restate 提供 run(),做语句级标注:

Without run(), these operations would produce different results during replay, breaking deterministic recovery.

Temporal · 函数级拆分Restate · 语句级标注把代码拆成 Workflow / Activity 两层优点:显式 —— 结构上一眼就能看出哪些是外部操作代价:已有代码需要重新组织在不确定的调用外面包一层 run()优点:对已有代码侵入很小代价:漏包一处不会报错,只在崩溃恢复时才出错右边那条代价值得单独记一笔:它是一类静默失败 —— 代码跑得好好的,测试也过,只有在真的崩了一次之后才暴露。侵入小和容易出错,在这里是同一件事的两面:正因为不需要改结构,编译器和 code review 也就都看不出你漏了哪一处。
选型时可以这样问:你的团队更怕「改造成本高」,还是更怕「一个平时看不出来的坑」?这两条代价没有优劣,只有匹配不匹配。
# 不确定操作必须包在 run() 里,其返回值会被记入执行日志。
# 重放时不再真正调用,直接返回日志中的结果。
summary = await ctx.run("call-model", lambda: call_model(prompt))

# ⚠️ 漏包的后果:下面这行在首次执行时完全正常,
# 但重放时会真的再打一次模型,且返回值可能不同 → 后续路径分叉。
# 编译器和运行时都不会提示。
summary = call_model(prompt) # 错误写法

代价的性质不同:函数级拆分是编译期可见的结构,语句级标注漏了照样能跑,只在恢复时暴露。

2.2 Awakeable:挂起等待外部事件

Restate 把"等待外部事件"做成了一等公民:

原语官方描述
Signal"A durable notification addressed by invocation ID and signal name"
Awakeable"A convenient shorthand for a one-shot signal",带唯一 ID
Workflow promise按 workflow key 作用域的具名值

这直接对应 Agent 里的人在环场景:

Awakeable 对应的就是 Agent 里的「人在环」场景Agent 执行到需要人工批准处生成 Awakeable ID把 ID 附在发给审批人的通知里工作流挂起不占用任何进程挂三天也是零成本审批人点击批准系统用该 ID 唤醒从挂起处继续关键在第四格:挂起期间没有线程、没有协程、没有连接被占着�,只有存储里的一条记录。所以挂三天和挂三秒的资源开销是一样的。自己手搓的实现通常做不到这一点 —— 要么进程一直等着,要么轮询数据库。这正是 01 篇六个问题里的第 ④ 个。
人工审批、等待外部回调、等待另一个 Agent 完成 —— 这三类需求在实现上是同一件事:把一个执行点变成一个可以被外部唤醒的名字。

02 篇里 LangGraph 的 Interrupt 解决同一问题,差异在于:LangGraph 的中断只在图内、需要外部主动再调用一次;Restate 的挂起由运行时管理,可挂任意久。

2.3 状态保留期是个需要确认的细节

官方说明状态"对 Virtual Object 无限期保留,对 Workflow 按配置的保留期"。

Workflow 的状态会过期。 若人在环审批可能拖两周,必须确认保留期配置足够 —— 否则审批人回来点批准时,工作流已被清理。

三、三条轻量路线对照

LangGraphDBOSRestate
额外基础设施无(用你的 Postgres)一个运行时进程
自动恢复中断任务❌ 需自写巡检✅ 三阶段恢复
确定性约束(快照式)(重放式)(重放式)
对已有代码的侵入仅限图内装饰器 + 结构调整run() 语句级标注
挂起等外部事件Interrupt,需外部再调用队列Awakeable,一等公民
写放大每超级步一次每步一次 + 每流两次run() 一次
多语言 SDKPython / JSPython / TS / Go / Java多语言

3.1 最容易被忽略的一行

第三行。LangGraph 是快照式,不重放代码,因此没有确定性约束 —— 节点内可以随意调模型、读时间、用随机数。

DBOS 和 Restate 都是重放式,完整继承了 Temporal 的确定性心智负担。

这个分界比"要不要跑集群"更重要,但在三家的宣传材料里几乎不被强调。"只要一个 Postgres"降低的是运维成本,不是认知成本。

下一篇05 - 选型与落地

← 回到 专题索引  ·  Agent Infra 板块总览