假设你向 agent 提交了一条请求:

帮我规划去维也纳的旅行。先查天气、博物馆和列车,再整理成行程。

模型决定并行查询三项信息。天气和博物馆已经查完,列车还在查,进程突然退出。

重新启动后,我们希望它保留已有结果,只处理没完成的查询,然后继续生成行程。你在查询期间追加的“行程完成后,再推荐餐厅”,也应该还在等待处理。

这篇笔记就沿着这个过程解释 Pi Durable。为了先讲清基本执行机制,旅行请求直接使用三个查询工具,不引入子 agent。

从“恢复对话”到“恢复执行”,差在哪里?#

普通 Pi 已经会保存对话历史,包括用户消息、模型回复中的工具调用,以及已记录的工具结果。重启后,这些内容可以重新进入模型上下文。因此,恢复天气和博物馆的查询结果,并不是 Pi Durable 才有的能力。

但恢复模型能读取的内容,不等于接续进程退出前尚未完成的执行。

回到旅行请求:天气和博物馆已经查完,列车还没结束,进程退出了。

普通 Pi 重新打开会话后,能够恢复“三项工具调用,以及前两项的结果”。但只加载这些消息,不会自动恢复原来正在等待的查询。若要继续,需要模型根据历史决定下一步,或者由应用自己编写恢复逻辑,处理缺失结果、重试和后续执行。

Pi Durable 则另外保存了已经确定的执行安排:

重启后,框架直接读取这些状态:保留已完成的结果,按重放策略处理列车查询,等待条件满足后,再请求模型整理行程。不需要先让模型重新判断哪些工作已完成、是否还在等待,以及如何接续原来的执行流程。

模型仍然要思考旅行方案;如果后续还需要查酒店,也仍由模型决定。Durable 接续的是已经确定的程序步骤,不是预先保存了整个任务未来的所有决策。

这也不意味着它能确认一切外部操作的实际结果。文件编辑如果在写入文件后、保存成功记录前崩溃,框架仍可能不知道文件是否已修改;这种不确定性需要工具提供额外保障,后文会展开。

普通 Pi 保存模型继续工作所需的对话;Pi Durable 进一步保存程序继续执行所需的状态,并提供恢复调度。 如果你在普通 Pi 上自行实现这些记录和调度,也能做到类似的恢复,但这部分工作需要自己维护。

请求刚被接受时,就要先记住这项工作#

应用需要先配置好模型、工具和持久化存储,然后打开 Pi Durable 的运行实例。SDK 将这个管理会话、执行任务和存储状态的实例称为 Harness。

先看保存哪些数据#

这次请求需要保存的数据主要有四类。选择不同的持久化后端,实际存储方式也不同:

下面先看这四类数据分别保存什么、什么时候写入或更新;文件布局和清理过程在后面的存储章节展开:

保存的数据保存什么什么时候写入或更新
对话记录(entry)用户消息、模型回复、工具结果输入进入对话时追加用户消息;模型回复和工具结果完成后追加对应记录
输入处理记录(submission)这条输入是否排队、进入对话、得到回答接受输入时创建,处理进度变化时更新
任务记录(task)每项工作的 ID、归属、执行状态、当前阶段和继续执行所需的数据创建任务时保存初始状态;在任务代码明确设置的边界更新;结束时保存最终记录
结构化状态(document)输入队列、当前运行信息、部分回复、工具进度等相关状态变化时提交;流式更新会合并后定期提交,不是每个 token 单独写入

对话记录主要是追加新内容;另外三类则会随执行过程更新当前状态。下面沿着旅行请求,看这些数据在什么时机一起保存。实际落到数据库还是文件,后面的存储章节再展开。

从提交旅行请求开始#

假设这些配置已完成,启动旅行请求的关键调用是:

const conversation = await harness.root(context);
const submission = await conversation.submit(
{
type: "input",
content: "查天气、博物馆和列车,再规划维也纳行程。",
requestId: "vienna-trip-001",
},
context,
);
const settled = await submission.wait(context);

这里的 context 用于传递取消信号等调用上下文,不是发给模型的聊天历史。

为什么先提交,再等待?因为“输入已经被接受”和“模型已经回答”是两个不同的时刻。submit() 在输入被成功保存后返回 submission 的操作句柄;submission.wait() 等待这条输入处理结束,不是等它刚刚被接收。

稳定的 requestId 让客户端断线后可以在同一会话中用同一个 ID 重试,取得原来的 submission,而不是再次启动一份相同工作。

