跳转至

07 · 会话树、上下文投影与压缩

7.1 一份日志为什么能表示多条路线

Session format version 在 1.0 中为 3。JSONL 首行是 header;后面 entries 有 id、parentId、timestamp 和 type。每个 entry 指向父节点,所以 append-only 文件可以包含一棵树。

flowchart TD H[会话 header] --> U1[用户:研究入口] U1 --> A1[助手:入口是 main] A1 --> U2[用户:按方案 A 修改] U2 --> A2[助手:方案 A] A1 --> U3[用户:改用方案 B] U3 --> A3[助手:方案 B 当前 leaf]

当前 leaf 是 A3,模型上下文不包含方案 A 这条废弃支线,但磁盘仍保留它。历史查看和当前请求必须分开。

不要直接编辑真实 JSONL 来试验分支:不合法 parentId、toolCall 对应或 schema 都可能破坏恢复。先用课程 SessionManager 临时目录测试。

7.2 entry 类型不是消息 role

entry type 是否通常进入模型 作用
message 是 system/user/assistant/toolResult 等
model_change / thinking_level_change 作为设置恢复 选择记录
compaction 转摘要和 system checkpoint 压缩上下文
branch_summary 转换为分支摘要消息 跨路径带回信息
custom 默认不进入 扩展状态
custom_message 按转换进入 扩展给模型的内容
context_edit 影响目标消息投影 替换或删除模型可见内容
label / session_info 通常不进入 UI 标签与会话信息
usage 费用信息 非普通对话内容

appendCustomEntry() 保存状态;sendMessage() 等产生模型可见 custom message。把这两个混淆,会造成「我保存了指令但模型没看到」或「不该进模型的日志进入了上下文」。

7.3 SessionManager 的关键方法

方法 语义
create(cwd, sessionDir) 新持久会话
inMemory(cwd) 同样的树模型,不写文件
open(file) 打开历史文件
continueRecent(cwd, sessionDir?) 在指定 cwd / 可选目录继续最近会话
appendMessage(message) 加到当前 leaf 后,推进 leaf
branch(entryId) 切换分支点,后续 append 形成新路线
getBranch() 当前根到 leaf 的路径
buildSessionProjection() 带来源映射的模型上下文
buildSessionContext() messages 与恢复设置

SDK 会管理这些更新,应用不应在 streaming 时绕过 SDK 并发写 manager。

7.4 L476–510:buildContextEntries()

先沿 leaf 构建路径,再找路径中最新 compaction。

  • 没有 compaction:使用整条路径。
  • 有 compaction:先放该 compaction entry,再放压缩前从 firstKeptEntryId 开始的保留部分,最后放压缩后的新 entries。
  • 保留范围中的旧 system messages 被跳过,由 compaction 的 system checkpoint 提供有效基线。

这不是简单 messages.slice(-N)。如果你自己裁剪,至少要维持 system baseline 和完整工具请求/结果对,不能只按字符数切开。

7.5 L543–573:buildSessionProjection()

投影不仅输出 messages,还保留 sourceEntry。它在当前上下文 entries 中收集 context_edit,按目标 ID 应用最近编辑。replacement 为 null 时隐藏该条模型消息;原始 entry 仍在日志。

较旧 compaction entry 恰好落入最新保留范围时,不再次贡献旧摘要,以免重复注入。只有最新 checkpoint 提供摘要。

flowchart LR J[全量 JSONL] --> P[active branch] P --> C[latest compaction + kept entries] C --> E[context edits] E --> M[messages + provenance]

对自己的知识助手,可以使用类似结构,把审计历史保留完整,同时给模型呈现一个有控制的视图。但不要让应用授权状态只存在会被压缩的自然语言摘要里。

7.6 L1172–1200:持久化的实际边界

新会话没有实际 conversation 时,Pi 可能延迟建文件;第一次需要 flush 时使用独占创建 wx,写现有 entries;以后 append 完整 JSON 行。

这保证的是程序所定义的 JSONL 存储行为,不是外部操作恰好执行一次的 durable transaction。普通 SDK 工具可以在写结果前已执行动作,如果进程此刻崩溃,日志缺少最终结果。

