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 做三件事:

  1. 定位分支:从当前 leafId 出发,沿 parentId 回到 root,反转得到当前分支路径(buildSessionPath())。
  2. 应用压缩规则(buildContextEntries()):路径上有 compaction,就用"最新摘要 + 切点后原文"替代更早历史;没有就直接用分支路径。
  3. 投影成消息(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 生命周期事件定义
posted @ 2026-09-20 09:12  woodWu  阅读(75)  评论(0)    收藏  举报