这里避免的是客户端重发造成的重复提交,不是禁止工具再次执行。例如,同一份旅行请求只会建立一份处理记录,但其中的列车查询如果中途被打断,恢复时仍可能按安全重放策略重新执行。requestId 不负责保证每个工具只执行一次。

现在看这次调用具体写入了什么。假设会话当前空闲、没有排队输入,SDK 在同一次存储提交中保存:

  1. 对话记录:追加这条旅行请求的用户消息。SQLite 写入 entries 表;JSONL 写入 main.jsonl。
  2. 输入处理记录:创建 submission,记录输入已进入对话、尚未得到回答。SQLite 写入 submissions 表;JSONL 写入 main.jsonl。
  3. 任务记录:创建第一轮生成任务 G1,保存它的初始执行状态。SQLite 在 tasks 表中创建 G1 的记录;JSONL 将初始状态追加到 task-<G1的ID>.jsonl,在 main.jsonl 中保存本次提交对它的引用。
  4. 结构化状态:记录会话正在由 G1 处理这条输入。这是会话的 pi.live 文档:SQLite 保存在数据库的结构化状态相关表中;JSONL 写入对应的 doc-<id>.jsonl,由 main.jsonl 中的提交记录确认。

这里的 <G1的ID>、<id> 都是实际 ID 的占位写法。G1、T1 等只是本文为了便于讲解使用的任务编号。

这组变化要么一起保存成功,要么都不生效,这就是这里的“原子提交”。保存成功后,调度器才执行新创建的 G1。这里的“存储提交”指保存这组状态变化;submit() 是应用发送输入的 API,它在内部完成这次存储提交。

如果会话正在忙,按默认排队方式,SDK 先创建 submission,并把输入内容放入持久化队列;等到处理时机,再追加用户消息、安排后续执行。输入被接受,不代表它已经进入模型上下文。

G1 是一项具体的 task,有自己的 ID 和执行状态。这里的 task 不是自然语言待办事项“规划旅行”,而是可以独立执行、等待和结束的程序工作。G1 负责一轮模型生成及其后续工具处理;稍后每个工具调用也会有自己的 task。

G1 刚创建时,已经保存了:

状态:等待调度
阶段:prepare,准备模型请求
尝试次数:1

“阶段及该阶段继续运行需要的数据”就是 checkpoint。第一份由任务定义里的 initial() 生成,创建任务时就会保存,不需要等模型回复。

接着,框架准备模型配置、系统提示词和请求上下文。准备完成后,先把 G1 的 checkpoint 改成 request,保存模型、请求配置和上下文截至哪条消息,然后才发起模型请求。

这次保存仍然针对同一个 G1:SQLite 更新 tasks 表中 G1 的记录;JSONL 向同一个 task-<G1的ID>.jsonl 追加新状态,并向 main.jsonl 追加确认本次提交的记录。这是更新执行进度,不是向对话历史追加一条消息。

这样,若进程在请求期间退出,程序至少知道自己停在请求阶段,而不是重新从接收用户输入开始。

模型决定查三项信息,程序要把这个决定完整保存#

模型返回的不是最终行程,而是三个工具调用:查天气、查博物馆、查列车。

接下来不能只把模型回复写入历史,再凭内存启动工具。否则可能刚保存回复就崩溃,工具还没创建,重启后留下不完整的执行状态。

Pi Durable 将下面三件事放在同一次提交里,它们分别写入:

  1. 包含工具调用的模型回复:这是一条对话记录。SQLite 追加到 entries 表;JSONL 追加到 main.jsonl。
  2. 新建的 T1、T2、T3:这是三条独立的任务记录。SQLite 在 tasks 表中分别创建记录;JSONL 将各自的初始状态写入 task-<T1的ID>.jsonl、task-<T2的ID>.jsonl、task-<T3的ID>.jsonl,并在 main.jsonl 的同一提交记录中保存引用。
  3. G1 等待工具的状态:这是更新已有任务,不是创建新 G1。SQLite 更新 tasks 表中 G1 的记录;JSONL 向 task-<G1的ID>.jsonl 追加等待状态,同样由 main.jsonl 中的本次提交确认。

所以,模型回复里的“调用天气工具”和 T1 的执行状态不是同一条记录:前者用于保留对话内容,后者用于调度和恢复这次调用。

工具在这组存储变化提交完成后才开始执行,不会先调用工具,再补记任务。

此时 G1 保存的状态大致如下,字段已简化:

