Appearance
第 11 章:会话树 — 分支、回溯与 DAG
线性对话只有一条路。如果想回到之前一个问题,继续问呢?
这一章要解决什么问题?
线性 messages 数组有一个限制:对话只有一条路径。用户说了 A,模型回了 B,用户接着说了 C。如果用户觉得 C 问得不好,想回到 B 之后换一种问法 — 线性结构做不到。
这一章把 messages 从线性数组演化为树形 DAG(有向无环图)。每条消息有 id 和 parentId,可以从历史中任意一个节点分支出去。传给模型的 messages 是从根到当前叶子的路径,而所有分支都保留在存储中。
从线性到树形
线性结构:
A → B → C → D → E(当前)只有一条路径,没有回溯余地。
树形结构:
A → B → C → D → E(分支 1)
↘
C' → D'(分支 2:从 B 分出去的新对话)从 B 节点"分叉",创建了一条新的对话路径。两条路径共享 A → B 的前缀,之后各走各的。
数据模型
每条消息多两个字段:id(自己是谁)和 parentId(父节点是谁)。
typescript
interface StoredMessage {
id: string; // 唯一标识(nanoid 或 UUID)
parentId: string | null; // 父消息 ID,根节点为 null
message: Message; // 实际的消息内容
}所有消息平铺存储在 JSONL 文件中(只追加,和第 09 章一样)。树的结构通过 id/parentId 关系重建。
Active Path:传给模型的消息序列
模型每次调用只看一条线性路径 — 从根节点到当前叶子节点的路径。这条路径叫 active path。
typescript
function getActivePath(leafId: string, store: StoredMessage[]): Message[] {
const path: Message[] = [];
let currentId: string | null = leafId;
while (currentId !== null) {
const node = store.find(n => n.id === currentId);
if (!node) break;
path.unshift(node.message); // 加到开头(从叶子往根回溯)
currentId = node.parentId;
}
return path;
}从叶子开始,沿 parentId 往上走,直到根节点(parentId 为 null)。收集路上经过的所有消息,就是传给模型的 messages。
分支操作:fork
从历史中的某个节点创建新分支:
typescript
function fork(fromNodeId: string, newMessage: Message, store: StoredMessage[]): StoredMessage {
const newNode: StoredMessage = {
id: generateId(),
parentId: fromNodeId, // 新消息的父节点是分支起点
message: newMessage,
};
store.push(newNode);
return newNode;
}fork 做的事情很简单:创建一个新节点,它的 parentId 指向你想回溯到的那个节点。新节点成为新分支的第一条消息。
用例:用户重新提问
场景:对话进行了三轮(A → B → C → D → E),用户觉得 C 这条消息问得不好,想从 B 之后重新问。
typescript
// 当前 store 中的消息
// A(root) → B → C → D → E
// 用户选择从 B 节点分支
const newBranch = fork("B", { role: "user", content: "换一种问法...", timestamp: Date.now() }, store);
// 现在 store 中有:
// A → B → C → D → E (原分支)
// ↘ newBranch (新分支)
// 切换 active path 到新分支
const activePath = getActivePath(newBranch.id, store);
// activePath = [A.message, B.message, newBranch.message]模型看到的 messages 变成了 [A, B, 新问题] — 它不知道还存在原来的 C → D → E 分支。
JSONL 存储格式
树形消息在 JSONL 中仍然是逐行追加,每行多了 id 和 parentId:
{"id":"msg_001","parentId":null,"message":{"role":"user","content":"hello","timestamp":1718400000}}
{"id":"msg_002","parentId":"msg_001","message":{"role":"assistant","content":[...],"timestamp":1718400005}}
{"id":"msg_003","parentId":"msg_002","message":{"role":"user","content":"继续","timestamp":1718400010}}
{"id":"msg_004","parentId":"msg_002","message":{"role":"user","content":"换一种问法","timestamp":1718400020}}msg_003 和 msg_004 的 parentId 都是 msg_002 — 从同一个节点分出了两条路径。恢复时读取全部行,在内存中重建树结构。
不变量
- 只追加不修改 — 分支不删除旧消息,JSONL 文件只有 append 操作
- parentId 不可变 — 节点一旦写入,它的父节点关系永远不变
- Context 从任意叶子重建 — 给一个叶子 ID,就能通过 getActivePath 重建完整的 messages
这三条保证了:不管有多少分支,任何一条路径都能独立重建,互不干扰。
与线性 Session 的关系
线性 Session(第 09 章)是树形 Session 的特例:所有节点的 parentId 都指向前一个节点,没有分支。
线性: A(null) → B(A) → C(B) → D(C)
树形: A(null) → B(A) → C(B) → D(C)
↘ C'(B) → D'(C')如果你的应用不需要分支功能,用线性 Session 就够了(实现更简单)。但理解树形结构能帮你理解 Claude Code 和 Cursor 怎么实现"编辑历史消息后重新对话"的功能。
实现对话编辑
"编辑之前的消息"本质上就是 fork:
typescript
function editMessage(nodeId: string, newContent: string, store: StoredMessage[]): StoredMessage {
// 找到要编辑的节点
const node = store.find(n => n.id === nodeId);
if (!node) throw new Error("Node not found");
// 创建一个新节点,挂在同一个父节点下
// (不修改原节点 — 只追加的原则)
const editedNode: StoredMessage = {
id: generateId(),
parentId: node.parentId, // 和原节点共享同一个父节点
message: { ...node.message, content: newContent, timestamp: Date.now() },
};
store.push(editedNode);
return editedNode;
}编辑 = 在同一个 parentId 下创建新节点。原节点保留(历史不丢),新节点成为新的 active leaf。
真实场景:fork 是怎么发生的
上面讲了数据结构,但在真实产品里,fork 不是用户喊一声"请 fork"触发的。来看几个实际场景。
场景 1:用户编辑历史消息
最常见的 fork 触发方式。用户觉得之前某条消息问得不好,想改。
在 Claude.ai / ChatGPT 的 Web UI 中:用户点历史消息旁边的"编辑"按钮,修改内容,点"重新发送"。
在 CLI Agent 中,可以这样实现:
typescript
// 用户输入 /edit 命令
// 例如:/edit 3 换一种问法...
function handleEdit(editIndex: number, newContent: string, store: StoredMessage[], currentLeafId: string) {
// 1. 拿到当前 active path
const path = getActivePath(currentLeafId, store);
// 2. 找到要编辑的消息在 store 中的节点
// editIndex 是用户视角的序号(只数 user 消息)
const userMessages = path.filter((_, i) => {
const node = store.find(n => n.message === path[i]);
return node?.message.role === "user";
});
const targetNode = store.find(n => n.message === userMessages[editIndex]);
if (!targetNode) throw new Error("Message not found");
// 3. fork:在同一个 parentId 下创建新节点
const forkedNode: StoredMessage = {
id: generateId(),
parentId: targetNode.parentId, // 关键:挂在同一个父节点下
message: { role: "user", content: newContent, timestamp: Date.now() },
};
store.push(forkedNode);
// 4. 切换 active leaf 到新节点
return forkedNode.id; // 新的 leafId,后续 agent loop 从这里继续
}交互效果:用户编辑了第 3 条消息 → 系统 fork → 从新节点重新调用模型 → 模型基于修改后的上下文生成新回复。旧分支静默保留,不删除。
场景 2:工具执行失败,用户想回退重跑
这是 Agent 场景特有的问题。考虑这个对话历史:
user: "帮我重构这个文件"
assistant: [tool_use: edit_file(...)] ← 模型决定编辑文件
user(tool_result): "文件已修改" ← 工具执行结果
assistant: "重构完成,我修改了..." ← 模型总结用户看了结果不满意,想回到 "帮我重构这个文件" 之后让模型重新决策。
关键问题:不能 fork 到 assistant 消息上(因为下一条必须是 user 角色)。也不能 fork 到 tool_result 上(那样模型还是会看到那次工具调用的上下文)。正确的 fork 点是最后一条真正的 user 消息。
typescript
function forkBeforeToolUse(store: StoredMessage[], currentLeafId: string): string {
const path = getActivePathNodes(currentLeafId, store);
// 从当前叶子往回找,找到最近一条「真正的 user 消息」(不是 tool_result)
for (let i = path.length - 1; i >= 0; i--) {
const node = path[i];
if (node.message.role === "user" && !isToolResult(node.message)) {
// 找到了。fork 点是这条消息的父节点
// 创建一个新的 user 消息(内容可以一样,也可以加补充指令)
const forked: StoredMessage = {
id: generateId(),
parentId: node.parentId,
message: {
role: "user",
content: node.message.content + "\n\n(补充:上次的方案不好,试试别的思路)",
timestamp: Date.now(),
},
};
store.push(forked);
return forked.id;
}
}
throw new Error("No user message found to fork from");
}这里的关键认知:tool_result 消息虽然 role 是 user,但它不是用户写的,它是工具系统自动填的。fork 时必须区分"真正的用户输入"和"tool_result"。
场景 3:模型生成了多个候选回复
有些产品(如 Claude.ai 的 "retry")让用户对同一条输入重新生成回复。这也是 fork — 只不过 fork 的是 assistant 节点:
user: "解释量子计算"
├─ assistant(v1): "量子计算是..." ← 第一次生成
└─ assistant(v2): "从经典计算说起..." ← retry 生成(同一个 parentId)typescript
function retry(userNodeId: string, store: StoredMessage[]): string {
// 不创建新的 user 节点,直接在同一个 user 节点下发起新的模型调用
// 模型的回复会自动挂在 userNodeId 下,形成兄弟 assistant 节点
return userNodeId; // active leaf 回退到 user 节点,重新跑 agent loop
}分支管理:切换与展示
fork 多了之后,用户需要能切换分支。实现方式是维护一个 currentLeafId:
typescript
class SessionTree {
private store: StoredMessage[] = [];
private currentLeafId: string | null = null;
// 列出某个节点的所有子分支
getBranches(nodeId: string): StoredMessage[] {
return this.store.filter(n => n.parentId === nodeId);
}
// 切换到另一个分支的叶子
switchBranch(leafId: string): void {
this.currentLeafId = leafId;
// active path 自动变化 — getActivePath(leafId) 会返回新路径
}
// 找到某个分支的最深叶子(用于切换后继续对话)
findDeepestLeaf(nodeId: string): string {
const children = this.store.filter(n => n.parentId === nodeId);
if (children.length === 0) return nodeId; // 自己就是叶子
// 取最后一个子节点(最新的分支),递归找它的最深叶子
return this.findDeepestLeaf(children[children.length - 1].id);
}
}在 CLI 中可以暴露这些命令:
/branches — 列出当前节点的所有分支
/switch 2 — 切换到第 2 个分支
/edit 3 新内容 — 编辑第 3 条消息(自动 fork)
/retry — 重新生成当前回复Claude Code 的实际做法
Claude Code 的实现(简化):
- 编辑即 fork — 用户编辑任何历史消息时,自动在那条消息的 parentId 下创建新节点,切换 active leaf
- 旧分支静默保留 — 不删除、不提示,JSONL 里一直在。用户可以通过 UI 切回去
- Context 重建基于 leafId — 每次调用模型前,从 currentLeafId 回溯 getActivePath,拿到干净的线性 messages
- Compact(上下文压缩)也是 fork — 当上下文太长需要压缩时,压缩后的 summary 作为新的根节点,本质上也是在树上开辟新路径
第 4 点是个有趣的设计:context compaction 不是修改已有消息,而是创建一条 summary 消息作为新分支的起点。这样如果用户想回到压缩前的完整历史,只要切换回旧分支的叶子就行。
小结
会话树把线性 messages 演化为 DAG:每条消息有 id 和 parentId,支持从任意节点分支。Active path 是从根到当前叶子的路径(传给模型的 messages),所有分支在 JSONL 中平铺存储,只追加不修改。分支操作(fork)和编辑操作本质相同 — 在同一个 parentId 下创建新节点。不管树有多复杂,给定一个叶子 ID 就能通过 parentId 链重建完整的线性 messages。
真实场景中,fork 由用户编辑历史消息、retry、工具执行回退等操作触发。关键的实现细节是:fork 点必须选对(不能 fork 到 tool_result 上),tool_result 和真正的 user 消息要区分,分支切换只需要改 currentLeafId。
下一章
会话越来越长,但模型的上下文窗口是有限的。100 轮对话后 messages 可能有几万个 token,超过窗口大小。下一章来看怎样在不丢失会话历史的前提下,按预算构建传给模型的 Context。