好文集ChatGPT Plus¥119

Pi Durable

出处:Earendil Engineering(2026-10-01)


今天 Earendil 和 Pi 社区一起发布了 Pi 1.0。这反映了我们的一个判断:经过无数小时的加固、维护和积极开发,Pi 现在是一个稳固的产品。

但我们想探索的那些方向,用一个 coding agent 是够不到的。这篇讲的是我们做的事。

为什么不能直接用 Pi

原文把对比写得很清楚:

Pi 这个 coding agent 是跑在你的(远程)机器上、在终端里、由一个人驱动的。如果进程死了,你看一眼发生了什么,然后叫它继续。

这是 Pi 1.0 专注和擅长的,这一点不变。

那我们需要什么? 原文列了五条:

我们需要一个 harness:能跑在任何地方、能从不同界面触达、支持无限长的对话、能在灾难性的内部和外部故障中活下来、并且让多个人同时操控同一个 agent。

Pi Durable 就是为了这五条而做的。

一个重要的边界:它不替代 Pi 的 coding agent。 它是一个用于构建任意 agentic 应用的框架——coding agent 只是其中一种。它和 Pi 共享代码(pi-ai)和两条核心原则:极简主义,以及超级可塑性。

为什么要另开一个包而不是改 Pi? 原文给的答案很实在:这样可以让他们探索,而不打扰 Pi;而学到的经验会回流到 Pi。

支柱一:应用状态和对话同等持久

这是整个设计里我认为最根本的一条。

我们希望构建在 agent 之上的应用,其状态和对话本身一样持久。

在 Pi Durable 里,应用状态——比如一个待办清单、一份计划、一张工单、或者一段对话跑在哪个沙箱里——活在「文档」(documents)里。

关键在于文档是怎么存的:

文档是类型化 JSON,存在 transcript 旁边,并且在同一批原子提交里被修改,所以状态永远不会和产生它的 transcript 产生分歧。

而且每个文档要声明分叉时带什么:

import { defineDoc } from "@earendil-works/pi-durable";

const Todos = defineDoc<{ items: string[] }>({

kind: "app.todos",

version: 1,

scope: "conversation",

history: "rewindable",

fork: "asOf", // 分叉从父级在分叉点上的那份待办开始

initial: () => ({ items: [] }),

});

注意 fork: "asOf" 这个声明——它让「从某一点分叉出去」这个动作在应用状态层面也有确定语义,而不只是对话历史层面的。

支柱二:Replay 语义必须显式声明

我认为这是整篇文章里最硬的一个设计决定。

在 Pi Durable 里,一个工具要么被标记为 replay: "safe",要么没有。这个区别决定了崩溃恢复时它会不会被重跑。

import { Type } from "@earendil-works/pi-ai";

import { defineTool } from "@earendil-works/pi-durable";

const searchIssues = defineTool({

name: "search_issues",

description: "Search the issue tracker",

parameters: Type.Object({ query: Type.String() }),

replay: "safe", // 只读,所以崩溃后重跑没问题

execute: async (args, api) => {

api.output(searching for ${args.query}\n); // 流给每个在看的客户端

return {

content: [{ type: "text", text: await tracker.search(args.query) }],

};

},

});

const deploy = defineTool({

name: "deploy",

description: "Deploy a version to production",

parameters: Type.Object({ version: Type.String() }),

// 没有 replay:被崩溃打断的部署会报告给模型,绝不重跑。

execute: async (args) => ({

content: [{ type: "text", text: await ci.deploy(args.version) }],

}),

});

请把这两段注释连起来读:

· replay: "safe" —— 只读,所以崩溃后重跑没问题

· 没有 replay —— 被崩溃打断的部署会报告给模型,绝不重跑(原文:a deploy interrupted by a crash is reported to the model, never repeated)

这意味着「崩溃恢复」不再是一个全局策略,而是每个工具自己的属性。 而缺省是不重跑——也就是安全的那一侧。

同一个机制也能用在子 agent 上:

