Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

会话树与上下文压缩

TL;DR:Pi 把一次会话存成一个 append-only 的 JSONL 文件,每条 entry 用 id/parentId 指向前驱,整份文件是一棵树,当前 leaf 到根的那条路径就是“活动分支”。下一次模型请求的上下文不是“内存里的消息数组”,而是 SessionManager 每次从活动分支重新投影出来的(0.87 起它就是 provider 上下文的唯一来源)。回到旧节点重试只需移动 leaf 再追加,分支零拷贝,旧路径原样保留。上下文快满时,compaction 用一次额外的 LLM 调用把旧消息总结成一条 compaction entry、保留最近约 20K token 原文;摘要只在之后的请求里替代旧消息,原始 entry 仍在树里。触发点有两个:一轮工具执行完后超过阈值就先压缩再继续,provider 报上下文溢出就压缩后重试一次。

为什么会话要是一棵树

聊天机器人的会话是线性的,一问一答到底。coding agent 的工作流天然是非线性的:

  • agent 重构时改了 5 个文件,你发现方向错了,想回到改之前换种做法;
  • 一条 bash 命令结果不对,你想从那条命令之前重来;
  • 第 20 轮才发现第 5 轮的决策有问题,想跳回第 5 轮开一个新分支。

用线性列表存,要么做不到回溯,要么每次分叉都得复制整段历史。Pi 的做法是让每条 entry 带一个 parentId:分支就是同一父节点下的多个子节点,“回到过去”就是把 leaf 指针移到旧节点,之后追加的 entry 自然挂在它下面。文件只追加、从不改写旧行,所以分支是零拷贝的。

A: user "分析 auth 模块"
└── B: assistant
    ├── C: user "用策略模式重构 login"
    │   └── D: assistant ...         (旧分支,仍在文件里)
    └── [branch_summary: 试过策略模式,太重]
        └── E: user "用简单 if-else 重构"
            └── F: assistant         ← 当前 leaf,A→B→summary→E→F 是活动分支

运行形态上,这一切都在 Pi 进程内:SessionManager 是一个普通的类,持有内存里的 entry 列表、byId 索引和 leafId,负责追加、建树、导航和投影上下文;AgentSession 订阅 agent 事件,决定“什么时候存什么”。没有数据库,也没有常驻服务。

一句话:AgentSession 决定什么时候保存什么,SessionManager 决定以什么结构保存、以及之后如何恢复成模型上下文。

文件里存了什么

文件第一行是 header(type: "session",含版本号、会话 id、创建时的 cwd,fork/clone 出来的会话还有 parentSession 指回来源文件)。之后每行一条 entry,都有 id(通常是 8 位十六进制短 id,方便在 /tree 里显示)、parentId、timestamp。格式经历过 v1(纯线性)→ v2(加 id/parentId 成树)→ v3(统一 custom 角色名),旧文件加载时自动迁移。

entry 记录的是事件而不是状态:不维护一个“当前配置”对象,而是把每次变化(换模型、改 thinking level、压缩、命名)作为不可变事件追加,当前状态靠沿路径重放得出。这是 event sourcing 的思路,在 append-only 存储里也是最自然的做法。按“是否影响模型上下文”可以把 entry 分成几类:

类别entry 类型进入模型上下文?
对话message(system / user / assistant / toolResult 等)是
摘要compaction、branch_summary是,以摘要消息的形式
上下文编辑context_edit自身不产生消息,但会删除或替换某条旧消息在上下文里的内容
扩展custom_message(进上下文)、custom(扩展私有状态,不进)前者是,后者否
状态与元数据model_change、thinking_level_change、label(书签)、session_info(会话名)、usage(不属于 assistant 消息的用量,如缓存预热)否,只用于恢复设置、UI 和计费

有两点是 1.x 相对旧版本的重要变化,面试时值得点出:

system prompt 也进了会话文件。 第一次请求前,Pi 会追加一条 role: "system" 的消息,里面是按名字分段的 prompt(sections)和完整的工具声明;之后 prompt 或工具集有变化(比如 /reload 后 skill 列表变了),只追加一条“补丁”system 消息,只写变化的 section 和 toolsAdded/toolsRemoved。按顺序重放这些 system 消息就得到当前 prompt 和工具集,没有单独的“prompt 状态”。好处是会话可以精确复现“当时模型看到了什么”,导出和分享也能带上 prompt。

context_edit 让“改上下文”也变成追加。 它指向一条更早的 entry,replacement: null 表示从今后的模型上下文里删掉它,非空则只替换其内容。原始 entry 在 UI、导出、计费里都不变;编辑是分支相关的,导航到编辑之前的位置,原内容就又生效了。溢出恢复时 Pi 正是用它把失败的那次尝试从上下文里“抹掉”(见后文)。