{
status: "waiting",
checkpoint: {
phase: "tools",
assistant: "发起调用的模型回复ID",
tools: ["T1", "T2", "T3"],
pending: []
},
on: ["T1", "T2", "T3"]
}

status 表示任务正在等待;checkpoint 说明它进入了处理工具的阶段,并记住对应回复和任务;on 明确列出等待对象。这个例子并行启动三个查询,所以没有尚未启动的调用。顺序执行时,未启动的调用会保存在 pending,后续逐个创建任务。

T1、T2、T3 都是 G1 的子任务,属于同一个旅行会话。这个归属关系不只是用于显示:取消 G1 时,框架能确定需要取消哪些工作。

原子提交保证的是框架内部记录一致,不是让外部 API 和框架存储自动成为一个事务。

每个查询都要区分“准备执行”和“已经完成”#

以列车任务 T3 为例。它刚创建时的 checkpoint 是 call,表示需要找到工具实现、校验参数,并处理调用前的拦截逻辑。

真正执行工具前,SDK 再提交一份 checkpoint:

{
phase: "execute",
arguments: { destination: "Vienna" },
replay: "safe"
}

这次提交保存的是最终参数和重放策略,写入的仍是 T3 的任务状态:SQLite 更新 tasks 表中 T3 的记录;JSONL 向 task-<T3的ID>.jsonl 追加这份 checkpoint,再由 main.jsonl 确认提交。保存成功后才执行查询,此时还没有列车查询结果写入对话。

为什么调用前就要保存?因为崩溃后,“还没进入执行”和“可能已经执行了一部分”需要不同处理。进入 execute 但没有完成记录,意味着框架不能确定外部调用执行了多少。

查询结束后,SDK 在同一次提交中保存两项数据:

此时保存的是结果和结束状态,而不是再生成一个“调用后 checkpoint”。JSONL 中的 task-<T3的ID>.jsonl 随后可以清理;最终任务记录和工具结果仍在主文件中,具体清理过程在后文解释。

天气和博物馆任务也是如此,各自保存进度,各自结束。每完成一个工具,不必重写 G1 的 checkpoint:G1 已经记住等待哪些任务,框架检查它们的状态即可。

这里的 checkpoint 并不是定时自动截取内存,也不是模型生成的。SDK 的任务代码在明确的位置调用 runtime.commit()。以工具执行前的保存为例,简化后的调用是:

await runtime.commit(
() => ({
status: "running",
checkpoint: {
phase: "execute",
arguments: finalArguments,
replay: "safe",
},
}),
context,
);

这是“提交任务新状态”的代码片段,省略了参数校验和实际工具执行。这里保存成功后才调用工具。任务的调度状态也可以变化而 checkpoint 不变;两者不是同一个字段。

正常路径中,关键持久化边界是:

G1 创建:prepare
→ 请求准备完成:request
→ 收到工具调用:创建 T1/T2/T3,G1 等待工具
→ 各工具执行前:保存 execute 和最终参数
→ 各工具执行后:保存结果和结束状态

重试时还会保存次数和等待时间;异步模型请求需要轮询时,会保存请求句柄。理解基本流程不需要记住全部阶段名称。

现在崩溃:重启后不先让模型猜执行进度#

回到开头的时刻:天气、博物馆已经完成,列车查询中断。

此时保存的东西分成两类:

对话历史执行状态
原始旅行请求原始 submission 尚未回答
模型发起的三个工具调用G1 等待 T1、T2、T3
天气结果T1 已完成,关联天气结果
博物馆结果T2 已完成,关联博物馆结果
尚无正常的列车最终结果T3 停在 execute,保存参数和重放策略

新进程打开同一份存储,加载对应的工具和任务实现,并启用恢复调度。调度器负责检查哪些任务可以执行、哪些仍需等待,然后运行对应阶段的代码。

恢复不是把整个旅行请求从头执行一遍:

  1. T1、T2 已结束,保留已有结果,不重新查询;
  2. T3 未结束,框架检查其重放策略;
  3. 如果保存的策略和当前工具声明都允许安全重放,就用保存的参数重新查询列车;
  4. T3 结束后,G1 的等待条件满足,继续处理这一轮。

replay: "safe" 是开发者的安全声明,不是框架自动实现幂等。只读查询通常可以重新执行,但第二次查询结果也可能不同。

如果 T3 是部署、发邮件等不允许安全重放的工具,框架会记录“调用中断,可能已经部分执行”,把错误结果交给后续模型处理,而不是自动重复操作。

