A6 记忆与会话:明天它还记得吗? 第 4 课
想回到三轮前换条路,会话该存成列表还是树? 会话存成列表还是树
T10 第一天在第 1 轮的工具结果之后换了一条路:先读完测试再一次修全。会话存成列表,换路只能把后面 15 条删掉,那条最后成功了的原路径从文件里消失;存成树,每条记录记下自己的 parent,新分支从旧节点长出来,两条路并存。「当前对话」就是根到当前叶子的那条路径,也正是下一次发给模型的 messages;分叉的第一个请求与原路径共享前缀,实测 1,236 token 命中 1,197。
❓ 本课一问: 跑到一半发现方向不对,想回到三轮前换一条路。会话在磁盘上该存成一个消息列表,还是一棵树?
这一课先不碰: 把活分给子 agent(A7);多个 agent 同时写同一个会话。
从已有出发
从已有出发 · 累计数据流 点已学环节看它回答什么
任务 贯穿任务 → ① 编排选择 A0 → 每一轮循环都经过 ② 循环 A1 → ③ 工具层 A2 → ④ 权限闸 A3 → ⑤ 沙箱 A4 → ⑥ 上下文管理 A5
→ ⑦ 记忆与会话本课 A6 → ⑧ 子 agent A7 → ⑨ 模型接入与计费 A8 → ⑩ 验证与评测 A9
A5 第 4 课 拦过一个误解:「压缩之后历史就没了」——被压掉的只是发给模型的那一份,完整历史还在磁盘上。那么磁盘上的它长什么样?
T10 的冻结组第一天,项目线录了一次「换条路」:在第 1 轮 list_dir 的结果(entry e0005)之后,追加一条新的 user 消息,从那里另起一个分支:
Change of plan from here: before editing anything, read test_dateparse.py and list every assertion in test_parse_date, then fix all causes in one pass.
分叉点之前没有任何写文件的操作(录制核对:0 次),所以新分支在同一时刻的仓库副本上接着干。原路径跑了 9 轮,验收通过;新分支跑了 7 轮,验收通过。两条路都有价值:想比较哪种修法更好,两条都得留着。
先自己设计:会话就是一个消息列表
最直接的存法:把 messages 数组整个存下来。CoreCoder 就是这么做的:
session.py CoreCoder@v0.7.0:corecoder/session.py:41-56 需联网 MIT 41 def save_session(messages: list[dict], model: str, session_id: str | None = None) -> str:
42 """Save conversation to disk. Returns the session ID."""
43 SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
44
45 session_id = _normalize_session_id(session_id)
46
47 data = {
48 "id": session_id,
49 "model": model,
50 "saved_at": time.strftime("%Y-%m-%d %H:%M:%S"),
51 "messages": messages,
52 }
53
54 path = _session_path(session_id)
55 path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
56 return session_id
👆 带下划线的行可以点:点哪行,讲哪行。
① 朴素方案 ② 它在哪失败 ③ 改进方案 朴素方案 列表存储:会话就是一个 messages 数组
用下面组件自己试:切到「列表」存储,选中 e0005,点「从选中的点继续」。被删掉的条目画成红叉,下方列表里划掉。
改进方案 树存储:每条记录带 parent,换路 = 从旧节点长出新分支
每条记录除了内容,再记一个 parent:它接在谁后面。追加 = 在当前叶子下面挂一个孩子;换路 = 把「当前位置」挪回旧节点,再挂一个孩子。原来的孩子一个都不动。
✗ 失败点 从 e0005 换条路,列表只能把它后面的 15 条 删掉(含 8 条 模型回复、1,239 token 的内容),那条跑了 9 轮、最后验收通过的原路径从会话文件里消失。要么就只能另存一份整个文件。
看它在哪失败 换成改进方案 重来一遍
改进:会话是一棵树
会话树:T10 冻结组的真实会话文件 system user 调用工具 回复 工具结果 会话起点 system 刷新
树(Pi · miniagent) 列表(CoreCoder · lcc) 原地分叉(Pi /tree) 复制成新会话(/branch · /fork) resume(同一会话追加) 从选中的点继续 ▶ 复原
数据:本课项目 miniagent 的 T10 会话文件(traces/sessions/t10-frozen.jsonl,一个 JSONL,每条 entry 有 id / parent_id;2026-09-26 录制,已脱敏)。token 是这条内容本身的 token 数(录制模型 tokenizer,不含模板);带 usage 的回复另标出那一轮真实请求的 prompt 与缓存命中。
🎯 试三件事:① 默认选中的是 fork 分支的叶子:看加粗的那条路径,它从哪里离开了 day1 那一行?下方列表的前几条是谁?② 选中 e0005,在「树」存储下点「从选中的点继续」,再切到「列表」存储点一次,对比两次的结果。③ 点 day2 那一行的任意一个点:它的路径和 day1 连着吗?为什么第二天是一个新的根?
组件里能读出的三件事:
「当前对话」是一条路径。 文件里有 3 个叶子,就有 3 条可以接着走的对话;选中哪个叶子,从它沿 parent 走回根,反过来就是下一次要发给模型的 messages。
分叉不丢任何东西。 fork 分支从 e0005 长出来,day1 原来的 15 条记录原封不动。
第二天是新的根。 新会话从空的 messages 开始,和前一天不连;把前一天带过去的是 AGENTS.md 与记忆(前三课),不是会话树。
源码:几家怎么存
Pi :整棵树在一个 JSONL 里,每条 entry 有 id 与 parentId:
session-manager.ts pi@31b513e3:packages/coding-agent/src/core/session-manager.ts:45-50 需联网 MIT 45
46 export interface SessionEntryBase {
47 type: string;
48 id: string;
49 parentId: string | null;
50 timestamp: string;
👆 带下划线的行可以点:点哪行,讲哪行。
session-manager.ts pi@31b513e3:packages/coding-agent/src/core/session-manager.ts:844-855 需联网 MIT 844 /**
845 * Manages conversation sessions as append-only trees stored in JSONL files.
846 *
847 * Each session entry has an id and parentId forming a tree structure. The "leaf"
848 * pointer tracks the current position. Appending creates a child of the current leaf.
849 * Branching moves the leaf to an earlier entry, allowing new branches without
850 * modifying history.
851 *
852 * Use buildSessionContext() to get the resolved message list for the LLM, which
853 * handles compaction summaries and follows the path from root to current leaf.
854 */
855 export class SessionManager {
👆 带下划线的行可以点:点哪行,讲哪行。
从叶子走回根的那几行,本页组件用的就是它的 JS 移植;页面加载时,对 T10 两棵树的每个叶子(5 个)与 bun 在 pin 源码上跑出的结果比对:
session-manager.ts pi@31b513e3:packages/coding-agent/src/core/session-manager.ts:1255-1269 需联网 MIT 1255 /**
1256 * Walk from entry to root, returning all entries in path order.
1257 * Includes all entry types (messages, compaction, model changes, etc.).
1258 * Use buildSessionContext() to get the resolved messages for the LLM.
1259 */
1260 getBranch(fromId?: string): SessionEntry[] {
1261 const path: SessionEntry[] = [];
1262 const startId = fromId ?? this.leafId;
1263 let current = startId ? this.byId.get(startId) : undefined;
1264 while (current) {
1265 path.push(current);
1266 current = current.parentId ? this.byId.get(current.parentId) : undefined;
1267 }
1268 path.reverse();
1269 return path;
👆 带下划线的行可以点:点哪行,讲哪行。
Pi 的 README 把三种「换条路」的命令并列写着,并点明压缩有损、历史无损:
README.md pi@31b513e3:packages/coding-agent/README.md:256-268 需联网 MIT 最新版已变化 · 勘误表 P1 256
257 **`/tree`** - Navigate the session tree in-place. Select any previous point, continue from there, and switch between branches. All history preserved in a single file.
258
259 <p align="center"><img src="docs/images/tree-view.png" alt="Tree View" width="600"></p>
260
261 - Search by typing, fold/unfold and jump between branches with Ctrl+←/Ctrl+→ or Alt+←/Alt+→, page with ←/→
262 - Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
263 - Press Ctrl+X to copy the selected message
264 - Press Shift+L to label entries as bookmarks and Shift+T to toggle label timestamps
265
266 **`/fork`** - Create a new session file from a previous user message on the active branch. Opens a selector, copies the active path up to that point, and places the selected prompt in the editor for modification.
267
268 **`/clone`** - Duplicate the current active branch into a new session file at the current position. The new session keeps the full active-path history and opens with an empty editor.
👆 带下划线的行可以点:点哪行,讲哪行。
Claude Code (官方 sessions 文档,2026-09-26 读取):/branch 或 --continue --fork-session 把到当前为止的对话复制 成一个新的会话 ID,原会话原样留在磁盘上;/rewind 把对话退回到更早的一轮。会话 JSONL 里每条记录也带 uuid 与 parentUuid(文件格式没有官方文档,调研档案据本机文件核查)——下面的实验课可以把你自己的会话文件拖进组件里看。
Codex :rollout 是只追加的 JSONL,压缩也写成其中一条可回放的记录,带着替换后的历史和「上下文窗口链」的编号:
protocol.rs codex@8cabf5a:codex-rs/protocol/src/protocol.rs:3245-3258 需联网 Apache-2.0 3245
3246 #[derive(Serialize, Clone, Debug, PartialEq, JsonSchema, TS)]
3247 pub struct CompactedItem {
3248 pub message: String,
3249 #[serde(default, skip_serializing_if = "Option::is_none")]
3250 pub replacement_history: Option<Vec<ResponseItem>>,
3251 /// Monotonic position of this context window within the thread.
3252 #[serde(default, skip_serializing_if = "Option::is_none")]
3253 pub window_number: Option<u64>,
3254 /// UUIDv7 identity of the first context window in this thread's window chain.
3255 #[serde(default, skip_serializing_if = "Option::is_none")]
3256 pub first_window_id: Option<String>,
3257 /// UUIDv7 identity of the context window immediately before this one.
3258 #[serde(default, skip_serializing_if = "Option::is_none")]
👆 带下划线的行可以点:点哪行,讲哪行。
本课项目 miniagent 的 session.py 走的是 Pi 这条路:一个 JSONL,每条 entry 有 id / parent_id,fork(from_entry, new_session) 从任一节点长出新会话,messages(session) 从叶子走回根再重建。代价写在它的注释里:重建消息要沿 parent 走一遍,不能直接读数组。
⚠ 常见误解一:「压缩掉的就永远没了。」 (回扣 A5·L4)
压缩改的是「下一次发给模型的那一份」;在树形存储里,压缩本身也只是树上的一条记录。Pi 的 README 原话是压缩有损,但完整历史仍在 JSONL 里,可以用 /tree 回去;Codex 把压缩写成 CompactedItem,本课项目的 trace 与会话文件同样一条不少。
常见误解二:「换条路就得从头付全价。」 分叉出来的新分支,开头和原路径一模一样,前缀缓存照样能读。T10 的 fork 第 1 轮请求 1,236 token,实测命中 1,197:它的前 1,197 个 token 就是原路径第 2 轮请求(共 1,199 token)的开头。Claude Code 的官方文档也写明:fork 出来的对话继承父对话的 system、工具与历史,第一个请求读父对话的缓存;/rewind 退回的是一个已经缓存过的前缀。
极小真数字:fork 的第一个请求
T10 fork 分支第 1 轮:服务端 prompt 1,236 token,其中命中缓存 1,197
1,236 = 1,197 + 39
👆 公式里带下划线的每一项都可以点 ——点哪项,讲哪项。
按「最近 10 个请求里的最长前缀」推算,这一轮命中 1,197,与实测一致。列表存储下想得到同样的效果,只能先复制整份文件再截断;树存储里分叉天然共享前缀。
证据三问
问 本课的回答 ① 它解决什么问题? 换一条路时不丢原来那条路,「当前对话」随时可以切换 ② 为什么树一定不丢? 追加只挂孩子、分叉只挪叶子指针,已有的记录从不改写 可证明 证据等级 · 数学可证明 可证明 理论动机 实验验证 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条 数据结构性质:只追加的树里,任何一个节点到根的路径在之后的操作中保持不变。本页的路径算法与 Pi 原函数在 T10 每个叶子上的结果一致。 出处 待补 ③ 实验上呢? Pi 的树形会话是满票结论并有源码直证 实验验证 证据等级 · 实验验证 可证明 理论动机 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条 调研档案 HP #6 满票;Claude Code 的 /branch 行为来自官方 sessions 文档。 出处 Pi pin 源码 · 满票(HP #6) ↗ 需联网 ;T10 录到了一次真实的分叉与它的缓存命中 实验验证 证据等级 · 实验验证 可证明 理论动机 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条 项目实测:一次分叉、一次运行。说明分叉共享前缀,不说明分叉后的路线更好——两条路这次都通过了验收。 出处 待补
演进地图:你在这里
记忆与会话的演进:每一代都被上一代的某个真数字逼出来
最省事的做法:任务说明里顺手写上「不许改测试文件」「日期用 DD/MM/YYYY」
解决了 不用改 harness,当场就生效。
卡在 A5 的分层压缩运行里,规矩写在任务说明中,17 份真实摘要只有 2 份 还带着「不许改测试文件」;换一个新会话更彻底:T10 day2 的第一个请求只有 2 条消息、1,138 token,来自前一天对话的是 0 条 。
Claude Code 的 CLAUDE.md、Codex / OpenCode 的 AGENTS.md(Codex 从项目根到 cwd 逐级叠加、32 KiB 上限;OpenCode 同名文件 first-match)、Hermes 三段 prompt 的 context 段
解决了 会话开始从磁盘读一次,拼进每个请求;压缩之后从磁盘重新注入。T10 day2 的每一轮都带着 AGENTS.md 块(87 token),验收「按 AGENTS.md 的格式输出」:通过。
卡在 它只装人写好的规矩。agent 自己在干活时学到的东西(比如这个仓库怎么跑测试)写到哪、什么时候生效?
本课项目 miniagent 的 refresh 模式(T10 刷新组)
解决了 本会话马上就能用上刚学到的东西。
卡在 T10 刷新组 day1 第 7 轮:上一轮命中 2,072 token,刷新后这一轮 2,300 token 只命中 294 ——前缀在记忆块处断开,后面的工具定义与全部历史都要重算。
Hermes MEMORY.md(2,200 字符)/ USER.md(1,375 字符);miniagent frozen 模式;仓库准则「Per-conversation prompt caching is sacred」
解决了 会话内 system 字节不变:T10 冻结组 day1 的 8 个后续请求里,8 个把上一轮的请求整个读回了缓存(最多差 1 个模板 token);day2 开新会话,第一个请求就带着前一天写的记忆。
卡在 流程性知识越攒越多:Hermes 随包的 77 个 SKILL.md 全文合计 215,494 token ,是录制模型窗口(40,960)的 5.3 倍,全塞进快照放不下。
Pi(name + description + location)、Hermes(描述截到 60 字符)、Claude Code(单条 1,536 字符上限)、agentskills.io 规范;分发:MCP Skills Extension(SEP-2640,2026-09-13 Final)
解决了 同样 77 个 skill,Hermes 实际注入的索引 2,209 token;模型觉得相关时再读正文。
卡在 知识的问题解决了,会话本身怎么存?想回到三轮前换条路:T10 从 e0005 分叉,线性列表只能删掉它后面的 15 条 (原来那条成功的路径)。
Pi(id / parentId,一个 JSONL,/tree /fork /clone)、Claude Code(/branch、--fork-session)、Codex rollout(append-only,压缩写成 CompactedItem)、miniagent session.py
解决了 两条分支并存,压缩有损、历史无损;分叉的第一个请求与原分支共享前缀:T10 fork 第 1 轮 1,236 token,实测命中 1,197 。
卡在 一个会话、一个窗口的边界还在:一件事大到一个 agent 的上下文装不下、或需要别人把关时 → 下一章 A7 子 agent。
旁支 按相关性挑选记忆注入,或检索(RAG) 没有比较证据 learn-claude-code s09(每次用户发话先让模型从目录里挑相关记忆,最多 20,000 字符);Hermes 的外部记忆 provider 插件(默认关闭,每轮预取、只拼进当轮 user 消息)
和文件式冻结快照谁更好?档案里没有任何比较性证据——能讲清的只有设计维度:谁决定加载、什么时候注入、会不会打穿前缀缓存。
数字口径:A5 的摘要来自 pd-v2-layered-s1 的真实运行(a05-context.json);其余来自本课项目 miniagent 的 T10 两天会话(Qwen3-8B-MLX-4bit,本机 mlx_lm.server,2026-09-26),缓存命中是服务端报告的 cached_tokens;skill 的 token 数由录制模型的 tokenizer 在 Hermes pin 的 77 个 SKILL.md 上现算(a06-memory.json,2026-09-26)。
一句话
🚦 先停在这里: 记住三个操作——追加(挂孩子)、分叉(挪叶子)、重建(从叶子走回根);以及「树存储」和「复制成新会话」是两种不同的分叉。
再深一层: 去实验课 把你自己的 Claude Code 或 Pi 会话文件拖进来,数一数它有几个叶子。
下一个缺口
一个 agent 的上下文装不下整件事、或需要别人把关时,怎么分工? → 下一章 A7:子 agent。
这一章让一个 agent 能跨会话地记住规矩、记忆与技能,也能在一个会话里换路不丢历史。但它始终只有一个窗口:要在整个仓库里找全 parse_date 的调用点,全读进这一个窗口吗?
你可能会问
「树形存储有什么代价?」 重建一次消息要从叶子沿 parent 走回根(Pi 较新的压缩记录直接内嵌压缩后保留的消息 retainedTail,重建时不必再走更早的条目,见 docs/session-format.md);文件只增不减,需要自己清理;同一个会话被两个进程同时追加时,各自的叶子指针会打架——Claude Code 官方文档提醒,不 fork 就在两个终端里 resume 同一个会话,两边的消息会交错写进同一份记录。
「Pi 的 /tree 离开一个分支时,那条分支的内容能带过来吗?」 可以选择让模型把离开的那条分支摘要一下,挂到新分支上(BranchSummaryEntry,docs/session-format.md),这样新分支知道旧路试过什么,又不用把旧路的全部消息带进来。