Claude Code 源码深度讲解 · 必修篇 5/5

Session 存储 / Resume / Transcript

长会话 agent 的存在本身依赖于持久化。Claude Code 用append-only JSONL + 冗余 metadata + 懒验证把"几百轮对话跨天跨设备恢复"做得像"打开上次的编辑器"一样丝滑。

01引言:长会话必须持久化

一个严肃的 coding agent 经常跑几十轮甚至上百轮,一次任务可能跨天。没有持久化就没有产品。

1.1 持久化要解决的 4 个问题

问题Claude Code 机制
Crash 后恢复JSONL append 流式追加,任何时刻都是一致的前缀
关闭重开claude --resume 列出所有 session 让用户选
审计回溯transcript 文件就是完整审计日志,可 grep
Prompt cache 保持contentReplacementState 持久化,replay 后字节一致

1.2 为什么这个话题被低估

持久化是面试常被忽略的工程硬菜
大多数 agent 教程只讲 tool loop,不讲"进程崩了怎么办"。 但生产环境 50% 的 bug 都跟持久化/恢复有关: transcript 格式变更、replay 时的边缘情况、跨设备路径、prompt cache 失效 ... 搞清楚这个话题直接让你从"做 demo"晋升到"上生产"。

02为什么是 JSONL

JSONL(JSON Lines)= 每行一个 JSON 对象。看似简单,但它是生产级持久化的最佳选择。

2.1 JSONL vs JSON 数组对比

特性JSONLJSON 数组
写入方式appendFile('line\n')读 → push → 整体写
写入成本O(1) — 只写新增部分O(n) — 要重写整个文件
并发安全系统调用原子写一行(<4KB)读-改-写窗口期易 race
损坏恢复坏的行丢弃,其他行仍可读一处损坏整个文件废
流式读取逐行解析必须整个 load
grep/sed直接工作需要 jq

2.2 写入的原子性

// Node.js fs.writeSync 调 POSIX write(2) — 对 <PIPE_BUF(4KB)原子
writeSync(fd, jsonStringify(entry) + '\n')

// 但 Claude Code entry 可能超过 4KB(大 tool_result)
// 所以用 O_APPEND flag 确保每次 write 都 seek 到文件末尾
openSync(path, 'a')   // 'a' = O_APPEND
// 即使多进程同时 append,kernel 保证顺序写(无交错)

2.3 损坏恢复的实战

// 读取 transcript 时容错
const lines = content.split('\n')
const entries: Entry[] = []
for (const line of lines) {
  if (!line.trim()) continue
  try {
    entries.push(jsonParse(line))
  } catch {
    // 坏行静默跳过 — 可能是写一半 crash
    logForDebugging(`Skipping malformed transcript line`)
  }
}

03transcript 文件布局

3.1 完整目录结构

~/.claude/projects/
├── -Users-me-project-A/                      # 项目目录路径的 sanitize 版
│   ├── <sessionId-1>.jsonl                   # 主 transcript
│   ├── <sessionId-2>.jsonl
│   ├── subagents/                             # 子代理记录
│   │   ├── <agentId-a>.jsonl
│   │   ├── <agentId-b>.jsonl
│   │   └── workflows/                         # 可选 subdir 分组
│   │       └── <runId>/
│   │           └── <agentId>.jsonl
│   ├── agent-metadata/                        # agent 元信息
│   │   └── <agentId>.json
│   ├── tool-results/                          # 落盘的大 tool_result
│   │   ├── <toolUseId-1>.txt
│   │   └── <toolUseId-2>.txt
│   └── content-replacements/                  # contentReplacement 状态
│       └── <sessionId>.json
└── -Users-me-project-B/                      # 另一个项目
    └── ...

3.2 sanitize 规则

// 项目路径 "/Users/me/project" 被转换为
"-Users-me-project"
// 斜杠 → 连字符,其他非字母数字字符 → '-'
getProjectDir(cwd) === projectsDir + sanitize(cwd)

3.3 sessionId 的生成

// bootstrap/state.ts:getSessionId
// UUID v4,会话唯一,写入 transcript 每一行
let sessionId = randomUUID()
// --resume <id> 时覆盖为已有 session 的 id

3.4 MAX_TRANSCRIPT_READ_BYTES

export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024   // 50MB
// 超过这个大小的 transcript 不加载到内存
// 生产中极少达到(50MB = 数千轮对话)

04Entry 类型体系

transcript 每一行是一个 Entry,有 5 种主要类型。

4.1 完整类型

