libGDX 作者花一年时间,把 AI 编程 Agent 的崩溃恢复做成了一台状态机

发布于 · 4,431 字 · 约 11 分钟#Github 解读#Agent 框架原文链接
libGDX 作者花一年时间,把 AI 编程 Agent 的崩溃恢复做成了一台状态机 封面图
  • Durable AgentHarness 通过全量状态记录实现崩溃恢复,程序计数器直接持久化避免推理缺失
  • Lane 机制允许多个并行操作共享同一会话树,编排与对话状态分离
  • Compaction 用 reserveTokens 和 keepRecentTokens 公式控制上下文窗口,分支摘要复用同一摘要格式
  • 供应链安全通过精确版本锁定、生命周期脚本白名单和定时审计实现偏执级防护
  • 扩展机制鼓励用户注册自定义工具和命令,核心贡献采用高门槛审查和 issue 门控

如果你做过游戏开发,大概率听过 libGDX。那个用 Java 写的跨平台游戏框架,养活了一整代安卓独立开发者,作者叫 Mario Zechner,网名 badlogic。

2025 年 8 月,这个人开了个新仓库叫 pi,定位是 AI agent toolkit。一年后,它有了 85932 个 Star,10676 个 Fork,几乎一周一个版本(最新是三 天前的 v0.84.1)。从游戏引擎跳到 AI 编程 Agent,听起来像转行,但你要是读懂他写的代码,会发现这两件事的底层逻辑是一样的。都在处理同一个问题,状态如何在崩溃中活下来。

libGDX 要解决的是手机游戏切后台被杀、内存告警回收、 Activity 重建后怎么恢复游戏存档。Pi 要解决的是 AI Agent 跑到一半进程崩了、流式输出中断了、工具调用执行到一半挂了,怎么让整个操作完整恢复。

这不是又一个 Codex 或 Claude Code 的套壳。Pi 真正下功夫的地方,是那套叫 Durable AgentHarness 的设计。我自己读完它 4612 行的设计文档和几个核心源码文件之后,觉得这套东西值得每个做 Agent 运行时的人看看。

先说清楚 Pi 是什么

Pi 不是一个单点工具,而是一个 monorepo,里面五个 npm 包,各管一摊。

@earendil-works/pi-ai 统一了 30 多家 LLM Provider 的 API,从 OpenAI、Anthropic、Google 到 DeepSeek、Kimi、小米 MiMo、通义千问,都能用同一套接口调。@earendil-works/pi-agent-core 是 Agent 运行时,管工具调用和状态。@earendil-works/pi-coding-agent 是你能直接装来用的编程 Agent CLI。@earendil-works/pi-tui 是一个终端 UI 库,支持差分渲染和内联图片。@earendil-works/pi-telemetry 管厂商中立的遥测契约。

最值得拆的是中间那个 agent-core,它的核心文件 packages/agent/src/harness/agent-harness.ts 有 19004 字符,配套的设计文档 harness-v2.md 写了 4612 行。Mario 把这套设计叫「Durable AgentHarness」,durable 这个词在整篇文档里反复出现,是理解 Pi 的钥匙。

为什么大多数 Agent 不关心崩溃恢复

你用过的多数 AI 编程工具,不管是 Cursor 还是 Windsurf 还是早期的 Claude Code,对崩溃这件事的态度都差不多,爱莫能助。进程一挂,上下文丢一半,流式输出断在哪算哪,工具调用执行到 rm -rf 还没跑到 git commit,那就得你自己收拾残局。

这不是这些工具偷懒,而是 Agent 的崩溃恢复真的难。难在哪?我读完 Pi 的设计文档之后,总结成一句话。外部效应没法做到既持久又恰好一次。

什么叫外部效应?Agent 跑起来要做五类事,写持久化存储、调 LLM Provider、执行工具、触发 Hook、等待重试。每一类都会改变外部世界。Provider 已经扣了你 token 费,工具已经改了你硬盘上的文件,Hook 已经发了 HTTP 请求。这些副作用一旦发生,进程崩溃后你没法假装它没发生,也没法保证它只发生一次。

Pi 的设计文档里有一段话说得很直白,我直接翻译过来。「外部效应一般没法在进程失败时同时做到持久和恰好一次。Provider 请求、工具、Hook 和 Provider 计费可能在它们的结果还没落盘之前就发生了。实现必须用幂等性、声明的安全重放、对账或者接受不确定性。Harness 把这种不确定性显式化,但没法消除它。」

这段话是整个 Durable AgentHarness 设计的哲学原点。它不假装能解决不可解的问题,而是把每种崩溃状态都变成「可观察、可恢复、可推理」的。