const triage = defineTool({

name: "triage",

description: "Label an incoming issue as bug, feature, or question",

parameters: Type.Object({ issue: Type.String() }),

// 崩溃后重跑会找到同一个子 agent 和同一次提交

replay: "safe",

execute: async (args, api, context) => {

const child = await api.commit(async (tx) => {

const existing = (

await tx.scanConversations({ ownerTaskId: api.taskId }, 1)

).items[0];

if (existing !== undefined) return existing;

// ...

});

// ...

},

});

「崩溃后重跑会找到同一个子 agent 和同一次提交」——这句话是 replay: "safe" 在会话层面上的含义:重跑不是重新开始,而是接上原来那条线。

支柱三:Ownership —— 中止该传播到哪

有归属的任务,在中止时会连带中止它拥有的东西:

await api.createTask(

Checkout,

input,

{ ownership: { kind: "task", taskId: api.taskId } },

context,

);

原文对这段的说明是:「由这次调用拥有,所以中止这次调用就会中止子 agent」;而在支付那个例子里,注释写得更具体——中止调用会中止结账,并退还它的付款。

反过来的一侧是背景任务:

await api.createTask(

Reminder,

// ...

{ background: true },

);

后台任务在对话进入空闲时继续运行,而且 Esc 不会碰它。 原文给的适用场景很实用:一个应该比启动它的那一轮活得更久的子 agent,或者一个明天才触发的提醒。

把这两个放在一起看,你会发现它们在回答同一个问题:这条工作的生命周期归谁? 归这一轮,就跟着这一轮中止;不归,就活下去。

支柱四:多人同时操控同一条对话

我们希望多个人和多个客户端同时与同一条对话工作:观看它、中途加入、操控它。

做到这一点的手段是:UI 所需要的一切都是已提交状态(committed state)。 所以任意数量的客户端可以挂到 harness 里的任意一条对话上。

客户端拿到的东西分两段:先拿当前视图(transcript、正在流式输出的答案、运行中的工具及其输出、排队的消息、agent 和用量),之后只拿变化的部分。晚加入或者重连的客户端从当前视图开始。

任何客户端都能操控一条正在运行的对话,或者排一个后续消息:

// 第二个客户端在 agent 工作时加入这条线程

const view = await thread.viewState(context);

render(view.value);

view.subscribe((value) => render(value));

// 并且操控它。这条消息会在当前工具调用之后加入正在跑的工作。

await thread.submit(

{ type: "input", content: "Check the staging logs first", whenBusy: "steer" },

context,

);

注意 whenBusy: "steer" ——它不是插队,是在当前工具调用之后接入。

远程客户端用 thread.watch() 拿到每次提交的精确操作,小到可以直接走 socket。如果你更习惯 coding agent 那套熟悉的事件,watchEvents() 把提交转成那些事件——代价是线上传的字节更多。

扩展与 system prompt

我们希望 agent 能做的每件事都是可插拔的,并且每个插进来的部件都参与持久化。

在 Pi Durable 里,一个扩展就是一组有名字的东西:system prompt 的各个 section、工具、钩子和任务。 应用把扩展安装进一个 registry;每条对话选择它用哪些扩展和工具,并且只存它们的名字。

而 system prompt 的处理方式值得单说:

system prompt 在每次请求之前,从这条对话各扩展的 section 重新构建,所以一个改过的 section 会在下一个请求里被接上。Pi Durable 把「什么变了」记录进 transcript,而且记在它变化的位置,所以一次重启或一次分叉看到的是模型当时看到的东西。

在支持对话中途修改 system prompt 和工具的模型上,只发送变化的部分,所以 prompt cache 保持有效。

import { defineExtension, section } from "@earendil-works/pi-durable";

const ProjectContext = defineExtension({

name: "project-context",

sections: [

// 从对话的执行环境里读取。这些文件可以在后台被加载和监听;

// 每次请求都会渲染最新状态。

section("agents_md", (input) => agentsMd.latest(input.env)),

section("skills", (input) => skills.latest(input.env)),

],

});

