对话的价值不在于实时,而在于可以随时恢复。
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 对象:
{"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——去掉开头的 /,用 - 替换所有路径分隔符,变成合法目录名。
// 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 标识符,在进程启动时生成并固定:
// 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 的算法极为简单:
// 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
}
算法:
- 读取整个 JSONL,建立
Map<uuid, message> - 找到最新的叶子节点(最后一条
type=user|assistant消息) - 从叶子沿
parentUuid向上遍历,收集所有祖先 - 反转数组,还原时间顺序
11.2.6 写入策略:带缓冲的追加队列
Claude Code 没有直接调用 fs.appendFile,而是通过内部 Project 类管理一个写入队列:
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 钩子会:
- 调用
project.flush()强制刷盘所有缓冲消息 - 调用
project.reAppendSessionMetadata()重新追加会话元数据(确保标题等字段在文件尾部,便于快速读取)
11.2.7 50MB 大文件保护
长期运行的会话 JSONL 可能很大。Claude Code 的应对策略:
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 中完成会话持久化模块。
接口规范(已提供,不要修改):
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[]
你需要实现:
getStorageDir()、getSessionFilePath()、ensureStorageDir():路径工具函数appendEntry():fs.appendFileSync(path, JSON.stringify(entry) + '\n')parseJSONL():按行解析,跳过空行和损坏行findLatestLeaf():收集所有被引用的 uuid,叶子 = 未被引用的节点buildChain():沿 parentUuid 向上遍历,用 seen Set 防循环loadSession():parseJSONL → findLatestLeaf → buildChain → map to ApiMessagelistSessions():扫描目录,读取每个 JSONL 的首条用户消息
验收
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 批次,降低系统调用开销;进程退出前强制刷盘,确保不丢消息。