第 11 章:会话持久化与历史回放

# 第 11 章:会话持久化与历史回放

> 对话的价值不在于实时,而在于可以随时恢复。

---

## 11.1 核心问题

第 10 章的 Agent 能记住用户偏好,但对话记录本身却不持久:

**问题:关掉终端 = 丢失上下文**

```
第一次会话(一小时的调试工作):
  用户:帮我分析这个性能问题
  Agent:好的。先查一下 ...(20 轮来回后)
  Agent:找到了!是 N+1 查询,在 order.ts 第 47 行
  用户:(关闭终端,第二天继续)

第二次会话:
  用户:继续昨天的工作
  Agent:我没有昨天对话的记录,请重新描述问题…
```

LLM 是无状态的 HTTP 服务——每次 API 调用独立,对话历史完全由**调用方**在请求体里维持。Claude Code 必须把所有消息持久化到磁盘,在 `--resume` 时把它们重新塞回 `messages` 数组。

---

## 11.2 原理讲解

### 11.2.1 存储格式:JSONL

Claude Code 用 **JSONL(JSON Lines)** 格式存储对话——每行一个 JSON 对象:

```jsonl
{"type":"user","uuid":"a1b2...","parentUuid":null,"sessionId":"sess-01","timestamp":"2024-01-15T10:00:00Z","message":{"role":"user","content":[{"type":"text","text":"帮我分析性能问题"}]}}
{"type":"assistant","uuid":"c3d4...","parentUuid":"a1b2...","sessionId":"sess-01","timestamp":"2024-01-15T10:00:05Z","message":{"role":"assistant","content":[{"type":"text","text":"好的,先看一下 ..."}]}}
{"type":"user","uuid":"e5f6...","parentUuid":"c3d4...","sessionId":"sess-01","timestamp":"2024-01-15T10:00:30Z","message":{"role":"user","content":[{"type":"text","text":"继续查一下数据库层"}]}}
```

JSONL 的核心优势:
- **追加写入**:新消息直接 `append`,无需加锁或读取整个文件
- **流式读取**:按行解析,不需要把整个文件加载进内存
- **容错**:单行损坏不影响其他行(`JSON.parse` 失败跳过该行)

### 11.2.2 存储路径

```
~/.claude/
└── projects/
    └── Users-zhang-mywork-my-project/   ← sanitizePath(cwd)
        ├── <session-id-1>.jsonl          ← 会话 A 的完整对话记录
        ├── <session-id-2>.jsonl          ← 会话 B 的完整对话记录
        └── <session-id-3>/               ← 子 agent 目录
            └── subagent-<id>.jsonl
```

**`sanitizePath(cwd)` 的变换规则**:将绝对路径 `/Users/zhang/mywork/my-project` 转为 `Users-zhang-mywork-my-project`——去掉开头的 `/`,用 `-` 替换所有路径分隔符,变成合法目录名。

```typescript
// src/utils/sessionStorage.ts
export function getProjectsDir(): string {
  return join(getConfigDir(), 'projects')         // ~/.claude/projects/
}

export const getProjectDir = memoize((projectDir: string): string => {
  return join(getProjectsDir(), sanitizePath(projectDir))
  // e.g. ~/.claude/projects/Users-zhang-mywork-project/
})

export function getTranscriptPath(): string {
  const projectDir = getProjectDir(getOriginalCwd())
  return join(projectDir, getSessionId() + '.jsonl')
  // e.g. ~/.claude/projects/Users-zhang-project/abc-123.jsonl
})
```

`getProjectDir` 被 `memoize` 包裹——同一个 `cwd` 字符串只计算一次(每轮 turn 会被调用 12+ 次)。

### 11.2.3 会话 ID:UUID v4

每个会话有一个唯一的 **UUID v4** 标识符,在进程启动时生成并固定:

```typescript
// src/bootstrap/state.ts(简化)
let sessionId: string | null = null

export function getSessionId(): string {
  if (!sessionId) {
    sessionId = crypto.randomUUID()  // v4 UUID
  }
  return sessionId
}
```

