解密 Pi 的 Harness 工程:Agent 会话如何实现持久化与恢复

在 OpenClaw 里跑一个长任务,跑到一半直接 Ctrl+C 退出。再打开,对话还在,接着聊就行。
这个体验太日常了,日常到没人会多看一眼。但最近读 Pi 源码的时候我意识到,这件"理所当然"的小事,背后是一整套严密的工程。
之前我把 Pi 的 agent-loop 从头到尾读了一遍,弄明白了循环是怎么转的。但循环只回答了"一次请求怎么跑完",读完之后,我脑子里反而多了一堆问题:
- 会话是怎么保存的?进程都杀掉了,凭什么能恢复?
- turn 跑到一半切换模型,正在发的请求怎么办?
- 上下文压缩之后,被压掉的旧消息去哪了?
- 中途 Ctrl+C,那半条消息会丢吗?
这些问题的答案,藏在 Pi 的另一个模块里——harness。
harness 这个词直译是"马具"。马有力气,但你没法直接用,得给它套上挽具和缰绳,力气才能变成拉车的动力。测试领域早就借走了这个词:test harness,把被测代码套进一个可控的执行环境里。
Agent 领域的 harness 是同一个思路。LLM 本身只是个抽象的模型,给它文本,它吐文本。它没有手,碰不到文件系统;没有记忆,进程一关就忘。要让它真正干活,得给它套上一副"身体"——工具是手,session 是记忆,事件流是神经。这副身体,就是 harness。
这篇文章就来拆解一下 Pi 的这副身体:会话的生命周期怎么设计,会话怎么持久化,上下文怎么压缩,这些机制又是怎么组合的。
一、为什么循环之外还需要一层工程
先回答一个更根本的问题:循环已经能跑通任务了,为什么还要专门搞一层 harness?
Pi 的文档里有一句很实在的话:一个完全持久化的 harness 并不现实,因为一些关键依赖是宿主应用(host app)在运行时提供的活代码:
- 工具实现
- 模型 / 鉴权 provider
- 扩展和 hook 处理器
- 资源加载器
- system prompt 的回调 / 修改器
这五样东西有个共同点:都是函数、都是对象、都带着闭包和网络连接,没法序列化到磁盘。你能存下"当时激活了哪些工具"的名字清单,但存不下工具本身的函数体。
而且它们还会变。今天 host 注册了五个工具,明天可能变成八个;这次用的是 Anthropic 的 key,下次可能换成代理网关。对模型来说,这些资源可用不可用、什么时候变了,都是要紧的信息。一个没有 harness 的 Agent 感知不到工具的可用性变化,最直观的症状就是:对着一个已经不存在的工具反复调用、反复失败。
所以 Pi 给自己定的目标很清醒,文档原话叫 semi-durable harness,半持久化:
- 会话信息(session)是持久的、只能追加(append-only)的状态树
- harness 把自己拥有的状态写进 session
- 宿主应用负责在恢复时重新提供那些没法持久化的运行时依赖
- 恢复永远从持久化边界重启,从不指望接上一条运行了一半的大模型输出流
一句话划清界限:数据归 session,代码归宿主。承认有些东西存不了,把能存的存扎实,剩下的定义清楚交接协议——这比追求一个"全都能恢复"的幻想工程要靠谱得多。
二、AgentHarness:循环之上的编排层
Pi 里承担这个角色的类叫 AgentHarness,文档给它的定义是:位于底层 agent loop 之上的编排层(orchestration layer),负责会话持久化、运行时配置、资源解析、操作锁定,以及面向扩展的变更语义。
职责列了五项,合起来其实是一件事:运行时。循环负责跑,harness 负责让"跑"这件事可管理——运行、观测、控制,三位一体。
顺带说明一下,Pi 里这套设计有两份实现:通用包里的 AgentHarness,和 coding-agent 产品侧的 AgentSession 加 SessionManager。存储都是 JSONL,消息写入都挂在同一个事件上,压缩 API 也同构。下文统一用 harness 指代,不再区分。
三、AgentHarness 的生命周期:五个 phase
harness 用一个 phase 状态机管理自己,类型定义就一行:
type AgentHarnessPhase =
"idle" | "turn" | "compaction" | "branch_summary" | "retry";