因此不能把「会话会保存」等同「任何工具都能安全重放」。支付、发消息、部署之类动作需要业务幂等键、状态查询和恢复逻辑。

7.7 Compaction 具体解决什么

模型窗口是有限的。Pi 估算 projected context tokens,并在阈值处压缩;也有 provider 报 overflow 后的恢复路径。摘要保留目标、限制、进度、关键决定、文件状态和下一步,近期消息保留为具体证据。

7.7.1 触发条件不是“聊天很长”

compaction.ts L126–130 的默认值是 enabled:true、reserveTokens:16384、keepRecentTokens:20000。shouldCompact() 用严格大于:

enabled && contextTokens > contextWindow - reserveTokens

例如窗口 128000、预留 16384,则阈值是 111616;等于阈值尚不触发。预留用于下一次输出与摘要周转,不是你产品的精确货币预算。keepRecentTokens 是切点的估算目标,不保证保留下来的数据恰好等于 20000 tokens。

7.7.2 token 估算为什么要读取来源映射

estimateContextTokens() 首先找最新有效 assistant usage:error / aborted 与全零 usage 被跳过。使用 native totalTokens;不可用时把 input、output、cacheRead、cacheWrite 相加,再加 usage 后的新消息估算。

但某次响应的 usage 只描述当时请求。后来 context_edit 隐藏或替换消息,或者 compaction 改成摘要,旧 usage 就不能直接代表当前 projected context。estimateProjectedContextTokens() 用 projected entries 的 sourceEntry 找 usage 所属原始 entry,比较之后是否出现 context_edit / compaction;usage 比最近失效 entry 新才继续用,否则重估当前 system 的有效状态和所有非 system projected messages。

flowchart TD P[当前 projection] --> U{有有效 assistant usage?} U -->|有| I[sourceEntry 定位 usage entry] I --> L{之后有 edit 或 compaction?} L -->|没有| A[usage 加后续消息估算] L -->|有| R[重放有效 system 并重估 projected messages] U -->|没有| R

默认估算按字符量除以 4 并向上取整;system 包括 sections 和工具声明 JSON,assistant 包括 thinking / tool args,图片用固定 4800 字符量估计。它是启发式,不是目标模型 tokenizer,对中文、图片和不同 provider 不能保证总是高估或精确。

7.7.3 从最新往回找一个合法切点

prepareCompaction() 先调用 buildSessionProjection,因此按编辑后模型可见内容算切点,不按原始文件行数。它从最新内容向前累计,达到 keepRecentTokens 后选相邻合法边界:

  • 可切在 user-like 或 assistant message 前。
  • 不以 toolResult 作为切点,否则会保留结果而丢失调用。
  • 可切在有工具请求的 assistant 前,因为其后结果一起保留。
  • 相邻不贡献模型内容的 metadata 可以纳入保留范围,但不跨越上一压缩边界。
  • 当前传入的 active branch 路径尾部已是 compaction、或者没有可摘要内容时返回 undefined,而非再造空 checkpoint。

若尾部只有已被 omission edits 隐藏的失败 assistant attempt,算法还有封闭恢复尾部的专门处理;任意 metadata 不允许把切点推进到尚未发送的用户输入之后。

7.7.4 split turn 为什么需要两种摘要

很长的一轮可能有多个工具请求。如果切点位于本轮中间,仅摘要更早完整轮次会丢掉本轮原始用户目标。prepareCompaction 因此输出 history messages 与 turnPrefixMessages:

flowchart TD OLD[更早的完整轮次] --> HS[历史摘要: 可更新旧摘要] USER[本轮用户目标] --> PREFIX[本轮切点前的进度] PREFIX --> PS[Turn Context 摘要] CALL[切点: 保留 assistant toolCall] --> RESULT[对应 toolResults] RESULT --> TAIL[之后真实消息] HS --> FINAL[新 summary: 历史摘要加本轮前缀摘要] PS --> FINAL FINAL --> REQUEST[下一次模型上下文] TAIL --> REQUEST SYSTEM[有效 system checkpoint] --> REQUEST

