07 · 会话树、上下文投影与压缩¶
7.1 一份日志为什么能表示多条路线¶
Session format version 在 1.0 中为 3。JSONL 首行是 header;后面 entries 有 id、parentId、timestamp 和 type。每个 entry 指向父节点,所以 append-only 文件可以包含一棵树。
当前 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 提供摘要。
对自己的知识助手,可以使用类似结构,把审计历史保留完整,同时给模型呈现一个有控制的视图。但不要让应用授权状态只存在会被压缩的自然语言摘要里。
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() 用严格大于:
例如窗口 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。
默认估算按字符量除以 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:
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 恢复。课程新增真实磁盘测试验证这个边界。
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 文档。