idle:稳定态。只有在这里,才可以发起 prompt、skill、compact、navigateTree 这些结构性操作。
turn:正在执行一次 agent turn。模型流、工具调用、队列消费、save point,全发生在这个阶段。
compaction:上下文压缩。基于当前分支准备摘要,写入 compaction entry,然后回到 idle。
branch_summary:树导航。会话在 Pi 里是一棵树,跳到别的分支时,把旧分支总结一下再走。
retry:类型里预留了这个位置,文档也在讨论错误恢复,但完整的重试路径还在收敛,目前当保留位理解就好。
五个状态看着朴素,真正有信息量的是背后的三条设计原则。
第一,这个状态机管的是 session 一致性,别把它当成给 UI 看的进度条。 phase、save point、pending writes、turn 快照,这几样合在一起解决的是同一个问题:运行中不断变化的状态,怎么才能既不打乱当前请求,又不丢失到 session。UI 能顺便拿到阶段展示,只是副产品。
第二,运行中可以改配置,但只影响未来。 turn 进行中你随时可以切模型、调 thinking 级别、增减工具,harness 照单全收——但正在运行的 provider 请求一个字节都不会变。这些变更会在下一次快照里生效。
第三,恢复能力围绕 session log 建立,跟内存里的 JS 对象无关。 能持久化的只有 session entries;工具函数、provider 实例、hook 处理器,恢复时都得由宿主重新提供,然后从 log 重建出消息和配置。
四、会话持久化:存什么、何时写、怎么读
会话持久化,Pi 的做法可以压缩成三句话:
- 存什么:一棵 append-only 的 JSONL 状态树,一行一个 entry
- 何时写:由 harness phase 决定——idle 立即写,turn 按消息边界写,结构性操作写专用 entry
- 怎么读:从 leaf 回溯,应用压缩边界,投影成模型上下文
逐个展开。
存什么:一行一个 entry 的状态树
session 文件的第一行是 header,之后每行一个 entry。entry 的类型包括 message、model_change、compaction、branch_summary、leaf 这么几种,每个 entry 带一个 parent 指针,整个文件读进来是一棵树,leaf 标记当前分支走到了哪。
注意存的粒度:存的是一条条事件式的 entry,没有任何"把整个 Agent 对象 dump 下来"的操作。transcript 可序列化,工具、provider、hook 不进 session——这正是第一节里"数据归 session,代码归宿主"的落地。
何时写:phase 说了算
不同 phase 下,写盘的策略完全不同。整理成一张表:
| Phase | 写什么 | 怎么写 |
|---|---|---|
| idle | 配置变更、手动 append、压缩/导航的结果 | 立即写入 |
| turn | user / assistant / toolResult 消息 | message_end 事件时追加 |
| turn 中途改配置 | model / thinking / activeTools | 先进 pending 队列,不碰当前请求 |
| save_point | 攒下的 pending 变更 | turn 结束后统一 flush,保证排在本轮消息之后 |
| 回到 idle | 残留的 pending | agent 收尾时再 flush 一次 |
| compaction | compaction entry | 摘要 + firstKeptEntryId 边界标记 |
| branch_summary | branch_summary + leaf | 树导航结果落盘 |
这张表里有两个巧妙的设计。
第一个是 pending 队列。turn 中途改配置,写盘太早会有个隐蔽的顺序问题:配置 entry 排到了本轮还没落盘的消息前面,恢复时重放顺序就错了。Pi 的解法是攒着——变更先进 pendingSessionWrites 队列,等 turn 结束的 save point 统一 flush,天然保证配置变更排在本轮修改之后。
第二个是 中断的处理。会话中断在这套设计里走的是正常路径:仍然算在 turn 里,等运行 settle,被中断的消息照常落盘,然后回到 idle。设计上甚至没给 abort 单独设一个 phase。开头那个问题——"中途 Ctrl+C,半条消息会丢吗"——答案就在这:已经完整落盘的不会丢,被打断的那条会以 aborted 状态收尾写进去,谁也不丢。
把一条典型 turn 的落盘顺序串起来看:
idle
└─ prompt()
turn
├─ createTurnState() // 从 session 分支读快照(只读不写)
├─ user 消息 → message_end → 落盘
├─ assistant 消息 → message_end → 落盘(工具调用挂在消息内容里)
├─ toolResult → message_end → 落盘
├─ save_point → flush pending(模型切换、工具增减……)
└─ agent_end → flush 残留 → 回到 idle