Harness v2,把程序计数器直接存下来

读完 harness-v2-state-machine.md 之后我才明白,Pi 的 Harness 设计其实经历了两个版本。v1 用的是隐式恢复,持久化很多小的编排事件(orchestration events),然后从这些事件的组合和条目的缺失中,反推出一个隐藏的程序计数器。

这个做法的问题在于,同一次 Assistant 的结算结果,可能以四种不同的持久化前缀存在。只有响应、响应加用量、响应加用量加工具计划、还有其他几种变体。队列状态、失败清理、导航进度,全都要靠推理。代码越写越复杂,因为每一种组合都要处理。

v2 的核心改动就一句话,把程序计数器直接存下来。

具体怎么存?Pi 定义了一个叫 OperationStateRecord 的全量状态记录。每次状态转换都追加一条记录,包含当前这个操作的全部可变编排状态,工作流状态、重试状态、工具计划和每个调用的状态、待处理的操作队列和写入、延迟源、取消控制。revision 从 1 开始,每次加 1,最新的那条就是权威状态。

关键是文档里反复强调的那句,「total means total」。一个 OperationStateRecord 不是补丁,不需要读旧的状态记录才能理解。它可能通过 ID 引用不可变的会话条目和用量记录,但不会有更旧的状态记录来补充它缺失的部分。

Mario 在文档里明确写了,「第一个实现接受全量状态记录的存储开销。不要引入增量链、子状态日志或补丁重放来优化它们。」这是很反工程直觉的决定。谁都知道全量存比增量存占空间,但他宁可先存对,也不存省。因为增量一旦引入,崩溃恢复的推理就又回来了,v1 的坑就又踩一遍。

Lane,让一个会话里跑多个并行操作

大多数 Agent 的并发模型是「一个会话一个操作」。你要并行?开多个会话。Pi 不这么想,它引入了一个叫 Lane 的概念。

Lane 是会话里的一个命名位置,最多挂一个操作。每个会话都有一条叫 main 的 Lane,应用可以自己创建更多。文档里举了个很实际的例子,一个 Slack 频道可以是一个会话,每个 thread 是一条 Lane。交互式的 Pi 只用一条隐藏 Lane。子 Agent 可以在父 Agent 的会话里借用另一条 Lane。

Lane 的精妙之处在于,它解决了「看起来像多写者」的工作负载。多个 Lane 之间共享同一棵对话树(tree),但各自维护自己的 leaf、操作日志、队列和总配置。Harness 还是单一写者,所有 Lane 的记录和条目在共享序列里交错,但逻辑上互不干扰。

我读完这块最大的启发是,很多人在纠结 Agent 怎么做多轮对话的状态管理,Pi 给的答案是别在对话层做,把对话树做成被动的、只增不删的共享数据,把「当前在哪、在干什么」这种活跃状态全塞进 Lane 的操作记录里。对话归对话,编排归编排,两层不混。

读 packages/agent/src/harness/ 下的源码结构你就能看清这个分层。session/ 目录管会话树和存储后端,compaction/ 目录管上下文压缩,tools/ 目录管 bash、read、write、edit 这些工具的实现,agent-harness.ts 是总控。每一层职责清晰,不互相侵入。

Pi 系统架构
Pi 系统架构

从上往下看,接入层是 CLI 和 TUI,编排层是 Harness 核心,上下文管理层管压缩和提示词,工具与扩展层暴露给 LLM 和用户,最底下的持久化层保证崩溃可恢复。pi-ai 和 pi-telemetry 作为两个侧翼,分别统一了 LLM 接口和遥测契约。

Compaction,上下文窗口的生存术

LLM 都有上下文窗口限制。对话一长就得压缩,这事不新鲜。但 Pi 的 compaction 做得比大多数工具讲究,值得单独说说。

它的触发逻辑写在 packages/coding-agent/docs/compaction.md 里,核心公式是 contextTokens > contextWindow - reserveTokens,默认 reserveTokens 是 16384。这个 reserve 不是随便拍的数字,它要给 LLM 的响应留地方。

压缩分两种,compaction 和 branch summarization,用同一套结构化摘要格式,都累积追踪文件操作。区别在于触发场景。compaction 是上下文超限自动触发,branch summarization 是你在 /tree 里导航切分支时触发,为了不丢上下文。

Compaction 的流程是,从最新消息往回走,累加 token 估计值直到达到 keepRecentTokens(默认 20k),找到切点 firstKeptEntryId。然后把切点之前的消息送给 LLM 做摘要,生成一个 CompactionEntry 追加到树尾。会话重载时,用摘要加上 firstKeptEntryId 之后的条目,发给 LLM。