一次会话的生命周期

下面沿着一次真实使用的顺序走一遍:在 /project 里启动 Pi,聊几轮,回到旧节点换种做法,聊到上下文快满,退出后再 pi --continue。

1. 启动:文件不会立刻出现

pi 启动时 SessionManager.create(cwd) 在内存里建好 header,并算出文件路径:

~/.pi/agent/sessions/--project--/<timestamp>_<session-id>.jsonl

目录按 cwd 分组(去掉开头的 /,把 / \ : 换成 -),所以 --continue 能直接找到“这个目录最近的会话”。位置可以用 --session-dir、PI_CODING_AGENT_SESSION_DIR 或 sessionDir 设置覆盖,CLI 参数优先。

此时文件还不存在。模型选择、thinking level、首条 system 消息这些“准备性” entry 只留在内存里,直到出现第一条 user 或 assistant 消息才一次性把内存中的全部 entry 写进新文件,之后每条 entry 只 appendFileSync 一行。这样“打开 Pi 又直接退出”不会留下空会话;而从 user 消息(不是第一条 assistant 回复)开始落盘,保证第一轮没跑完时你的 prompt 也不会丢。

2. 对话:在 message_end 时持久化

AgentSession 订阅底层 agent 的事件。流式过程中的 message_update 只用来刷新 UI,不逐 token 落盘;一条消息完整结束(message_end)时,才把它追加为 message 或 custom_message entry,leaf 前移。追加是“先改内存再写盘”,写盘失败会把内存回滚,避免出现“父节点不在文件里”的悬空 entry。

用户中途 /model 换模型,追加 model_change;/name 命名,追加 session_info。这些都只是新的一行。

3. 上下文从哪来:每次从活动分支重新投影

这是 1.x 最值得讲的设计点。0.87 之前,运行时上下文实际是 agent.state.messages 这份内存数组,SessionManager 更像旁路日志;0.87 起 SessionManager 是 provider 上下文的唯一来源:直接给 session.agent.state.messages 赋值不再改变后续请求,要改上下文就得往会话里追加 entry(或导航 leaf),再让 session 刷新。

投影过程分三步:

  1. 从 leaf 沿 parentId 回溯到根,得到活动分支的 entry 列表,顺带从路径上取出当前模型和 thinking level;
  2. 如果路径上有 compaction,只用最新那条:先放 compaction 本身,再放从 firstKeptEntryId 到 compaction 之间被保留的 entry,再放 compaction 之后的所有 entry;更早的 entry 不进上下文;
  3. 对每个目标应用最新的 context_edit(删掉或替换内容),再把 entry 转成消息:message 原样、compaction 变成“system 检查点 + 摘要消息”、branch_summary 变成摘要消息、custom_message 变成自定义消息,其余类型不产生消息。

所以模型看到的始终是:

[当前 system prompt + 工具] [压缩摘要] [保留的近期原文] [压缩后的新消息]

只有当前活动分支参与,其他分支一个 token 都不占。把“存储”(完整树)和“投影”(模型上下文)分开,是后面分支和压缩都不需要改写历史的前提。

4. 走回头路:/tree、/fork、/clone

聊到第 5 轮,你发现第 3 轮让它“用策略模式重构 login”是个错误决定。三种做法:

操作结果适用场景
/tree在同一个文件里移动 leaf相关的几种尝试应该放在一起
/fork从某条更早的 user 消息起,新建一个会话文件另一条路要变成独立的工作
/clone把当前活动分支复制成新会话想要一份当前状态的独立副本

用 /tree 选中第 3 轮那条 user 消息时,它的文本会回到编辑器里,leaf 移到它的父节点;你改成“用简单的 if-else 重构”再提交,新消息就成了同一父节点下的第二个孩子。选中 assistant 回复或其他 entry,则从那条之后继续,编辑器为空。整个过程没有复制任何旧 entry。

离开旧分支时 Pi 可以问你要不要总结被放弃的路径。如果要,它找到新旧位置的共同祖先,把从旧 leaf 回溯到祖先的那段 entry(在 token 预算内、优先最新的)交给 LLM 生成摘要,作为 branch_summary 挂在新位置。这样模型在新分支里知道“之前试过策略模式、为什么放弃”,又不用背着旧分支的全部消息。

/fork 和 /clone 会写一个新文件,header 的 parentSession 指向原文件,只复制选中的那条路径(不含其他分支)。它们也是“修剪”一个越长越大的会话文件的方式:原文件不动,新文件只保留一条路径。

5. 退出后恢复

