会话树状管理 — Tree-based Session Architecture
会话树状管理 — Tree-based Session Architecture
问题定义
线性对话历史在以下场景中不够用:
- 分支探索:用户希望从对话的某个中间点”分叉”,尝试不同的指令路径,而不丢弃原始对话
- 上下文压缩:长对话超出模型上下文窗口时,需要将早期历史替换为摘要,同时保留关键信息
- 状态追溯:需要能够重建任意时刻的对话快照,用于调试、回放或审计
树状会话管理(Tree-based Session)将对话历史建模为一棵有根树,每个节点是一条消息或一个元事件(模型切换、思考级别变化、压缩、分支摘要),通过 parentId 建立父子关系,通过 leaf 指针追踪活跃分支末端。
核心概念
会话树条目(SessionTreeEntry)
树中的每个节点是一个 SessionTreeEntry,包含通用字段和类型特有字段:
interface SessionTreeEntryBase { type: string; // 条目类型标识 id: string; // 全局唯一 ID parentId: string | null; // 父节点 ID(根节点为 null) timestamp: string; // 创建时间戳}条目类型分类
会话树条目按语义可分为四类:
| 分类 | 条目类型 | 作用 |
|---|---|---|
| 消息 | message | LLM 对话消息(user/assistant/toolResult) |
| 消息 | custom_message | 应用自定义消息(如通知、状态更新) |
| 元事件 | model_change | 记录模型切换 |
| 元事件 | thinking_level_change | 记录思考级别变化 |
| 元事件 | active_tools_change | 记录可用工具集变化 |
| 元事件 | session_info | 会话元信息(名称等) |
| 元事件 | label | 给特定节点打标签 |
| 压缩 | compaction | 压缩标记,含摘要和保留起始点 |
| 压缩 | branch_summary | 分支摘要,记录从某节点分叉的总结 |
| 结构 | custom | 自定义数据节点 |
| 结构 | leaf | 活跃分支末端指针 |
树的结构
root (parentId: null) ├── message: user "hello" ├── message: assistant "hi there" ├── message: user "write a function" ├── message: assistant [tool calls] ├── message: toolResult ├── message: assistant [final answer] ├── compaction ← 压缩标记 │ ├── summary: "用户请求编写函数,助手完成实现" │ └── firstKeptEntryId: "msg-5" ← 从此节点开始保留原始消息 │ ├── branch_summary ← 分支摘要 │ │ ├── fromId: "msg-3" │ │ └── summary: "分叉路径的交互总结" │ └── leaf ← 当前活跃分支末端 │ └── targetId: "msg-8"关键操作
1. 路径重建(Path to Root)
给定一个 leafId,从叶子沿 parentId 链回溯到根,得到从根到叶的完整路径。这是构建模型上下文消息列表的基础。
getPathToRoot(leafId) → [root, ..., leaf] 的反转2. 分支(Fork)
在树的任意节点处分叉,创建新的子分支。分叉操作需要指定:
- 分叉点:目标节点 ID
- 位置:
before(在目标之前)或at(在目标之后)
分叉后,新的 leaf 指针指向新分支的末端,旧分支保留在树中不被删除。
3. 上下文构建(Build Session Context)
从根到活跃 leaf 的路径中,提取所有可转换为 LLM 消息的条目,构建模型上下文:
路径条目 → 过滤/转换 → AgentMessage[] → LLM Message[]压缩标记的处理特殊:遇到 compaction 节点时,将摘要作为一条系统消息插入,然后从 firstKeptEntryId 开始继续保留原始消息。
4. 上下文压缩(Compaction)
当对话 token 数接近模型上下文窗口时,触发压缩:
- 计算当前 token 数:基于 LLM 返回的 usage 统计
- 判定阈值:
contextTokens > contextWindow - reserveTokens - 选择切割点:从旧到新扫描,找到保留
keepRecentTokens个最近 token 的切割位置 - 生成摘要:调用 LLM 对切割点之前的历史生成总结
- 插入压缩节点:将摘要和
firstKeptEntryId写入树中
压缩后的上下文 = [压缩摘要] + [firstKeptEntryId 之后的原始消息]
5. 分支摘要(Branch Summary)
当从某节点分叉时,生成分支摘要:
- 总结该节点之前对话的关键信息
- 保留在树中作为分支点的上下文锚
- 新分支的上下文构建时会包含此摘要
持久化策略
JSONL 存储
每个会话对应一个 .jsonl 文件,每行是一个 JSON 序列化的 SessionTreeEntry。追加写入,不修改历史行。
优点:
- 追加写入天然支持崩溃恢复
- 每行独立,可逐行读取而不加载整个文件
- 兼容标准文本工具(grep、awk、head)
文件命名:{timestamp}_{sessionId}.jsonl,按工作目录分目录组织。
内存存储
用于测试和短期会话,条目存储在内存数组中。接口与 JSONL 存储一致,通过 SessionStorage 接口统一抽象。
设计权衡
| 维度 | 选择 | 理由 |
|---|---|---|
| 数据结构 | 树而非图 | 简化路径重建逻辑,避免环检测 |
| 持久化格式 | JSONL 而非数据库 | 轻量、可文本处理、易调试 |
| 压缩策略 | 摘要替换而非滑动窗口 | 保留语义上下文,而非简单的截断 |
| 分支处理 | 保留所有分支 | 支持事后比较不同路径的结果 |
| ID 生成 | UUID v7 | 时间有序、全局唯一、可排序 |
陷阱与对策
| 陷阱 | 对策 |
|---|---|
压缩后 firstKeptEntryId 指向不存在的节点 | 压缩时验证 ID 有效性,写入前校验 |
| 多压缩节点链导致上下文重建错误 | 按时间顺序遍历,只使用最新的压缩节点 |
| 分叉后 leaf 指针未更新导致读取旧分支 | fork 操作原子性地更新 leaf 指针 |
| JSONL 文件损坏导致部分条目丢失 | 逐行解析,单行失败不影响其他行 |
| 压缩摘要丢失关键文件操作信息 | 压缩时记录 readFiles/modifiedFiles 列表到 details 字段 |