从0打造编码Agent 阅读 + 实验 ≈ 30 分钟 页内实验 ×2
A6 记忆与会话:明天它还记得吗? 第 4 课

想回到三轮前换条路,会话该存成列表还是树?

会话存成列表还是树
T10 第一天在第 1 轮的工具结果之后换了一条路:先读完测试再一次修全。会话存成列表,换路只能把后面 15 条删掉,那条最后成功了的原路径从文件里消失;存成树,每条记录记下自己的 parent,新分支从旧节点长出来,两条路并存。「当前对话」就是根到当前叶子的那条路径,也正是下一次发给模型的 messages;分叉的第一个请求与原路径共享前缀,实测 1,236 token 命中 1,197。
❓

本课一问:跑到一半发现方向不对,想回到三轮前换一条路。会话在磁盘上该存成一个消息列表,还是一棵树?

这一课先不碰:把活分给子 agent(A7);多个 agent 同时写同一个会话。

从已有出发

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

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
41def 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
👆 带下划线的行可以点:点哪行,讲哪行。
  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案列表存储:会话就是一个 messages 数组

用下面组件自己试:切到「列表」存储,选中 e0005,点「从选中的点继续」。被删掉的条目画成红叉,下方列表里划掉。

改进:会话是一棵树

会话树:T10 冻结组的真实会话文件 systemuser调用工具回复工具结果会话起点system 刷新

数据:本课项目 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 连着吗?为什么第二天是一个新的根?

组件里能读出的三件事:

  1. 「当前对话」是一条路径。文件里有 3 个叶子,就有 3 条可以接着走的对话;选中哪个叶子,从它沿 parent 走回根,反过来就是下一次要发给模型的 messages。
  2. 分叉不丢任何东西。fork 分支从 e0005 长出来,day1 原来的 15 条记录原封不动。
  3. 第二天是新的根。新会话从空的 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
46export 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 */
855export 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)]
3247pub 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
= +
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
按「最近 10 个请求里的最长前缀」推算,这一轮命中 1,197,与实测一致。列表存储下想得到同样的效果,只能先复制整份文件再截断;树存储里分叉天然共享前缀。

证据三问

问本课的回答
① 它解决什么问题?换一条路时不丢原来那条路,「当前对话」随时可以切换
② 为什么树一定不丢?追加只挂孩子、分叉只挪叶子指针,已有的记录从不改写
③ 实验上呢?Pi 的树形会话是满票结论并有源码直证 ;T10 录到了一次真实的分叉与它的缓存命中

演进地图:你在这里

记忆与会话的演进:每一代都被上一代的某个真数字逼出来
  1. 最省事的做法:任务说明里顺手写上「不许改测试文件」「日期用 DD/MM/YYYY」
    解决了不用改 harness,当场就生效。
    卡在A5 的分层压缩运行里,规矩写在任务说明中,17 份真实摘要只有 2 份还带着「不许改测试文件」;换一个新会话更彻底:T10 day2 的第一个请求只有 2 条消息、1,138 token,来自前一天对话的是 0 条。
  2. 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 自己在干活时学到的东西(比如这个仓库怎么跑测试)写到哪、什么时候生效?
  3. 本课项目 miniagent 的 refresh 模式(T10 刷新组)
    解决了本会话马上就能用上刚学到的东西。
    卡在T10 刷新组 day1 第 7 轮:上一轮命中 2,072 token,刷新后这一轮 2,300 token 只命中 294——前缀在记忆块处断开,后面的工具定义与全部历史都要重算。
  4. 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 倍,全塞进快照放不下。
  5. 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 条(原来那条成功的路径)。
  6. 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 的调用点,全读进这一个窗口吗?

你可能会问