compact() 必要时先更新历史摘要,再生成本轮前缀摘要,合并 usage;最近 tail 仍保留真实消息。system prompt / tools 不靠模型记忆:appendCompaction() 存入重放得到的 system checkpoint,投影保留这个基线。已有 summary 更新、turn prefix 摘要和 system checkpoint 是三个职责,不能合并理解。

7.7.5 摘要请求也有输出与失败边界

历史摘要输出上限为 min(floor(0.8 * reserveTokens), model.maxTokens),split-turn 前缀为 0.5 倍预留(模型 maxTokens 没有正值时不以它限制)。一次性 summary 请求设置 cacheRetention:"none",沿用 caller sessionId 或生成新路由 ID,不承诺 summary 请求免费或自动命中缓存。

getSummarizationFailure() 拒绝 error 与 length;length 表示摘要不完整,不能存成有效 checkpoint。摘要或前缀响应包含 toolCall 也会拒绝,避免把摘要步骤变成额外工具执行。取消的处理还取决于请求 signal 与 SDK 路径,不能把 helper 对 error/length 的判断误写为它独自覆盖所有取消情况。

7.7.6 对自己的 Agent 的具体应用

用结构化状态保存授权、提交 ID 与预算,用摘要保存目标和工作进展;建立“压缩前/后引用来源一致”的评测。课程测试真实 appendCompaction,确认旧全文变为摘要、近期消息保留、最新 policy section 和工具声明从 checkpoint 正确重建;它不证明真实模型生成的摘要无损。

更细源码:估算与失效检查、合法切点、projected 切点和准备、摘要完成检查。

摘要生成是模型任务,可能调用真实 provider 并产生费用,也可能丢失信息。压缩不是精确无损压缩。

在摘要里应该保存

任务目标、用户已作决定、进行到哪一步、关键文件/资源 ID、未验证结论、失败与下一步。不能只写一篇漂亮的回顾文章。

应保存在摘要外

账户权限、预算余额、外部动作幂等键、工单状态、已审批版本。这些放结构化业务数据库;prompt 只提供必要的读视图。

7.8 RAG 与会话不是同一个问题

会话历史记录「刚才做了什么」;知识检索回答「领域里有什么资料」。不要把全部公司文档永久塞进 SessionManager;建一个窄检索工具,返回文档 ID、片段和出处,再按需读全文。

信息也需要过期策略:历史里一个小时之前的价格或工单状态不是当前事实。真实业务工具应重新读取。

7.9 分支与 fork 的区别

必须按具体 API 区分复制范围,不能把所有叫 fork 的入口概括成一种行为:

API 内存/文件行为 历史范围
branch(entryId) 只改本实例内存 leaf,不新建文件 原全树保留
createBranchedSession(leafId) 持久模式创建新文件,并让 manager 切换到它;重建 label 与 parent chain 指定 root → leaf 路径
SessionManager.forkFrom(sourcePath, targetCwd, sessionDir?) 新 header、cwd、session ID、parentSession,新 manager 源文件全部非 header entries,包含旁支

导航并非独立持久光标:branch(root) 本身不 append 日志。若原文件尾是 A,切到 root 后立即退出,重新 open 会按文件末尾 entry 恢复为 A;之后 append B 才把 B 写成文件尾,并使重开时沿 B 的 parent chain 恢复。课程新增真实磁盘测试验证这个边界。

flowchart LR A[磁盘最后 entry A] --> B[branch root: 仅内存 leaf 改变] B --> C{之后有没有 append?} C -->|没有| D[重开仍从 A 恢复] C -->|append B| E[文件尾 B: 重开沿 B 路径]

CLI / SDK 的高层 fork 还会整理模型、输入与扩展事件,应沿具体入口追到所调用的 manager 方法,不能只看名字。多个界面共享同一文件时,服务端应管理唯一写入者和导航所有权。

7.10 练习:验证历史与投影

课程测试创建 root → branch A,回到 root 后加 branch B;打开磁盘文件确认 A 仍存在;重新 open 并 buildSessionContext 确认只出现 B。

进一步练习:手写一个用 SessionManager.inMemory 的小程序,保存 custom entry,再检查其是否进入模型 messages;比较 custom message。所有实验先用临时目录,避免破坏自己的会话。

源码:SessionManager、session format、compaction 实现、compaction 文档。