Skip to content

Latest commit

 

History

History
239 lines (182 loc) · 8.98 KB

File metadata and controls

239 lines (182 loc) · 8.98 KB

第十一章 · Session 树与上下文构建

第八章讲了 Session 为什么是树、JSONL 为什么合适。这一章回答一个更硬的问题: 从一棵可能有分支、有压缩点的树,到底怎么算出"这次要发给 LLM 的消息列表"? 答案是两个函数:buildContextEntries()buildSessionContext()


11.1 两层视角:Storage 层 vs SessionManager 层

第八章从 SessionStorage / SessionRepo 接口切入——那是 agent-core 的存储抽象层。 coding-agent 在其之上提供了更高层的 SessionManager 门面,多数应用直接用它

@startuml
skinparam backgroundColor transparent

rectangle "SessionManager\n(coding-agent)" as SM #E8F5E9 {
  rectangle "buildContextEntries()" as B1
  rectangle "buildSessionContext()" as B2
  rectangle "appendMessage() / branch() ..." as A
}

rectangle "JSONL 文件\n(树状 entries)" as J #E3F2FD

SM --> J : 追加 / 读取

note bottom of SM
  两层职责分离:
  · 存储层管字节
  · SessionManager 管语义
end note

@enduml

本章聚焦 SessionManager,因为"如何从树重建上下文"的逻辑在这一层。

11.2 Entry ≠ Message

第八章列过 SessionTreeEntry 的多种类型。关键要建立的一个区分是:Entry 是磁盘上的记录,Message 是发给 LLM 的东西。两者不是一一对应。

树里的每个 Entry 都继承同一个基类:

interface SessionEntryBase {
  type: string;
  id: string;               // 8 字符 hex
  parentId: string | null;  // 第一条为 null
  timestamp: string;        // ISO 时间戳
}

message 类型的 Entry 里装的是一个 AgentMessage——它是个联合类型,比 pi-ai 的基础三种消息更宽:

type AgentMessage =
  | UserMessage           // pi-ai 基础类型
  | AssistantMessage      // pi-ai 基础类型
  | ToolResultMessage     // pi-ai 基础类型
  | BashExecutionMessage      // coding-agent 扩展:!命令执行
  | CustomMessage             // 扩展注入、参与上下文
  | BranchSummaryMessage      // 分支摘要
  | CompactionSummaryMessage; // 压缩摘要

后四种是 coding-agent 在 pi-ai 之上的扩展。比如 BashExecutionMessage 记录 ! 前缀直接执行的命令,还带一个 excludeFromContext!! 前缀时为 true,表示不进 LLM 上下文)。

哪些 Entry 进上下文,哪些不进

Entry 类型 进 LLM 上下文? 用途
message 正常对话消息
compaction 转成 compactionSummary(+ retainedTail)
branch_summary 转成 branchSummary
custom_message 扩展注入的消息
custom 扩展状态持久化,仅重载时读取
model_change 元数据,影响"用哪个模型"但不是消息
thinking_level_change 同上
label / session_info 书签、会话名等元数据

记住这张表——它正是下一节两个函数要做的筛选。

11.3 buildContextEntries:从树选出"活跃路径"

第一步,从当前 leaf 一路走到 root,选出这条路径上"有效"的 entries。核心难点是遇到压缩点怎么办

@startuml
skinparam backgroundColor transparent
skinparam ActivityBackgroundColor #f8f9fa
skinparam ActivityBorderColor #dee2e6

start
:从 leaf 向 root 收集路径上所有 entries;

if (路径上有 CompactionEntry?) then (yes)
  :先放入 compaction entry 本身;
  if (compaction 带 retainedTail?) then (yes)
    :retainedTail 是自包含检查点;
    :直接纳入 compaction 之后的 entries;
    note right
      新版本:不必回溯
      compaction 之前的旧 entry
    end note
  else (no, 旧格式)
    :纳入 firstKeptEntryId → compaction 之间的 entries;
    :再纳入 compaction 之后的 entries;
  endif
else (no)
  :纳入整条路径;
endif

:保留选定范围内的非消息 entry;
note right: 供交互模式渲染
stop
@enduml

这就是 firstKeptEntryIdretainedTail 两种检查点机制的分野——第十二章会讲它们在压缩时如何被写入。这里只需知道:新版压缩把保留的尾部消息直接钉在 compaction entry 上(retainedTail),使它成为自包含检查点,重建时无需读取更早的 entry。旧格式则靠 firstKeptEntryId 指针回溯。

11.4 buildSessionContext:把 entries 变成 messages

