第 12 章 · Durability 持久化与恢复
本章目标:理解 Flue 的"受理即负责"合约——输入一旦被接受,runtime 就欠这次会话一个持久的终态;掌握恢复重放机制与 @flue/postgres 持久化适配器。
12.1 受理即负责(The Accepted-Work Contract)
Durability 的核心承诺只有一句话:每一个到达 agent 的输入——直接 HTTP prompt、dispatch(...) 调用、channel 投递、定时触发——一旦被受理(admitted),runtime 就必须在进程崩溃、重启、重新部署之后,仍然交付一个持久的终态结果。
text
提交(submission)→ 受理(admitted)→ 执行中……💥崩溃 → 重启 → 恢复重放 → 终态 ✅typescript
// dispatch 返回的是"受理",不是执行结果
// 这意味着调用方可以立即返回,执行由 runtime 保证最终完成
const submission = await dispatch(triage, [
{ role: 'user', content: '分诊 issue #42' },
]);
console.log('已受理:', submission.id); // 之后即使进程重启也会继续12.2 恢复时会发生什么
中断后,恢复(recovery)会重放必要的工作让会话走到终态:
typescript
// 恢复语义的伪代码理解
// 1. 已完成的 LLM 回合:从持久化状态读取,不重复计费
// 2. 进行中的回合:从头重放该回合
// 3. durable 工具调用:按工具的幂等声明决定是否重放
type RecoveryPlan = {
completedTurns: number; // 无需重做的轮次
replayFrom: number; // 从第几轮开始重放
};两类特殊成员的恢复行为不同:
- Durable tools:声明为 durable 的工具在恢复时不会被盲目重复执行;
- Delegated tasks(子代理任务):子任务本身也是"被受理的工作",拥有独立的恢复链路。
12.3 状态存哪里:Database 与 Postgres
持久化状态存放在 Database 层。开发时可用内置默认,生产用 @flue/postgres:
bash
npm install @flue/postgres pgtypescript
// src/db.ts
// 生产级持久化:Postgres 适配器
import { postgresAdapter } from '@flue/postgres';
import { Pool } from 'pg';
export const db = postgresAdapter({
pool: new Pool({
connectionString: process.env.DATABASE_URL!, // 例如 postgres://user:pass@host:5432/flue
max: 10,
}),
});typescript
// src/server.ts
// 把适配器挂到 runtime 上,所有会话状态自动持久化
import { createApp } from '@flue/runtime/node';
import { db } from './db.ts';
const app = createApp({
database: db, // 会话、受理记录、恢复点全部落库
});
app.listen(3000);为什么不是 SQLite
Cloudflare Workers 等边缘目标没有本地文件系统,SQLite 天然不可用。Postgres 是跨目标最通用的选择——本地开发和线上用同一个适配器。
12.4 各部署目标的恢复行为
"重启后恢复"具体怎么发生,取决于你跑在哪里:
| 目标 | 崩溃场景 | 恢复触发 |
|---|---|---|
| Node.js 长驻进程 | 进程被 kill / OOM | 进程重新启动时扫描未完结的受理并续跑 |
| Cloudflare Workers | isolate 被回收 | 下一次请求/闹钟唤醒时从数据库续跑 |
| GitHub Actions job | runner 超时或失败 | 由 workflow 重试策略 + 数据库中的进度接力 |
typescript
// Workers 场景:利用 cron 触发恢复检查
export default {
async scheduled(_event: ScheduledController, env: Env) {
// 定时唤醒:把数据库里仍处于"执行中"的受理继续推进
await resumePendingSubmissions(env.DB);
},
};12.5 刻意不做持久化的部分
文档明确列出了 "what is deliberately not durable"。理解边界同样重要:
typescript
// 不保证持久化的典型例子:
// 1. 内存沙箱里的临时文件 —— virtual sandbox 本来就是易失的
// 2. 未声明 durable 的普通工具调用的副作用 —— 恢复时可能被重复执行
// 正确姿势:有副作用的操作要么幂等,要么声明为 durable tool
const chargeCard = defineTool({
name: 'charge_card',
durable: true, // 声明后恢复时不会盲目重扣款
async execute(args: { orderId: string; amount: number }) {
return payments.charge(args.orderId, args.amount);
},
});12.6 本章小结
- 合约:输入被受理 = runtime 欠一个持久终态,跨越崩溃/重启/重部署;
- 恢复通过重放实现:已完成回合读状态、进行中回合重放、durable 工具受保护;
- 生产用
@flue/postgres统一存储,Workers 等无盘环境也能工作; - 各目标的恢复触发时机不同(进程重启 / 请求唤醒 / CI 重试);
- 非持久部分要显式处理:副作用工具必须幂等或声明 durable。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. Flue Durability 的核心承诺是?
2. dispatch(...) 调用返回时意味着什么?
3. 为什么生产环境推荐 @flue/postgres 而非本地 SQLite?
4. 关于恢复(recovery)时的工具调用,正确的做法是?
🛠️ 动手实践
- 用 Node 版 demo 制造一次中途 kill -9,重启进程后观察会话是否走到终态,记录日志证据。
- 把
@flue/postgres接入你的项目,写 SQL 查看 runtime 存了哪些表、各存什么。 - 给一个发邮件的工具加上幂等保护(以 message id 为键),再对比声明
durable: true后恢复行为的差异。