type Entry =
  | UserMessageEntry            // 用户输入 / tool_result
  | AssistantMessageEntry       // 模型响应
  | SystemEntry                 // compact boundary 等
  | SnapshotEntry               // 全局状态快照(少见)
  | SummaryEntry                // compact 产出的摘要
  | ProgressEntry               // (legacy,逐步移除)

4.2 UserMessageEntry 结构

{
  "parentUuid": "prev-entry-uuid",    // 链上一条
  "sessionId": "abc...",
  "type": "user",
  "uuid": "entry-uuid",
  "timestamp": "2026-05-01T12:00:00Z",
  "message": {
    "role": "user",
    "content": [
      { "type": "text", "text": "帮我看看 foo.ts" }
    ]
  },
  "isMeta": false,                  // isMeta: true 的 UI 不显示
  "cwd": "/Users/me/project",
  "version": "2.1.888",              // Claude Code 版本
  "gitBranch": "main"
}

4.3 AssistantMessageEntry 结构

{
  "parentUuid": "...",
  "type": "assistant",
  "message": {
    "role": "assistant",
    "content": [
      { "type": "thinking", "thinking": "...", "signature": "..." },
      { "type": "text", "text": "我来帮你..." },
      { "type": "tool_use", "id": "toolu_X", "name": "Read", "input": {...} }
    ],
    "model": "claude-opus-4-7",
    "usage": { "input_tokens": 1234, "output_tokens": 567, ... }
  },
  "requestId": "req_..."          // API request id
}

4.4 SystemEntry 结构(compact boundary 等)

{
  "type": "system",
  "subtype": "compact_boundary",
  "summary": "前面聊了 foo/bar 的重构...",
  "preCompactTokenCount": 187000,
  "postCompactTokenCount": 28000
}

4.5 UUID 链

// 每条 Entry 有 uuid,parentUuid 指向前一条
// 形成一条链,resume 时按这个链重建顺序
entry[0] { uuid: 'a', parentUuid: null }
entry[1] { uuid: 'b', parentUuid: 'a' }
entry[2] { uuid: 'c', parentUuid: 'b' }
// 这让 fork / merge 变成可能

05写入时机与原子性

什么时候写?每次 yield 的消息都写吗?

5.1 写入点

写入点触发
每条 user messageprocessUserInput 阶段
每条 assistant message从 query() stream 拿到 final message
每条 tool_result工具执行完
system compact_boundaryautocompact 完成
attachment 消息附件管线注入后

5.2 不写入的

  • stream_event 增量(只是流式碎片)
  • tombstone 消息(逻辑上"没发生过")
  • progress 消息(工具执行中间态)

5.3 同步 vs 异步写

// Claude Code 用同步 writeSync — 违反 Node.js 常识
// 原因:保证 crash 前数据落盘
// 权衡:写 < 10KB 的 JSON 很快(几毫秒)

const fd = openSync(transcriptPath, 'a')
try {
  writeSync(fd, jsonStringify(entry) + '\n')
} finally {
  closeSync(fd)
}

5.4 如果写失败怎么办

// 写失败是严重问题 — 但不 crash 主流程
try {
  writeEntry(entry)
} catch (error) {
  logError(`Failed to write transcript: ${error}`)
  // 继续执行,session 仍能使用,只是不能 resume
}

06sidechain 子代理记录

子 agent(subagent/fork)有独立的 transcript 文件,不混入主 session。

6.1 路径结构

~/.claude/projects/-Users-me-project/
├── <mainSessionId>.jsonl              # 主 session transcript
└── subagents/
    ├── <agentId-1>.jsonl              # 子 agent 1 transcript
    ├── <agentId-2>.jsonl
    └── workflows/<runId>/              # 可选分组目录
        └── <agentId-3>.jsonl

6.2 recordSidechainTranscript

// runAgent.ts:741 — agent 启动时记录初始消息
void recordSidechainTranscript(initialMessages, agentId).catch(err =>
  logForDebugging(`Failed to record sidechain: ${err}`)
)
// 后续每条消息通过 isRecordableMessage 判断是否写入

6.3 为什么子 agent 独立存

两个核心原因
  • 可独立 resume:可以单独恢复一个子 agent,继续它的工作(见 6.4)
  • 避免主 transcript 膨胀:fork 子 agent 可能跑几十轮,把那些 tool_use 都塞主 transcript 会让 resume 时 replay 爆炸

6.4 resumeAgentBackground