UUID 既是文件名(`<uuid>.jsonl`),也嵌入每条消息的 `sessionId` 字段,确保即使多个会话的 JSONL 混在同一目录下也能区分归属。

### 11.2.4 消息链:parentUuid 树

JSONL 中的每条消息都有 `uuid` 和 `parentUuid` 字段,形成一棵**链表树**:

```
null ← (root)
  │
  ▼
msg-A (用户:分析性能)
  │
  ▼
msg-B (助手:好的,先看…)
  │
  ▼
msg-C (用户:继续查数据库)
  │
  ▼
msg-D (助手:找到了,N+1 查询)
```

**为什么是树而不是链表?**  
因为用户执行 `/rewind` 时,历史消息**不会删除**,而是从 `msg-C` 处开辟新分支:

```
msg-A → msg-B → msg-C → msg-D  ← 旧分支(dead branch)
                    ↓
                   msg-E (用户:换个思路,看索引)  ← 新分支
                    ↓
                   msg-F (助手:发现缺少索引)
```

JSONL 文件里 `msg-D` 仍然存在,但 `buildConversationChain` 从最新叶节点 `msg-F` 沿 `parentUuid` 向上追溯,永远不会走到 `msg-D`。这种设计让追加操作完全不需要修改已写内容。

### 11.2.5 读取策略:从叶子向上追溯

恢复会话时,核心函数 `buildConversationChain` 的算法极为简单:

```typescript
// src/utils/sessionStorage.ts(简化)
export function buildConversationChain(
  messages: Map<UUID, TranscriptMessage>,
  leafMessage: TranscriptMessage,
): TranscriptMessage[] {
  const transcript: TranscriptMessage[] = []
  let current: TranscriptMessage | undefined = leafMessage

  while (current) {
    transcript.push(current)
    current = current.parentUuid
      ? messages.get(current.parentUuid)
      : undefined
  }

  transcript.reverse()  // 从旧到新排列
  return transcript
}
```

算法:
1. 读取整个 JSONL,建立 `Map<uuid, message>`
2. 找到**最新的叶子节点**(最后一条 `type=user|assistant` 消息)
3. 从叶子沿 `parentUuid` 向上遍历,收集所有祖先
4. 反转数组,还原时间顺序

### 11.2.6 写入策略:带缓冲的追加队列

Claude Code 没有直接调用 `fs.appendFile`,而是通过内部 `Project` 类管理一个**写入队列**:

```typescript
class Project {
  private writeQueues = new Map<string, Array<{entry: Entry; resolve: () => void}>>()
  private FLUSH_INTERVAL_MS = 100   // 100ms 批量写入
  private MAX_CHUNK_BYTES = 100 * 1024 * 1024  // 100MB 单次写入上限

  // 消息入队,100ms 后批量写入
  enqueue(filePath: string, entry: Entry): Promise<void>

  // 显式触发刷盘(进程退出前调用)
  async flush(): Promise<void>
}
```

**设计意图**:Bun 的 `appendFile` 每次都是一次系统调用。LLM 流式输出时每个 token 都触发一次 `saveMessage`,如果每次都立刻落盘,I/O 压力会很大。100ms 的批量写入将多次写合并为一次 `appendFile`,显著降低系统调用次数。

进程退出时,注册的 `cleanup` 钩子会:
1. 调用 `project.flush()` 强制刷盘所有缓冲消息
2. 调用 `project.reAppendSessionMetadata()` 重新追加会话元数据(确保标题等字段在文件尾部,便于快速读取)

### 11.2.7 50MB 大文件保护

长期运行的会话 JSONL 可能很大。Claude Code 的应对策略:

```typescript
export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024  // 50MB

// loadTranscriptFile 内部:超过阈值时只读 postBoundary 部分
if (size > SKIP_PRECOMPACT_THRESHOLD) {
  const scan = await readTranscriptForLoad(filePath, size)
  buf = scan.postBoundaryBuf  // 只解析最后一个 compact 边界之后的内容
  // pre-boundary 的会话元数据(标题、模式等)另外用轻量字节扫描恢复
}
```

`/compact` 命令会在 JSONL 中插入一个 **compact boundary**(压缩边界)条目,之前的冗长消息被摘要替代。加载时直接从边界之后开始解析,不需要反序列化几十兆的历史内容。

