# 记忆宫殿：状态管理与持久化架构

Claude Code 的状态管理覆盖四个子系统——内存中的运行时 AppState、磁盘上的 JSONL 会话日志、文件历史快照、以及跨对话持久的 Memory 系统。它们各自服务不同的目的（运行时状态、会话恢复、文件回滚、长期记忆），而非层次化缓存。本章解析每个子系统的存储策略、一致性保证和设计权衡，揭示一个 CLI 工具如何管理比大多数 Web 应用更复杂的状态生命周期。

> **源码位置**：`src/state/`（6 个文件：AppState.tsx, AppStateStore.ts, onChangeAppState.ts, selectors.ts, store.ts, teammateViewHelpers.ts）、`src/utils/fileHistory.ts`（1000+ 行）、`src/memdir/`（记忆系统）、`src/utils/sessionStorage.ts`（JSONL 读写）

---

## 引子：RAM 和硬盘的分工

计算机有两种存储：RAM（内存）和硬盘。RAM 快但断电就丢，硬盘慢但永久保存。操作系统的工作之一就是管理这两者之间的数据流动——运行中的数据放 RAM，需要保留的数据写硬盘。

Claude Code 面临同样的问题。一次对话中有大量"运行时状态"——当前消息历史、权限决策、工具执行进度——这些在内存中。但用户关闭终端后还想继续对话（`/resume`），想回滚到之前的状态（`/rewind`），想让 AI 记住跨对话的信息（Memory 系统）——这些需要持久化。

> 💡 **通俗理解**：状态与持久化就像**游戏存档系统**——**当前进度**（AppState）= 内存中的游戏状态，随时可能丢失；**存档文件**（JSONL 会话）= 硬盘上的存档，关机也不丢；**快速存档点**（File History）= 每个关卡自动保存，可以随时读档回到任意关卡；**永久成就**（Memory）= 跨存档的全局记录，换个游戏档也带着。这四者**不是层次化缓存**（没有 evict/load 级联），而是各自服务不同目的：AppState 管"此刻"、JSONL 管"这次对话"、File History 管"这次修改"、Memory 管"跨对话的我"。

---

## 🌍 行业背景：AI Agent 的"记忆力"之战

状态管理和持久化是 AI 编程助手中最容易被忽视、但实际最影响用户体验的模块。一个 AI Agent 能不能"记住"上下文、能不能恢复中断的工作、能不能跨会话保留偏好——这些直接决定了它是"一次性工具"还是"长期搭档"。

**各家竞品的策略差异极大：**

- **Cursor**：状态寄生于 VS Code 的 workspace 机制。会话历史存在 IDE 内部的 SQLite 数据库中，依赖 VS Code 的生命周期管理。优点是与编辑器深度绑定，缺点是脱离 IDE 就完全失效——你无法在终端里恢复一个 Cursor 的对话。
- **Aider**：极简的文件级持久化。聊天历史写入 `.aider.chat.history.md`（纯 Markdown），输入历史写入 `.aider.input.history`。没有文件快照，没有分支，没有跨会话记忆——一个"够用就好"的最小方案。
- **Continue.dev**：会话存储在本地 SQLite 数据库（`~/.continue/sessions/`），支持会话列表和恢复。有基本的上下文管理，但没有文件历史回滚和跨会话记忆系统。
- **Claude Code**：一个内存运行时 + 三个独立的持久化子系统——AppState（内存运行时状态，进程退出即消失）、JSONL 会话存储（持久化）、File History 文件快照（持久化）、Memory 跨对话记忆（持久化），外加分支、回退、跨 session 硬链接复用等高级特性。注意：这四者各自服务不同目的，并非层次化缓存——它们之间没有 evict/load 的级联关系。严格来说只有后三者是"持久化子系统"，AppState 是纯内存的运行时状态树；本章把它们放在一起讲是因为它们共同构成了 CC 的状态管理全景。这是目前已知 AI 编程工具中状态管理最复杂、最完整的方案。

**Claude Code 的独到之处**在于：它不只是"保存聊天记录"，而是构建了一套完整的**时间旅行基础设施**——你可以回退文件到任意历史状态（`/rewind`），可以从对话中分叉出新路线（`/branch`），可以让 AI 永久记住你的偏好（Memory）。这些能力在竞品中要么不存在，要么需要用户手动管理。

---

## 1. 全局状态：AppState

`src/state/AppStateStore.ts` 定义了全局可变状态 `AppState`——整个应用运行时的"内存"。