怎么读:读回来的日志,还要投影一次
最后一步最容易被忽略:持久化日志和下一次发给模型的上下文,是两回事。
恢复会话时,harness 调用 buildContext()(coding-agent 侧是 buildSessionContext()),做三件事:
- 从当前叶子节点沿着 parent 指针回溯到 root,拿到这条分支的完整历史
- 应用最近的 compaction 边界——
firstKeptEntryId之前的旧消息不再进上下文,用摘要顶替 - 把剩下的 entry 投影成消息数组,交给 agent loop
所以会话 session 的准确定位是 durable state log,一份持久的状态日志;而 agent loop 每次得到的是投影后的上下文。在经过上下文压缩之后,会话日志并没有丢失,同时模型的上下文也得到了压缩。压缩掉的旧消息去哪了?还躺在日志里一行没动,只是投影上下文的时候被摘要替代了——你随时可以回溯,模型永远轻装上阵。
五、Pi 的上下文压缩是如何实现的?
上一节末尾说,压缩掉的旧消息"还躺在日志里一行没动"。这话听着有点反直觉——都叫压缩了,怎么什么都没删?值得专门深入的了解一下。
先给结论:压缩本身也是一次会话日志追加。session log 是 append-only 的,压缩不改任何旧 entry,只往树上多写一条:
{ type: "compaction", summary, firstKeptEntryId, tokensBefore, ... }
关键就两个字段。summary 是旧历史的摘要;firstKeptEntryId 是一道边界——从这条 entry 起,消息原样保留;它之前的,投影时统统跳过,用摘要顶替。于是上一节 buildContext() 的投影,就多了一步替换:
LLM 上下文 = [summary] + [firstKeptEntryId 起的保留消息] + [压缩之后的新消息]
磁盘上的历史一行没少,发给模型的上下文却瘦了一大圈。所谓"压缩",压的从头到尾都是投影;日志只会越写越长。
单次压缩的四步骤
第一步,触发。 入口有三个:阈值——turn 结束时检查 token 用量,越过"上下文窗口减去预留余量"这条线就触发;overflow——模型直接报上下文溢出,被动兜底;手动——用户敲 /compact。
第二步,选切点。 四步里最讲究的一步。在当前分支上,从上次压缩边界之后算起,从尾部往前累加 token,攒够一个保留窗口(keepRecentTokens,默认约两万 token)就停——最近这段是模型的短期工作记忆,尽量原样保留。停的位置还必须是合法边界:user、assistant、bash 这类消息的起点都行,唯独不能切在 toolResult 中间——tool_use 和 tool_result 严格配对,从中间断开,模型会看到一个没有回应的工具调用,请求直接被 provider 拒收。切点一定,世界分成两半:之前的送去总结,之后的原样保留。
第三步,生成摘要。 切点之前的消息交给 summarizer 模型,按固定结构写一份 checkpoint:Goal、Constraints、Progress、Decisions、Next Steps、Critical Context,末尾附上这段历史里读过、改过的文件清单——恢复上下文后,模型最常见的第一个动作就是重新打开这些文件。若之前已有摘要,这一步做增量更新:旧摘要当底稿,叠上新折叠的消息,产出新摘要,全程不回头重读完整历史。
这里还有个容易漏的细节。合法边界包括 assistant 消息的起点,所以切点可能落在一轮 turn 的腰上——user 发起的那半截被划进待总结区。Pi 会把这半截 turn 前缀单独总结一次,拼进最终摘要,避免"最近这轮工作是怎么开的头"这个关键信息丢掉。
第四步,写回。 appendCompaction() 落盘,phase 回到 idle。下次投影时拿最新一条 compaction,丢掉边界之前的 entry,把摘要包装成一条带说明前后缀的 user 消息,垫在上下文最前面。
举一个具体的上下文压缩的🌰
下面四张快照追踪同一条会话:m1…m12 是按时间顺序的消息 entry,cmp1 / cmp2 是 compaction entry;虚化的块已被摘要顶替,蓝色高亮是发给模型的部分。
快照 A,第一次压缩前。 日志里躺着 m1…m8,投影几乎等于整条分支加 system prompt。turn 结束时 usage 越线,进入 compaction phase:

