Pi 的不朽人生(在 Temporal 上运行 Pi 编程智能体)

HN AI Best Practices 2026-10-09T05:46:10.244794

我们在 Temporal 构建了 pi-temporal,让 Pi 编程智能体在执行命令中途遇到机器故障时也能恢复。它跑在一组 Temporal Worker 上——也就是跨多台机器执行任务的进程。你的 Pi 会话和工具都照旧,包括 Pi 终端界面(Terminal UI)。如果某个回合进行到一半机器挂了,另一位 Worker 会接手把这个回合做完,而且不会自动重放那个被打断的工具调用。

the-immortal-life-of-pi-hero-blog

这种接管适用于由 Worker 运行、通过 /background 或 CLI 启动的任务。终端界面里的回合则跑在你本地的 pi 进程里。这个进程要是挂了,重新打开 pi 就能恢复被打断的回合。下面讲讲我们是怎么走到这一步的。

Earendil 团队发布了 Pi 1.0,同时推出了 Pi Durable——他们那套让智能体活过终端会话的框架。我们读的时候一直在点头。任务在推进之前会先做检查点(checkpoint)。提交时带上请求 ID,所以重复提交只会返回已有的那次提交,而不会重新起一个。「效果三明治」(effect sandwich)会先记下意图,再去执行外部副作用,最后提交结果。Memo(备忘)把某个值只记录一次,重试时直接复用,不会重新算。

如果你在 Temporal 工作,这份清单读起来就像我们自己写的文档。一位 Hacker News 网友的总结比我们更到位:这类工具是「大家鼓捣着造类 Temporal 系统这件事的自然演进」。各种智能体框架(agent harness)都是在解决自己遇到的故障过程中,一步步走到持久执行(Durable Execution)这条路上的。

有一点差异对我们很关键:宿主机挂掉之后,谁来接手。Pi Durable 内置的持久化后端同一时刻只有一个进程持有存储,没有跨进程锁。它默认的 SQLite 配置挺得过进程崩溃,但 README 提醒说,最新的提交「在断电或宿主机故障时可能会丢失」。想在另一台机器上恢复,就得能访问到幸存的那份存储,还要新起一个进程把它重新打开。Pi Durable 支持自定义存储后端。而我们想要的,是让 Temporal 在我们整个 Worker 集群上做到自动故障转移。

于是我们做了个实验:保留 Pi 的世界模型,让 Temporal 提供执行引擎。

Temporal 和 Pi 的分工

Pi 把对话存在自己的会话文件里,你继续用它的终端界面。Temporal 负责驱动执行。一个“回合”(turn)涵盖从用户输入提示到 agent 给出最终回复的整个过程,里面可以包含多个步骤,每个步骤由一次模型调用加上模型请求的工具调用组成。一个会话就是一个 Workflow,回合里的每个工作单元都是一个 Activity:

runModelCall -- runToolCall (one per call) -- sealStep

runModelCall 把模型的回答追加到转录记录中。runToolCall 执行一次工具调用并上报结果。sealStep 按模型请求的顺序记录该步骤的工具结果,并报告这个回合是否结束。

Workflow 围绕这些 Activity 循环运转:它统计步骤数,在配置的预算范围内调度下一个 Activity。它不保存对话状态。每个 Activity 自己读取转录记录来判断该做什么,sealStep 则告诉 Workflow 要不要再循环一轮。

这里有一条核心规则:转录记录就是对话状态。每个 Activity 在开始工作前,先从会话文件重建当前回合。结果在回合推进之前就已记录好,所以恢复不依赖某个 Worker 的内存。我们假设进程随时会挂掉。

另一个 Worker 可以接手某个回合,因为它能访问对话和项目文件。会话文件放在共享目录里,所有 Worker 读到的都是同一份记录。工具编辑过的文件也会跟着走,传输方式和 Git 传输历史记录一样。

这些文件在每个 Worker 的项目目录里就是普通文件。每完成一步,Worker 就对该目录做一次快照,把一个 Git bundle 写入 <session>.jsonl.tree/,位置就在共享存储中会话文件旁边。Temporal 不存项目文件,项目自己的 .git 也不受影响。

下一步开始前,Worker 会补上它错过的所有 bundle,并检出最新的树(tree)。如果是第一次加入会话的机器,它会同时从共享存储拿到对话记录和已保存的项目状态。

pi-temporal-the-split-v3

Temporal 负责驱动执行循环。Pi 的会话文件保存对话内容,Git bundle 保留项目快照。

混沌演示

仓库里有一个混沌(chaos)演示。一条命令就能在 Docker 里启动带三个 Worker 的 Temporal 开发服务器,并提交一个多步骤的编码任务。演示每 15 到 40 秒就杀掉当时正在干活的那个 Worker。任务只提交一次,之后不再重新提交。

21:25:18  running: runToolCall attempt 1 on worker-1
21:25:31  chaos: killed worker-1 while running: runToolCall attempt 1
21:25:36  chaos: worker-1 is back, as a fresh container
21:25:49  running: runModelCall attempt 1 on worker-2
          | tool result: The outcome of this tool call is unknown.
            The session stopped after the call started but before the
            result was recorded. It can have taken effect. Check the
            current state before you try again.
          | tool: bash
          | tool result: ls: cannot access '/project/*.txt': No such file