「记在它变化的位置」这一条是重点——它让 transcript 成为一个可以精确重建「模型当时看到了什么」的记录,而不是只能重建「模型说过什么」。

长时间运行,跑在任何地方

harness 可以跑在一台机器上,而它的工具跑在另一台机器上。

机制是:你的 env 函数从对话的工作目录出发,为每一次工具调用构建环境,所以每条对话可以跑在不同的地方。

import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";

import { createModels } from "@earendil-works/pi-ai/models";

import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";

import { createRegistry, Harness } from "@earendil-works/pi-durable";

import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";

最好的说明书是一个 demo

原文给了一个度假规划器的完整演示序列,我原样保留——因为它把上面四个支柱全部串起来了:

  1. 一个带 TUI 的度假规划器,构建在 Pi Durable 上

  2. 一个子 agent 并行跑三个搜索,每个都是一个 durable task

  3. 与此同时,主 agent 可以自由聊天

  4. 进程死了。天气和博物馆完成了,火车没有。

  5. 重启。search 可以安全重跑:只有火车重新跑了一遍。

  6. 切到子 agent 并操控它

  7. 回到主 agent:提问、压缩、操控,而这一切都发生在它工作的同时

  8. 报告以消息形式到达;主 agent 把它变成一份计划

这个规划器大约 1300 行 TypeScript,其中大部分是 TUI。 原文还专门澄清了一句:「如果它看起来像个 coding agent,那只是因为它的 TUI 组件借用了 Pi 的 coding agent。」

跑这两个 demo:

npm install && npm run build

node packages/coding-agent/src/experimental/durable/main.ts

node packages/coding-agent/src/experimental/vacation/main.ts

packages/durable 下还有超过三十个 example。

我读到的三件事

一、这篇的价值不在「Pi 出了新包」,在它给出的几个设计模式。

把「崩溃恢复」变成每个工具的显式声明、把「中止传播到哪」变成归属关系、把应用状态和 transcript 放进同一个原子提交——这三条跟 Pi 无关,任何做长期运行 agent 的人都能直接拿走。 尤其第一条:

一个工具要么声明自己可以安全重跑,要么就必须在被中断时报给模型、绝不重放。

这比「我们实现了崩溃恢复」有用得多,因为它把幂等性从一个大家默认成立、实际常常不成立的假设,变成了一个必须回答的问题。

二、「同时支持一键中止和后台长跑」是同一个问题的两面。

ownership 和 background 看起来是两个特性,其实是同一个问题的两种答案:这条工作归谁所有? 归这一轮,中止就传播过去;不归,它就活过这一轮。把它们当成一个整体去设计,比分别加两个开关要清楚得多。

三、「记在变化的位置」这种细节,决定了 transcript 到底是不是一份可信记录。

大多数 harness 的 transcript 是「模型说了什么」。Pi Durable 想让它成为「模型当时看到了什么」——包括 system prompt 的哪个 section 在哪个位置变了。这才让 fork 和重启变成可复现的,而不是近似复现的。

边界

· 这是厂商技术文(Earendil Engineering,作者 Mario),所有演示和机制描述都是他们自己的,我没有独立复现任何一个

· 原文明确标注它是实验性的:「Pi Durable is experimental, and the API might still change.」 上面的代码示例都可能变

· 我没有实际跑过那两个 demo,文中的运行命令是原文给的

· 本账号日常使用 Pi,这构成立场问题,读者自行判断

· 同一批发布的 Pi 1.0 我此前已单独写过一篇,两篇讲的是不同的东西:1.0 是 Pi 这个 coding agent 的发布,Durable 是另一个包

链接

原文:https://earendil.com/posts/pi-durable/

Pi 1.0 发布文:https://earendil.com/posts/pi-1-0/

README:https://github.com/earendil-works/pi/blob/main/packages/durable/README.md

三十多个示例:https://github.com/earendil-works/pi/tree/main/packages/durable/test/examples

度假规划器:https://github.com/earendil-works/pi/tree/main/packages/coding-agent/src/experimental/vacation

#AgentEngineering #持久化执行 #Pi

全文完
查看原文