读懂 pi-observational-memory:记忆怎样进入上下文,以及一段压缩空档

写于 2026 年 9 月 26 日。源码和本地会话统计基于 pi-observational-memory 3.1.4。文中的 m1、m2 等消息都是合成例子。

Pi 会把完整会话存下来,但模型一次只能看到有限的上下文。对话长了,Pi 用一段摘要替换较早的消息,保留最近一段原文。我读完这层基础机制后,开始追 pi-observational-memory:它究竟把「记忆」放在哪里,怎样让压缩后的 agent 接着做事?这次我借助 AI 对话逐个拆问题;遇到关键判断,再回源码和自己的 session 记录核对。

顺着源码和自己的 session JSONL 走了一遍,我最后发现一个边界问题:有些原始消息在压缩后既不在近期原文里,也没有对应的观察进入插件生成的摘要。原始记录仍在磁盘上;这里说的「空档」,指的是下一轮模型实际收到的上下文。

第一问:插件写下的记忆,模型马上能看到吗?

插件把记忆写成 Pi 会话树里的 custom 记录,主要有三种:记录观察、记录反思、记录被移出活跃记忆的观察 ID。源码:记录类型

我起初需要先分清两个视角:JSONL 里存了什么,以及这一轮请求给模型发了什么。这些 custom 记录可以留在会话树上,供插件以后读取,却不会因为刚写入就自动变成下一轮模型的消息。直到压缩时,插件才从记录中选出适用的记忆,渲染成摘要,让 Pi 在后续请求里使用。

源码把这一串记录叫 ledger。我现在把它理解为「记忆流水账」:每次新增观察、形成反思、移除观察,都是往账上追加一笔;旧记录不必原地改写。foldLedger() 做的事是从头读账,遇到新增就加入,遇到移除就按 ID 标记,最后得到当前仍活跃的观察和已有反思。源码:foldLedger

这也解释了插件为什么不必每轮都改写模型的上下文:后台记忆可以继续增长,会话原文仍按 Pi 的正常方式连续使用;等到压缩,已有的记忆才集中进入摘要。这种安排也有利于复用两次压缩之间相同的请求前缀。

第二问:谁写这本账?

插件有三个记忆工作者。它们处理的对象和时机不同:

工作者读什么写什么
Observer上次观察覆盖位置之后的原始消息,按独立的 chunk 上限分批读取带时间、相关程度和来源 ID 的观察
Reflector活跃观察与已有反思值得长期保留的反思,以及支撑它的观察 ID
Dropper活跃观察、反思和观察池预算可以从活跃记忆中移除的观察 ID

agent_start 和 turn_end 会检查观察或反思是否到了运行门槛;检查通过后,记忆流程在后台启动。插件尽量用 Pi 提供的当前上下文 token 用量衡量增长;取不到可靠基线时,退回对未覆盖原始条目的本地估算。Observer 从上次 coversUpToId 后继续读,不会因为某条观察只引用了较早的消息,就从那个引用处重新开始。源码:触发与处理流程

我在这里卡了很久的是两个容易混在一起的 ID:

观察 O1:sourceEntryIds = [m2]   // 这条观察的证据来自 m2
批次 A:coversUpToId = m4       // Observer 这一批已读到 m4

sourceEntryIds 给一条观察提供溯源;coversUpToId 推进这一批观察的处理位置。O1 只引用 m2,不表示下一批要从 m2 接着读。Observer 可以读完 m1~m4,只把其中值得记的事情写成 O1。观察的 low、medium、high、critical 也由 Observer 按提示词判断,不是后来根据引用次数自动算出的分数。源码:Observer 提示词

Reflector 再从观察中提炼较稳定的约束、决定和完成结果;它不把每一条观察都改写成反思。Dropper 只在同一轮产生新反思、且活跃观察池超过目标预算等条件满足后运行。即使某条观察相关程度低,或已有反思引用它,也不会按一个固定公式自动删除;Dropper 的规则要求比较语义,拿不准就保留。源码:流程条件、Dropper 规则

这一层记忆想解决的是长对话压缩后的偏离:摘要里既有较稳定的反思,也有按时间排列的近期观察。比如用户早已否定某个方案,反思或观察能提醒压缩后的 agent 不要再次照着旧方案做;需要核对原话时,还能通过记忆 ID 找回来源。它提供延续任务的线索,但不保证每条原文都被逐字保存。源码:摘要中的使用说明

第三问:为什么压缩时能很快?

插件在 agent_settled 后,可能因原始消息累计达到自己的门槛而主动调用 ctx.compact()。这里的 agent_settled 是一轮 agent 工作真正结束后的事件。无论压缩由插件主动发起,还是 Pi 通过其他途径发起,session_before_compact 钩子都会收到 Pi 已经选好的 firstKeptEntryId:从这条记录开始的近期内容要原样保留。源码:主动触发、压缩钩子

钩子没有在压缩现场再叫一次 LLM 总结原文。它读取当前分支的记忆记录,按边界做一次筛选,然后由 renderSummary() 把固定说明、反思和观察拼成文本。下面是我把关键路径压缩后的伪代码:

const F = preparation.firstKeptEntryId;
const memory = buildCompactionProjection(branchEntries, F);
const summary = renderSummary(memory.reflections, memory.observations);

if (summary) return { compaction: { summary, firstKeptEntryId: F } };
// 没有可用记忆时,让 Pi 继续自己的原生压缩。

