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 为什么这个话题被低估
02为什么是 JSONL
JSONL(JSON Lines)= 每行一个 JSON 对象。看似简单,但它是生产级持久化的最佳选择。
2.1 JSONL vs JSON 数组对比
| 特性 | JSONL | JSON 数组 |
|---|---|---|
| 写入方式 | 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 message | processUserInput 阶段 |
| 每条 assistant message | 从 query() stream 拿到 final message |
| 每条 tool_result | 工具执行完 |
| system compact_boundary | autocompact 完成 |
| 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 文件
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 命中
09Resume 流程总览
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 流程
- 从
agent-metadata/<agentId>.json读 agentType, worktreePath 等 - 从
subagents/<agentId>.jsonl读完整历史 - 用 AgentDefinition + 历史 messages 重建 context(类似 runAgent 的 Stage 1-6 但用既有 agentId)
- 追加 user prompt 消息
- 进入新回合,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端到端示例
用一个完整场景串起来。
-
Day 1 10:00 — 用户启动 claude,问"帮我重构 auth 模块"
创建 sessionId =
abc123,transcript 文件abc123.jsonl开始写。 -
Day 1 10:05 — 模型跑了 5 轮,写了 3 个文件
transcript 已有 ~30 条 entries。一个 200KB 的 Grep 结果被 applyToolResultBudget 落盘到
tool-results/toolu_X.txt,contentReplacement 记入 JSON。 -
Day 1 10:10 — 派了一个 security-reviewer 子 agent 后台跑
subagents/agent_xyz.jsonl开始写,agent-metadata/agent_xyz.json记录 metadata。 -
Day 1 10:30 — 用户按 Ctrl+C 关闭 claude
主 session 状态都在磁盘。subagent 子进程被终止(因为同进程)。如果是 async + 独立进程,它会继续跑完。
-
Day 2 09:00 — 用户重新
claude --resumeFuzzyPicker 显示 session 列表,用户选
abc123(首条消息 "帮我重构 auth..." 为预览)。 -
Resume 重建
- 读
abc123.jsonl30 条 entries → 转成 messages - 扫描 Read/Edit 重建 readFileState
- 读
content-replacements/abc123.json重建 replacements - 协议检查:没有孤儿 tool_use(上次干净退出)
- agent-metadata 扫描:发现 agent_xyz 曾经启动过,列入"历史子 agent"
- 读
-
进入 REPL — 用户看到完整对话历史,输入"继续"
下一次 API 调用字节级跟 Day 1 一致(因为 contentReplacement 恢复) → prompt cache 命中 → 首个 TTFT < 500ms(而不是 5s)。
-
恢复子 agent(可选)
用户:"security-reviewer 的结果呢?" → 主 agent 判断子 agent 结束了 → 调 resumeAgentBackground 让它生成最终报告。
16可迁移设计原则
- Append-only 是持久化之王 — JSONL 比任何复杂格式更可靠,O(1) 写入 + 损坏可恢复
- sync write 换崩溃安全 — 性能损失 (~ms) 换到"crash 后一切可恢复",在 agent 场景绝对值得
- 冗余 metadata — 不是只存一份 source of truth,而是把"常用查询字段"冗余存一份,避免 N×全扫描
- 持久化替换决策 — 不只存数据,还要存"对数据的决策",resume 后字节一致才能 cache 命中
- 子流程独立记录 — sidechain 让子 agent 可独立 resume,同时不膨胀主 transcript
- 协议修复而非拒绝 — 遇到孤儿 tool_use 不 panic,而是补占位让协议合法,让模型继续
- 懒验证 / 容错读取 — 坏行跳过,缺文件 fallback,字段缺失默认值。用户的 3 年前 session 都该能打开
- UUID 链 — parentUuid 让 fork / branch / merge 变成可能,虽然现在没完全用,但为未来留路