```
AppState
  ├── 权限状态
  │   ├── permissionMode（当前权限模式）
  │   ├── permissionDenials（拒绝历史）
  │   └── approvedTools（已批准的工具列表）
  │
  ├── MCP 状态
  │   ├── mcpClients（已连接的 MCP 客户端）
  │   └── mcpClientStatus（连接状态）
  │
  ├── Task 状态
  │   └── tasks: { [taskId: string]: TaskState }（统一任务状态表）
  │
  ├── UI 状态
  │   ├── isCompacting（是否正在压缩上下文）（注：存于 statusLineText 等衍生状态）
  │   ├── mainLoopModel（当前模型）
  │   ├── companionReaction（小动物反应）
  │   └── companionPetAt（上次摸小动物的时间戳）
  │
  └── 会话状态
      ├── sessionId（当前会话 ID）
      ├── cwd（工作目录）
      └── totalUsage（累计 token 用量）
```

**更新模式**：函数式更新器模式（类似 Zustand）。`store.setState(prev => ({ ...prev, field: newValue }))`——调用方传入一个 `(prev: T) => T` 的纯函数，store 内部用 `Object.is()` 判断是否真正变化，变化后通知所有订阅者。这比 Redux 简单得多：没有 action type、没有 reducer 拆分、没有中间件——就是一个带订阅的函数式更新器（见 `src/state/store.ts`，仅 34 行）。

> 📚 **课程桥接**：AppState 对应计算机体系结构中**存储层次的最顶层——寄存器/L1 缓存**。它速度最快（内存直接访问）、容量最小（只存当前运行状态）、生命周期最短（进程退出即消失）。后面的 JSONL 会话、File History、Memory 各自服务于不同的目的（会话恢复、文件回滚、跨对话记忆），但并不构成严格的层次化缓存体系——它们之间没有上下层的 evict/load 关系。这里用"四层"作为教学类比帮助理解各部分的角色，而非表示它们像 CPU 缓存层次那样级联工作。
>
> **🎓 语气校准**：函数式更新器（functional updater）是 React 生态中 Zustand 等轻量状态库的**常见模式**，Claude Code 在这里没有发明新东西，而是正确地复用了前端社区经过充分验证的状态管理方案。

---

## 2. 会话存储：JSONL 文件

### 2.1 存储位置

```
~/.claude/projects/{项目路径 slug}/
  ├── {sessionId1}.jsonl    ← 会话 1 的完整消息历史
  ├── {sessionId2}.jsonl    ← 会话 2
  └── ...
```

> 项目路径 slug 由 `sanitizePath()`（`src/utils/sessionStoragePortable.ts:311`）生成：**把项目绝对路径里所有非字母数字字符替换为 `-`**（例：`/Users/foo/my-project` → `-Users-foo-my-project`）。**不是哈希**——是可读的文件名安全化。只有当路径超过文件系统名长限制（255 字节）时才会截断并追加短 hash 后缀以保证唯一性。

### 2.2 JSONL 格式

每行一个 JSON 对象——消息、工具调用、工具结果、元数据：

```jsonl
{"type":"user","content":"帮我看看 README.md"}
{"type":"assistant","content":[{"type":"text","text":"好的，我来读取文件"},{"type":"tool_use","name":"Read","input":{"file_path":"README.md"}}]}
{"type":"user","content":[{"type":"tool_result","tool_use_id":"xxx","content":"# README\n..."}]}
{"type":"assistant","content":[{"type":"text","text":"这个 README 包含..."}]}
```

### 2.3 为什么用 JSONL 而不是 JSON

- **追加友好**：新消息直接 append 到文件末尾，不需要读取→修改→重写整个文件
- **崩溃安全**：即使写入过程中崩溃，最多丢失最后一行——之前的完整对话不受影响
- **流式读取**：恢复会话时可以逐行读取，不需要把整个文件加载到内存
- **大小可控**：长对话产生大文件，但 JSONL 格式让每行独立——可以逐行处理

**比喻**：JSONL 就像记账用的流水账本——每笔交易写一行，永远只往后写，不修改之前的记录。出了问题只看最后几行就能定位。

> 📚 **课程桥接**：JSONL 会话文件的设计思想直接对应数据库中的 **WAL（Write-Ahead Log，预写日志）**。WAL 的核心原则是"只追加、不修改"——PostgreSQL 用 WAL 保证事务崩溃恢复，Redis 用 AOF（Append-Only File）做持久化，Claude Code 用 JSONL 做会话恢复。三者是同一个思想的不同实例。区别在于：数据库的 WAL 有 checkpoint 机制定期压缩，而 Claude Code 的 JSONL 永远不压缩——因为对话历史需要完整保留。
>
> **🎓 语气校准**：选择 JSONL 而非 SQLite 是一个**务实但非独创的决策**。很多日志系统和消息队列都使用追加写入文件。Claude Code 的独到之处不在格式本身，而在于**将这个简单格式与 /resume、/rewind、/branch 三个高级命令串联起来**，构成了一个完整的会话生命周期管理方案。