这没有消除外部副作用的不确定性。例如工具已经发送邮件,却在保存结果前崩溃,框架无法仅凭自己的记录确定邮件是否发出。仍然需要外部服务提供幂等键、结果查询,或由人处理。

checkpoint 也不恢复 JavaScript 调用栈。恢复时重新进入保存阶段的处理函数,阶段内未提交完成的代码可能重跑。若中断的是普通模型流式请求,保存的部分回复会标记为中断,再重新发起请求;模型内部的推理不会被恢复。

它可靠保存的是已提交的执行进度,不是任意代码和外部系统的完整运行现场。

三项查询结束,还需要再请求一次模型#

取得三项结果后,模型才能整理行程。因此,一条 prompt 不等于一个生成任务。

G1 结束这一轮,框架在同一次存储提交里记录其完成状态、创建下一轮生成任务 G2,并把会话的 pi.live 文档中处理这条输入的当前任务从 G1 切换为 G2。

SQLite 在 tasks 表中更新 G1、创建 G2;JSONL 将 G1 的最终记录写入 main.jsonl,将 G2 的初始状态写入 task-<G2的ID>.jsonl,并由主文件确认提交。pi.live 的变化也在同次提交中保存:SQLite 更新对应的结构化状态,JSONL 写入对应的 doc-<id>.jsonl。这时没有新的用户输入,也没有最终回答,所以原来的 submission 还未结束。

关系是:

旅行会话
├─ G1:生成工具调用,并等待、处理三项查询结果
│ ├─ T1:天气
│ ├─ T2:博物馆
│ └─ T3:列车
└─ G2:第二轮生成,模型读取结果并整理行程

G2 是后续生成任务,属于会话,不是 G1 的子任务。同一轮生成内部的失败重试可以继续使用原任务,并不是每一次 HTTP 请求都必然创建一个新 G。

G2 输出最终行程后,SDK 保存回答、结束 G2,并将原始 submission 标为已回答,关联到这条最终回复。这三项分别对应 SQLite 的 entries、tasks、submissions 表;JSONL 则把回答、G2 的最终任务记录和 submission 的新状态写入 main.jsonl,在同一次提交中生效。

如果模型还要调用酒店查询工具,则继续产生下一轮,而不是先把原始请求标为完成。

这条输入从 G1 继续到 G2,但仍然是原来的 submission。这个分工让三个不同问题分别有明确记录:

查询期间追加的餐厅请求怎么办?#

“行程完成后,再推荐餐厅”作为 follow-up 先排队,不需要立即放进当前模型上下文。

它有自己的 submission 记录;正文和排队方式则放在持久化的会话队列中。这份队列是一份 document,内置名称是 pi.inbox。

到处理时机,框架在同一次提交里从队列取出输入、追加用户消息、更新处理状态,并安排后续执行。重启后不会因为队列原来在内存里而丢掉这条输入。

应用自己的计划、审批决定、sandbox 标识等也可以用 document 保存,并与消息和任务变化一起提交。不必每次重启都让模型从对话中重新推断这些数据。

界面也可以读取和订阅已提交的运行状态。因此浏览器断线或另一位用户加入时,可以重新取得当前进度,不必拥有之前全部临时事件。这解释了博客为什么把崩溃恢复、应用状态和多客户端访问放在一起:它们依赖同一套可重新读取的状态。

这些记录存在哪里?不是全部塞进聊天历史#

对话消息保持追加式历史,任务状态和队列则表示当前执行情况。两者逻辑上分开,但可以在同一次存储提交中一起变化。

存储由应用创建 Harness 时选择。内存存储适合测试,进程结束后无法恢复;跨重启运行需要磁盘或其他持久化后端。

SQLite 模式中,应用指定一个数据库文件。对话、任务、输入处理记录和 document 都保存在其中。对话进入 entries;任务进入 tasks,同一个任务 ID 的状态和 checkpoint 会更新。checkpoint 不是每次都新增一行并永久保留。

JSONL 模式也不是所有内容都在一个文件里。当前实现使用:

存储目录/
├─ main.jsonl # 提交记录、消息等数据及辅助记录引用
├─ task-<id>.jsonl # 未结束任务的状态变化
└─ doc-<id>.jsonl # document 内容或变化

一次提交先追加相关辅助数据,再向主文件追加完整提交记录。恢复以主文件确认的提交为准,不会仅因为辅助文件中出现了某段数据就把它当成已提交。

只追加记录,为什么还能清理旧 checkpoint?#

关键是主文件和辅助文件分开存储。以列车任务 T3 为例,下面是文件内容的示意,不是实际 JSON 格式:

