编者摘要:Pi‑Durable 是 Earendil 推出的 TypeScript 智能体持久化框架,核心目标:进程中途崩溃视为暂停,而非任务丢失。它将智能体每一步可见操作原子提交到 SQLite/JSONL 存储,重启后恢复断点,不用全部重新运行任务。
整套体系由四层堆叠而成:宿主应用、Harness(执行框架)、Session 会话内核、持久化存储。Session 拥有唯一变更队列(mutation line),所有写入都经过该队列,保证提交原子性:一次提交内的对话记录、任务、应用文档要么全部落盘,要么全部回滚。外部网络、模型调用、工具执行绝对不能放在提交回调内部,避免阻塞整个会话,这就是效果三明治模式:先提交执行意图,再执行外部操作,最后提交执行结果。
框架定义 5 种核心存储记录:对话、条目、任务、提交记录、文档。Entry 条目是不可变对话转录记录,不会修改旧内容;依靠head字段实现上下文裁剪(压缩、重置会话),不会删除历史数据。Task 任务是可恢复状态机,每一个阶段会写入检查点;进程崩溃重启后,所有运行中任务回置为 pending,从最近检查点继续执行。工具存在两种重放策略:safe安全(可重复执行,崩溃后自动重跑)、unsafe不安全(不可重复,崩溃后返回中断报错,告知模型工具可能部分执行)。
Document 文档用来存放业务状态,和对话转录原子一同提交,杜绝内存状态和会话记录不一致。文档支持基础快照 + 增量 Delta,支持版本迁移;fork 分支可以按策略继承文档状态。
内置运行机制包含:提交队列 Inbox、Generation 模型调用任务、Tool 工具任务、Compaction 上下文压缩。扩展代码全部驻留内存注册表,磁盘仅保存扩展名字,重启重新加载代码即可恢复历史任务,无需持久化业务代码。
框架有明确非目标:不支持多进程同时读写一份存储,不提供 CRDT 多用户离线合并,不会自动回滚外部真实副作用。存储失败分三类:提交前失败直接回滚;存储明确拒绝,会话继续;存储结果不确定,会话被 “污染”,必须关闭并重新打开存储。
整个系统 8 条不变量约束保障持久化正确性:提交原子、存储落盘后才对外发布、无易变发布通道、外部效果脱离事务、记录 ID 不可复用、草稿提交后失效、变更队列贯穿全流程、不确定存储故障直接污染会话。开发者编写任务、工具时,所有重启恢复依赖存储内检查点,内存变量进程重启全部丢失。
20 个关键问题问与答
- Pi‑Durable 主要解决什么问题?
答:解决 LLM Agent 进程崩溃、休眠、重新部署时任务丢失;重启后从断点恢复,区分工具是否可以安全重跑。 - 核心设计原则是什么?
答:所有对外可见状态必须完成原子提交才对外暴露;崩溃只会丢失最后一次提交之后的进度。 - Mutation line 变更队列作用?
答:所有提交串行执行,保证原子性;防止并发写冲突;外部网络 IO 不能阻塞队列。 - 什么是效果三明治?
答:先提交执行意图,再执行外部工具 / 网络操作,最后提交结果;崩溃时存储留下明确记录用于恢复。 - safe 与 unsafe 工具 replay 区别?
答: safe:操作幂等,崩溃重启自动重跑;unsafe:不可重复执行,重启返回中断错误,告诉模型工具可能部分运行。 - Task 任务 5 种状态分别含义?
答: pending待调度;running正在执行;waiting等待子任务;completing自身完成,等待子任务结束;terminal任务彻底结束。 - 进程崩溃重启,正在运行的 task 会变成什么?
答:全部退回 pending,读取存储中的 checkpoint 检查点继续执行。 - Entry 条目为什么是不可变?如何实现会话重置?
答:历史记录绝不修改;通过新增条目设置 head,修改模型上下文起始点,原始记录保留在存储。 - Document 文档作用?
答:存放 Agent 业务状态,与会话条目在同一个 commit 原子落盘,避免业务状态与对话记录不一致。 - base 基础快照和 delta 增量是什么?
答:base 保存完整文档对象;delta 只保存修改补丁;系统可配置阈值自动生成 base,避免增量无限膨胀。 - fork 会话分支和 owned 拥有会话的差异?
答:fork:共享父会话历史,用于 “换个方案重试”;owned:任务拥有全新空会话,用于子代理,父任务中止则子任务一并中止。 - Inbox 收件箱队列用途?
答:Agent 繁忙的时候,新消息排队;区分 steer 中途介入、followUp 等待本轮结束、write 写入笔记。 - Compaction 压缩会删除旧对话吗?
答:不会删除存储数据;新增摘要条目 + head 指针,模型不再读取旧内容,但原始记录仍保留,可以 fork 回看历史。 - Registry 注册表为什么代码不保存在磁盘?
答:扩展工具、钩子代码保存在内存;磁盘只保存扩展名字;升级代码重启加载注册表即可恢复旧任务。 - 什么是 Session 会话 “被污染 poisoned”?
答:存储调用后结果不确定(不确定是否写入成功);会话不再允许操作,必须 close 后重新打开存储。 - abort 中止任务流程?
答:写入 abortRequested 标记,信号运行实例;等待全部子任务完成;执行本任务 abort 清理处理函数;输出 terminal 中止结果。 - 什么情况任务会 orphaned(孤立)?
答:任务被中止,但是对应的 task 代码扩展没有安装,无法运行 abort 清理逻辑,标记为 orphaned,外部资源需要开发者自己校验。 - Pi‑Durable 不支持哪些能力?
答:不允许多进程同时操作同一存储;不会自动回滚外部真实副作用;没有 CRDT 离线多人编辑合并。 - commit 提交回调中绝对不能做什么?
答:禁止模型调用、网络请求、工具执行等外部 IO;会阻塞全局变更队列,同时造成状态不一致风险。 - 开发 Durable 任务最重要注意点?
答:进程重启所有内存变量全部丢失,所有恢复需要的数据,必须写入 checkpoint 检查点或者 Document 文档。
附录:Pi‑Durable:面向 LLM 智能体的可容错持久化运行框架,解决进程崩溃、重启后智能体任务丢失、工具调用状态不明的问题;版本 1.0.3
Pi‑Durable 技术手册・详细总结附录
一、手册定位与阅读方法
《Pi Durable Technical Manual》是 npm 包 @earendil-works/pi-durable(版本 1.0.3,工程版,2026 年 10 月)的权威技术文档,来自 earendil-works/pi 仓库的 commit 5b6c792。它的特殊性在于:这不是根据记忆编写的说明书,而是对 “某一个包在某一次提交下的状态映射”—— 书中出现的每一条记录、每一个文件、每一个事件,都来自对该包的一次真实运行。这意味着书中的所有示例都可以复现,所有存储行都是真实落盘的结果,而非示意。
手册明确给出资料权威性排序:源码(src/**)>规范(docs/spec.md)>README 与更新日志>设计笔记>测试>示例与运行记录。当源码与规范冲突时,以源码为准,正文会明确指出差异。规范文档用内部名 “Pico5” 称呼该系统,且先于代码存在(§2.2 甚至仍写着 “Pico5 尚未实现”);而手册其余部分统一使用包的正式名称 Pi Durable。
捕获工具包(capture kit)是本书的底气所在:书中所有记录由脚本驱动真实包、并转储其存储内容得到。这些脚本运行在 @earendil-works/pi-ai的假提供者(faux provider)之上,因此无需网络、无需 API 密钥即可复现全部内容。捕获记录中 ID 与提交号跨运行稳定,但时间戳、进程 ID、UUID 不稳定;一个可变的点是:被节流的进度提交可能提前或延后一个提交,导致后续序号偏移一位。
手册的读者假设是 “已经构建或使用过 LLM 智能体的 TypeScript 工程师”:理解 “把转录发给模型、运行它返回的工具调用、再把结果发回去” 这一循环。读过本书后,读者应能设计带正确阶段(phase)的任务、预测运行任意时刻崩溃后哪些内容能幸存、能逐行阅读存储转储、能编写可安全重跑的工具、能定位运行卡住的原因。
二、核心规则与八大不变量
整本书用一个句子定义了系统:“会话(Session)原子地提交不可变条目、完整任务记录和由 Chord 跟踪的文档;只有已提交的状态可被观察。”前半句说明提交的内容 —— 转录记录、任务记录、文档变更作为一个整体写入;后半句说明观察的边界 —— 屏幕、等待者(waiter)、观察者(watch)、调度器本身,全部读取已提交状态,不存在旁路通道(side channel)。
这个设计被概括为 “以提交为唯一入口的门”:所有可见变更都必须穿过提交这道门,所有观察者都坐在门后。系统围绕它定义了八个不变量,每个都堵住一类具体故障:
- 一次提交跨所有记录与文档写入原子
—— 要么全部落盘,要么全部没有。若拆成多次写入,崩溃就会留下 “永远没有任务去执行的工具调用”。 - 文档更新只在存储提交成功后才发布
—— 避免屏幕上出现崩溃即被抹掉的状态。 - 所有可见进度都是持久的,无易变发布路径
—— 不存在 “不提交也能显示” 的旁路,半截回答与工具输出也被提交(由 settings.progress 节流)。 - 外部效果不进入变更事务
—— 模型、进程、工具、网络、人工效果都在变更队列之外运行,防止一次慢调用拖垮所有会话。 - 条目与 ID 不可变、永不重用
—— 所有记录共享一个 ID 空间,记录之间用 ID 互相引用,ID 重用会让引用悄悄改变含义;即使某次提交因故障失败,其占用的 ID 也保持占用。 - 草稿随回调结束而失效
—— 提交回调内的 tx.doc()返回可变草稿,回调落定时草稿被撤销,越界引用无法在任意提交之外修改文档。 - 变更队列贯穿到存储落定与采纳
—— 下一提交永远从上一提交已存储的状态开始;提交观察者在队列上运行但只捕获不可变值,用户的 watch 与文档状态回调在队列外稍后运行,慢 UI 不会卡死框架。 - 不确定的存储故障使会话失效(poisoned)
—— 存储返回 “可能写了也可能没写” 时,内存无法确知磁盘内容,会话拒绝后续所有调用,必须关闭重开;只有 “存储明确拒绝(StorageRejected)” 能保证什么都没写入,才允许会话继续。
对开发者最核心的三条纪律是:任何展示或作用于其上的状态都必须是已提交的;相关联的状态变更要放进同一次提交;绝不在提交回调内调用模型、工具或网络。
三、四层架构与内存 / 存储分工
手册把包看作四层堆叠:宿主进程(你的程序)在最上,调用 Harness;Harness(运行框架)把调用转成一次提交;Session 会话内核拥有唯一的变更队列、对话、条目、任务、提交与文档;存储(Storage)在最底层保存每一次提交。宿主在打开 Harness 时把框架要运行但不拥有的代码 —— 扩展、模型、执行环境 —— 注入进来。
Harness 内部由四个组件构成,各司其职:Conversation 句柄(只是 ID 加方法,不含状态,按 ID 比较);Submissions 提交组件(输入的准入、每个会话的收件箱、wait () 背后的等待者);任务调度器(每个存活任务的内存镜像、保留 / 运行 / 中止);视图组件(viewState、watch、任务图、智能体事件)。框架用三个内置任务回答输入:pi.generation(准备提示并调用模型)、pi.tool(运行一次工具调用)、pi.compaction(总结旧上下文)。它们是内置的,不可移除也不可替换,因此必须用 createRegistry()构建注册表,Harness.open()会拒绝缺少它们的注册表。
内存与存储的分工是整个包的关键:存储保存真相,内存只保存 “总能从存储重建的工作集”。内存中的东西包括:存活任务(打开时全部加载,并把死进程遗留的运行中任务在单次提交内退回 pending)、已加载文档缓存、等待者与观察者订阅。进程死亡时,这些都不重要 —— 重新打开存储就能全部读回,“重新打开存储就把工作从停止处接起来”。
包的入口面:@earendil-works/pi-durable暴露 Harness、createRegistry、defineExtension/defineTool/defineTask/defineDoc、内置文档与任务、MemoryStorage、createSession 等;storage/sqlite与 storage/jsonl分别提供可移植核心(后者基于 FileSystem,也运行在 Bun 或 Cloudflare Durable Objects),/node后缀提供 Node 专属实现;env提供执行环境接口;tools提供读写编辑与 bash 工具;testing提供存储与环境一致性测试。包要求 Node 21.9.0 或更高。
四、会话与提交(Session and Commits)
会话(Session)是系统的内核,拥有唯一一条变更队列、对话、条目、任务、提交与文档。提交(Commit)是原子保存:一次调用 Storage.commit(writes),要么全部存储,要么全部不存。所有记录共享一个 ID 空间,记录之间靠 ID 互相引用。
变更队列(mutation line)一次只运行一个提交,从回调一直持有到存储落定、新修订被采纳。这样每个提交都从上一提交已存储的状态出发。提交观察者在队列上同步运行(仅捕获不可变值),而用户的 watch 与文档状态回调在队列外稍后运行。在 “一次回答一个工具调用” 的捕获中,一次小交互产生约 17 次提交,其中每次提交都对应存储中的一个序号(seq)。
提交失败的处理按失败阶段区分:回调准备失败(如草稿中出现非 JSON 值)发生在存储看到批次之前,正常回滚;存储明确拒绝(StorageRejected)保证什么都没写入,会话可继续;存储结果不确定则使会话 “中毒”,此后必须关闭并重新打开存储。此外,HEAD等特殊字段用于控制上下文起点,使会话支持压缩、重置而不删除历史。
五、对话与上下文(Conversations and Context)
对话(Conversation)是一个转录作用域,可以分叉(fork)出另一个对话。条目(Entry)是不可变转录记录。轮次(Turn)指一次助手回答及其工具调用;运行(Run)是从被接收的输入到最终回答的轮次序列,运行活跃期间对话处于 “忙碌(busy)” 状态 —— 而 “忙碌” 被定义为 pi.live.run存在。
内置条目类型覆盖整个生命周期:用户输入(pi.user)、系统提示(pi.system)、助手消息(pi.assistant)、工具调用(pi.tool)、工具结果(pi.tool-result)、进度(pi.live)、任务报告(pi.task-report)等。转录要变成模型上下文,会经过一条生成管线:渲染系统提示与工具列表、设定思维级别与截断边界。
分叉(fork)让新对话读取父对话到某一点的条目,然后写自己的;owned 会话则是任务拥有的全新空会话,用于子代理 —— 父任务中止,其子对话一并中止。重置(reset)通过新增条目设置 head,而不是删除历史;交接(handoff)与 heads机制管理多分支下的上下文起点。所有旧条目仍保存在存储中,可随时 fork 回去查看。
六、文档(Documents)
文档(Document)是应用的类型化 JSON 状态,与会话条目在同一次原子提交中变更 —— 这是手册反复强调的要点:待办列表永远不可能领先于修改它的那条消息。文档由 定义(Definition)声明,定义是 TypeScript 声明,命名文档、设定初始值与存储规则。
文档有三种作用域 / 生命周期形式:随会话保存或随任务保存。底层实现采用 base 基础快照 + delta 增量:base 保存完整文档对象,delta 只保存修改补丁;系统按配置阈值自动生成 base,防止增量无限膨胀,并支持版本迁移(migration)。
五个内置文档分别是:pi.live(运行状态)、pi.inbox(收件箱)、pi.usage(用量)、pi.provider(提供者)、pi.agent(智能体配置,存模型选择)。它们随根对话的创建在同一次提交中建立(ID 2–6)。这些文档用 Chord 库跟踪,每个已加载文档在会话内核里有一个跟踪器。
七、任务(Tasks)
任务(Task)是可恢复的状态机,附着在一个对话上,运行于任何提交之外。每次模型调用和每次工具调用都是一个任务。
任务有多个阶段(phase):prepare(准备)、request(请求)、execute(执行)、report(报告)等;每个阶段都会写入检查点(checkpoint)。进程崩溃后,任务从最近检查点恢复。
任务状态机包含:pending(待调度)、running(运行中)、waiting(等待子任务 / 工具)、completing(自身完成、等子任务收尾)、terminal(彻底结束)。调度器保留每个存活任务,只通过已发布的提交得知新任务。
效果三明治(effect sandwich)是工具与外部效果的核心模式:先提交执行意图(把最终参数与重放策略写入任务),再在变更队列之外运行 execute()(工具、进程、网络),最后把结果作为新提交提交回去。这样崩溃时,存储里留下了明确意图记录。
工具重放(replay)策略是关键设计:声明为 safe的工具是幂等的,崩溃后重开存储会把它重新运行一遍;声明为 unsafe的工具不可重复执行,崩溃后框架不会重跑,而是写一条带 “工具可能部分执行(interrupted)” 错误的工具结果,让模型基于已提交的输出行与错误信息作答。开发者必须逐工具决定 “运行两次是否安全”,这一个决定决定了被中断调用的命运。
拥有关系与结构化并发(ownership & structured concurrency):任务拥有子任务,中止(abort)自上而下传播 —— 写入 abortRequested 标记、发出信号、等待全部子任务完成、执行本任务的 abort 清理钩子、输出 terminal 中止结果。若任务被中止但对应代码未安装(无法运行清理),任务被标记为 orphaned(孤立),外部资源需开发者自行核验。pi.generation任务把 pi.live.generation写成 {attempt: 1}等进度记录。
八、运行(Runs)
运行是从输入到答案的完整周期。提交(Submission)是准入单位:一次提交把用户输入追加为 pi.user 条目、创建 submission 记录、创建首个 pi.generation 任务、并在 pi.live中把对话标记为忙碌。收件箱(Inbox)在对话忙碌时给新消息排队,区分 steer(中途介入)、followUp(等本轮结束)、write(写入笔记)等意图。
运行控制由 pi.live文档承载,定义忙碌 / 空闲。生成(generation):request 阶段先提交 pi.live.generation = {attempt:1},再在队列外调用 models.streamSimple();流式输出以默认最小 100ms 间隔提交进度(throttle),慢流会产生被节流的中间提交。工具调用与重放见上文。压缩(compaction):通过新增摘要条目并推进 head,让模型不再读取旧内容,但不删除存储数据;旧记录仍保留,可 fork 回看。
用量与成本(usage & cost)由 pi.usage文档累计,提供按请求、按会话的用量核算。
九、扩展与智能体(Extensions and the Agent)
注册表(Registry)保存已安装扩展:工具、提示片段、钩子、包装、自定义任务。关键设计是代码不持久化—— 磁盘只保存扩展名字,代码只在内存注册表中;升级扩展后重新打开存储、重载注册表,即可恢复旧任务继续运行。因此 README 提醒:恢复未完成工作前,要重新安装相同的扩展与任务代码。
每个对话一个智能体(per-conversation agent):智能体配置(模型选择等)存在 pi.agent文档里,随对话创建。
钩子(Hooks)提供在提交、观察等时机注入行为的机制。定义工具(defining tools):用 defineTool 声明工具名、参数、执行逻辑与重放策略。系统提示(system prompt)由 prompt sections 组成,prepare 阶段渲染为 pi.system 条目。重载(reload while running)支持运行中更新扩展。子代理(subagents)通过 owned 会话实现:父任务调用子任务,子任务拥有独立空对话,父中止则子一并中止,结果作为任务结果提交回父对话。
十、观察(Watching)
系统提供多级可观察性:Chord 文档状态,通过文档跟踪器可订阅每次提交的文档变更;对话视图与 watch (),把每次提交的操作(ops)发布给订阅者,一个视图一个帧(frame per commit);智能体事件(agent events),捕获任务状态流转;任务图与 inspect (),展示存活任务的依赖与状态。
观察者可以读取、steer 一个对话,但所有写入仍只经过一个 Harness—— 这是 “多客户端可看可引导、但单写入者” 的体现。时序图中的每个箭头是一种交换样式,实线 / 虚线 / 点线区分调用、提交、任务提交、模型请求、工具执行、发布视图、中止、回复等。
十一、存储与环境(Storage and Environment)
存储契约:存储后端保存记录与文档,并原子地提交一批写入。包提供三种:MemoryStorage(内存,不持久)、SqliteStorage(单数据库文件)、JsonStorage(目录中的文件)。storage/sqlite核心是可移植的同步数据库门面上的核心,/node提供 Node 专属入口。
SQLite:对话、条目、任务、提交、文档、记录 ID 都落在 session.sqlite的单文件里,所有记录共享一个 ID 计数器。JSONL:每个会话一个文件,每行一个 JSON 对象,type标记行类型,第一行是会话头。一致性测试:registerStorageConformance()与 registerEnvConformance()验证后端是否符合契约。执行环境(ExecutionEnv):每次工具调用与提示渲染都会构建一个执行环境,定义工具可访问的 I/O 能力;env提供接口,env/node提供 NodeExecutionEnv。
十二、实践与边界(In Practice)
构建编码智能体:手册用 read/write/edit/bash 四个工具(CodingTool 扩展)演示如何组合文档、任务与运行机制构造真实编码代理。“会咬人的契约(contracts that bite)”:几个最容易踩的坑 —— 把外部 IO 放进提交回调、依赖内存状态做恢复、允许多进程写同一存储、认为崩溃会自动回滚外部效果。
限制与非目标(limits and non-goals)明确列出:一次存储同时只允许一个进程拥有,无跨进程锁,框架不是集群调度器,监督与重启进程是宿主的工作;不提供 CRDT / 离线多写合并;提交只在存储内原子,外部效果不在会话变更事务内运行,当框架无法确知支付是否到账时,它会明说,并把答案交给幂等键或外部记录。
十三、参考(Reference)要点
参考部分提供完整 API(Harness 与 Conversation API)、内置条目类型清单、设置与默认值、错误码与原因(如 Interrupted、StorageRejected、Session poisoned)、术语表、来源清单与图索引。它是开发者在实现时逐项对照的权威速查。

