阶段 3:上下文与会话树
本阶段的位置:阶段 2 已经知道循环每轮都要“重建上下文”。现在回答:上下文从哪里来、存在哪里、压缩和投影到底是什么关系。 源码目录:
$PI_SRC。核心文件:packages/coding-agent/src/core/session-manager.ts、src/core/compaction/compaction.ts、src/core/system-prompt.ts、src/core/resource-loader.ts。 官方材料:packages/coding-agent/docs/session-format.md、compaction.md、sessions.md、how-pi-works.md的 Context 与 Sessions 两节。
一、先记住一条主线
跳转到“一、先记住一条主线”阶段 2 说“模型请求无状态”,这会引出一个问题:如果模型不记事,Pi 每次到底把什么发给它?
答案不是“把整个会话文件发过去”,而是:
会话文件(完整账本) → 当前活动分支(一条连续路径) → 应用最新压缩和上下文编辑 → 投影(模型可见的对话历史) → convertToLlm(内部格式 → 模型格式) → 请求模型这条线里有四个容易混淆的概念:
| 概念 | 是什么 | 是否落盘 | 作用 |
|---|---|---|---|
| 会话树 | 所有 entry 组成的完整记录 | 是 | 保存事实,支持回溯、分支、恢复 |
| 压缩 | 把旧历史概括成摘要并追加一条记录 | 是 | 减少 token,同时保留关键信息 |
| 投影 | 从活动分支按规则算出的模型可见对话历史 | 否 | 决定模型这次看到哪些对话内容 |
| 上下文 | 本次请求真正发给模型的全部内容 | 否 | 模型实际读到的内容 |
压缩不是投影,投影也不是压缩后的树。 压缩是树里的一条摘要记录;投影是每次请求临时生成的对话历史视图,摘要只是投影的输入之一。
二、为什么用树,而不是一条聊天记录?
跳转到“二、为什么用树,而不是一条聊天记录?”一条线性聊天记录只能表示“发生过什么”。但真实工作经常需要:
- 回到旧消息,换一种做法;
- 保留原来的尝试,方便比较;
- 压缩旧历史,但不删除原文;
- 从某个中间状态复制一份工作。
树结构同时满足这些需求。
2.1 entry:会话文件里的“行”
跳转到“2.1 entry:会话文件里的“行””会话文件是 JSONL:每一行是一个 JSON 对象。共同字段是:
id:这条记录的标识;parentId:上一条记录是谁;timestamp:时间。
文件的第一行是 session header,不属于树:
{"type":"session","version":3,"id":"uuid","timestamp":"2026-10-19T…","cwd":"D:\\…"}从当前节点沿 parentId 一路回溯到根,就是活动分支。当前所在位置叫 leaf。
2.2 一棵树可以有多条连续分支
跳转到“2.2 一棵树可以有多条连续分支”树看起来分叉,但每条分支自身都是连续的:
根└─ m1 用户 └─ m2 助手 ├─ m3 用户 → m4 助手 ← 分支 A └─ m5 用户 → m6 助手 ← 分支 Bm2 是两条分支共享的旧节点。你回到 m2 再发新消息,Pi 不是改掉原来的 m3,而是追加一个以 m2 为父节点的新 entry。于是同一会话文件里并存两条路线。
一句话:一棵树是“多条连续分支的集合”,不是“把一条对话切碎”。
这里说的“分支”不是把对话切成几段,而是给同一个旧节点增加新的孩子。分支 A 和 B 共享根到 m2 的前半段,后半段各自延伸;选中哪个 leaf,就只沿它到根的那条路径读取。所以每条分支自身仍然是从根到当前消息的连续对话。
切换分支时,Pi 还可以生成一条 branch_summary:它把刚刚离开的那条分支中、分叉点之后的要点写成摘要,追加到新分支。这样新分支能知道“另一条路做过什么”,但旧分支的原文仍然不会直接进入新分支的投影。
三、会话文件里到底有什么?
跳转到“三、会话文件里到底有什么?”Pi 的 entry 不只有消息。理解“哪些会进模型上下文”,比背字段名更重要。
| 类型 | 作用 | 会进模型上下文吗? |
|---|---|---|
message |
用户、助手、工具结果或系统消息 | 是 |
compaction |
压缩摘要和系统检查点 | 是,代替被摘要的旧消息 |
context_edit |
追加式修改某条旧 entry | 否,但会改变目标的投影结果 |
branch_summary |
切换分支时保留旧路径要点 | 是 |
custom_message |
扩展注入的消息 | 是 |
model_change |
记录模型切换 | 否,但影响后续请求 |
thinking_level_change |
记录思考级别 | 否,但影响后续请求 |
usage |
用量与费用记录 | 否 |
custom |
扩展状态数据 | 否 |
label |
书签/标记 | 否 |
session_info |
会话名等信息 | 否 |
可以把它们分成三类:
- 内容:
message、compaction、branch_summary、custom_message; - 状态:
model_change、thinking_level_change、usage、session_info; - 修改/标记:
context_edit、label、custom。
3.1 实测:把会话文件画成树
跳转到“3.1 实测:把会话文件画成树”$dir = Get-ChildItem "$HOME\.pi\agent\sessions" -Directory | Where-Object Name -like "*pi-1.3.0*" | Select-Object -First 1
$file = Get-ChildItem $dir.FullName -Filter *.jsonl | Sort-Object LastWriteTime -Descending | Select-Object -First 1
$file.FullName
Get-Content $file.FullName | ForEach-Object { $j = $_ | ConvertFrom-Json $parent = if ($null -ne $j.parentId) { $j.parentId } else { "----" } "{0,-9} {1,-22} {2}" -f $j.id, $j.type, $parent}你不需要立刻理解每一种 entry。先观察:消息之间有 parentId 串起来,compaction、context_edit 等记录夹在中间。
四、从会话树到模型上下文:四步推导
跳转到“四、从会话树到模型上下文:四步推导”这一节是阶段 3 的核心。源码在 session-manager.ts 中对应四个层次。
第 1 步:找活动分支
跳转到“第 1 步:找活动分支”buildSessionPath() 从当前 leaf 沿 parentId 回溯到根,得到一条连续路径。
这一条路径是后续所有推导的范围。其他分支即使还在文件里,也不会被读取。
第 2 步:应用最新压缩
跳转到“第 2 步:应用最新压缩”buildContextEntries() 沿活动分支处理压缩记录。规则可以简化成:
- 分支上可能有多次压缩,只有最新的那个生效;
- 最新压缩之前、且不在保留范围内的旧 entry 被摘要代替;
- 从
firstKeptEntryId开始保留原文; - 压缩之后的 entry 原样继续;
label、usage等状态记录仍可保留在列表里,但投影时不会变成模型消息。
可以把压缩 entry 想成一张卡片:
c1: “m1~m5 的摘要” firstKeptEntryId = m6那么路径:
m1 → m2 → ... → m8 → c1 → m9 → m10投影时会读成:
[c1 的摘要] + m6 + m7 + m8 + m9 + m10原始 m1~m5 仍然在会话树里,只是不再进入这次请求的对话历史;m6~m8 因为落在保留范围内,仍以原文出现。
第 3 步:应用上下文编辑
跳转到“第 3 步:应用上下文编辑”context_edit 是对旧 entry 的追加式补丁:
targetId指向被修改的 entry;replacement是新内容;replacement: null表示从本次上下文中省略;- 同一目标以活动分支上最新的一条编辑为准;
- 原始 entry 不变,只是投影结果变了。
普通聊天很少产生 context_edit,它更多用于压缩溢出恢复、扩展和 SDK 的精细上下文管理。但它是“投影”概念最纯粹的体现:原始记录不动,视图按规则变化。
第 4 步:把 entry 转成消息
跳转到“第 4 步:把 entry 转成消息”buildSessionProjection() 和后续转换会把 entry 分成不同结果:
| entry | 投影结果 |
|---|---|
message |
原样成为内部消息 |
compaction |
系统检查点 + 摘要消息 |
branch_summary |
分支摘要消息 |
custom_message |
自定义消息 |
| 其他 | 通常不产生模型消息 |
最后得到 AgentMessage[],再经 convertToLlm 转成 provider 能接受的 Message[]。
4.1 完整例子
跳转到“4.1 完整例子”假设活动分支是:
m1 用户:重构 utils.tsm2 助手:先读文件 + read 调用m3 工具结果:文件内容m4 助手:开始修改 + edit 调用m5 工具结果:已修改m6 助手:重构完成m7 用户:继续,把类型也理顺m8 助手:好的c1 压缩:摘要 m1~m5,firstKeptEntryId = m6m9 用户:刚才改了哪些文件?m10 助手:主要改了 utils.ts……下一次请求的投影大致是:
[c1 摘要] + m6 + m7 + m8 + m9 + m10不是:
m1 + m2 + ... + m10也不是:
[c1 摘要] + [另一份投影内容]正确理解是:摘要本身就是投影中的一项;投影负责生成整份模型可见的对话历史,系统提示词、工具声明等其他部分再和它一起组成完整上下文。
五、压缩到底在做什么?
跳转到“五、压缩到底在做什么?”5.1 为什么需要压缩
跳转到“5.1 为什么需要压缩”上下文窗口有限,而且历史越长:
- 请求越慢;
- 费用越高;
- 无关信息越多,模型越容易被噪声干扰;
- 真正重要的近期内容可能被挤掉。
压缩的目标不是“删除历史”,而是用更少的 token 保留足够的任务信息。
5.2 什么时候触发
跳转到“5.2 什么时候触发”- 上下文接近模型上限时自动触发;
- 手动执行
/compact; - 请求因上下文溢出失败时,压缩后重试;
- 某些输出被长度截断的情况下触发恢复。
5.3 压缩后的内容会进上下文吗?
跳转到“5.3 压缩后的内容会进上下文吗?”会,但要分清三件事(这里说的是投影中的对话历史部分):
- 摘要:会进入后续投影;
- 压缩点之后保留的原文消息:会进入后续投影;
- 被摘要覆盖的旧消息原文:不再进入后续投影中的对话历史,但仍留在会话树里。
用公式表示:
模型可见的对话历史 = 最新压缩摘要 + 压缩点之后保留的原文消息 + 之后新增的消息 + 必要的系统检查点如果从未压缩过,就没有摘要,直接使用活动分支上的内容。
5.4 为什么压缩不直接删除旧消息
跳转到“5.4 为什么压缩不直接删除旧消息”因为会话树不是“当前上下文”,而是“完整事实账本”。保留原文带来:
- 可回溯:回到压缩点之前仍能看到原文;
- 可分支:从旧节点重新走一条路线;
- 可审计:知道当时到底说过什么;
- 可恢复:压缩出错时还有原始材料。
这也是 Pi 把“压缩”做成追加 entry,而不是覆盖文件的原因。
六、系统提示词、上下文文件和技能
跳转到“六、系统提示词、上下文文件和技能”模型上下文不只有对话消息,还包括系统提示词和工具声明。
6.1 系统提示词由哪些部分组成
跳转到“6.1 系统提示词由哪些部分组成”Pi 的系统提示词通常由多节组成,例如:
- 基础行为指令;
- 工具说明;
- 项目规则;
- 文档/附加说明;
- 上下文文件;
- 技能描述;
- 当前工作目录等。
具体节名和拼接规则在 system-prompt.ts,但初读只需要理解:系统提示词是每轮请求的“工作说明书”,不是用户可见的聊天内容。
6.2 上下文文件
跳转到“6.2 上下文文件”项目中的 AGENTS.md 等上下文文件会进入系统提示词的一部分。它们让模型知道这个仓库的规则、命令和约定。
注意:上下文文件不是“永久记忆”,而是每次请求重新组装的系统提示的一部分。
6.3 技能为什么懒加载
跳转到“6.3 技能为什么懒加载”技能可能有很多,且每个技能的完整说明都很长。如果全部塞进系统提示词:
- 占用大量 token;
- 大多数技能当前根本用不到;
- 模型会被无关指令干扰。
所以 Pi 通常只在系统提示里放技能的名称和描述;真正需要时,再加载完整技能说明。这叫“按需加载”。
七、分支、fork 和 clone:别把“新会话”与“新分支”混了
跳转到“七、分支、fork 和 clone:别把“新会话”与“新分支”混了”| 操作 | 结果 | 文件 |
|---|---|---|
/tree 回到旧消息继续 |
同一会话里长出新分支 | 不变 |
/new |
全新会话 | 新文件、新树 |
/fork |
从旧消息创建新会话 | 新文件、新树,记录来源 |
/clone |
复制当前活动分支为新会话 | 新文件、新树 |
/fork 和 /clone 复制的是历史内容,复制完成后两个会话独立演进,不会互相同步。
八、常见误区
跳转到“八、常见误区”| 误区 | 正确理解 |
|---|---|
| 会话文件就是模型上下文 | 模型只读活动分支投影出的对话历史,不读完整文件 |
| 压缩会删除旧消息 | 不删除;只追加摘要记录,原文仍在 |
| 投影就是把整棵树转成消息 | 投影只基于当前活动分支,并应用压缩/编辑 |
| 压缩后的摘要和投影是两份东西 | 摘要是投影的一部分 |
| 一个会话只有一条分支 | 同一文件可以有多条分支 |
| 新会话就是同一棵树的新分支 | 新会话是新文件、新树;分支才在同一文件里 |
| 技能全文总在系统提示里 | 通常只放描述,完整内容按需加载 |
九、通关自检
跳转到“九、通关自检”请先不看文档,完成下面三件事:
- 从一个包含 8 条消息、1 条压缩记录和 1 条编辑记录的活动分支,推导出下一次请求的消息列表;
- 解释“压缩摘要已经在上下文中”与“投影是上下文的历史部分”为什么不矛盾;
- 打开一个真实会话文件,画出它的
id/parentId链,并指出当前 leaf。
建议产出:
- 一张“会话树 → 活动分支 → 投影 → 模型消息”的图;
- 一份压缩前后上下文对照表;
- 一段自己的话解释“树、压缩、投影、上下文”的区别;
- 一条真实会话文件的树形记录。
下一站是阶段 4:上下文里的工具说明只是“说明书”。模型真正调用工具后,Pi 如何校验、执行、截断并安全回填?见阶段 4。
配套学习路线阶段 3 · 基于 v1.3.0(71023aec)