pi --continue 打开当前目录最近的会话,--resume / /resume 打开选择器。加载时逐行解析 JSONL、必要时迁移版本、重建 byId 索引,文件里最后一条 entry 就是 leaf,然后按第 3 步重新投影上下文。会话会记住创建时的 cwd,--resume 会在会话自己的目录里运行(工具路径、项目配置、context 文件都按那里解析);--fork 则在当前目录新建会话。

--no-session 是纯内存会话,退出即消失。SDK 场景下可以用 SessionManager.inMemory(cwd, { id }, entries) 把外部存储(比如数据库里的 entry)恢复成一个内存会话,再交给 AgentSession 使用,这也是 0.87 之后恢复上下文的正规方式。

Compaction:把长对话装进有限窗口

要解决的问题

一个任务跑上几十轮,用户消息、assistant 回复、tool call、tool result(尤其是 read 和 bash 的输出)不断堆积,迟早超过模型的 context window。最简单的做法是截断最早的消息,但 coding agent 的上下文不是闲聊,第 5 轮读过的文件结构可能是第 50 轮决策的基础,暴力截断会丢掉关键记忆。

Pi 的方案是 compaction:用 LLM 把旧消息总结成一份结构化摘要,保留最近一段原文,摘要作为一条 compaction entry 追加到会话里。它放在 coding-agent(产品层)而不是 agent-core(runtime),因为它依赖会话树、设置、模型选择和扩展钩子,这些都是 harness 的概念。

一句话:Compaction 不修改历史事实,只改变“如何把完整会话投影成有限长度的模型上下文”。

什么时候触发

判定公式只有一行:

contextTokens > contextWindow - reserveTokens      (reserveTokens 默认 16384)

contextTokens 优先用最近一条 assistant 消息里 provider 报告的真实 usage,再加上它之后新增消息的估算(字符数 / 4);没有可用 usage 时(报错、全零用量)退回到纯估算。reserveTokens 只决定何时开始压缩,生成摘要时用的是模型自身的输出上限(必要时为了塞进窗口再收缩)。以 200K 窗口为例,上下文超过约 184K 就会触发。

自动触发有两个检查点,正好对应“预防”和“补救”:

  1. 阈值检查(一轮结束后)。一次 prompt 里 agent 可能连续跑多轮 LLM → tool → LLM,某个巨大的 tool result 可能让下一次请求直接溢出。所以在工具执行完、结果已追加、下一次 assistant 请求开始之前(prepareNextTurn 阶段),Pi 用规范化后的投影检查一次,超了就先压缩再继续;如果这批工具已经让这次运行结束且没有排队消息,就跳过这次检查。新用户 prompt 进来前也会检查。
  2. 溢出恢复(报错之后)。provider 返回上下文溢出错误,或者回答被过早截断(stopReason: "length"),Pi 会做一次“压缩后重试”:先追加 context_edit 把失败的那次尝试从上下文里删掉,再以 reason: "overflow" 压缩,成功后作为一次全新的运行重试。如果压缩失败或被取消,保留删除编辑、不重试;重试后仍溢出就报错,提示换更大窗口的模型。这样失败尝试仍在原始历史、导出和计费里,只是不再污染模型上下文。

此外可以随时手动 /compact [instructions],附加说明让摘要侧重某个主题。关掉自动压缩(compaction.enabled: false)不影响手动命令。

总结什么、保留什么

压缩前(活动分支):
  hdr | usr ass tool | usr ass tool tool ass tool
        └─ 要总结的 ─┘ └────── 保留的近期原文 ──────┘
                        ↑ firstKeptEntryId

压缩后追加一条 cmp,模型看到:
  system | summary | usr ass tool tool ass tool | 之后的新消息
  1. 找切点:从最新的消息往前累加 token 估算,直到达到 keepRecentTokens(默认 20000)。切点只能落在 user、assistant、bash 执行、自定义消息或分支摘要上,绝不落在 tool result 上,否则 tool call 和它的结果会被拆开,很多 provider 会直接拒绝这种请求。
  2. 处理“超大单轮”:正常情况下切在 user 消息边界上;如果最近一轮(从一条 user 消息到下一条之间)本身就超过预算,切点会落在这轮中间的某条 assistant 消息上。这时 Pi 生成两份摘要再合并:一份总结更早的历史,一份总结这一轮的前半段。
  3. 生成摘要:把待总结的消息序列化成纯文本([User]: ...、[Assistant tool calls]: read(path=...)、[Tool result]: ...),防止模型把它当作要续写的对话;thinking 块不带,tool result 截到 2000 字符。摘要是固定结构:Goal、Constraints & Preferences、Progress(Done / In Progress / Blocked)、Key Decisions、Next Steps、Critical Context,末尾附上读过和改过的文件列表。
  4. 增量更新:如果之前已经压缩过,上一份摘要作为 previous summary 一起交给模型,要求在其基础上更新而不是从头重写,避免“摘要的摘要”越压越失真。再次压缩的范围从上次的 firstKeptEntryId 开始,上次保留下来的原文这次也会被纳入总结。
  5. 文件操作累积:从 tool call 里提取读/改过的文件,并继承上一次(Pi 生成的)压缩里的文件列表,存进 details.readFiles / modifiedFiles。对话细节被压掉了,agent 仍然知道这次会话碰过哪些文件。
  6. 追加 entry 并重新投影:compaction entry 记录摘要、firstKeptEntryId、tokensBefore、生成摘要的 usage,以及压缩边界处完整的 system prompt 和工具检查点。之后的请求按“投影”那一节的规则重建上下文。