---

## 11.3 源码索引

| 文件 | 关键函数 | 作用 |
|------|---------|------|
| `src/utils/sessionStorage.ts` | `getProjectsDir()` | 返回 `~/.claude/projects/` |
| `src/utils/sessionStorage.ts` | `getProjectDir(cwd)` | 返回项目目录(memoized) |
| `src/utils/sessionStorage.ts` | `getTranscriptPath()` | 返回当前会话的 .jsonl 路径 |
| `src/utils/sessionStorage.ts` | `loadTranscriptFile(path)` | 解析 JSONL,返回消息 Map |
| `src/utils/sessionStorage.ts` | `buildConversationChain()` | 从叶子追溯还原对话链 |
| `src/utils/sessionStorage.ts` | `loadMessageLogs(limit?)` | 列出所有历史会话摘要 |
| `src/utils/sessionStorage.ts` | `sessionIdExists(id)` | 检查会话文件是否存在 |
| `src/bootstrap/state.ts` | `getSessionId()` | 获取/生成当前会话 UUID |

---

## 11.4 最小化产出物

> 代码骨架位于 `../chapters/11/src/`,参考实现位于 `../chapters/11/solution/`。
> **前置条件**:需要 `ANTHROPIC_API_KEY` 环境变量(`npm start` 需要)。

### 本章要实现什么

在 `../chapters/11/src/storage.ts` 中完成会话持久化模块。

**接口规范**(已提供,不要修改):

```typescript
export function getStorageDir(): string          // ~/.mini-agent
export function getSessionFilePath(id: string): string
export function ensureStorageDir(): void

export function appendEntry(sessionId: string, entry: JournalEntry): void
// 追加一行 JSON 到 JSONL 文件(原子操作)

export function parseJSONL(sessionId: string): Map<string, JournalEntry>
// 解析 JSONL,返回 Map<uuid, entry>,损坏行跳过

export function findLatestLeaf(entries: Map<string, JournalEntry>): JournalEntry | null
// 找没有被引用的节点(叶子),取时间戳最新的

export function buildChain(entries: Map<string, JournalEntry>, leaf: JournalEntry): JournalEntry[]
// 从叶子沿 parentUuid 向上追溯,reverse() 后返回

export function loadSession(sessionId: string): ApiMessage[]
export function listSessions(): SessionSummary[]
```

**你需要实现**:
1. `getStorageDir()`、`getSessionFilePath()`、`ensureStorageDir()`:路径工具函数
2. `appendEntry()`:`fs.appendFileSync(path, JSON.stringify(entry) + '\n')`
3. `parseJSONL()`:按行解析,跳过空行和损坏行
4. `findLatestLeaf()`:收集所有被引用的 uuid,叶子 = 未被引用的节点
5. `buildChain()`:沿 parentUuid 向上遍历,用 seen Set 防循环
6. `loadSession()`:parseJSONL → findLatestLeaf → buildChain → map to ApiMessage
7. `listSessions()`:扫描目录,读取每个 JSONL 的首条用户消息

### 验收

```bash
cd docs/chapters/11
npm install
npm test
```

卡住时查看 `../chapters/11/solution/storage.ts`。

---

## 11.5 本章小结

**JSONL = 追加日志**:对话被写成只增不改的日志文件。每行一个 JSON 对象,`appendFile` 是原子操作,不需要锁。

**`parentUuid` 链 = 不可变树**:消息之间通过 `parentUuid` 链接,形成树形结构。`/rewind` 不删除旧消息,只是开辟新分支。`buildConversationChain` 从最新叶子向上追溯,自动忽略死分支。

**`sanitizePath` = 文件系统友好的项目哈希**:工作目录路径被转换为合法目录名,不同项目的会话自动隔离。

**缓冲写入 = 性能优化**:`Project` 类将频繁的小写合并为 100ms 批次,降低系统调用开销;进程退出前强制刷盘,确保不丢消息。

---

## 下一章

→ [第 12 章:斜杠命令与 Skills 系统](12-slash-commands)

## 11.5 本章小结