// 用户:"那个 security-reviewer 的结果你看看"
// 但子 agent 已经结束了 — 怎么让它继续?
await resumeAgentBackground({
  agentId,
  prompt: "继续你上次的审查,再加一点...",
  setAppState,
})
// 1. 从 subagents/<agentId>.jsonl 读出完整历史
// 2. 重建 agent context
// 3. 追加新 user message 继续跑

6.5 transcriptSubdir 分组

// 工作流里多个子 agent 归到同一目录,方便管理
setAgentTranscriptSubdir(agentId, `workflows/${runId}`)
// → ~/.claude/projects/.../subagents/workflows/<runId>/<agentId>.jsonl

07agent metadata 持久化

除了 transcript,还要单独存一些"快速查询"的 metadata。

7.1 AgentMetadata 字段

type AgentMetadata = {
  agentType: string              // "general-purpose" / "security-reviewer" ...
  description?: string           // 任务描述
  worktreePath?: string          // 如果 isolation: 'worktree'
  createdAt?: number            // 时间戳
  parentAgentId?: string        // 父 agent(用于链路追踪)
  status?: 'running' | 'completed' | 'failed'
}

7.2 为什么要独立 metadata 文件

性能考量
列出所有子 agent 时,如果每个都要 parse 整个 transcript 拿 agentType,就是 N×读文件。 独立 metadata 文件(小 JSON)让 ls 就能拿到关键信息。

7.3 写入

// runAgent.ts:744
void writeAgentMetadata(agentId, {
  agentType: agentDefinition.agentType,
  ...(worktreePath && { worktreePath }),
  ...(description && { description }),
}).catch(err => logForDebugging(err))
// 路径:~/.claude/projects/.../agent-metadata/<agentId>.json

7.4 RemoteAgent metadata

// 远端 agent(CCR)的 metadata 单独目录
~/.claude/remote-agents/
├── <taskId-1>.json
└── <taskId-2>.json

// 字段
type RemoteAgentMetadata = {
  taskId: string
  sessionId: string              // 远端 session
  sessionUrl: string             // claude.ai URL
  description?: string
  command?: string
  createdAt: number
  status: 'running' | 'completed' | ...
}

08contentReplacement 持久化

这是个不起眼但关键的设计 — 保证 resume 后 prompt cache 仍能命中。

8.1 问题

工具结果预算(applyToolResultBudget)会把大 tool_result 落盘替换成预览。 这个"替换决策"是有状态的:同一个 tool_use_id 必须每次都替换为同一字符串,否则 prompt 字节会变,cache 失效。

8.2 ContentReplacementState

type ContentReplacementState = {
  seenIds: Set<string>               // 见过的 tool_use_id
  replacements: Map<string, string>   // id → 替换后的字符串
}

8.3 持久化

// 只对可 resume 的 querySource 持久化
const persistReplacements =
  querySource.startsWith('agent:') ||
  querySource.startsWith('repl_main_thread')

if (persistReplacements) {
  void recordContentReplacement(records, agentId).catch(logError)
}
// 写到 ~/.claude/projects/.../content-replacements/<sessionId>.json

8.4 Resume 时重建

// 恢复时先读持久化的替换决策
const replacements = await loadContentReplacementState(sessionId)

// 创建 subagent context 时传入
createSubagentContext(parent, {
  contentReplacementState: replacements,   // ← 不是克隆,是加载的
  ...
})
// 这样下轮 applyToolResultBudget 会用同样的替换字符串 → 前缀字节一致 → cache 命中
这个细节的价值
没有这个持久化,resume 之后第一次请求总是完全 cache miss,一次请求多花 ~5s。 用户感受:"resume 打开后为什么响应这么慢?" 这个~300 行的小系统,直接决定 resume 体验。

09Resume 流程总览

claude --resume ① 扫描 ~/.claude/projects/ 列出该项目的所有 sessionId + 首条用户消息 ② 用户选择要恢复哪个 通过 FuzzyPicker 搜索 + 时间排序 ③ 读取 transcript 文件 流式按行 JSON.parse,容错坏行 ④ 重建 state messages / fileStateCache / contentReplacement ⑤ 协议修复 孤儿 tool_use → 补占位 tool_result ⑥ 进入 REPL,等待用户继续
6 步从"我按了 --resume"到"可以继续对话"

9.1 关键细节

  • FuzzyPicker:用首条用户消息(前 80 字符)作为 session 预览,按时间倒序
  • sessionIdExists:先确认 sessionId 对应文件存在,避免路径穿越
  • 懒加载:超过 MAX_TRANSCRIPT_READ_BYTES(50MB)的不加载

10Replay 时的状态重建

读完 transcript 只是一半,要把它"变回活的 agent state"还有一堆工作。