pi-temporal-demo-timeline

在 Temporal 的界面里,两次被中断的工具调用显示为橙色条块,每次后面都跟着另一个 Worker 上的新步骤。

在我们测试过的多次运行中,这一轮最终都能跑完,请求生成的文件也确实落到了磁盘上,内容正确。

结果未知的情况

如果 Worker 在一条 shell 命令执行到一半时挂掉,即使命令已经生效,会话文件里也可能没有记录结果。我们不能假定任意一条命令都带幂等键:git push 可能在结果被记录下来之前就已经成功了,而一次文件写入也可能根本还没开始。

执行器会把这通被打断的调用记为「结果未知」,而不是自动重跑。它会告诉模型先检查当前状态,再决定要不要发起新的调用。模型去看看实际的状态:如果活儿在那儿已经干完了,就继续往下走;如果没有,就重做这一步。

这正是那行 ls: cannot access 输出所反映的:在恢复中的 Worker 检查的项目目录里,那个文件并不存在,所以模型重做了这一步。这次检查只能确认「此刻文件不存在」,没法确认被中断的命令究竟执行到了哪一步。想确认文件内容,还得把文件读回来。执行器给出的保证更窄:结果未知的中断调用不会自动重放,它只是把这份不确定性报告给模型。

「未知」背后的机制是磁盘上的一条声明(claim)。每次调用工具之前,分发逻辑会在会话文件旁边写下一条声明,路径是 <session>.jsonl.pending/<turn>/<step>/。命令返回后,结果会存到声明旁边。

重试时如果找到了结果,就直接返回结果,不再跑一遍工具。如果只找到声明,说明工具可能已经启动,但结果没被记录下来,这时重试就报告这份不确定性。Temporal 自己的尝试次数计数器回答不了这个问题,因为一次 Activity 尝试可能在还没碰到工具之前就失败了。

模型调用的处理方式不一样。重试会先读对话记录。如果失败的那次尝试已经把答案写进去了,就直接返回那个答案,不再调用模型。如果没有保存答案,就重新发送提示词。这会多花一些 token,也可能得到不一样的回答,但生成回答本身并不会执行工具。

因此,Temporal 可以把模型调用类的 Activity 放到另一台 Worker 上重试。重试如果找到已保存的答案,就不需要再向模型发请求。在演示用的 Temporal UI 里,模型调用在另一台机器上走到了第 2 次尝试,而被中断的工具调用则以「结果未知」收尾。恢复的流程是:模型先检查当前状态,然后发起一次全新的调用。

还有一种故障值得讨论:没死透的尝试。Temporal 可以停止等待某个 Worker,却无法证明该 Worker 真的停了。一个已超时的尝试可能还在运行,之后还可能产生副作用。

我们会关闭被遗弃的步骤,并把关闭结果记录到共享存储。每次向会话文件追加内容都带着 Worker 的租约,写守卫(write guard)在接受追加前会检查这个租约。当旧的尝试恢复过来想要发布结果时,守卫会拒绝它的追加,哪怕它的字节内容与已接受的结果完全一致。

由于一个步骤的各类工具共享同一个工作目录,一旦步骤启动,就会一直留在同一个 Worker 上。它的工具调用被绑定在运行其模型调用的那个 Worker 上。宿主一旦丢失,步骤就关闭;对话轮次会转移到另一个 Worker 上继续。

pi-temporal-one-kill-v2

被遗弃的步骤以未知结果关闭。写守卫会拒绝过期尝试对会话文件的延迟追加。

让恢复生效的规则

Temporal 允许工作流在崩溃后于另一个 Worker 上继续,前提是所需的基础设施仍然可用。超时并不能证明之前的尝试已经停止:重叠的尝试仍可能试图写入会话文件或调用工具。我们的集成方案遵循以下规则来处理这种重叠:

结论

Pi Durable 的编程模型选对了。我们希望 Pi coding agent 在换机器之后还能恢复运行,同时保持原有的会话格式不变。Temporal 负责执行循环,共享存储则把保存下来的对话和项目状态交给下一个 Worker。

Cloudflare 也把 Pi Durable 与 Durable Objects 集成到了一起。它的 Agents SDK 提供了 PiHarness 集成,配套的生命周期管理能让任务在重启后继续跑下去。我们的实验保留了 Pi coding agent 原有的实现和它自带的 shell 工具,只是把执行分散到我们自己选定的基础设施上的 Temporal Worker 里。

这里的所有东西都是通过对 Pi 做一个小型 fork 改造出来的。如果你正在开发一个新的 agent,实验性的 Temporal Agent Harness 可以作为一个起点,它是围绕 Workflow 构建的。它支持在一轮对话内部做恢复,也支持让工具审批暂停下来等人做决定。它的事件流会把 agent 的执行过程暴露出来。我们在这里学到的东西都会反哺到它里面。

查看原文