Skip to content
BaiRuic
Go back

Pi Agent 的 Session Tree(下):如何生成模型上下文

目录

Pi Agent 的 Session Tree(上):会话历史如何保存中,我们基于开源 Agent 框架 pi 探讨了 Session Tree 的数据结构基础:通过基于 parentId 的单向指针与 Append-Only Log 模型保存会话记录,因此已有记录不会被覆写,进程中断时已经写入的内容也能保留。

然而,LLM 的底层协议是严格线性的。无论是 OpenAI 还是 Claude,模型的推理接口只接受一个按照时间正向排列的 messages 数组。

这就带来了一个关键的工程问题:

存储在磁盘上的非线性树状拓扑,如何精准 Flatten,并投影为符合模型 Context Window 的线性消息?

本篇将深入剖析 Session Tree 如何转换为运行时上下文(buildSessionContext 算法)、历史配置如何随当前分支恢复、BranchingBranch 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 上的 e3e4e5 不会出现在分支 B 的上下文中。

2. 提取配置变更(getSessionContextSettings

在会话生命周期中,用户可能会多次切换模型或调整思考强度(Thinking Level)。为了确保配置生效准确,系统需要知道“当前这条分支上,最后一次生效的配置是什么”。

状态提取会按照 Root 到 Leaf 的顺序扫描路径。每遇到 model_changethinking_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()
  });
}

BranchSummaryMessageCompactionSummaryMessage 的核心差异

  • 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
  • 固化当前生效的 modelthinkingLevel 和激活工具列表。
  • 将快照传入 runAgentLoop

所有运行中的配置修改,只会在当前 Turn 结束、准备进入下一轮(prepareNextTurn)时才会生效。

2. 写缓冲机制(Pending Session Writes)与 Save Point

当 Agent 处于忙碌状态(phase === "busy")时,如果有扩展插件或外部事件试图写入会话树:

  1. 系统不会立即操作底层文件,而是将操作打包推入 pendingSessionWrites 队列。
  2. 当 Agent 完成当前 Turn,收到 turn_end 事件时,系统首先执行 flushPendingSessionWrites() 将缓冲的变更按因果顺序写入磁盘。
  3. 写入完成后,触发 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 个设计准则

  1. 状态节点化(State as Nodes) 配置变更(模型、思考深度)不要仅仅存在全局变量中,将其作为时序节点记录在会话树上。这样在用户进行历史跳转和回退时,历史运行配置能够天然自我恢复。
  2. 写不可变性(Append-Only Immutability) 放弃“删除数据”的线性思维。采用单向反向指针与叶子游标(leafId),用 O(1)O(1) 的指针移动代替危险的文件重写,提供安全且无损的多分支探索能力。
  3. 存储与模型输入分开处理(Tree Storage, Linear Context) 存储系统保留完整的分支和历史记录;调用模型前,buildSessionContext 只从当前分支生成标准的线性 Prompt。

这样,存储层可以保留完整的分支和历史记录,而模型层仍然只接收标准的线性消息。