10.1 需要重建的 state

状态来源重建方法
messages[]直接从 entries 转filter isTranscriptMessage + map
readFileState扫描 Read / Edit tool_use重建 file state cache
contentReplacementState独立文件直接 load JSON
todos / tasks独立 V1 内存 / V2 文件V1 丢失 / V2 仍在
appState.tasks关联的 sub task metadata重新扫 agent-metadata/
MCP 连接无,每次重新连connectToServer 按配置重连

10.2 readFileState 重建

// 遍历所有 Read / Edit / Write tool_use,重建"Claude 读过/改过哪些文件"
for (const entry of entries) {
  if (entry.type !== 'assistant') continue
  for (const block of entry.message.content) {
    if (block.type === 'tool_use') {
      if (block.name === 'Read') {
        readFileState.set(block.input.file_path, { readAt: entry.timestamp, ... })
      }
      if (block.name === 'Edit' || block.name === 'Write') {
        readFileState.set(block.input.file_path, { modifiedAt: entry.timestamp, ... })
      }
    }
  }
}

10.3 丢失的状态

不可恢复的部分
  • V1 Todos:内存存储,resume 后空
  • MCP 运行时状态:比如 server 缓存的 session,重连后丢
  • 工具并发 state:例如跑了一半的 Bash 命令,不 resume
  • 子 agent 运行状态:异步 agent 如果还在跑,resume 会让它独立继续,不跟主 session 自动 reconnect

11协议配对修复

Resume 时可能遇到"tool_use 有但对应 tool_result 没有"(上次 crash 在工具执行中)。直接发给 API 会 400。

11.1 修复逻辑

// 扫描 messages,找出没有配对 tool_result 的 tool_use
function filterIncompleteToolCalls(messages: Message[]): Message[] {
  const resultsForIds = new Set<string>()
  for (const msg of messages) {
    if (msg.type === 'user' && Array.isArray(msg.message.content)) {
      for (const block of msg.message.content) {
        if (block.type === 'tool_result') resultsForIds.add(block.tool_use_id)
      }
    }
  }

  // 去掉对应 tool_use 没 result 的消息(或补占位)
  return messages.filter(msg => { ... })
}

11.2 补占位 tool_result

// 如果模型上次发了 tool_use 但 crash 了
// resume 时给每个孤儿 tool_use 补一条 tool_result(is_error, "Session resumed; task interrupted")
for (const orphanId of orphanToolUseIds) {
  messages.push(createUserMessage({
    content: [{ type: 'tool_result', tool_use_id: orphanId,
      content: "Session resumed; previous task was interrupted. You can retry.",
      is_error: true }],
  }))
}

这让模型知道"上次这个工具没完成,现在要么重试要么换方法"。

12resumeAgentBackground 流程

不只是主 session,单独的子 agent 也可恢复。

12.1 用例

// 场景 1:主 agent 派的后台 agent 已经完成,用户想跟它"对话"
// 场景 2:长 workflow 里某步失败,想只恢复失败那一步
// 场景 3:用户关闭 Claude 后,后台 agent 仍然完成了(通过 LocalAgentTask),
// 下次启动想继续用它的输出

12.2 流程

  1. agent-metadata/<agentId>.json 读 agentType, worktreePath 等
  2. subagents/<agentId>.jsonl 读完整历史
  3. 用 AgentDefinition + 历史 messages 重建 context(类似 runAgent 的 Stage 1-6 但用既有 agentId)
  4. 追加 user prompt 消息
  5. 进入新回合,query() 继续

12.3 agentId 的保持

// runAgent 里
const agentId = override?.agentId ? override.agentId : createAgentId()
// resume 时 override.agentId 传入 → 复用原 agentId
// 这样 sidechain 文件会继续追加到同一文件

13跨设备 resume

同步 transcript 到另一台机器能 resume 吗?有一些约束。

13.1 跨设备的困难

问题原因当前处理
路径不一致transcript 里的 cwd 是绝对路径⚠️ 需要用户手动调整 sanitize 后的目录名
tool-results 文件依赖落盘大结果需要同步过去需要整个 project 目录一起同步
MCP server 本地路径stdio server 的 command 是本地路径配置不迁移,MCP 连接可能失败
OAuth token 依赖 keychain每台机器独立需要重新登录

13.2 实用做法

# 把整个项目的 transcript 打包
cd ~/.claude/projects/
tar czf project-backup.tar.gz -Users-me-project/

# 在另一台机器解开到同样结构
tar xzf project-backup.tar.gz -C ~/.claude/projects/