我读到的一个有意思的细节,compaction 和 branch summary 的请求都用新的 routing session ID,并且在 Provider 支持的情况下禁用 prompt-cache 写入。原因很实在,这些一次性摘要 unlikely to be reused,写缓存纯属浪费。

但 compaction 这块也不是没有坑。issue #6879 就报了一个真问题,auto-compaction 在上下文涨过 100% 之后反而不触发,直到 Provider 溢出才动。这种边界 bug 在状态机复杂的设计里几乎是宿命,逻辑越精密,角落情况就越多。

五层供应链加固,偏执到什么程度

读 Pi 的 README 时,有一整段叫「Supply-chain hardening」的内容让我印象深刻。一个 Agent CLI 工具,为什么要花这么大篇幅讲供应链安全?

因为 AI 编程 Agent 会执行代码、读写文件、调网络请求,攻击面比普通 CLI 大得多。一旦依赖被投毒,影响的是所有用 Pi 的开发者。

Pi 的做法细致到这种程度。直接外部依赖全部 pin 到精确版本,内部 workspace 包保持版本范围。.npmrc 里设置 save-exact=true 和 min-release-age=2,后者是为了避免 npm 解析时拉到当天刚发的依赖。package-lock.json 是依赖真相来源,pre-commit 钩子会拦截意外的 lockfile 提交,除非你显式设了 PI_ALLOW_LOCKFILE_CHANGE=1。shrinkwrap 生成有一个显式白名单控制哪些依赖能跑 lifecycle script,新的带 lifecycle script 的依赖直接挂检查,直到人工 review。

CI 安装用 npm ci --ignore-scripts,还有一个定时的 GitHub workflow 跑 npm audit --omit=dev 加 npm audit signatures --omit=dev。发布冒烟测试用 npm run release:local,在仓库外面构建、打包、创建隔离的 npm 和 Bun 安装,然后才打 tag。

这套东西的偏执程度,说实话比很多商业公司都强。Mario 做游戏引擎出身,见过太多依赖地狱,这种防御性思维带到了 Agent 工具里。

必须说清楚的局限

Pi 不是银弹,它自己 README 里就把最大的坑写在明面上,没有内置权限系统。默认情况下,它以启动它的用户和进程权限运行,能读你所有文件,能跑所有命令。

如果你需要更强的边界,README 让你自己去容器化或沙箱化,给了三种模式。Gondolin 扩展把工具和 ! 命令路由到本地 Linux 微 VM,Plain Docker 把整个 pi 进程跑在容器里,OpenShell 跑在策略控制的沙箱里。

这是很诚实的设计选择。与其做一个半吊子的权限系统给人虚假安全感,不如明确告诉你「这事我不管,你自己隔离」。但这也意味着如果你无脑装来跑别人的项目,风险是实打实的。

另一个我在 issue 区挖到的真实痛点,是 TUI 的性能。issue #6665 标题叫「TUI pins a full core while streaming」,内容非常详细。长会话流式输出时,TUI 占满一个核。原因有两个,一是 Intl.Segmenter(ICU BreakIterator)在 wrap/truncate 路径里没缓存,非 ASCII 行(俄文、emoji、box-drawing 字符)每帧都重新分词。二是 AssistantMessageComponent.updateContent() 每次收到 message_update 就 clear() 再 new Markdown(...),导致 pi-tui 自己的 cachedLines 在流式时永远命中不了,整条消息从头重新词法分析、重新换行、重新分词,跑满 60fps,成本随答案长度增长。

报这个 issue 的人实测,M3 芯片、Node 22.23、pi 0.80.7,每个流式会话占约 105% 的核。他还写了个插件 pi-render-cache 做了两层缓存,把占用从 105% 压到 10-16%,输出字节级一致。这种性能问题在追求功能迭代的项目里太常见,但也说明 Pi 的 TUI 层还有不少优化空间。

还有一点容易被忽略的成本。Claude Pro/Max 的订阅用户要注意,README providers 文档里明确写了,「第三方 harness 使用 Anthropic 订阅时,从 extra usage 里扣,按 token 计费,不走 Claude plan 的额度」。很多人以为有 Claude Pro 就能随便跑 Pi,结果账单可能出乎意料。OpenAI Codex 这边相对友好,需要 ChatGPT Plus 或 Pro 订阅,而且 OpenAI 官方背书过 Codex for OSS。

