Skip to content

第 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 pg
typescript
// 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 Workersisolate 被回收下一次请求/闹钟唤醒时从数据库续跑
GitHub Actions jobrunner 超时或失败由 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)时的工具调用,正确的做法是?

🛠️ 动手实践

  1. 用 Node 版 demo 制造一次中途 kill -9,重启进程后观察会话是否走到终态,记录日志证据。
  2. @flue/postgres 接入你的项目,写 SQL 查看 runtime 存了哪些表、各存什么。
  3. 给一个发邮件的工具加上幂等保护(以 message id 为键),再对比声明 durable: true 后恢复行为的差异。