08. 会话日志与恢复
Agent 运行时的事实源应该是会话日志,而不是内存里的 messages 数组。内存会丢,UI 会刷新,进程会崩溃,用户会明天回来继续。只要日志足够完整,你就能重建上下文、显示历史、审计工具调用、恢复未完成任务,甚至从某个中间节点分叉。
为什么不能只保存 prompt
很多最小实现只在退出时保存当前 messages。这样做有几个问题:
- 工具执行进度和错误丢失。
- 模型切换、压缩、用户插话等非消息事件丢失。
- 无法表达分支,只能线性覆盖。
- 崩溃时最后一段状态可能完全没写入。
Agent 会话不是聊天记录,而是事件日志。聊天 UI 只是日志的一种投影。
JSONL 追加日志
教学项目可以采用 JSONL:每行一个 entry,追加写。这样崩溃时最多损坏最后一行,前面的事实仍可解析。每个 entry 至少需要 id、parentId、timestamp 和 type。
type SessionEntry =
| { type: "session"; id: string; timestamp: string; cwd: string; version: number; parentSession?: string }
| { type: "message"; id: string; parentId: string; timestamp: string; message: Message }
| { type: "model_change"; id: string; parentId: string; timestamp: string; model: string }
| { type: "compaction"; id: string; parentId: string; timestamp: string; summary: string; firstKeptEntryId: string }
| { type: "custom"; id: string; parentId: string; timestamp: string; customType: string; data: unknown }
| { type: "label"; id: string; parentId: string; timestamp: string; targetId: string; label: string };
parentId 让会话成为树,而不只是数组。用户从历史某一点继续时,可以创建新分支;原来的后续记录仍然保留。当前活动分支可以用 leaf id 表示。
entry id 用 UUIDv7 这类时间有序的 id 是个实用选择:排序即时间序,还能取前缀做 UI 短显示。成熟系统的 entry 类型比上面更多——推理强度切换、分支摘要、扩展注入的自定义消息、给某个节点打标签、会话改名,都是独立的 entry 类型。共同原则是:凡是影响“会话之后如何运行”的状态变化,都进日志;只影响当前进程的临时状态(比如滚动位置),不进。
存储布局与发现
单文件不够,还要考虑“这台机器上所有会话”的组织方式。一个经过验证的布局是:按项目目录分组,把工作区路径编码进目录名,每个会话一个 JSONL 文件,文件名含会话 id。这样支持三种高频操作:
- “继续这个项目最近的会话”:扫描该项目目录下的文件,按修改时间取最新。
- “列出这个项目的历史会话”:只读每个文件的第一行 header(限定前几百字节),不解析全文。
- “用 id 前缀恢复某个会话”:先在当前项目找,再全局找。
header 里必须有 cwd 和格式 version。cwd 用于校验“这个会话属于哪个项目”(注意软链接要归一化,否则同一项目会出现两份历史);version 用于迁移。
日志是事实源,上下文是投影
构建模型上下文时,不是直接读取整个日志发给模型,而是从当前 leaf 往 root 回溯,得到活动分支,再把 entry 投影成 LLM messages。投影规则可以是:
messageentry 进入上下文。model_change不进入上下文,但影响后续请求使用的模型。custom默认不进入上下文,除非扩展声明它是 message 类型。compaction进入上下文时变成一条 summary message,并决定哪些旧 entry 被保留。label、会话改名等纯元数据永不进入上下文。
这样日志可以记录完整事实,而上下文只包含模型需要继续工作的内容。
恢复流程
恢复会话时,运行时应该做这些事:
- 解析 JSONL,跳过或报告损坏行。
- 校验 session version,必要时迁移旧 entry。
- 按 id 建索引,找到当前 leaf,沿 parentId 回溯构建活动分支。
- 根据 entry 重建消息、模型、压缩状态、队列状态。
- 重新创建工具和 provider。
- 让 UI 从日志投影历史,而不是要求模型复述。
恢复不应该自动重新执行历史工具。工具结果已经是事实。除非用户明确要求重跑测试,否则恢复只重建状态。
第 2 步的迁移不是假想需求。真实系统的日志格式一定会演化:从线性数组升级成树(给所有旧 entry 补 id 和 parentId)、重命名消息角色、把索引引用改成 id 引用。每次变更都要写一个幂等迁移,按版本号顺序执行,并在测试里保留旧格式样本。没有迁移机制的日志格式,第一次 breaking change 就会把用户的所有历史会话变成废纸。
分支与 fork
分支不是高级功能,而是 Agent 的自然需求。用户可能让 Agent 尝试方案 A,发现不满意后从中间点改走方案 B。如果日志是树,你只需要把 leaf 指向某个历史 entry,再追加新消息。旧分支仍可查看。
跨会话的 fork 也可以建立在同一机制上:把某个会话的活动分支复制成新文件,header 里记录 parentSession 指向来源。这样“从这次讨论开一个新任务”有完整的谱系可查,两个会话又互不干扰。
注意分支和压缩的关系。压缩不应销毁旧日志,它只是告诉上下文构建器“从某个点开始,用 summary 代表更早内容”。如果压缩直接删除历史,你就无法回到压缩前分支,也无法审计早期工具调用。
运行观察
一段会话日志可能长这样:
{"type":"session","id":"s1","timestamp":"2026-01-01T10:00:00.000Z","cwd":"/repo","version":1}
{"type":"message","id":"m1","parentId":"s1","timestamp":"2026-01-01T10:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"修复测试"}]}}
{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-01-01T10:00:03.000Z","message":{"role":"assistant","stopReason":"toolUse","content":[{"type":"toolCall","id":"t1","name":"bash","input":{"command":"npm test"}}],"model":"example","usage":{"inputTokens":500,"outputTokens":40}}}
{"type":"message","id":"m3","parentId":"m2","timestamp":"2026-01-01T10:00:06.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"bash","isError":true,"content":[{"type":"text","text":"1 test failed"}]}}
这几行已经足够恢复出用户请求、模型动作、工具结果和下一步上下文。
练习
实现 JSONL session storage。
验收标准:
- 每条消息追加写入,不覆盖旧文件;损坏的末行不影响之前的 entry。
- 进程重启后能恢复当前 active branch。
model_change能影响恢复后的默认模型,但不进入 LLM messages。- 从历史 entry fork 后,旧分支仍可读取。
- 压缩 entry 不删除旧 entry,只改变上下文投影。
- 写一个 v1 到 v2 的格式迁移(比如给线性日志补 parentId),旧样本文件迁移后能正常恢复。