# Step 09: 会话持久化

一句话导读:给对话装上「存档/读档」按钮——用一个 20 行的 JSON 读写模块,把只活在内存里的对话变成能跨进程恢复的「检查点」,并第一次正视「上下文塞不下」这个 AI 应用的命门。


# 一、这一步做了什么(What)

到 step08 为止,对话只在内存里,关掉终端等于失忆。step09 把它落盘:

  1. utils/storage.ts:saveSession / loadSession / listSessions 三个函数,往 .sessions/ 目录读写 JSON;
  2. 新增五个命令:/save <name>、/load <name>、/sessions、/history [n];
  3. 上下文超长警告:轮次超过 MAX_HISTORY_WARN = 20 时提示「可 /save 后 /clear」;
  4. .gitignore:排除 .sessions/ 和 .env,避免会话和密钥进 git。

核心逻辑不到 20 行,却构成了完整的会话 CRUD。


# 二、面试官视角:为什么要做?(Why)

面试题:AI 对话为什么特别需要「存档/读档」?普通聊天记录不就是往数据库里塞条记录吗,有什么特殊的?

因为 AI 对话有个普通聊天没有的痛点:上下文既是资产又是负债,而且随时可能被污染。

场景 没有存档 有存档(检查点模式)
对话跑偏 / 被污染 只能全清重来,历史资产全丢 /load 回到污染前的检查点
上下文塞满 被迫丢历史,进退两难 /save 归档后 /clear,需要时再 load
换台机器 / 重启 全部上下文蒸发 从磁盘恢复
想探索一个分支 不敢,怕污染主线 save 一个检查点,随便试,失败就 load 回来

关键洞察:一次错误的对话会污染整条上下文,而模型没有「撤销」。存档就是软件工程里的「快照/回滚」搬到对话上——/save + /load 本质是检查点(checkpoint)模式。这也是为什么它比普通聊天记录重要得多。


# 三、原理:它是怎么工作的(How)

# 存储就是一个 JSON 文件

saveSession 把 { savedAt, turnCount, history } 序列化写盘,ensureDir 保证目录存在:

const SESSION_DIR = join(process.cwd(), '.sessions');

export function saveSession(name, history, turnCount): string {
  ensureDir();
  const data: SessionData = {
    savedAt: new Date().toISOString(),
    turnCount,
    history,
  };
  const path = join(SESSION_DIR, name + '.json');
  writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8');
  return path;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

loadSession 反过来,读不到就返回 null(而不是抛异常,把「没这个会话」当正常分支处理):

export function loadSession(name): SessionData | null {
  const path = join(SESSION_DIR, name + '.json');
  if (!existsSync(path)) return null;   // ← 缺失是正常情况,不是错误
  return JSON.parse(readFileSync(path, 'utf-8'));
}

export function listSessions(): string[] {
  if (!existsSync(SESSION_DIR)) return [];
  return readdirSync(SESSION_DIR)
    .filter(f => f.endsWith('.json'))
    .map(f => f.replace('.json', ''));   // 去掉扩展名,还原会话名
}
1
2
3
4
5
6
7
8
9
10
11
12

# 加载是「原地替换」而非「追加」

/load 的关键是先清空当前 history 再灌入,否则会把两段对话拼在一起:

if (input.startsWith('/load')) {
  const data = loadSession(name);
  if (!data) { showError('未找到会话: ' + name); continue; }
  history.length = 0;              // ← 先清空,这一步不能少
  history.push(...data.history);   // 再灌入存档内容
  turnCount = data.turnCount;      // 连轮次一起恢复
  showSuccess('已加载: ' + name + ' (' + data.turnCount + ' 轮)');
}
1
2
3
4
5
6
7
8

# 数据流

/save mytask
   ↓
saveSession → .sessions/mytask.json  { savedAt, turnCount, history }

(关掉终端、换机器、或 /clear 之后)

/load mytask
   ↓
loadSession → 读 JSON
   ↓
history.length=0 → push(...data.history) → turnCount=data.turnCount
   ↓
对话状态完整恢复,继续聊
1
2
3
4
5
6
7
8
9
10
11
12
13

# 超长警告:把「负债」显性化

const MAX_HISTORY_WARN = 20;
if (turnCount >= MAX_HISTORY_WARN) {
  showInfo('上下文较大 (' + turnCount + ' 轮),可 /save 后 /clear');
}
1
2
3
4

它不自动截断,只提醒。这是刻意的——把「怎么处理超长」的决策权留给用户(save 归档?还是 clear 重来?),对应真实版 services/compact/ 的自动压缩机制的雏形。


# 四、深入追问(面试常见 follow-up)

Q:/load 前为什么必须 history.length = 0?直接 push 不行吗? A:不行。history 是同一个数组引用,直接 push 会把存档内容追加到当前对话后面,导致两段无关对话拼接、上下文错乱。必须先清空到长度 0 再灌入,保证「加载 = 原地替换当前状态」。

Q:loadSession 找不到文件时返回 null 而不抛异常,为什么? A:因为「会话不存在」是可预期的正常分支,不是程序错误。用返回值 null 让调用方用 if (!data) 优雅处理并给友好提示;用异常则会打断控制流、还得包 try/catch。异常应留给「真正意外」(如磁盘损坏、JSON 解析失败)。

Q:为什么要把 .sessions/ 和 .env 加进 .gitignore? A:.env 含 API Key,进 git 就是密钥泄露;.sessions/ 是本地运行时数据,属于个人对话记录,既没有版本管理价值,还可能含敏感内容。运行产物和密钥都不该进版本库,这是安全基线。

Q:这套 JSON 存储离生产级差在哪? A:差不少。真实 sessionStorage.ts 还有加密(对话可能含敏感信息)、压缩(历史大了 JSON 会膨胀)、跨平台路径处理(Windows/macOS 存储位置不同)、旧会话自动清理、格式迁移等。我们只做了最核心的 CRUD 骨架。


# 五、踩坑 / 设计权衡

  • savedAt 用 ISO 字符串而非 Date 对象:因为 JSON.stringify 会把 Date 转成字符串,但读回来是字符串不是 Date;直接存 toISOString() 让「存进去」和「读出来」类型一致,避免隐蔽的类型 bug。
  • 警告而不自动截断:自动丢历史可能丢掉用户还需要的上下文。把决策权交还用户(save/clear/继续),比替他做主更稳妥——这是「可观测优先于自动干预」的取舍。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
storage.ts 三函数裸 JSON 读写 utils/sessionStorage.ts + sessionStoragePortable.ts 跨平台
明文 JSON 加密 + 压缩存储
超长只警告,手动 /clear services/compact/(15 个文件)自动压缩历史
.sessions/ 平铺文件 结构化存储 + 旧会话自动清理 + 格式迁移

# 七、一句话总结

step09 = 给对话装检查点:20 行 JSON 读写实现 save/load/list 的完整 CRUD,靠「先清空再灌入」保证加载是原地替换,靠「找不到返回 null」区分正常分支与真异常,并第一次把「上下文是有限负债」这件事显性化——为后续的对话压缩埋下伏笔。

# 下一节预告

Phase 1 收官。step10 不写新功能,而是翻开真实 Claude Code 源码,把我们这 9 步和它逐项对照,看看我们的骨架和它的血肉差在哪。