在Pi Agent 的 Session Tree(上):会话历史如何保存中,我们基于开源 Agent 框架 pi 探讨了 Session Tree 的数据结构基础:通过基于 parentId 的单向指针与 Append-Only Log 模型保存会话记录,因此已有记录不会被覆写,进程中断时已经写入的内容也能保留。
然而,LLM 的底层协议是严格线性的。无论是 OpenAI 还是 Claude,模型的推理接口只接受一个按照时间正向排列的 messages 数组。
这就带来了一个关键的工程问题:
存储在磁盘上的非线性树状拓扑,如何精准 Flatten,并投影为符合模型 Context Window 的线性消息?
本篇将深入剖析 Session Tree 如何转换为运行时上下文(buildSessionContext 算法)、历史配置如何随当前分支恢复、Branching 与 Branch Summarization 如何工作,以及 Compaction 与 Agent Harness 运行时的写缓冲和轮次快照设计。
一、 全景视角:从 Session Tree 到网络数据包
在深入算法细节前,先建立完整的四层转换视角:
上一篇我们聚焦在第 1 层;本篇重点介绍第 1 层如何生成第 3 层的运行时消息,以及这些消息如何进一步转换为第 4 层模型接口需要的格式。
二、 核心算法:buildSessionContext 如何构建运行时上下文
从 Session Tree 到运行时上下文的核心转换函数是 buildSessionContext()。这个函数先通过 buildSessionPath() 找到当前叶子节点对应的路径,再通过 getSessionContextSettings() 提取当前路径上的模型与思考级别,最后调用 buildContextEntries() 和 sessionEntryToContextMessages(),将 Entry 转换为 LLM/runtime messages。
当前 leafId ───> [ 1. 链路回溯: buildSessionPath ] ───> 一维 Entry 链路 (Root -> Leaf)
│
┌──────────────────────────────┴──────────────────────────────┐
▼ ▼
[ 2. 状态提取: getSessionContextSettings ] [ 3. Entry 转换: sessionEntryToContextMessages ]
(重放生效的 model 与 thinkingLevel) (过滤元数据,将 Entry 映射为 AgentMessage[])
│ │
└──────────────────────────────┬──────────────────────────────┘
▼
SessionContext(Runtime Context)
{ messages, model, thinkingLevel }
1. 从叶子节点反向定位主干路径(buildSessionPath)
当一个会话包含多个分支时,第一步是确定当前叶子节点对应的那条路径。
具体做法是:以当前的 leafId(叶子节点)为起点,沿着节点的 parentId 指针一路向上找到根节点,再将收集到的节点反转。这样就能得到一条从 Root 到 Leaf 的线性路径,其他分支不会被加入当前上下文。
function buildSessionPath(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map<string, SessionEntry>
): SessionEntry[] {
const index = byId ?? buildEntryIndex(entries);
// 1. 定位当前叶子节点 (若未指定则默认取最新节点)
let leaf = leafId ? index.get(leafId) : entries[entries.length - 1];
if (!leaf) return [];
// 2. 沿 parentId 单向指针向上回溯
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
path.push(current);
current = current.parentId ? index.get(current.parentId) : undefined;
}
// 3. 将 (Leaf -> Root) 反转为正向时序 (Root -> Leaf)
path.reverse();
return path;
}
e1 (model_change)
└── e2 (user)
├── e3 (assistant) ── e4 (tool) ── e5 (assistant) [被放弃的分支 A]
└── e6 (user) ── e7 (assistant) ── e8 (tool) [当前活跃分支 B (leafId)]
回溯链路 (Root -> Leaf): [e1, e2, e6, e7, e8]
结果:分支 A 上的 e3、e4、e5 不会出现在分支 B 的上下文中。
2. 提取配置变更(getSessionContextSettings)
在会话生命周期中,用户可能会多次切换模型或调整思考强度(Thinking Level)。为了确保配置生效准确,系统需要知道“当前这条分支上,最后一次生效的配置是什么”。
状态提取会按照 Root 到 Leaf 的顺序扫描路径。每遇到 model_change 或 thinking_level_change 节点,就用节点中的值更新当前配置;扫描结束后留下的配置,就是本次调用 LLM 时生效的配置。
function getSessionContextSettings(path: SessionEntry[]): { thinkingLevel: string; model: ModelConfig | null } {
let thinkingLevel = "off";
let model: ModelConfig | null = null;
for (const entry of path) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel;
} else if (entry.type === "model_change") {
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
// 兜底记录:从实际生成回复的 assistant message 中提取当时使用的模型
model = { provider: entry.message.provider, modelId: entry.message.model };
}
}
return { thinkingLevel, model };
}
设计精妙之处:回退时的配置自动还原 pi 将配置变更保存为 Entry。如果用户从
e8回退到e2,当前路径会缩短为[e1, e2];重新扫描这条路径后,系统就会得到当时生效的配置,不需要额外维护撤销/重做堆栈。
3. 将 Entry 转换为 LLM/runtime messages(sessionEntryToContextMessages)
在回溯得到的路径上,并非每个 Entry 都是可以直接发给模型的文本消息。例如模型配置变更已在第 2 步被提取为参数,UI 标记和私有扩展数据则完全不需要进入模型上下文。
这个阶段会逐个检查路径上的 Entry:普通对话消息会被取出,压缩节点和分支摘要会被转换成特殊消息,模型配置和 UI 标记等元数据则会被跳过,最后得到 AgentMessage[]。
function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {
switch (entry.type) {
case "message":
return [entry.message]; // 解包为 UserMessage / AssistantMessage / ToolResultMessage
case "custom_message":
// 展开为带扩展属性的自定义消息 (在 convertToLlm 时转换为模型可读 Prompt)
return [createCustomMessage(entry.customType, entry.content, entry.display, entry.details)];
case "compaction":
// 将压缩节点转为 CompactionSummaryMessage
return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore)];
case "branch_summary":
// 将分支摘要转为 BranchSummaryMessage
return [createBranchSummaryMessage(entry.summary, entry.fromId)];
case "model_change":
case "thinking_level_change":
case "custom":
case "label":
case "session_info":
// 纯元数据/状态变更,不产生 LLM 消息
return [];
}
}
三、 Session Tree 的两大核心操作:Branching 与 Branch Summarization
Session Tree 主要解决两个问题:允许保留从历史节点开始的不同尝试(Branching),以及在放弃某条分支后提取其中的经验(Branch Summarization)。
分叉点 e2 (用户: "如何重构数据层?")
├── 废弃分支 A: e3 ──> e4 ──> ... ──> e18 (尝试方案 A 走入死胡同,消耗大量 Token)
│
[选择 1: branch] └── 新分支 B1: e19 ──> e20 (仅移动 leafId 指针,全新空白探索,无历史负担)
│
[选择 2: branchWithSummary]
└── e19 (BranchSummaryEntry: "方案 A 依赖项冲突已证伪") ──> e20 (新分支 B2,汲取教训)
1. Branching:无损分支
- 普通聊天应用的痛点:用户回退修改提示词,后续的聊天历史全部被硬截断删除。
- pi 的解法:执行
branch(targetId)仅改变内存中的leafId指针。旧分支完整保留在树中,未来可以随时通过 TUI/CLI 切换回去。
2. Branch Summarization:萃取废弃分支经验(branchWithSummary)
在实际工程开发中,用户在方案 A 上探索了很久(例如反复调试某个 ORM 库),虽然最终发现方案走不通,但探索过程中获取的信息(例如“该库在 Node 22 环境下存在并发死锁”、“必须开启特定连接池参数”)对后续开发极具参考价值。
branchWithSummary 的设计直击这一痛点:它先调用轻量模型将废弃分支的交互提炼为一份高密度的经验总结,然后回退到分叉点,并将生成的 BranchSummaryEntry 节点挂载在分叉点上,成为新分支的第一个前置节点。
async function branchWithSummary(
branchFromId: string,
abandonedLeafId: string,
sessionManager: SessionManager
): Promise<void> {
// 1. 获取废弃分支上的完整消息链路
const abandonedPath = sessionManager.buildSessionPath(abandonedLeafId);
const messagesToSummarize = abandonedPath.flatMap(sessionEntryToContextMessages);
// 2. 调用轻量模型生成结构化摘要 (包含 Goal / Progress / Key Learnings / Why Abandoned)
const summaryText = await generateStructuredSummary(messagesToSummarize);
// 3. 将 leafId 重置到分叉点
sessionManager.branch(branchFromId);
// 4. 追加一条 BranchSummaryEntry 节点 (其 parentId 自动指向 branchFromId)
sessionManager.appendEntry({
type: "branch_summary",
fromId: abandonedLeafId,
summary: summaryText,
timestamp: new Date().toISOString()
});
}
BranchSummaryMessage 与 CompactionSummaryMessage 的核心差异:
CompactionSummaryMessage:语义为 “当前分支主线太长,压缩前半段历史”,代表的是当前分支的前序因果步骤。BranchSummaryMessage:语义为 “这是一条平行探索分支的失败/验证经验”。在convertToLlm()阶段,它会被包装在专用的<branch_exploration_summary>标签中作为辅助参考,防止模型把废弃分支的中间状态当成当前主线。
四、 树级上下文压缩:滑动窗口与摘要锚点(buildContextEntries)
当某条分支的交互深度很大、Token 消耗逼近上下文窗口极限时,系统会触发上下文压缩并在此分支末尾插入一个 CompactionEntry 节点。
在构建上下文时,buildContextEntries 会先放入压缩摘要,再保留 firstKeptEntryId 及之后的消息,跳过已经被摘要覆盖的旧消息,最后加入压缩节点之后新产生的节点。
function buildContextEntries(entries: SessionEntry[], leafId?: string | null): SessionEntry[] {
const path = buildSessionPath(entries, leafId);
// 1. 查找路径上最后一个生效的 CompactionEntry
let compaction: CompactionEntry | null = null;
for (const entry of path) {
if (entry.type === "compaction") {
compaction = entry;
}
}
if (!compaction) return path; // 没有压缩节点,返回完整路径
const compactionIdx = path.findIndex((e) => e.id === compaction!.id);
const contextEntries: SessionEntry[] = [compaction];
// 2. 在压缩节点之前的 Entry 中,只保留 firstKeptEntryId 及之后的近期消息
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = path[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) {
contextEntries.push(entry);
}
}
// 3. 压缩节点之后的所有新 Entry 完整保留
contextEntries.push(...path.slice(compactionIdx + 1));
return contextEntries;
}
回溯路径: [e1, e2, e3 (firstKept), e4, e5 (CompactionEntry), e6, e7]
└────── 被保留的历史 ──────┘
最终有效上下文: [e5(摘要), e3, e4, e6, e7]
不同分支互不影响:压缩只会在当前分支追加 CompactionEntry。如果用户切回历史节点,该节点的路径上没有这条压缩记录,因此仍然可以读取原来的完整上下文。
五、 Agent Harness 运行时集成与一致性保障
在复杂的 Agent 系统中,底层核心(Agent Loop)负责依次执行模型请求和工具调用;外层的应用容器(如 AgentHarness)负责将这些操作按顺序写入会话树。
1. Turn Snapshot:轮次快照
Agent 往往具备长时间的推理或复杂工具调用链路(一个 Turn 可能持续数分钟)。在此期间,用户可能会在 UI 上修改模型、切换配置或输入后续指令。
为了防止这些并发操作污染当前正在进行的模型请求,AgentHarness 在每次 Turn 开始时通过 createTurnState() 截取快照:
- 从
Session.buildContext()中获取稳定的messages。 - 固化当前生效的
model、thinkingLevel和激活工具列表。 - 将快照传入
runAgentLoop。
所有运行中的配置修改,只会在当前 Turn 结束、准备进入下一轮(prepareNextTurn)时才会生效。
2. 写缓冲机制(Pending Session Writes)与 Save Point
当 Agent 处于忙碌状态(phase === "busy")时,如果有扩展插件或外部事件试图写入会话树:
- 系统不会立即操作底层文件,而是将操作打包推入
pendingSessionWrites队列。 - 当 Agent 完成当前 Turn,收到
turn_end事件时,系统首先执行flushPendingSessionWrites()将缓冲的变更按因果顺序写入磁盘。 - 写入完成后,触发
save_point事件。
这样可以保证缓冲中的写入按顺序连接到正确的父节点,避免并发写入破坏父子关系。
六、 全局总结与设计准则
通过上下两篇的剖析,我们可以将 LLM Agent 的消息与会话架构归纳为一个自洽的四层闭环:
[持久化层] Session Tree (JSONL) ──> 单向反向指针,Append-Only,保存全部分支拓扑
│ (buildSessionContext 投影)
[运行时层] AgentMessage[] (App Msg) ──> 表达富状态(自定义事件、UI 标记、分支/压缩摘要)
│ (transformContext & convertToLlm)
[模型层] Message[] (Model Msg) ──> 跨厂商清洗(标准的 user, assistant, toolResult)
│ (Provider Adapter)
[网络层] Provider Wire Format ──> OpenAI / Claude 原生 HTTP 数据包
现代 AI Agent 架构的 3 个设计准则
- 状态节点化(State as Nodes) 配置变更(模型、思考深度)不要仅仅存在全局变量中,将其作为时序节点记录在会话树上。这样在用户进行历史跳转和回退时,历史运行配置能够天然自我恢复。
- 写不可变性(Append-Only Immutability)
放弃“删除数据”的线性思维。采用单向反向指针与叶子游标(
leafId),用 的指针移动代替危险的文件重写,提供安全且无损的多分支探索能力。 - 存储与模型输入分开处理(Tree Storage, Linear Context)
存储系统保留完整的分支和历史记录;调用模型前,
buildSessionContext只从当前分支生成标准的线性 Prompt。
这样,存储层可以保留完整的分支和历史记录,而模型层仍然只接收标准的线性消息。