持久性
持久性是 Flue 对已接受工作的契约:一旦输入被接纳,运行时就对该对话负有持久的终结结果义务——无论经历多少次进程崩溃、重启和重部署。状态存储在哪里是数据库指南的主题。本指南涵盖提交和已接受工作的契约、中断后恢复回放的内容、持久工具和委托任务、持久化状态、每个目标的恢复行为,以及刻意不持久的内容。
提交和已接受工作的契约
到达智能体的每个输入——直接 HTTP 提示、dispatch(...) 调用、init() 句柄的 dispatch(...)、频道投递、定时触发器——都被接纳为一个提交:负载在任何模型工作开始之前被持久记录。该接纳记录是 202 响应和调度回执所证明的内容,它承载着一项义务:
每个被接受的提交都恰好达到一个持久的终结结果——
completed、failed或aborted——无论中间发生多少次崩溃。
结果作为 submission_settled 记录写入对话的规范流中,因此等待该工作的任何事物即使跨重连也能观察到它:Flue 智能体 SDK 的 wait() 从该记录中解析或拒绝,被等待的 init().read(...) 用已结算的回复解析或用已结算的错误拒绝。
一个对话的提交形成一个按接纳顺序处理的持久队列:一次运行一个提交,智能体繁忙时到达的消息要么在轮次边界加入实时响应,要么排队等待,排队的消息永远不会丢失——错过实时响应的投递作为自己的提交运行。处理以尝试为单位进行:协调者认领提交,运行它,然后结算它。中断消耗该尝试;恢复认领一个新的,直到重试预算用尽。
中止遵循相同的规则。POST /:id/abort(或 SDK 的 abort())在对话中每个未结算的提交上记录持久的中止意图;然后每个提交通过正常的尝试机制以独特的 aborted 结果结算——即使运行工作的进程已经消失。已完成的工作不受影响:在完成的响应之后到达的中止以 completed 结算。
中断后的恢复
崩溃不会留下自身的记录——死掉的进程停止写入。当替代所有者唤醒时(如何发生因目标而异)恢复运行,并完全基于持久证据工作:规范对话记录、提交的接纳行及其尝试簿记。
恢复分两个阶段进行。首先它收敛流:死掉的尝试持久化的任何部分流式助手输出被作为中止条目关闭——无条件且幂等地,因此没有任何崩溃形态能让对话看起来处于流中间。部分输出保留在历史中。然后它分类记录所证明的内容并从那里继续:
| 输入后的持久证据 | 恢复的操作 |
|---|---|
| 输入从未被持久化 | 重新排队提交以进行干净的首次尝试。 |
| 已完成的助手响应 | 以 completed 结算——完成的工作永远不会被丢弃,即使超过重试预算。 |
| 带有文本或推理的部分响应 | 告诉模型其流被中断,并从持久的部分继续。 |
| 带有未解析调用的工具轮次 | 修复工具批次(见下文),然后继续轮次循环。 |
| 临时提供商错误(速率限制、中断) | 在退避后重试轮次,在有界错误预算下进行。 |
| 上下文溢出响应 | 压缩对话并重试轮次。 |
| 持久中止意图 | 以 aborted 结算。 |
工具批次修复是刻意保守的。崩溃前记录的结果被精确保留——那些调用永远不会再次运行。未解析的普通调用_不会_被重新执行,因为运行时无法知道它的哪些副作用已经发生;相反,它以模型可以看到并作出反应的显式未知结果错误结算。两种调用解析真实结果而非标记:durable: true 工具重新执行,已完成的步骤从记录中回放,进行中的委托任务从自己的转录中恢复。
总体规则是在精确一次记录之上的一次或多次执行。已持久提交的工作——记录的响应、记录的工具结果、提交的状态写入——永远不会重新运行。在提交前被中断的工作在下一次尝试中重新运行,这包括你的事件钩子回调:它们的持久效果原子提交且永不重复,但其中一个的外部副作用(电子邮件、页面通知)可能很少发生两次。用持久化状态或应用级幂等性保护任何不允许重复的内容。
恢复后的对话总是停在下一条消息正常处理的状态——被中断的提交不能阻塞其后面的队列。中断在时间线中保持可见:中止的部分、任何被中断的工具标记,以及(在结算失败时)一个终结性信号。
重试预算和超时
每次中断消耗一个尝试。当提交耗尽其尝试次数或超过挂钟超时时,重试停止:对话被结算到静止状态,一个 submission_interrupted 信号落入时间线,提交以 failed 结算——等待者以结构化错误拒绝,包括哪些工具调用留有未知结果。
超时是预防性强制执行的,不仅仅在尝试之间。协调者在其唤醒节奏上监督运行中的尝试:在截止时间它触发尝试的中止信号,因此挂起在信号感知等待上的工作(提供商调用、沙箱命令、任何工具——忽略信号的 run 被放弃而不是等待)展开并通过正常路径结算——而在可放弃层之下挂起的尝试在短暂宽限期后以 failed 结算在挂起的 fiber 上,其延迟写入被围栏化。挂起最多延迟结算截止时间加宽限期;它永远不会让提交停滞。大多数停滞根本不会到达截止时间:沉默超过其空闲超时的模型流作为临时提供商错误失败,轮次在错误预算下重试。
默认值是每个提交 10 次尝试和一小时。通过 durability 静态属性按智能体覆盖:
'use agent';
import { useModel } from '@flue/runtime';
export function IssueTriage() {
useModel('anthropic/claude-opus-4-6');
return 'Triage the bound issue end-to-end.';
}
IssueTriage.durability = { maxAttempts: 5, timeoutMs: 7_200_000 };该静态属性在智能体函数_未_运行时由平台应用,因此在崩溃后仍然有效——包括智能体自身渲染中的崩溃。超时是从第一次尝试开始的总挂钟预算;轮次边界加入和响应继续不会延长它。完整字段参考:DurabilityConfig。
持久工具和 step.do
普通的工具调用在飞行中中断后,恢复时以未知结果错误结算。对于必须完成的工作——付款、配置任务、多步同步——将工具声明为 durable: true:它的 run 接收 step,每个副作用通过 step.do(name, fn) 执行,恢复重新执行调用而不是标记为中断:
import { defineTool } from '@flue/runtime';
import * as v from 'valibot';
import { billing, projects, DEFAULT_PROJECTS } from '../shared/provisioning.ts';
export const provisionWorkspace = defineTool({
name: 'provision_workspace',
description: 'Create the customer tenant, then seed each default project.',
input: v.object({ customerId: v.string() }),
durable: true,
async run({ data, step }) {
const tenant = await step.do('create-tenant', () => billing.createTenant(data.customerId));
for (const project of DEFAULT_PROJECTS) {
await step.do(`seed:${project.name}`, () => projects.seed(tenant.id, project));
}
return { output: { tenantId: tenant.id } };
},
});每个完成的 step.do 在解析前持久记录其返回值。恢复时整个调用重新运行,已完成的步骤返回其记录值而不执行,执行从第一个未完成的步骤继续。步骤记录是操作性簿记——模型只看到工具的最终结果。它们以工具调用 id 为键,因此跨同一次调用的尝试携带——并限定在该范围内,因此工具的新调用会全新运行每个步骤。
该契约的两个边界:
- 步骤是精确一次记录、一次或多次执行。 在步骤函数完成和其记录落地之间的窗口中的崩溃会重新运行该步骤,因此围绕外部效果的步骤应该是各自幂等的。
- 重部署可以撤回契约。 如果恢复发现当前渲染不再声明该工具——或不再标记为
durable——该调用回退到普通的中断标记路径,而不是猜测。
工具指南详细介绍了编写持久工具;defineTool(...) 记录了完整契约。
委托任务
子智能体任务作为带有自己持久对话流的子会话运行,因此任务中间的崩溃不会丢失子智能体的任何进度。当恢复修复包含未解析 task 调用的工具批次时,它不会用标记结算该调用——它重新连接到子智能体的持久转录,在上述相同的恢复规则下将子智能体恢复到完成,并将子智能体的真实最终结果作为父级的工具结果提交。这是递归的:在其委托中被中断的子智能体首先恢复孙智能体。在一个并行批次中被中断的多个任务在批次提交前全部恢复。
委托没有自己的持久性配置:恢复的子工作在父级的尝试内运行,受父级的重试预算和超时约束。
两个边缘情况:
- 被重部署移除的委托。 如果恢复运行时子智能体不再被声明,该调用以错误结果结算,父级继续;被重命名或移除的委托无法在任何重试下恢复。
- 终结性结算。 当提交在预算耗尽时仍有未解析的任务,为该调用写入的中断标记携带子智能体的对话 id,因此子智能体的持久转录仍可检查。
持久化状态
每次 usePersistentState 写入都是对话规范流中的一条记录,这就是状态在对话生命周期内能在重启后存活的原因。它的恢复行为取决于写入_何时_提交:写入与使其产生的工作单元原子地变得持久。工具中的写入与该轮次的工具批次一起提交;事件钩子中的写入与钩子接缝的检查点一起提交。如果恢复将批次结算为中断,写入从未发生——重新尝试从上次提交的状态渲染,精确匹配模型实际看到的已完成工作。
这种原子性是使持久化状态成为一次或多次回调的正确保护的原因:由发送电子邮件的同一工作单元设置的 sent 标志不能在它所保护的工作回滚时变为 true。
按目标的恢复
持久记录和恢复决策在两个目标上是相同的。不同的是谁拥有对话的工作以及替代所有者如何唤醒。
Node.js 恢复
在 Node 上,服务器进程内的协调者拥有提交处理。所有权基于租约:每个运行中的提交携带一个短租约,拥有的进程在工作时进行心跳。当进程死亡时,它停止心跳,租约到期,另一个实例可以认领该提交。
实际操作中,单主机部署通常只有一个运行中的实例。对于多副本部署,需要一个外部数据库和一个分布式锁机制来确保一次只有一个进程处理给定的提交。请参阅数据库了解外部存储选项。
Cloudflare 恢复
在 Cloudflare 上,每个智能体对话是一个 Durable Object。提交作为 Durable Object 存储中的记录到达,智能体代码作为该对象的请求处理程序运行。当 Durable Object 休眠时(无活跃连接),它被逐出内存。下次提交到达时,Cloudflare 重新实例化该对象,加载其存储,恢复从那一点继续。
Durable Object 的单线程执行模型意味着没有并发问题——一次只有一个提交在处理。恢复由 Durable Object 运行时自动处理,无需外部协调。
不持久的内容
某些内容被刻意排除在持久性契约之外:
- 沙箱文件。 沙箱中的文件不是对话记录的一部分。参见 沙箱。
- 内存中的变量。 智能体函数中的局部变量不在持久化范围内。使用
usePersistentState存储需要在重启后存活的状态。 - 提供商凭据和密钥。 通过环境变量或密钥管理服务提供,不存储在对话中。
- 临时工具状态。 工具中的缓存、连接池或其他运行时状态在重启后不保留。
后续步骤
- 数据库——配置持久存储后端。
- 工具——编写持久工具。
- 子智能体——委托任务和恢复行为。
- Cloudflare 目标——Cloudflare 上的持久性工作方式。