跳转到内容

阶段 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 助手 ← 分支 B

m2 是两条分支共享的旧节点。你回到 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 会话名等信息 否

可以把它们分成三类:

  1. 内容:message、compaction、branch_summary、custom_message;
  2. 状态:model_change、thinking_level_change、usage、session_info;
  3. 修改/标记: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 中对应四个层次。

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[]。

假设活动分支是:

m1 用户:重构 utils.ts
m2 助手:先读文件 + read 调用
m3 工具结果:文件内容
m4 助手:开始修改 + edit 调用
m5 工具结果:已修改
m6 助手:重构完成
m7 用户:继续,把类型也理顺
m8 助手:好的
c1 压缩:摘要 m1~m5,firstKeptEntryId = m6
m9 用户:刚才改了哪些文件?
m10 助手:主要改了 utils.ts……

下一次请求的投影大致是:

[c1 摘要] + m6 + m7 + m8 + m9 + m10

不是:

m1 + m2 + ... + m10

也不是:

[c1 摘要] + [另一份投影内容]

正确理解是:摘要本身就是投影中的一项;投影负责生成整份模型可见的对话历史,系统提示词、工具声明等其他部分再和它一起组成完整上下文。


五、压缩到底在做什么?

跳转到“五、压缩到底在做什么?”

上下文窗口有限,而且历史越长:

  • 请求越慢;
  • 费用越高;
  • 无关信息越多,模型越容易被噪声干扰;
  • 真正重要的近期内容可能被挤掉。

压缩的目标不是“删除历史”,而是用更少的 token 保留足够的任务信息。

  • 上下文接近模型上限时自动触发;
  • 手动执行 /compact;
  • 请求因上下文溢出失败时,压缩后重试;
  • 某些输出被长度截断的情况下触发恢复。

5.3 压缩后的内容会进上下文吗?

跳转到“5.3 压缩后的内容会进上下文吗?”

会,但要分清三件事(这里说的是投影中的对话历史部分):

  • 摘要:会进入后续投影;
  • 压缩点之后保留的原文消息:会进入后续投影;
  • 被摘要覆盖的旧消息原文:不再进入后续投影中的对话历史,但仍留在会话树里。

用公式表示:

模型可见的对话历史 = 最新压缩摘要
+ 压缩点之后保留的原文消息
+ 之后新增的消息
+ 必要的系统检查点

如果从未压缩过,就没有摘要,直接使用活动分支上的内容。

5.4 为什么压缩不直接删除旧消息

跳转到“5.4 为什么压缩不直接删除旧消息”

因为会话树不是“当前上下文”,而是“完整事实账本”。保留原文带来:

  • 可回溯:回到压缩点之前仍能看到原文;
  • 可分支:从旧节点重新走一条路线;
  • 可审计:知道当时到底说过什么;
  • 可恢复:压缩出错时还有原始材料。

这也是 Pi 把“压缩”做成追加 entry,而不是覆盖文件的原因。


六、系统提示词、上下文文件和技能

跳转到“六、系统提示词、上下文文件和技能”

模型上下文不只有对话消息,还包括系统提示词和工具声明。

6.1 系统提示词由哪些部分组成

跳转到“6.1 系统提示词由哪些部分组成”

Pi 的系统提示词通常由多节组成,例如:

  • 基础行为指令;
  • 工具说明;
  • 项目规则;
  • 文档/附加说明;
  • 上下文文件;
  • 技能描述;
  • 当前工作目录等。

具体节名和拼接规则在 system-prompt.ts,但初读只需要理解:系统提示词是每轮请求的“工作说明书”,不是用户可见的聊天内容。

项目中的 AGENTS.md 等上下文文件会进入系统提示词的一部分。它们让模型知道这个仓库的规则、命令和约定。

注意:上下文文件不是“永久记忆”,而是每次请求重新组装的系统提示的一部分。

技能可能有很多,且每个技能的完整说明都很长。如果全部塞进系统提示词:

  • 占用大量 token;
  • 大多数技能当前根本用不到;
  • 模型会被无关指令干扰。

所以 Pi 通常只在系统提示里放技能的名称和描述;真正需要时,再加载完整技能说明。这叫“按需加载”。


七、分支、fork 和 clone:别把“新会话”与“新分支”混了

跳转到“七、分支、fork 和 clone:别把“新会话”与“新分支”混了”
操作 结果 文件
/tree 回到旧消息继续 同一会话里长出新分支 不变
/new 全新会话 新文件、新树
/fork 从旧消息创建新会话 新文件、新树,记录来源
/clone 复制当前活动分支为新会话 新文件、新树

/fork 和 /clone 复制的是历史内容,复制完成后两个会话独立演进,不会互相同步。


误区 正确理解
会话文件就是模型上下文 模型只读活动分支投影出的对话历史,不读完整文件
压缩会删除旧消息 不删除;只追加摘要记录,原文仍在
投影就是把整棵树转成消息 投影只基于当前活动分支,并应用压缩/编辑
压缩后的摘要和投影是两份东西 摘要是投影的一部分
一个会话只有一条分支 同一文件可以有多条分支
新会话就是同一棵树的新分支 新会话是新文件、新树;分支才在同一文件里
技能全文总在系统提示里 通常只放描述,完整内容按需加载

请先不看文档,完成下面三件事:

  1. 从一个包含 8 条消息、1 条压缩记录和 1 条编辑记录的活动分支,推导出下一次请求的消息列表;
  2. 解释“压缩摘要已经在上下文中”与“投影是上下文的历史部分”为什么不矛盾;
  3. 打开一个真实会话文件,画出它的 id/parentId 链,并指出当前 leaf。

建议产出:

  • 一张“会话树 → 活动分支 → 投影 → 模型消息”的图;
  • 一份压缩前后上下文对照表;
  • 一段自己的话解释“树、压缩、投影、上下文”的区别;
  • 一条真实会话文件的树形记录。

下一站是阶段 4:上下文里的工具说明只是“说明书”。模型真正调用工具后,Pi 如何校验、执行、截断并安全回填?见阶段 4。


配套学习路线阶段 3 · 基于 v1.3.0(71023aec)