第十二章 任务为什么需要持久化
12.1 保存聊天,还不足以恢复工作
假设你让代理整理一批报告。它已经读完三个文件,开始运行一个耗时命令,此时进程退出。下次打开软件,聊天记录可能还在,但程序还需要回答几个问题:哪个输入尚未回答?哪个工具只是准备执行?哪个操作已经产生结果?只保存对话文本,无法完整表达这些事实。
持久化,就是把需要跨越进程生命周期的信息写入存储。运行时,则是管理模型请求、工具执行和任务状态的程序层。packages/durable 提供一种以持久化为中心的代理运行时,名称为 pi-durable。它直接使用 pi-ai 访问模型,使用 Chord 的文档和状态基础设施,并不先经过 pi-agent-core。
这里必须分清工程中的两条路线。默认命令行产品 pi-coding-agent 依赖 pi-agent-core,拥有自己的会话记录和恢复命令;Durable 是另外一个可供应用开发者选择的运行时。看到 Durable 支持检查点,不能据此断言默认 CLI 中途被杀死的命令会自动接着执行。保存历史、继续聊天与恢复任务执行,是不同的承诺。
12.2 提交为什么要先于显示
Durable 的中心概念是提交,代码中叫 commit。它是一次原子写入:本次提交中的变更要么一起成功,要么全部不生效。例如,应用可以在同一次提交里追加一条消息、修改待办文档并创建任务。这样不会出现界面显示“任务已创建”,实际存储里却没有任务的矛盾。
Harness 可以理解为管理一个存储空间及其代理任务的总入口。它下面的 Conversation 是会话,Entry 是不可变的记录,Document 是可更新的 JSON 状态。JSON 是由对象、数组、字符串、数字等组成的通用数据格式。输入交给会话后,调用方获得 Submission,即可以查询或等待的提交请求。
这套设计坚持先存储、后发布。界面观察到的视图来自已经提交的状态,不能先展示一个尚未保存的最终结果。一个简化执行过程如下,箭头表示先后关系,省略了提示词变更等细节:
接收输入 → 保存用户记录 → 创建模型生成任务
模型提出工具调用 → 保存调用意图 → 运行工具
保存工具结果 → 再次请求模型 → 保存回答 → 发布给观察者
模型流式输出和工具输出也会分批提交,默认进度间隔是约一百毫秒。进程突然退出时,尚未提交的那一小段仍可能丢失。因此“持久化”不意味着每个刚生成的字符都已经落盘,而是界面与存储按明确的提交边界保持一致。
等待结果与取消工作也有区别。取消一次 wait() 所使用的上下文,只结束这次等待,不会撤销已经接收的任务。例如,网页暂时断开,后台任务仍可继续,重新连接后再读取提交状态。真正要停止会话工作,需要使用相应的中止接口。把这两种操作分开,应用才能既支持用户离开页面,也支持用户明确取消任务。
12.3 检查点不等于外部操作只发生一次
任务在 Durable 中是一个持久化状态机。状态机的意思是:任务有若干明确阶段,每次运行根据当前状态决定下一步。检查点记录下次应从哪个阶段开始,以及那一步需要的数据。重启后程序读取检查点,而不是试图恢复已经消失的 JavaScript 调用栈。
例如,一个提醒任务可以先保存“等待到指定时间”,到期后进入“发送提醒”阶段。等待期间不需要保留一个一直运行的函数。需要注意,重启后某个阶段可能从头运行;等待之后的工作应放入下一阶段,必要的数据应写入检查点。
工具还涉及外部副作用,即存储之外发生的变化。读取文件通常可以重复,但转账、发送消息或追加日志可能重复产生影响。Durable 会在执行前保存工具调用意图;进程退出导致工具执行中断后,重新打开并启动恢复调度后,只有持久化调用意图与当前工具定义都声明 replay: "safe",才可能重跑。否则,模型获得带有已提交输出的中断错误结果。
“安全重放”是工具作者的责任,不能只为方便恢复就加上标记。设想服务器已经接受付款,但本地尚未来得及保存付款结果就断电。重新发送仍可能扣款两次。解决办法通常是让外部服务接受幂等键:同一个操作标识无论收到几次,都返回同一结果。请求标识 requestId 能避免同一会话输入重复入队,但不会自动让任意外部接口具备这种能力。
12.4 存储后端决定恢复的实际边界
后端是负责保存数据的具体实现。MemoryStorage 适合教学和临时运行,进程结束后数据消失。Node 环境可以选择 SQLite 或 JSONL;浏览器可以使用 IndexedDB;Cloudflare Durable Objects 有对应的 SQLite 适配器。应用必须先选择真正能保存数据的后端,重启恢复才有意义。
SQLite 将数据保存在数据库文件中。当前文档说明,Node 适配器使用 WAL(先写日志的数据库模式)与 synchronous = NORMAL,能应对进程崩溃,但宿主机故障或断电时,最新提交仍可能丢失。JSONL 把记录逐行追加到文件,便于观察,却也把完整历史留在内存;长期运行通常更适合 SQLite。JSONL 可选择在提交标记前执行 fsync,即请求操作系统将数据刷入存储设备。
重新打开同一存储、重新安装对应扩展和任务定义并取得根会话后,应用可调用 resume() 启动调度器;提交或等待也会启动调度。这里的“恢复”是按持久化状态继续运行,并不保证每次都得到相同的模型文字。缺失定义、迁移不兼容或达到重启次数上限时,任务可能阻塞或暂停,并非调用 resume() 就必然继续。一个存储同时由一个进程拥有,当前设计没有跨进程锁;浏览器的多个标签页也需要宿主应用协调所有权。
Durable 在本书基准版本明确标为 Experimental,也就是实验性接口,版本间可能不预告就改变 API。学习它的提交、任务和恢复思路很有价值;集成时应固定版本,阅读对应文档,并验证自己的工具副作用,而不是把示例直接当成生产服务保证。
12.5 小结与练习
本章小结:Durable 将会话、任务和应用文档放进共同的提交机制,先保存再发布,用检查点恢复执行。真正困难的是跨越本地存储与外部系统的边界。选择持久化后端和正确的重放策略是必要工作;更复杂的调度和子任务,应在需求出现后再引入。
练习一:把存储从 SQLite 换成 MemoryStorage,保留调用 resume() 的代码,能否在进程退出后恢复?参考答案:不能。调用调度器无法找回已经随内存消失的记录。
练习二:一个工具会向外部工单系统创建工单,是否可以直接声明安全重放?参考答案:先检查外部系统是否支持幂等键或可靠查重。工具可能已经成功创建工单,却尚未保存结果,重放会造成重复。
练习三:pi --continue 与 Durable 检查点恢复有何区别?参考答案:前者继续默认 CLI 保存的会话;后者由独立运行时按照任务状态继续工作,不能从一个命令推导另一个运行时的执行保证。