快照 B,第一次压缩完成。 日志追加 cmp1,切点落在 m4,它成了 firstKeptEntryId。投影变成 system + summary1 + m4…m8:m1…m3 从模型视野里消失,但日志里原样躺着,随时可以回溯:

快照 C,压缩后继续聊。 新消息 m9…m12 照常追加在 cmp1 之后,投影跟着胀大:summary1 + m4…m12,上下文再次涨回阈值附近:

快照 D,第二次压缩完成。 新切点落在 m10,这次要折叠的是 m4…m9——上次保留、如今变旧的那段。生成 summary2 走增量路线:summary1 当底稿,叠上 m4…m9,折叠出新摘要。投影只认最新边界:system + summary2 + m10…m12,连 cmp1 都不再发给模型:

每次只总结"上次保留、这次变旧"的增量,旧摘要作为底稿一路传下去。会话累计再长,单次压缩要处理的消息量始终有界——日志线性膨胀,但是压缩成本是可以保持不变的。
六、会话的生命周期和持久化,是怎么实现的?
最后,总结一下。Pi 的 harness 工程,主线就一句话:
通过维护了一棵只可追加的会话树,实现会话消息的全链路持久化。
树的特性主要使用用于会话的 fork 和回溯,本文也主要讲了正常链路中,会话是如何保存的。
对着开头那四个问题看一遍:
- 会话怎么恢复?——宿主重新注入运行时依赖,harness 从 JSONL 状态树回溯投影,各管各的
- turn 中途切模型怎么办?——phase 说现在没到能写的时候,变更进 pending,save point 统一落盘
- 压缩掉的旧消息去哪了?——日志里原封不动,投影时被摘要顶替
- Ctrl+C 半条消息丢吗?——abort 走正常收尾,aborted 消息照常落盘
四个看起来不相干的问题,答案全部落在同一套机制上。这就是优秀的代码架构设计的标志:机制少,覆盖面大。
最后说点感受。
读这部分源码之前,我以为"会话保存"就是找个时机把消息数组写成 JSON 文件,一个 fs.writeFile 的事。读完才发现,"写文件"只是最后一小步。什么状态下允许写、写入顺序怎么保证、恢复时哪些东西该由谁提供——这些边界问题才是工程的主体。
Pi 给出的答案也谈不上什么黑科技:一个五状态的 phase 机、一个 pending 队列、一棵 append-only 的 entry 树。但每一样都在划同一条线——把"确定能恢复的"和"必须重新提供的"分开,把"现在能写的"和"必须攒着的"分开。线划清楚了,会话中断后再打开还能接着聊这件事,就成了理所当然。
Anthropic 的 Thariq Shihipar 在 AI Engineer World's Fair 2026 的 keynote《Field Guide to Fable》里,把与新一代模型协作的第一课定为 "Unhobbling Claude"——为 Claude 松绑。他抛出一个略反直觉的判断:
真正束缚模型的,往往不是模型本身,而是我们——我们给它套上的 harness,以及我们 prompt 它的方式。能力更强的新模型到来时,若还沿用为旧模型设计的 harness 和提示词,就等于亲手把它的能力按住。所谓 unhobbling,就是主动拆掉这些我们强加的约束,把那些因过度设限而从未被激发的能力释放出来——模型的真实能力和 harness 之间,长期存在这样一段"能力溢出"(capability overhang)。
他说的是 Claude,但道理对所有 LLM 都成立。
随着模型能力越来越强,harness 也要越来越轻。随着 fable 5 和 gpt-5.6 新一代模型的出现,类似于 superpower 的 skill 都已经被模型内化了,因此我现在已经把通用 skill 都卸载了,业务领域的 skill 还是保留着。
Pi 的 harness 工程做得很扎实也很轻量,值得我们学习。
分析完 Pi 的 harness 工程,最后想说的反而是松绑:给 LLM 更多权限、更多上下文、更多能力,同时把整个架构保持在最简单的形态。不需要太多示例和规则,给足 Agent 信息、工具、时间,以及足够的预算 (^∇^),智能自会涌现。