第二步,把上一步选出的 entry 列表转成真正发给 LLM 的消息列表,同时解析出"当前用哪个模型、什么思维级别":

@startuml
skinparam backgroundColor transparent

rectangle "buildContextEntries()\n输出的 entry 列表" as E #E3F2FD
rectangle "buildSessionContext()" as B #FFE0B2
rectangle "{ messages, model, thinkingLevel }" as O #E8F5E9

E --> B
B --> O

note bottom of B
  1. 从完整路径解析 model / thinkingLevel
  2. 逐个 entry 转 message:
     message        → 存储的 AgentMessage
     compaction     → compactionSummary (+ retainedTail)
     branch_summary → branchSummary
     custom_message → CustomMessage
     custom         → 不产生消息
end note

@enduml

注意一个细节:model / thinkingLevel 是从"完整路径"解析的,而不是从压缩后的消息列表。因为 model_change / thinking_level_change 可能发生在被压缩掉的旧消息区间里——如果只看压缩后的消息就会丢掉这些设置。

至此,buildSessionContext() 返回的 messages 会再经过第五章讲的"最晚转换"(convertToLlm)才发给 Provider。整条链路:

树 → buildContextEntries → buildSessionContext → convertToLlm → stream()
   (选路径+压缩)      (entry转message)    (AgentMessage转Message)

11.5 分支操作:Fork / Clone / Branch

第八章介绍了 Fork 的概念。这里补齐 SessionManager 上三个容易混淆的操作:

操作 方法 效果
Branch(树内分支) branch(entryId) 把 leaf 移到更早的 entry,之后追加的消息成为新子分支——同一个文件内
Branch + 摘要 branchWithSummary(entryId, summary, ...) 同上,但在切换点写入分支摘要,保留被放弃分支的记忆
Fork / Clone(跨文件) createBranchedSession(leafId) / forkFrom(...) 把某个分支抽取成新的 JSONL 文件,可跨项目

核心区别:Branch 是文件内的树导航(利用 id/parentId 就地分叉,不新建文件),Fork/Clone 才产生新会话文件。新文件的 header 会记录 parentSession 指向来源。

@startuml
skinparam backgroundColor transparent

rectangle "同一 JSONL 文件" as F1 {
  rectangle "e1" as A1
  rectangle "e2" as A2
  rectangle "e3 (旧 leaf)" as A3
  rectangle "e4 (branch 后新 leaf)" as A4 #C8E6C9
  A1 --> A2
  A2 --> A3
  A2 --> A4 : branch(e2) 后追加
}

rectangle "新 JSONL 文件" as F2 #FFF3E0 {
  rectangle "header\nparentSession→原文件" as H
  rectangle "e1'..." as B1
  H .. B1
}

A2 ..> F2 : createBranchedSession

@enduml

11.6 SessionManager API 速览

按用途分组的常用方法(完整见 session-format.md):

// 静态创建
SessionManager.create(cwd, sessionDir?)         // 新会话
SessionManager.open(path, sessionDir?)          // 打开已有文件
SessionManager.continueRecent(cwd, sessionDir?) // 续最近的,或新建
SessionManager.inMemory(cwd?)                   // 不落盘(测试用)
SessionManager.forkFrom(sourcePath, targetCwd)  // 跨项目 fork

// 追加(都返回新 entry 的 id)
appendMessage(message)
appendModelChange(provider, modelId)
appendThinkingLevelChange(level)
appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)
appendCustomEntry(customType, data?)            // 扩展状态,不进上下文
appendCustomMessageEntry(customType, content, display, details?) // 进上下文

// 树导航
getLeafId() / getLeafEntry() / getEntry(id)
getBranch(fromId?)   // 从某 entry 走到 root
getTree() / getChildren(parentId)
branch(entryId) / branchWithSummary(entryId, summary, ...)

// 上下文构建(本章主角)
buildContextEntries()   // 活跃分支 entries(已应用压缩)
buildSessionContext()   // { messages, thinkingLevel, model }

11.7 本章检查清单

  • 能区分 Entry(磁盘记录)和 Message(发给 LLM)
  • 说得出哪些 Entry 类型不进 LLM 上下文
  • 理解 buildContextEntries 遇到压缩点的两种处理路径
  • 知道 retainedTail 为什么让新版压缩成为"自包含检查点"
  • 明白 model/thinkingLevel 为何要从完整路径而非压缩后消息解析
  • 区分文件内 branch() 与跨文件 fork/clone

上一章第十章 · Provider 与认证
下一章第十二章 · Compaction 内部机制 — 摘要到底怎么切、怎么生成