---

## 3. 文件历史：时间旅行

File History 系统（`src/utils/fileHistory.ts`，1000+ 行）让用户可以"回到过去"——`/rewind` 到任意一条 AI 消息之后的文件状态。

### 3.1 工作原理

```
Edit/Write 工具修改文件前
  → fileHistoryTrackEdit()
    → 保存文件的原始内容（快照）

AI 完成一条消息后
  → fileHistoryMakeSnapshot()
    → 记录此刻所有被追踪文件的状态

用户执行 /rewind
  → fileHistoryRewind(messageId)
    → 找到对应快照
    → 恢复所有文件到那个时刻的状态
```

### 3.2 级联去重（compareStatsAndContent）

为了避免冗余备份，`compareStatsAndContent` 函数实现了级联检查，逐级递进：

1. **存在性快速排除**：如果原文件和备份一个存在一个不存在 → 直接判定为"已变化"；都不存在 → 判定为"未变化"——不需要读内容
2. **size/mode 快速排除**：比较文件大小和权限位，任一不同即判定为"已变化"——只读 stat 元数据，零 I/O
3. **mtime 优化**：如果原文件的 `mtimeMs` 早于备份的 `mtimeMs`，说明原文件在备份之后未被修改，跳过内容比较
4. **内容字节比较**：以上都无法判定时，才实际读取两个文件的内容逐字节比较

此外，每个文件有独立的 v1, v2, v3... 版本号计数器，只在内容真正变化时递增。

### 3.3 存储格式

```
~/.claude/file-history/{sessionId}/
  ├── a1b2c3d4e5f67890@v1    ← 某文件的第一个版本
  ├── a1b2c3d4e5f67890@v2    ← 同一文件的第二个版本
  ├── 9f8e7d6c5b4a3210@v1    ← 另一个文件
  └── ...
```

文件名是**路径的 SHA256 哈希**（前 16 字符，即 16 个十六进制字符）+ 版本号。哈希的是路径而非内容——给定路径就能快速通过一次哈希查找到备份位置（命名策略意义上的"O(1) 查找"），但目录扫描或列举某文件所有版本仍是 O(n)。

### 3.4 Hard-Link 跨 Session 复用

当恢复之前的 session（`/resume`）时，系统使用 `link()`（硬链接）而不是 `copyFile()` 来复用之前的备份：
- 零额外磁盘开销（硬链接共享 inode）
- 失败回退到 `copyFile()`（跨文件系统时硬链接不工作）

### 3.5 100 快照上限

`MAX_SNAPSHOTS = 100`，FIFO 清理。超过 100 个快照时，最旧的自动移除。

**比喻**：游戏存档。系统在每个"关卡"（AI 消息）后自动存档，最多保留 100 个存档点。存档满了自动删最旧的。你可以随时"读档"回到任意存档点。

> 📚 **课程桥接**：File History 的快照机制对应数据库领域的**事务日志 + 快照隔离（Snapshot Isolation）**。每次 Edit/Write 前先保存原始内容 = 数据库修改前先写 undo log；按消息粒度创建快照 = 数据库的 savepoint；/rewind 回滚 = 数据库的 ROLLBACK TO SAVEPOINT。级联去重（存在性 → size/mode → mtime → 内容比较）则类似数据库的 **MVCC（多版本并发控制）** 中"只在数据真正变化时创建新版本"的思想。
>
> **🎓 语气校准**：路径哈希命名 + 版本号递增是**路径寻址 + 版本化**的标准命名策略（注意：这不是 Git 的"内容寻址存储"——Git 用内容 SHA 作键，同内容不同路径共享存储；这里用路径 SHA 作键，相同路径的多次变更用 `@v1/@v2` 区分版本）。但 **Hard-link 跨 session 复用**是一个精巧的独到设计——大多数工具在恢复历史会话时会复制文件，Claude Code 用硬链接实现了零磁盘开销的跨会话引用。这个优化在竞品中尚未见到。

---

## 4. 对话分支：Git for Conversations

`/branch` 命令让用户可以在对话中创建分支——从某条消息开始，走一条不同的路。

### 4.1 实现原理

