会话、记忆与检索:智能体如何延续任务
区分保存历史、本轮上下文、摘要与外部记忆,理解恢复任务时需要重新核查什么。
会话、记忆与检索:智能体如何延续任务
昨天让智能体检查了文件,今天继续问"上次结果还有效吗",系统需要解决两个问题:能不能找到昨天的记录,记录里的结论是否仍适用于今天。
保存与正确使用是两回事。把历史写到磁盘,只解决了数据还在,不代表模型每次都会看到全部历史,也不代表旧结论永远正确。
上一篇讲的是"这一轮该给模型什么信息"。这一篇接着往下走,讲跨轮次留下什么:一次任务结束后,哪些东西被存进磁盘,模型下一次实际看到的是哪一部分,以及当你怀疑它"忘了事"时该按什么顺序查。
六种容易混用的东西
这些机制可以组合。宿主可能保存完整会话,发送摘要与最近消息,再按需要检索项目资料;每一层都应说明来源和选择规则。
表格里最容易混的两行是"会话历史"和"本轮上下文"。区别只有一个问题:这次请求真的发出去的内容,和磁盘上存着的内容,是不是同一份? 前者只是"存在",后者才是"被使用"。
"会话树"是 Pi 真实存在的一层,后面单独讲。
会话文件在哪里,长什么样
Pi 把会话存成 JSONL 文件,默认位置按项目路径分组:
~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl路径里的 <path> 是创建会话时的工作目录,Pi 会去掉开头的分隔符,再把 /、\ 和 : 换成 -。所以一个在 /Users/jeffery/working/usework-agent-workspace 下创建的会话,落在:
~/.pi/agent/sessions/--Users-jeffery-working-usework-agent-workspace--/2026-10-20T14-35-54-185Z_01a159bd-81c9-7608-8c79-37d7db24af08.jsonl想看自己机器上有哪些会话,直接跑:
ls ~/.pi/agent/sessions/进去之后,每一行都是一条独立的 JSON 对象,带一个 type 字段。真实会话文件的第一行是会话头:
{"type":"session","version":3,"id":"01a159bd-81c9-7608-8c79-37d7db24af08","timestamp":"2026-10-20T14:35:54.185Z","cwd":"/Users/jeffery/working/usework-agent-workspace"}之后是各种 entry。message 类型的 entry 存一条消息,compaction 类型存一次压缩,branch_summary 存一次分支切换时的摘要:
{"type":"message","id":"8372203f","parentId":"4b2f0cef","timestamp":"2026-10-20T14:35:54.283Z","message":{"role":"user","content":"检查 src/greet.ts 中的命名 function 声明","timestamp":1792506954283}}
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2026-10-20T14:40:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000,"systemMessage":{"role":"system","content":"You are a coding assistant.","toolsAdded":[],"timestamp":1792507200000}}这条 compaction entry 有几个字段值得单独看。summary 是那段摘要文本;firstKeptEntryId 指向压缩后保留区间的第一条 entry;tokensBefore 是压缩前的上下文占用;systemMessage 是压缩边界上的完整系统提示与工具声明快照,重建上下文时它会成为新的开头。
session-format.md 还记了会话文件的版本演进:版本 1 是线性序列(旧格式,加载时自动迁移),版本 2 引入 id/parentId 树结构,版本 3 把 hookMessage 角色改名为 custom。加载旧会话时 Pi 会自动迁移到当前版本。
会话是一棵树,模型只看其中一条
上一节的 parentId 不是装饰。每条 entry 都通过 parentId 指向自己的父节点,根节点的 parentId 是 null,于是所有 entry 形成一棵树。
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← 当前叶节点
│
└─ [branch_summary] ─── [user msg] ← 另一条分支树上的"叶节点"(leaf)是当前所在位置。从叶节点往根走,得到的这条路径就是活动分支(active branch)。下一次模型请求发送的是活动分支,不是整个文件里的所有内容。
这个区分解释了很多困惑。同一场对话里试了两个方案、改了三次方向,磁盘上可能躺着四五条分支,但模型每次只看到其中一条。/tree 命令用来在这棵树里移动,Pi 会记住你当前在哪个位置。
走 /tree 离开一条分支时,Pi 可以为它生成一段摘要,附在你进入的那条分支上。这叫分支摘要(branch summary),存成 branch_summary entry,fromId 指向你离开的那个叶节点。
这里有一条容易被忽略的事实:摘要的内容会进入模型上下文,而被放弃分支上的原始消息不会。 离开一条分支等于把它的内容压缩成一段话再带走,而不是把整段历史搬过去。
会话的账本:用量、条数和导出
会话文件不只保存内容,也保存这次任务的账。usage 类型的 entry 记录模型产生的用量,它不进入模型上下文,但会计入会话的总 token 与总花费:
{"type":"usage","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2026-10-20T14:38:00.000Z","kind":"cache_warm","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":0,"output":0,"cacheRead":50000,"cacheWrite":0,"totalTokens":50000,"cost":{"input":0,"output":0,"cacheRead":0.015,"cacheWrite":0,"total":0.015}}}kind 是一个自由字符串,用来标识这次用量属于哪类操作,上面这条是 cache_warm。这类 entry 在会话树里是隐藏的,但计入总额——所以从界面看到的总花费,会包含压缩摘要那次调用和缓存预热这类"看不见的往返"。
不想翻文件的话,交互模式下的 /session 命令会直接给出当前会话的文件路径、ID、消息数、token 用量和费用。
需要把会话拿出来给别人看或者存档,用 /export 写成 HTML 或 JSONL,用 /share 上传后拿到一个查看链接。Pi 默认在配置了 Radius 认证时用 Radius 制品,否则用私有 GitHub gist,可以用 sessionShareDestination 设置强制走哪一个。
导出前要自己看一遍。Pi 在这一点上写得很直接:会话里可能包含提示词、模型回复、工具参数、命令输出、文件内容和扩展消息。这些内容一旦分享出去,就不再受本地权限约束了。
压缩:模型看到的是摘要,不是原文
上一讲讲过压缩的容量算法:上下文占用超过 contextWindow - reserveTokens 时触发,保留最近 keepRecentTokens 的内容,更早的消息换成摘要。这一讲补上另一半——压缩在会话文件里留下了什么,以及你能用什么证据确认它发生过。
Pi 的 JSON 模式会把压缩过程打印成事件:
pi --mode json "Review this repository"{
"type": "compaction_end",
"reason": "threshold",
"result": {
"summary": "Summary of conversation...",
"firstKeptEntryId": "abc123",
"tokensBefore": 150000,
"estimatedTokensAfter": 32000,
"usage": {"...": "..."},
"details": {}
},
"aborted": false,
"willRetry": false
}tokensBefore 是压缩前的上下文占用,estimatedTokensAfter 是压缩后的估算值。上面这组数字说明压缩把上下文从 15 万压到 3.2 万,代价是中间那些消息的原文被换成了一段摘要。
reason 字段区分触发原因:"manual"(/compact 手动触发)、"threshold"(超过阈值)、"overflow"(Provider 报了上下文溢出)。这三种原因指向不同的问题:手动压缩是你自己决定精简,阈值压缩是容量到了自动触发,溢出压缩说明上一次请求已经失败了。
压缩摘要本身也有固定结构,Pi 用它生成 Goal、Constraints & Preferences、Progress、Key Decisions、Next Steps、Critical Context 几段,末尾还可能带上本次涉及的文件列表:
## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
- [Requirements mentioned by user]
## Progress
### Done
- [x] [Completed tasks]
## In Progress
- [ ] [Current work]
## Key Decisions
- **[Decision]**: [Rationale]
## Next Steps
1. [What should happen next]
## Critical Context
- [Data needed to continue]这个结构解释了压缩的实际代价。压缩摘要不是原始全文,而是模型根据自己看到的消息重新写的一份文档。 Anthropic 在上下文工程一文里把这件事说得很直接:
The art of compaction lies in the selection of what to keep versus what to discard, as overly aggressive compaction can result in the loss of subtle but critical context whose importance only becomes apparent later.
"当下看着不起眼、事后才显出重要性的条件",正是压缩最容易丢的那类信息。摘要里出现"已完成检查"这样的结论,不代表它保留了当时检查的范围和限制。
还有一件事值得单独指出:压缩丢的是模型当前能看到的内容,不是任务的记录。 原始 entry 没有被删除,仍在会话文件里,只是不再发给模型。查不到某条原始信息时,这是检索的问题,不是记录丢了的问题。
三种延续任务的办法
前面讲的压缩只是其中一种办法。Anthropic 在同一篇文章里列了三种,并明确说选哪一种取决于任务本身的形状:
Compaction maintains conversational flow for tasks requiring extensive back-and-forth; Note-taking excels for iterative development with clear milestones; Multi-agent architectures handle complex research and analysis where parallel exploration pays dividends.
三选一不是风格偏好。需要来回讨论的任务,压缩能保住对话的连贯性;有清晰阶段划分的开发任务,笔记更容易看出"现在到哪一步";需要同时铺开多个方向的调研,靠并行分支更划算。
同一篇文章还讲了另一种思路,它不在上面这三条之列,因为它不改变对话的形状:不预先装载,而是运行时按需取回。文章把它称为"及时检索"(just in time):
Rather than pre-processing all relevant data up front, agents built with the "just in time" approach maintain lightweight identifiers (file paths, stored queries, web links, etc.) and use these references to dynamically load data into context at runtime using tools.
上下文里只保留文件路径、查询语句这类轻量标识,需要时再用工具把数据读进来。这解释了"外部检索"这一层为什么能省下容量:它存的不是内容,是内容的地址。
代价也在这里。及时检索要求那些地址到今天还能指到正确的东西——文件没被删改、查询语句仍然有效。如果地址指向一个昨天存在、今天已经被改掉的文件,检索回来的东西照样是错的,而且这次连"曾经检查过"的痕迹都没有。
一份摘要为什么可能过时
回到最开始的场景。教学上,昨天的记录可能是:
检查文件:src/greet.ts
观察结果:匹配到 greet
检查方法:只匹配命名 function 声明
未检查:调用关系、类型正确性与安全性这是贯穿本组文章的教学文件,不表示已经对你的项目执行了检查。
今天文件被改名或新增声明后,这份记录仍能说明昨天观察过什么,却不能证明当前内容。继续任务时应重新读取文件,而不是把"有记忆"解释为"不必再核实"。
这份记录比一句"检查完成"有用,原因在于它写了三件事:检查的是哪个文件、用什么方法检查的、哪些部分没查。最后一行尤其重要——它明确划出了这次结论不覆盖的范围。摘要如果只保留"检查完成",后续模型可能把局部文本扫描理解为全面验证。
判断一份摘要能不能用,可以问三个问题:它写清楚事实来源了吗?它写清楚检查范围了吗?它写清楚未完成事项了吗?三个都答不上来,这份摘要就只能当线索用,不能当依据用。
恢复历史不等于重新执行
界面重新显示过去的工具调用,只是在呈现记录。系统若据此重新运行工具,则是另一项操作,可能读取新状态,也可能重复副作用。
对于只读检查,重做通常较容易解释;对于发消息、付款、提交或发布,历史恢复不能成为自动重复执行的理由。需要用任务身份、操作结果与外部状态判断下一步。
判断方法还是第 1 篇那条:看有没有留下执行痕迹。 会话文件里存着一条工具调用记录,说明它曾经被请求过;有没有对应的 toolResult,才说明它执行过、结果是什么。
会话与项目范围也要对应
不同项目可能有同名文件,两个会话也可能分叉成不同任务。把"最近一次结果"保存在一个全局变量里,可能让新项目读到旧项目的报告。
Pi 的做法是把范围写进会话头。每个会话记录它创建时的工作目录,并且这个值不会变:
{"type":"session","version":3,"id":"...","timestamp":"...","cwd":"/Users/jeffery/working/usework-agent-workspace"}工作目录决定了工具、@path 引用、项目配置和上下文文件从哪里解析。--continue 打开当前目录最近的会话;--resume 或 /resume 打开选择器,选中的会话会在它自己的工作目录里运行,可以和当前目录不同。
这条设计值得单独记住,因为它把"这份数据属于谁"写进了文件本身,而不是靠调用方记住。如果你的系统打算把"最近一次结果"放进一个全局变量,那它至少也要像 Pi 这样,把所属会话和项目范围一并记下来。
分支场景尤其如此:已经放弃的尝试仍可能在完整历史中,不能因为时间较近就当作当前有效进展。
哪些信息值得记住
长期保存应服务于明确需求,例如稳定项目约定、用户明确希望复用的偏好,或需要接续的任务状态。临时猜测、敏感凭据、完整工具原始数据不应因为出现过就默认永久保存。
记忆还需要更正、过期和删除方式。记录一旦与事实冲突,系统要能回到权威来源;没有这条路径,"记得很多"反而可能持续放大旧错误。
Pi 提供了几种不同粒度的长期载体,可以对照着看它们各自的更新方式:
上下文文件的作用范围值得留意:AGENTS.md 在它所在目录以及该目录以下的所有子目录都生效,不需要额外的信任确认。Skill 则可以携带脚本和参考材料,但 Skill 里的"运行检查脚本"只是一条任务说明——要真正运行,仍需要可用工具、正确路径和执行权限。
因此,判断记忆机制是否有用,不只问它保存了多少,而要问:这次任务找到了哪些相关信息,这些信息是否仍有效,必要时能不能核实。
遇到"好像忘了昨天的事",怎么查
读完上面的机制,最实际的问题还是排查。下面的表按顺序排,每一步都在排除上一层的可能。
这张表从"文件在不在"一路查到"事实变没变",顺序不能跳。"找不到"和"记错了"是两个完全不同的问题,前者要查存储和分支,后者要查时间。
第 1 行和第 2 行是新手最常漏的两步。很多人直接跳到"是不是压缩把内容弄丢了",但如果当前活动分支根本不是昨天那条分支,那么不管有没有压缩,模型都不会看到昨天的内容。
第 3 行可以配合上一篇的排查顺序一起用:先看 usage.input 确认输入侧总量,再看有没有 compaction_start / compaction_end。如果 tokensBefore 是 15 万而 estimatedTokensAfter 是 3.2 万,那么"模型知道有这回事,但不记得细节"就是预期结果,不是故障。
第 5 行需要和第 1 讲的那个区分对齐:把文件拖进对话框或用 @ 引用路径,只是把内容放进了这一轮的用户消息,它和"工具执行后回填的结果"不是一回事。没有对应的工具执行记录,就说明模型没见过这个文件本身。
小结
会话、记忆与检索这一层,可以按三个问题收拢:
存的和用的是不是一回事。 会话文件保存全部历史,模型只看到活动分支;压缩把更早的部分换成摘要,但原始 entry 仍在磁盘上。
跨轮次延续任务有四条路。 压缩保住对话连贯,笔记适合有里程碑的迭代,多智能体适合并行探索,及时检索靠轻量标识在运行时取回内容。选哪条取决于任务形状。
旧结论需要重新核实。 摘要里保存的是当时的观察和它的适用范围,不是今天的事实。
排查"好像忘了昨天的事",按存储 → 分支 → 压缩 → 事实来源的顺序走一遍,比直接猜原因快得多。
下一篇会接着这个思路,介绍能力扩展:工具、Skill、MCP 与插件各自补的是哪一层,以及该用哪一层。