main.jsonl
提交1:引用 T3 的初始状态
提交2:引用 T3 的执行中状态
提交3:直接保存 T3 的最终记录
task-<T3的实际ID>.jsonl
T3 的初始状态
T3 的执行中 checkpoint

T3 结束后,最终记录已经追加到 main.jsonl,框架便可以删除整个 task-<ID>.jsonl 辅助文件,不需要从主文件中间删掉几行。

主文件中的旧引用仍然存在。恢复代码会先检查哪些任务已经有最终记录;对于这些任务,旧 checkpoint 已经不是恢复必需的数据,因此允许对应的辅助文件不存在。

删除的是 T3 执行期间的中间状态,不是 T3 这个子任务的一切记录。T3 的最终任务记录、对话中的工具调用和结果仍然保留。如果工具创建了 Durable 子会话,子会话中已保存的历史也不会因为工具任务结束而被删除。

对于一些只保留当前状态的 document,框架也可以把需要保留的数据写入新辅助文件,再替换旧文件。

因此,主提交记录采用追加方式,辅助文件则可以删除或重写。不能把 JSONL 理解为所有文件都永久只追加,也不能把 checkpoint 当成永久保留、可任意选择回退的状态历史。

对话历史会保存;任务恢复主要读取最新已提交状态。 上下文压缩减少的是发给模型的内容,不会因此删除旧对话记录。崩溃恢复也不是回退到某条旧消息。

这些保证依赖所选后端及其落盘配置。进程崩溃恢复不应直接理解为任何断电或磁盘故障下都不会丢失数据。

SDK 替开发者做什么,又没有替开发者做什么?#

如果只是让模型调用工具完成旅行请求,开发者配置模型、工具和存储,然后调用 conversation.submit() 即可。内置的生成任务、工具任务、checkpoint 和恢复调度由 SDK 处理,不必自己重写聊天循环。

如果你希望一个复杂业务操作在内部多个步骤之间恢复,就需要显式定义阶段。SDK 提供 defineTask() 定义任务,initial() 生成初始 checkpoint,runtime.commit() 保存后续阶段、等待状态或结果,再通过 tx.createTask() 等接口创建实例。

阶段边界由任务代码决定,不是框架自动分析任意 async 函数。例如“抓取网页,再整理文本”可以分两阶段:下载结束时保存文本和下一阶段;重启后直接整理文本。但如果下载已经结束、文本尚未提交就崩溃,仍然需要重新下载。

同样,幂等策略和必要的外部补偿操作由开发者负责。SDK 保存任务归属并组织取消,不会自动撤销已经发送的邮件或已经修改的文件。

这种设计的收益不是减少所有代码,而是让恢复规则集中在任务的阶段与提交中。模型继续决定旅行方案;框架负责已知执行流程;工具或业务任务负责自身外部操作的安全性。三者不必重复推断同一份进度。

为什么它仍然不能替代 pi-workspace-history 这类回退扩展?#

把旅行请求换成本地编码请求:agent 修改了一批文件,你觉得方向不对,想回到之前的对话节点,同时恢复当时的代码。

这不是“未完成工作如何继续”,而是“已经产生的文件修改如何撤销”。Pi Durable 保存工具调用和执行进度,并不自动保存工作区文件快照。

pi-workspace-history 则将受管理文件的快照与会话历史节点关联,用独立的内部 Git 仓库存储,让你选择一起恢复对话与文件,或者只回退对话、保留当前文件。恢复范围不包括被排除的文件、项目的 Git/Jujutsu 元数据以及外部服务副作用。

因此,个人在终端里交互式编码、频繁尝试和回退方案时,普通 Pi 加 workspace-history 很直接。需要用 SDK 构建后台 agent,让已接受的工作跨进程重启继续执行时,Pi Durable 更合适;一个用户的应用也可能需要它,并不以“大规模”为前提。它也不自动提供跨机器集群调度。

最后回到最初的旅行请求:程序能在重启后继续,不是因为它重新读了聊天记录并猜对了下一步,而是因为输入、任务、等待关系、阶段数据和结果都已经按一致的提交规则保存。重放策略只处理其中的外部调用风险。

普通 Pi 能恢复对话;Pi Durable 进一步管理可恢复的执行;workspace-history 则管理可回退的工作区文件状态。

Sources#

本文依据官方博客及 pi-durable 1.0.0 的源码实现核对(源码提交 7fbbd5f4a)。Pi Durable 仍属实验性 API,具体接口可能变化。