Skip to content
not500
Go back

Pi 的会话记忆机制

On this page

Pi 的“记忆”首先是一个会话系统:它把用户消息、助手回复、工具调用和工具结果持久化下来,再根据当前对话路径和上下文窗口,将其中一部分投影给模型。

这套设计不等同于向量检索、长期用户画像或跨项目知识库。它解决的是更具体的问题:一次 Agent 会话足够长、包含足够多工具操作时,如何既不丢失历史,又不把全部历史塞进模型上下文。

一、问题:完整历史不能直接等于模型上下文

编码 Agent 的一次任务往往包含读文件、编辑、执行命令、查看报错、调整方案等大量过程。完整保存这些过程很重要:它们是审计记录,也是回退和恢复的依据。

但模型有固定上下文窗口。若每轮都发送所有历史,token 成本和延迟会不断增加,最终还会超过模型限制。因此 Pi 把两件事分开:

可以把它理解为代码仓库与构建产物的关系:仓库保留完整源文件;某次构建只读取当前需要的依赖图。

二、持久化层:JSONL 会话树

Pi 默认将会话保存为 JSONL 文件。JSONL 的特点是每行一个 JSON 对象,适合追加写入:新增一条消息不需要重写整个文件。

会话文件第一行是元数据,后面是各种事件条目:

{"type":"session","version":3,"id":"...","cwd":"/project"}
{"type":"message","id":"a1","parentId":null,"message":{"role":"user","content":"修复登录失败"}}
{"type":"message","id":"a2","parentId":"a1","message":{"role":"assistant","content":[...]}}

每个条目包含 idparentId。这使历史不是只有一条线,而是一棵树:从某个旧消息继续输入时,新记录可指向该旧节点,原路径仍然保留。

flowchart LR
  A[用户:修复登录] --> B[助手:检查认证模块]
  B --> C[用户:继续 OAuth 方案]
  C --> D[助手:修改并测试]
  B --> E[用户:改试 API Key 方案]
  E --> F[助手:实现替代方案]

当前 leaf 表示本次会话正停在哪个节点。构建上下文时,Pi 只回溯 leaf 到根的路径,而不会把其他分支的后续消息混进来。

这种结构的意义不是 Git 分支。Git 分支管理代码版本;会话树管理对话和推理路径。它让错误方向、备用方案和已验证的工具输出都可以保留。

三、一轮对话到底保存了什么

用户消息和助手消息都是 message 条目。助手消息的内容除了普通文本,还可以包含思考块和工具调用:

{
  "role": "assistant",
  "content": [
    {"type": "text", "text": "我先读取回调逻辑。"},
    {
      "type": "toolCall",
      "id": "call_1",
      "name": "read",
      "arguments": {"path": "src/auth/callback.ts"}
    }
  ]
}

工具返回则作为独立的 toolResult 消息保存,并通过 toolCallId 关联到调用:

{
  "role": "toolResult",
  "toolCallId": "call_1",
  "toolName": "read",
  "content": [{"type": "text", "text": "...文件内容..."}],
  "isError": false
}

这样拆分的好处是清楚地区分“模型请求了什么”和“环境实际返回了什么”。同一会话还可保存模型切换、思考等级、标签、会话名称和扩展状态等条目。

四、从会话树到模型上下文

每次模型调用前,Pi 会执行两个动作:

  1. 从当前 leaf 回溯,取得活动分支。
  2. 将活动分支中的条目转换为模型消息。

普通消息、工具结果和可见的扩展消息会进入消息序列;模型切换和思考等级则被提取为当前运行配置。纯扩展状态默认不会进入模型上下文。

扩展有两种写入方式:

因此,“写入会话”与“发送给模型”不是同一件事。前者强调完整记录,后者强调当前任务所需的信息。

五、压缩何时发生

当估算的上下文 token 数超过下面阈值时,Pi 会自动压缩:

contextTokens > contextWindow - reserveTokens

默认会预留 16384 token 给下一次模型回复。除此之外,用户还可以手动执行 /compact

压缩并不删除旧记录,而是在会话树中追加一个 compaction 条目,其中包含:

六、firstKeptEntryId:摘要之外为什么还要保留原文

只留下摘要会损失当前工作所需的细节,例如精确报错、刚读取的文件片段、正在执行的工具调用和用户刚给出的约束。

Pi 会从活动路径末尾向前估算 token,默认保留约 20000 token 的近期记录,并以合适的消息边界作为切点。这个切点就是 firstKeptEntryId

压缩后的模型上下文顺序是:

System Prompt
压缩摘要
从 firstKeptEntryId 开始的原始记录
压缩后新增的记录
本轮用户输入

注意,压缩条目在 JSONL 中是在当时的叶节点之后追加的;但构建模型上下文时,摘要会被放在最前面,用来替代更早的原始记录。

如果某一轮对话特别长,Pi 可以在一轮中间切开,但不会把切点放在工具结果上,避免工具调用和其结果被无意义地分离。

七、工具结果如何进入摘要

压缩前,Pi 会把待压缩消息转成普通文本,以避免摘要模型把历史误当成仍待回答的聊天消息:

[Assistant]: 我先检查回调配置

[Assistant tool calls]: read(path="src/auth/callback.ts")

[Tool result]: throw new Error("redirect_uri_mismatch")

超长工具结果在摘要请求中最多保留前 2000 个字符,控制摘要请求本身的体积;JSONL 内的完整工具结果并不会被截断。

摘要模型应将工具输出中真正有价值的结论归入不同字段:

工具结果在摘要中的位置
已读取或修改的文件<read-files><modified-files>
测试成功与已完成编辑Progress / Done
失败日志与外部依赖Progress / BlockedCritical Context
由检查结果确定的方案Key Decisions
下一条待执行命令或验证Next Steps

对于 readwriteedit,Pi 还会从工具调用参数中收集文件路径,并把只读文件和被修改文件附加到摘要末尾。

八、结构化摘要的格式

摘要不是自由文本。首次压缩时,提示词要求严格输出以下结构:

## Goal
## Constraints & Preferences
## Progress
### Done
### In Progress
### Blocked
## Key Decisions
## Next Steps
## Critical Context

字段的分工很明确:

摘要系统提示词还约束模型:不要继续原对话,只输出结构化摘要;正文提示词要求保持简洁,并保留精确文件路径、函数名和错误信息。

九、多次压缩如何避免摘要失真

第二次压缩不会简单地“再摘要一次摘要”。系统会把上一轮摘要放入 <previous-summary>,再把自上次压缩以来变旧的原始消息一并交给模型,要求它更新同一个结构。

更新规则包括:保留仍有效的旧信息、加入新的进展和决策、将完成项从 In Progress 移到 Done、更新 Next Steps,并移除已经失效的内容。

这是一种增量状态更新:摘要承担长期工作状态,近期原文承担短期执行细节。

十、设计取舍与边界

这套机制的优点是:

它也有明确边界:

十一、看完本文后能够回答的问题

  1. Pi 为什么把完整会话存储与模型上下文构建分开?
  2. JSONL 和 id / parentId 如何支持会话分支?
  3. 工具调用与工具结果怎样保存,又怎样参与摘要?
  4. firstKeptEntryId 为什么是压缩设计的关键?
  5. 结构化摘要如何在多次压缩后维持任务状态?

Pi 的核心思路可以概括为一句话:完整历史留在可追溯的会话树中,模型只拿到“结构化历史摘要 + 最近可执行细节”。


Share this post:

Previous Post
什么是Cookie?
Next Post
RAG 向量检索: Embedding 和 HNSW