每条消息有两个 ID：
- `uuid`：自己的唯一标识
- `parentUuid`：父消息的标识

分支 = 创建一条新消息，它的 `parentUuid` 指向你想分叉的那条消息。后续对话从这里开始走不同的路径。

```
消息 A → 消息 B → 消息 C → 消息 D（主线）
                    ↓
                 消息 C' → 消息 D'（分支）
```

### 4.2 Prompt Cache 的保护

分支时有一个关键细节：必须复制 `content-replacement` 记录（这是系统在上下文压缩/工具结果替换等场景下，用来把原始消息内容替换为摘要或占位符的标记；两个分支如果共享同一条被压缩过的历史消息，必须都带着这条"替换已发生"的元数据，否则服务端计算出的缓存前缀会不一致）。如果不复制，新分支的消息前缀和主线不一致，Prompt Cache 会完全 miss——每次 API 调用都要付全额 token 费用。

这是一个"不复制也能工作，但会悄悄浪费钱"的 Bug——不会崩溃，只会让账单变高。

---

## 5. Memory 系统：跨对话的永久记忆

Memory 系统（`src/memdir/`）让 AI 可以在对话之间保留信息——不是在某次会话中记住，而是**永久记住**。

### 5.1 四种记忆类型

| 类型 | 用途 | 例子 |
|------|------|------|
| `user` | 关于用户的信息 | "用户是高级后端工程师" |
| `feedback` | 用户的偏好指导 | "不要在回答末尾总结" |
| `project` | 项目相关信息 | "本周三有代码冻结" |
| `reference` | 外部资源指针 | "Bug 追踪在 Linear INGEST 项目" |

### 5.2 存储结构

```
~/.claude/projects/{项目路径 slug}/memory/
  ├── MEMORY.md           ← 索引文件（被加载到 system prompt）
  ├── user_role.md        ← 一条 user 类型的记忆
  ├── feedback_testing.md ← 一条 feedback 类型的记忆
  └── ...
```

### 5.3 KAIROS 日志模式

记忆系统使用日期路径模板（`logs/YYYY/MM/YYYY-MM-DD.md`）而不是字面日期字符串。

**为什么**：源码注释明确说明了原因（`memdir.ts` 第 329–334 行）——memory prompt 被 `systemPromptSection('memory', ...)` 缓存，跨日后**不会**重新生成。如果 prompt 中内嵌了 "今天是 2026-04-02" 这样的字面量，日期变化就会破坏 Prompt Cache 前缀匹配，导致每次 API 调用都要付全额 token 费用。用路径模板代替字面日期，模型从 `date_change` 附件中获取当前日期，prompt 本身保持稳定——这是一个**Prompt Cache 友好性优化**，让 system prompt 跨日不失效。

> 📚 **课程桥接**：Memory 系统对应操作系统中的**虚拟内存与页面置换**。操作系统不可能把所有程序的数据都放在物理内存中，所以它把不常用的页面换出到磁盘（swap），需要时再换入。Claude Code 的 Memory 系统做了类似的事：不可能把所有历史对话的上下文都塞进 system prompt（物理内存有限），所以它把跨会话的关键信息提取出来，以独立文件的形式存储在磁盘上（~/.claude/projects/.../memory/），每次对话启动时按需加载到 system prompt 中（换入）。MEMORY.md 索引文件 = 操作系统的页表，各 .md 记忆文件 = 被换出到磁盘的页面。
>
> **🎓 语气校准**：KAIROS 日志模式（用路径模板代替字面日期以保护 Prompt Cache）是一个**独到设计**——这种"为缓存友好性而调整 prompt 格式"的思路在 AI Agent 工具中尚未普遍出现。其本质是 Prompt Cache 前缀匹配优化，与 WAL（预写日志）无直接关系。Memory 的四种类型分类（user/feedback/project/reference）则是**标准的知识管理分类法**，并无特别之处。

### 5.4 Team Memory：团队共享记忆

除了个人 Memory，Claude Code 还实现了一套 **Team Memory** 系统（`src/memdir/teamMemPaths.ts` + `src/services/teamMemorySync/`），让同一个仓库的团队成员可以**共享记忆**。

- **存储位置**：`~/.claude/projects/{项目路径 slug}/memory/team/MEMORY.md`，作为个人 Memory 的子目录（slug 规则同 §2.1）
- **同步机制**：通过 API 与服务器同步（pull 时服务器覆盖本地，push 时只上传哈希不同的条目）
- **安全防护**：写入路径经过严格的符号链接解析和路径遍历检查（`validateTeamMemWritePath`），防止 symlink 逃逸攻击；上传前会扫描敏感信息（`secretScanner.ts`）
- **删除策略**：本地删除文件不会传播到服务器，下次 pull 时会恢复——这是有意为之的保守设计，避免误删影响其他人