bus factor 也是个要留意的点。看最近 10 个 commit,核心提交者就 Mario Zechner、David Brailkovsky、Vegard Stikbakke 三个人。30 个贡献者里真正的核心维护者更少。一个 8.5 万 Star 的项目,核心开发者个位数,这既是开源的魅力也是风险。不过 Mario 的投入度是实打实的,最近连续 5 个 commit 全是他写的 Harness v2 设计文档迭代,时间戳从 8 月 8 日下午到 8 月 9 日凌晨,几乎是在通宵推进这个重设计。

扩展机制,这才是 Pi 真正的产品力

读完 packages/coding-agent/docs/extensions.md(2988 行)和 examples/extensions/ 下的示例,我才理解 Pi 为什么把自己定位成「self extensible coding agent」。

扩展是 TypeScript 模块,能做六件事。注册 LLM 可调用的自定义工具、拦截和修改工具调用、通过 ctx.ui 跟用户交互(select、confirm、input、notify)、注册自定义命令、做会话持久化、完全控制工具调用结果在 TUI 里的渲染。

它最聪明的贡献门槛设计在 .github/workflows/issue-gate.yml 里。所有新贡献者的 issue 和 PR 默认自动关闭。维护者每天 review 自动关闭的 issue,觉得值得的才 reopen。要获得不被自动关闭的权限,得靠维护者在 issue 下回复 lgtmi(未来 issue 不关)或 lgtm(issue 和 PR 都不关)。

这个反直觉的设计其实很合理。Mario 的哲学写在 CONTRIBUTING.md 里,「Pi 的核心是极简的。如果你的功能不属于核心,那它应该是扩展。会把核心搞臃肿的 PR 大概率被拒。」他宁可把贡献门槛拉高,也要保住核心的可维护性。功能诉求你可以用扩展实现,核心只收那些真正属于底座的东西。

CONTRIBUTING.md 里还有一条更狠的,「你必须理解你的代码。如果你没法解释你的改动做什么、怎么跟系统其余部分交互,你的 PR 会被关。用 AI 写代码没问题,提交不理解的 AI 生成垃圾不行。」

在 2026 年这个 AI 编程工具遍地、AI 生成 PR 泛滥的时间点,一个 AI Agent 项目主动要求贡献者「必须理解代码」,这种态度本身就值得尊重。

一个可带走的设计模式

读完 Pi 的代码,我脑子里提炼出一个东西,姑且叫它 「全量状态外置」模式(Total State Externalization)。

它的核心思想是,当一个有大量外部效应的复杂状态机需要崩溃恢复时,别试图从细碎的事件日志里反推程序计数器,也别试图做增量补丁链。把每一步的完整状态作为一个不可变的、自解释的记录存下来。宁可多存,也别存省。崩溃恢复时,加载一条不可变记录和一个最新全量状态,直接还原,不做 fold。

这个模式的适用场景很明确,你的系统同时满足三个条件。第一,有不可控的外部效应(网络请求、文件写入、计费)。第二,崩溃后必须能恢复到「要么没发生,要么已完成」两种状态之一,不能停在中间。第三,你愿意接受全量状态的存储开销换取恢复逻辑的简单性。

Pi 的 Harness v2 是这个模式的最完整实践。它用 OperationRecord 存不可变的接受数据,用 OperationStateRecord 存全量可变状态,用 provisioned ID 在效应发生前预分配结算要用到的所有 ID。每次状态转换都是一次原子的事务写入,输出、用量、下一个状态一起提交。

什么场景该用 Pi?如果你在做一个需要长期运行、跨多次会话、对崩溃敏感的 Agent 运行时,Pi 的 Harness 设计几乎是开源世界里能找到的最完整的参考实现。它的 monorepo 结构也让你可以只取你要的部分,比如只要 pi-ai 的多 Provider 统一层,或者只要 pi-tui 的终端渲染库。

什么场景不该用?如果你只是想快速套一个能跑的编程 Agent,对崩溃恢复没要求,Pi 的学习曲线会比 Codex CLI 或 opencodex 这种轻量工具陡得多。它的价值在「能持久化、能恢复、能推理」这三件事上,如果你的场景不需要,那就是杀鸡用牛刀。

说真的,一个人花一年时间把 AI Agent 的崩溃恢复做成一台状态机,这件事本身就值得敬佩。它不像那些一周攒出来的套壳工具,Pi 里有真正硬核的分布式系统思维。而这套思维来自一个做过游戏引擎的人。游戏存档的持久化、Activity 生命周期、状态机的有限状态转换,这些技能树点满了之后挪到 Agent 领域,正好命中了 Agent 运行时最难的这块骨头。

如果你在搭 Agent 框架,harness-v2.md 这篇文档值得打印出来读三遍。

评论互动

© 2026 王若风的技术博客 · Powered by Astro