这就是「快速推进」的来源:后台工作已经把长对话加工成观察和反思;压缩钩子主要做选择与字符串渲染。摘要非空时,插件接管这次压缩;摘要为空时,它退出,让 Pi 的原生摘要流程处理旧消息。模型下一轮看到的是这段摘要,加上从 firstKeptEntryId 开始保留的原文。源码:投影与渲染

边界问题是怎么露出来的

读到这里,我先担心 Observer 跑慢:如果压缩发生时,它还没读完即将被移出上下文的消息,那些消息由谁负责?再往下看 buildCompactionProjection(),发现还有另一种情况:账本标记 Observer 已经推进过边界,但一整批观察因为跨过 Pi 的保留边界而没有入选摘要。

插件按批次的 coversUpToId 选观察。用位置顺序写成简化条件,就是:

if (batch.coversUpToId <= firstKeptEntryId) {
  include(batch.observations);
}

这里的 <= 是源码先把 ID 映射为当前分支的位置,再比较位置,并非比较 ID 字符串。源码:覆盖位置的筛选

设 Pi 选择 m5 为第一条保留的原文。观察批次 A 覆盖到 m2;之后的 m3、m4 有一项重要信息,批次 B 为它们写了观察,但 B 继续读到了 m6。B 的 coversUpToId=m6 晚于 m5,因此整个 B 被排除。压缩摘要只有 A,原文从 m5 开始:m3、m4 不在两者之中。

如果 B 根本还没写出来,结果也相似:Observer 确实落后,A 的覆盖位置到 m5 之间存在未处理原文。这是我后来区分出的两类空档:

  1. 跨边界批次:观察已记录,但其 coversUpToId 超过 firstKeptEntryId,整批不进摘要。
  2. 真实观察积压:Observer 尚未覆盖到 Pi 准备移出上下文的原文。

更一般地,令 C 为最后一个入选观察批次的覆盖位置,F 为 Pi 的 firstKeptEntryId。只要当前分支里仍有原始消息位于 C 之后、F 之前,插件又返回了非空记忆摘要,就可能出现这段活跃上下文空档。sourceEntryIds 是证据引用,不能拿它代替 C;它只引用 m2,并不表示 Observer 没读过 m3、m4。

回查我自己的 session

我把本地 18 个 Pi session JSONL 文件按 parentId 重建为各次压缩前的活跃分支,并按压缩记录 ID 去重,避免分叉文件里的同一压缩被重复计算。3.1.4 插件接管的独立压缩共有 26 次。我只数最后一个入选观察的覆盖位置与 Pi 保留边界之间确实存在原始 message 的情况:

检查结果次数
跨边界观察批次,整批未入选17
Observer 真正落后5
该区间没有原始 message4
合计26

也就是 22/26 次有结构上的空档。这不是「22 次任务真的跑偏了」:我没有逐条判断被移出上下文的消息是否含有独一无二的事实,也没有证明某个后续回答因此出错。它证明的是更窄、也足够值得修的事:存储中有原文,但压缩后送给模型的摘要和保留区之间,没有结构性保障覆盖这段原文。

我会怎样修,又为什么还不能只看这一步

最小修法是:在钩子接管压缩之前,检查 F 前有没有未被入选观察覆盖的原始消息。如果有,把返回的 firstKeptEntryId 前移到第一条这样的消息,使它和后续消息继续作为原文保留。若第一条未覆盖的是工具结果,边界应再前移到发起该工具调用的 assistant 消息,避免只保留结果、丢掉调用。维护者在 #90 中给出的第一阶段范围

我也考虑过让边界向后移动,把跨界的 B 批次选进摘要。那会让 Pi 原本准备原样保留的近期消息提前退出原文区;这不是我希望默认做的取舍。前移边界则保持近期原文,并补上前面的空档。

不过,边界前移意味着保留更多原文。若这段内容太大,压缩可能释放不出足够空间,甚至让下一轮请求超过上下文预算。我们还讨论过「算出预算后退回 Pi 原生摘要」这条路,但它也有后续问题:原生摘要虽然会进入活跃上下文,却不会自动变成插件的观察或反思;再下一次由插件接管压缩时,必须确认只存在于上一次原生摘要里的事实仍能保住。

#90 的维护计划因此分阶段:先只做前移边界并验证;如果实际使用出现超预算或反复低进展压缩,再评估有预算约束的补救;是否加入原生摘要退路,留到更后面的独立决定。上面是方案与权衡,截至本文写作时并非已合并的修复。

把发现带回仓库

完成本地分析后,我去查了上游,才发现另一位贡献者已经提交过涉及同一问题的 PR #81。维护者认可风险,但因它同时涉及同步追赶观察、调度和摘要等多项变化,关闭了该 PR,并用 Issue #90 跟踪分阶段修复。

我没有重复新开 Issue,也尚未提交 PR。我在 #90 的评论 中留下了独立复现、脱敏的 26 次压缩统计、最小前移边界的建议,并询问维护者是否需要一份只针对这些情形的回归测试 PR。会话原文、路径和具体 ID 没有公开。

这次读源码,我最大的收获是把三件事分开看:会话树上保存的原文、流水账里记录的记忆、下一轮真正进入模型的上下文。它们各有自己的推进位置。插件能在后台处理长对话,也能在压缩时迅速生成可用的记忆摘要;但只要这些位置在边界处没有对齐,磁盘上「已经记录」并不等于模型下一轮「一定看见」。

更新#1

已经在作者欢迎下,提交了回归性测试pr和最小修复pr