从零构建 Coding Agent
English

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 }
  | { 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 };

parentId 让会话成为树,而不只是数组。用户从历史某一点继续时,可以创建新分支;原来的后续记录仍然保留。当前活动分支可以用 leaf id 表示。

日志是事实源,上下文是投影

构建模型上下文时,不是直接读取整个日志发给模型,而是从当前 leaf 往 root 回溯,得到活动分支,再把 entry 投影成 LLM messages。投影规则可以是:

  • message entry 进入上下文。
  • model_change 不进入上下文,但影响后续请求使用的模型。
  • custom 默认不进入上下文,除非扩展声明它是 message。
  • compaction 进入上下文时变成一条 summary message,并决定哪些旧 entry 被保留。

这样日志可以记录完整事实,而上下文只包含模型需要继续工作的内容。

恢复流程

恢复会话时,运行时应该做这些事:

  1. 解析 JSONL,跳过或报告损坏行。
  2. 校验 session version,必要时迁移旧 entry。
  3. 找到当前 leaf,构建活动分支。
  4. 根据 entry 重建消息、模型、压缩状态、队列状态。
  5. 重新创建工具和 provider。
  6. 让 UI 从日志投影历史,而不是要求模型复述。

恢复不应该自动重新执行历史工具。工具结果已经是事实。除非用户明确要求重跑测试,否则恢复只重建状态。

分支与 fork

分支不是高级功能,而是 Agent 的自然需求。用户可能让 Agent 尝试方案 A,发现不满意后从中间点改走方案 B。如果日志是树,你只需要把 leaf 指向某个历史 entry,再追加新消息。旧分支仍可查看。

注意分支和压缩的关系。压缩不应销毁旧日志,它只是告诉上下文构建器“从某个点开始,用 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。

验收标准:

  • 每条消息追加写入,不覆盖旧文件。
  • 进程重启后能恢复当前 active branch。
  • model_change 能影响恢复后的默认模型,但不进入 LLM messages。
  • 从历史 entry fork 后,旧分支仍可读取。
  • 压缩 entry 不删除旧 entry,只改变上下文投影。