---

## 6. 会话存储的大文件优化

`sessionStoragePortable.ts` 是会话存储的可移植层——它不依赖任何内部模块（无日志、无实验、无特性开关），可在 CLI 和 VS Code 扩展之间共享。

其中一个值得注意的优化是 **head-and-tail 读取策略**（`readHeadAndTail`）：对于大型 JSONL 会话文件，不读取整个文件，而是只读取头部和尾部各 64KB（`LITE_READ_BUF_SIZE = 65536`）。这使得列出会话列表、提取首条 prompt 等操作在面对几十 MB 甚至更大的会话文件时仍然快速。

另一个设计是**无需完整 JSON 解析的字段提取**（`extractJsonStringField` / `extractLastJsonStringField`）：用正则直接从原始文本中提取 `"key":"value"` 模式，避免对每行做完整的 `JSON.parse()`。这在处理大量 JSONL 行时减少了 GC 压力。

---

## 7. 状态的一致性保证

### 7.1 什么是一致的

- **JSONL 会话文件**：追加写入 + 崩溃安全 = 最多丢失最后一条消息
- **文件历史快照**：在 Edit/Write 之前创建 = 保证有"修改前"的版本
- **全局状态**：函数式更新器 + `Object.is()` 判断 = 不会出现半更新状态

### 7.2 什么不是一致的

- **Scratchpad**（Agent 间共享文件）：无锁，并发写可能丢失数据
- **Memory 文件**：手动管理，没有事务性保证
- **文件历史的 mtime 优化**：依赖文件系统时间戳精度——在 NFS 等网络文件系统上可能不可靠

---

## 8. 设计取舍

### 优秀

1. **JSONL 的选择**正好匹配"追加为主、偶尔全读"的对话历史访问模式——比 SQLite 简单，比 JSON 安全
2. **文件历史的级联去重**（存在性→size/mode→mtime→内容字节比较）让快照成本几乎为零——大多数时候什么都不需要做
3. **路径哈希命名**让备份查找是 O(1)——不需要维护索引
4. **Hard-link 跨 session 复用**是零成本的空间优化——优雅
5. **分支时保护 Prompt Cache**说明团队对"隐性成本"有敏锐的意识——这种 Bug 不会崩溃，只会让账单变高

### 代价与局限

1. **JSONL 没有索引**——恢复长对话需要扫描整个文件。如果会话超过 10,000 轮，加载时间可能成为问题
2. **100 快照上限**对长 session 可能不够——几百轮对话后，早期修改无法回退。然而增大上限会增加磁盘占用
3. **路径哈希意味着文件重命名被视为新文件**——重命名前的历史不关联到新路径，这是一个已知的边界情况
4. **Memory 系统是 AI 自管理的**——AI 可能写入不准确的记忆，风险在于没有人类审核机制，如果记忆错误可能导致后续对话失败
5. **Scratchpad 无锁**——在高并发的 Swarm 模式下可能产生数据竞争，复杂性被有意忽略以换取简单性

---

## 代码落点

- `src/state/store.ts`（34 行）：通用的函数式更新器 store 实现——`createStore()`、`setState()`、`subscribe()`
- `src/state/AppStateStore.ts`：全局 AppState 类型定义（`AppState` 类型）和默认值（`getDefaultAppState()`）
- `src/state/selectors.ts`：状态选择器函数（从全局 state 中提取特定字段）
- `src/utils/sessionStorage.ts`：JSONL 会话文件的读写逻辑——追加写入、逐行解析
- `src/utils/sessionStoragePortable.ts`：可移植的会话存储工具——head-and-tail 读取、无解析字段提取
- `src/utils/fileHistory.ts`，第 1 行：File History 完整实现——`fileHistoryTrackEdit()`、`fileHistoryMakeSnapshot()`、`fileHistoryRewind()`
- `src/utils/fileHistory.ts`，约第 640 行：`compareStatsAndContent()` 级联去重逻辑
- `src/utils/fileHistory.ts`，约第 50 行：`MAX_SNAPSHOTS = 100` 常量定义
- `src/memdir/memdir.ts`：Memory 系统入口——`loadMemoryPrompt()` 加载记忆到 system prompt
- `src/memdir/teamMemPaths.ts`：Team Memory 路径管理与安全校验
- `src/services/teamMemorySync/index.ts`：Team Memory 的服务器同步逻辑
