# 第 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 本章小结