关键在于:摘要只在之后的请求里替代旧消息,原始 entry 一条不删。/tree 里仍能看到全部历史,导航到压缩点之前的位置,那段原文就又成了活动上下文。

按模型调预算

不同模型窗口差别很大,1M 窗口的模型用 16K 的 reserve 意味着要攒到将近 1M 才压缩,成本和延迟都不划算。compaction.modelOverrides 允许按精确的 provider/modelId 单独设置 reserveTokens 和 keepRecentTokens,例如给某个 1M 模型设 reserveTokens: 400000,它就会在 600K 时压缩。两个值各自按“模型覆盖 → 普通设置 → 内置默认”回退,enabled 仍是全局的;手动压缩、阈值检查、溢出恢复和扩展看到的设置都用这套解析结果,切换模型后的下一次检查立即生效。

扩展可以接管

session_before_compact 在自动压缩和 /compact 之前触发,拿到的是已经算好的 preparation(待总结消息、超大单轮的前半段、上一份摘要、文件操作、firstKeptEntryId、生效的设置)以及触发原因(manual / threshold / overflow)和是否会重试。handler 可以返回 { cancel: true } 取消,也可以自己调用别的(比如更便宜的)模型生成摘要后返回 compaction 对象,Pi 照常追加 entry,fromHook 标记为 true。“准备”和“执行”分离,使扩展不必重新实现切点算法就能换掉摘要策略。对应地,session_compact_failed 报告失败或中止,session_before_tree 可以在 /tree 导航时取消或提供自定义的分支摘要。

取舍

得到的:append-only 的 JSONL 崩溃时最多丢最后半行,前面的 entry 完好(解析时跳过坏行);纯文本、可读可修,不依赖 SQLite 之类的存储引擎;分支和导航只是“追加一条 parentId 指向目标的 entry”,零拷贝;压缩、上下文编辑也都是追加,历史永远可审计、可回退。

放弃的:没有随机访问和索引,打开会话要从头解析整个文件、重建 byId,查“所有改过 auth.ts 的 tool call”只能遍历;文件只增不减,压缩只压上下文、不压文件,想瘦身只能 /fork 或 /clone 出一条路径;同步追加写不考虑多进程同时写同一个文件。对单用户、本地、会话大小在 KB 到几 MB 的场景,这些代价都可以接受。Compaction 本身是有损的,早期细节可能丢失,生成摘要还要多花一次 LLM 调用。

常见追问

为什么不把上下文直接存成内存数组,每次 push? 内存数组和会话文件是两份“真相”,谁改了一份另一份就可能对不上(扩展直接改数组、压缩后重建、分支导航都会碰到)。让 SessionManager 成为唯一来源后,所有改变上下文的操作都必须表现为追加 entry,可复现、可回放,也能导出“模型当时到底看到了什么”。

压缩后旧消息还能找回来吗? 能。原始 entry 一直在文件里,/tree 导航到压缩点之前即可;导出和分享也包含它们。压缩只影响从活动分支投影出的模型上下文。

分支摘要和压缩摘要有什么区别? 压缩是纵向的:同一条分支太长,把前半段总结掉。分支摘要是横向的:离开一条分支时,把被放弃路径的经验带到新分支。两者用几乎相同的结构化格式和文件追踪,触发方式和挂载位置不同。

溢出恢复为什么只重试一次? 压缩后仍溢出,说明问题不在旧历史(比如单条输入本身就太大),继续重试只会浪费调用;此时报错让用户换大窗口模型或减少输入。

源码入口(v1.5):packages/coding-agent/src/core/session-manager.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/src/core/compaction/compaction.ts、packages/coding-agent/src/core/compaction/branch-summarization.ts、packages/coding-agent/docs/session-format.md、packages/coding-agent/docs/compaction.md