Pi Agent 的记忆与上下文管理
本文发布于公众号:stringwu的工程笔记 Pi Agent 的记忆与上下文管理
系列第二篇,源码:github.com/earendil-works/pi
前面的文章:
1. 概要
Pi 没有做"万能记忆黑盒",而是把 agent 的长期工作过程变成了一套"可保存、可重建、可观察的工作记录系统"。
这套系统只做三件事:
-
存得下 —— 所有工作记录(消息、工具结果、压缩摘要、扩展状态)以 JSONL 形式追加式落盘,进程重启可恢复,历史可分叉不丢失。
-
管得住 —— 模型每次请求前,系统从长期记录中重建一份"刚好够用"的上下文:走当前分支、旧历史换摘要、内部消息翻译成厂商格式。
-
看得见 —— 执行过程全程事件化,TUI / JSON / RPC / 扩展消费同一套事件流,不用猜 agent 在干什么。
记住一句话:保存是记账,上下文是理账,事件是报账。 Pi 把这三件事拆开了,而大多数 agent 项目把它们搅在一起。
2. 为什么需要这套系统
很多人一听 Agent 有"记忆",第一反应是向量库、RAG、长期知识库。但跑过一个真实 coding 任务就知道,真正的痛点是另一组问题:
用户:修一下这个 bug
Agent:
1. 读报错日志 → 工具输出了 200 行
2. 搜相关代码 → 又多了几十条结果
3. 追调用链 → 上下文已经一大半
4. 改文件、跑检查 → 历史越来越长
5. 发现另一个问题 → 需要回退重试……
于是问题一个个冒出来:
| 症状 | 病根 |
|---|---|
| 历史太长,上下文塞不下 | 把"全部历史"直接当上下文 |
| 重要信息被工具输出淹没 | 保存的东西和喂给模型的没做区分 |
| 进程重启,任务状态丢了 | 模型上下文被当成唯一事实 |
| 回退重试会覆盖旧记录 | 历史是线性的,不是可分叉的 |
| 扩展保存的状态混进模型输入 | "私有状态"和"注入上下文"没有边界 |
| UI 只能显示"运行中" | 执行过程没有结构化的事件 |
注意这些症状的共同点:它们都不是"模型记不住",而是"系统没管好"。 这就是 Pi 的设计出发点——不追求让模型记住更多,而是让工程系统把记录管清楚。
3. 总览:一个类比贯穿全文
在深入代码之前,先建立一个心智模型:
| Pi 的机制 | 类比 |
|---|---|
| session JSONL | 一本逐行记账的工作日志 |
buildSessionContext() |
每天开工前把桌面摊开,只摆今天需要的材料 |
| compaction | 旧资料整理成摘要放桌面最前面,原件归档不扔 |
| session tree | 一本允许分叉的实验记录,试错不涂改 |
AgentEvent |
操作台上的状态灯,随时看得见在干嘛 |
| AGENTS.md / skills | 墙上的规章制度和工具柜索引(不是聊天记录) |
整篇文章的代码,都是在实现这张表。
4. 支柱一:存得下 —— append-only 的 JSONL session
本节结论:Pi 不把"模型当前看到的上下文"当唯一事实,真正的长期事实永远在 session 文件里;当前上下文只是从长期记录里重新算出来的投影。
4.1 账本长什么样
SessionManager 的定位,源码里写得很直白:
Manages conversation sessions as append-only trees stored in JSONL files.
三个关键词:append-only(只追加不改旧账)、trees(树形不是直线)、JSONL files(落盘可恢复)。
账本是一行一条 entry,首行是 session header,之后是各类记录:
session header
message ← 用户/助手/工具消息
compaction ← 历史压缩摘要
branch_summary ← 分支摘要
custom ← 扩展私有状态(不给模型看)
custom_message ← 扩展注入给模型的上下文
model_change ... ← 元信息
每条 entry 都有统一的骨架:
interface SessionEntryBase {
type: string;
id: string;
parentId: string | null; // ← 关键字段
timestamp: string;
}
4.2 为什么是树?因为开发本来就是试错
parentId 是理解 Pi 的钥匙。有了它,历史就从"一条直线"变成"一棵树":
A -> B -> C -> D
\
E -> F ← 用户从 B 分叉重试,C、D 原样保留
排查 bug 的真实过程就是"怀疑配置 → 查一圈不是 → 回到前面 → 怀疑缓存 → 继续验证"。线性聊天记录表达不了这个过程,涂改旧记录更是灾难;session tree 天然支持。
代价是什么? 账本会越来越长。这正是支柱二要解决的。
5. 支柱二:管得住 —— 上下文是"重建"出来的,不是"保存"出来的
本节结论:保存 ≠ 喂给模型。 Pi 在每次请求模型前,执行一条四段流水线,把"整本账"投影成"眼前一桌面"。
5.1 四段流水线总览
① 用户输入 ② 重建上下文 ③ 翻译给厂商 ④ 事件回流+落盘
AgentSession → buildSessionPath → transformContext → AgentEvent
.prompt() → buildContextEntries → convertToLlm → TUI/JSON/RPC
→ ...project成消息 → provider stream → message_end 落盘
这条链路上有一个至关重要的分工边界:
agent-core 只负责跑模型和工具循环(发动机)
coding-agent 负责 session、项目上下文、扩展、UI/RPC(车队管理)
provider adapter 负责把统一消息翻译给各家厂商(海关)
记忆和上下文不会污染 agent-core,厂商差异也不会污染 session 结构。
5.2 重建的三步
每次请求前,SessionManager 做三件事:
- 定位分支:从当前
leafId出发,沿parentId回到 root,反转得到当前分支路径(buildSessionPath())。 - 应用压缩规则(
buildContextEntries()):路径上有 compaction,就用"最新摘要 + 切点后原文"替代更早历史;没有就直接用分支路径。 - 投影成消息(
sessionEntryToContextMessages()):entry →AgentMessage[],这一步同时划定了哪些能进模型上下文:
| entry 类型 | 进上下文? | 说明 |
|---|---|---|
message |
✅ | 普通消息 |
custom_message |
✅ | 扩展主动注入的上下文 |
branch_summary / compaction |
✅ | 摘要类 |
custom |
❌ | 扩展私有状态,只用于恢复 |
label / session_info |
❌ | 元信息 |
model_change 等 |
❌(作为消息) | 仅用于恢复配置 |
5.3 compaction:整理背包,不是扔东西
上下文快满时,Pi 不删除任何历史,而是追加一条 compaction entry:
当前分支 → prepareCompaction() 找切点 → 调模型生成摘要
→ appendCompaction() 落盘 → 重建上下文
之后的上下文 = 摘要 + 切点后的最近原文。像把厚会议记录整理成"后续行动摘要":录音还在档案馆,下次开会不必重放全部。
明确的权衡:摘要可能丢细节,但换来更低成本、更长任务续航、更稳定的上下文长度。这是工程取舍,不是缺陷。
5.4 branch summary:走错路,带回结论就行
从旧节点分叉时,Pi 可选地对旧分支生成摘要注入新分支。类比:回到岔路口时,带回一句"那条路查过了,配置不是主因,更像缓存失效顺序问题"——足够让新路线少走弯路。
5.5 convertToLlm():出门前的海关
Pi 内部消息类型丰富(bashExecution、custom、branchSummary……),但 OpenAI / Anthropic / Google 都不认识。convertToLlm() 统一翻译:
bashExecution→ user 文本;标记了excludeFromContext的跳过branchSummary/compactionSummary→ 包进<summary>标签custom→ user 消息- 其余原样保留
收益:session 里只存中立的内部格式,今天换 OpenAI、明天切 Anthropic,随时重新翻译。
6. 支柱三:看得见 —— 执行过程本身事件化
本节结论:可观测性不是事后补一层日志,而是 agent 的执行模型天然就是事件流。
AgentEvent 定义了四层生命周期:
agent_start / agent_end ← agent 级
turn_start / turn_end ← 轮次级
message_start / update / end ← 消息级
tool_execution_start / update / end ← 工具级
事件由 agent-loop.ts 发出,AgentSession 分发给所有消费者:TUI(实时渲染)、JSON mode(结构化流)、RPC mode(远程客户端)、extensions(观察/拦截/改写)、持久化(message_end 落盘)。
关键点:所有人消费同一套事实。 TUI 不用猜"是不是在调工具",RPC 不用另造状态协议,扩展不会被错误状态误导。这在工具并行、重试、压缩时尤其重要。
7. 容易被忽略的两个"边界感"
7.1 custom vs custom_message:存状态 ≠ 喂模型
扩展常有内部索引、审计标记、同步状态——要保存,但绝不能进模型上下文。Pi 用两种 entry 把这个边界变成显式动作:
- 写
custom:只落盘,模型永远看不到。 - 写
custom_message:明确声明"这段话要进上下文"。
混在一起的系统,迟早会把内部状态、隐私数据随手塞进 LLM。
7.2 项目规则和会话记忆是两类东西
- AGENTS.md / CLAUDE.md(
resource-loader.ts加载)→ 拼进 system prompt 的<project_context>:这是墙上的规章制度,影响每次行为,但不是某次会话产生的记忆。 - skills(
skills.ts):system prompt 里只放名称+描述+路径,模型匹配到任务时再读完整SKILL.md(progressive disclosure)。工具柜平时只需要目录,用电钻时才翻说明书。
好处:技能库怎么增长,system prompt 都不爆;每次只加载真正相关的规则。
8. 常见误区自查表
| 误区 | Pi 的做法 |
|---|---|
| "记忆 = 向量库 / RAG" | 记忆首先是工作记录管理,向量检索是另一个可选课题 |
| "上下文 = 全部历史" | 上下文是从 session 树投影出的当前分支视图 |
| "压缩 = 删除历史" | 只追加摘要 entry,原文永远在 JSONL 里 |
| "换模型要重写存储" | session 存中立格式,convertToLlm() 统一翻译 |
| "保存的状态都能给模型看" | custom 与 custom_message 显式分开 |
| "UI 轮询/猜状态" | 一切状态来自同一套 AgentEvent |
9. 一句话总结
Pi 的记忆与上下文管理 = 一套可重建的工作记录系统:JSONL 记账(存得下)、投影重建理账(管得住)、事件流报账(看得见)。
它解决的核心矛盾只有一个:
模型需要:尽量少、但足够准确的上下文
系统需要:尽量完整、但不干扰模型的历史记录
这套设计的价值不在于显得多聪明,而在于透明、可恢复、可调试、可扩展。对 coding agent 来说,这比"看起来像有记忆"重要得多。
10 附录. 核心源码索引
| 组件 | 位置 | 职责 |
|---|---|---|
SessionManager |
packages/coding-agent/src/core/session-manager.ts |
session 文件、entry、树、leaf、上下文重建 |
AgentSession |
packages/coding-agent/src/core/agent-session.ts |
串联 session / agent-core / 扩展 / UI-RPC |
messages.ts |
packages/coding-agent/src/core/messages.ts |
内部消息定义与 convertToLlm() |
agent-loop |
packages/agent/src/agent-loop.ts |
模型流、工具循环、事件发送 |
compaction/ |
packages/coding-agent/src/core/compaction/ |
压缩切点、摘要生成 |
resource-loader.ts |
packages/coding-agent/src/core/resource-loader.ts |
项目规则、skills、扩展加载 |
system-prompt.ts |
packages/coding-agent/src/core/system-prompt.ts |
拼装 system prompt |
AgentEvent |
packages/agent/src/types.ts |
生命周期事件定义 |

浙公网安备 33010602011771号