# resume 时注意 sanitize 路径是否匹配新机器的 /Users/<name>/project

13.3 官方同步路径

Claude.ai 订阅用户有官方的 session sync,不需要手动 copy;但本地 OSS 版本需要自己管。

14失败恢复

14.1 坏的 transcript 行

// 读取时容错 — 坏行直接跳
for (const line of lines) {
  try { entries.push(jsonParse(line)) }
  catch { /* skip */ }
}
// 损坏行通常是 crash 在写 \n 之前
// 结果:部分历史可能丢失,但不影响剩下的 replay

14.2 metadata 丢失

// 如果 agent-metadata/<id>.json 不见了
async function readAgentMetadata(agentId) {
  try { return jsonParse(await readFile(path, 'utf-8')) }
  catch { return null }
}
// 返回 null,上层 fallback 到默认值(general-purpose agentType)

14.3 contentReplacement 丢失

// 最坏情况:prompt cache miss 一次
// 但不会 crash,新的替换决策会从头建立

14.4 schema 演进

// 老版本 transcript 字段可能缺
if (isLegacyProgressEntry(entry)) {
  // 转换成新格式或丢弃
}
// 整体设计:Entry 类型是宽松的,不强制 schema 严格版本匹配

15端到端示例

用一个完整场景串起来。

  1. Day 1 10:00 — 用户启动 claude,问"帮我重构 auth 模块"

    创建 sessionId = abc123,transcript 文件 abc123.jsonl 开始写。

  2. Day 1 10:05 — 模型跑了 5 轮,写了 3 个文件

    transcript 已有 ~30 条 entries。一个 200KB 的 Grep 结果被 applyToolResultBudget 落盘到 tool-results/toolu_X.txt,contentReplacement 记入 JSON。

  3. Day 1 10:10 — 派了一个 security-reviewer 子 agent 后台跑

    subagents/agent_xyz.jsonl 开始写,agent-metadata/agent_xyz.json 记录 metadata。

  4. Day 1 10:30 — 用户按 Ctrl+C 关闭 claude

    主 session 状态都在磁盘。subagent 子进程被终止(因为同进程)。如果是 async + 独立进程,它会继续跑完。

  5. Day 2 09:00 — 用户重新 claude --resume

    FuzzyPicker 显示 session 列表,用户选 abc123(首条消息 "帮我重构 auth..." 为预览)。

  6. Resume 重建
    • abc123.jsonl 30 条 entries → 转成 messages
    • 扫描 Read/Edit 重建 readFileState
    • content-replacements/abc123.json 重建 replacements
    • 协议检查:没有孤儿 tool_use(上次干净退出)
    • agent-metadata 扫描:发现 agent_xyz 曾经启动过,列入"历史子 agent"
  7. 进入 REPL — 用户看到完整对话历史,输入"继续"

    下一次 API 调用字节级跟 Day 1 一致(因为 contentReplacement 恢复) → prompt cache 命中 → 首个 TTFT < 500ms(而不是 5s)。

  8. 恢复子 agent(可选)

    用户:"security-reviewer 的结果呢?" → 主 agent 判断子 agent 结束了 → 调 resumeAgentBackground 让它生成最终报告。

16可迁移设计原则

  1. Append-only 是持久化之王 — JSONL 比任何复杂格式更可靠,O(1) 写入 + 损坏可恢复
  2. sync write 换崩溃安全 — 性能损失 (~ms) 换到"crash 后一切可恢复",在 agent 场景绝对值得
  3. 冗余 metadata — 不是只存一份 source of truth,而是把"常用查询字段"冗余存一份,避免 N×全扫描
  4. 持久化替换决策 — 不只存数据,还要存"对数据的决策",resume 后字节一致才能 cache 命中
  5. 子流程独立记录 — sidechain 让子 agent 可独立 resume,同时不膨胀主 transcript
  6. 协议修复而非拒绝 — 遇到孤儿 tool_use 不 panic,而是补占位让协议合法,让模型继续
  7. 懒验证 / 容错读取 — 坏行跳过,缺文件 fallback,字段缺失默认值。用户的 3 年前 session 都该能打开
  8. UUID 链 — parentUuid 让 fork / branch / merge 变成可能,虽然现在没完全用,但为未来留路
最深的启示
持久化不是事后补救,是架构核心。contentReplacementState 这种"写了 3 年才有人注意的细节", 是决定产品"能用 vs 好用"的关键。设计 agent 系统时,从 day 1 就想好"crash 和 resume 怎么办", 把它塞进每个子系统设计里,而不是等上线后加补丁。