s05: Session / JSONL — append-only 持久化与 resume
消息数组是 agent 的全部状态。把它落盘,就能断点续传。
s01 → s02 → s03 → s04 → s05 → s06
问题
s01 的 agent 一关就失忆:对话历史只在内存数组里,进程退出就蒸发了。
你跑到一半 Ctrl+C,或者 terminal 崩了,或者你就是想关机明天接着干——全都没了。下次启动,模型不记得昨天改了哪个文件、跑过什么命令、得出了什么结论。你只能从头复述。
更隐蔽的问题:agent 跑了 20 轮,第 18 轮的 tool_use 对应第 19 轮的 tool_result,这个对应关系靠的是数组下标。如果中间丢了一条消息,整个对话的因果链就断了。你需要一种存储格式,让”事件的顺序”和”事件的内容”一样可靠。
解决方案
一个 .jsonl 文件,每条消息一行 JSON,只追加不修改。
session.jsonl
{"role":"user","content":"列出当前目录的文件"}
{"role":"assistant","content":[{"type":"text","text":"..."},{"type":"tool_use",...}]}
{"role":"user","content":[{"type":"tool_result",...}]}
{"role":"assistant","content":[{"type":"text","text":"共有 3 个文件"}]}
两个函数搞定一切:
| 函数 | 做的事 | 对应的 fs 原语 |
|---|---|---|
saveMessages(msgs) | 每条消息 JSON.stringify + \n,追加写文件 | appendFileSync |
loadMessages() | 读全文,按 \n 切,过滤空行,逐行 JSON.parse | readFileSync |
启动时 loadMessages 恢复历史,每轮结束后 saveMessages 落盘新增消息。进程崩了?重启,读回来,接着写。
这就是 Event Sourcing 的最小形态:状态 = 事件序列的重放结果。不需要数据库,不需要 schema 迁移,一个文本编辑器就能检查”到底发生了什么”。
工作原理
打开 code.ts,在 s01 基础上只加了三块东西。
第 1 块:saveMessages —— 写入端。 核心就一行:
appendFileSync(CONFIG.sessionFile, `${JSON.stringify(msg)}\n`, "utf-8");
appendFileSync 在单进程内顺序追加,每条消息独立一行;即使进程写一半崩了,最多丢最后一条,前面的消息完整。生产环境若存在多进程并发写同一文件,需加文件锁或单 writer 设计。
第 2 块:loadMessages —— 回放端。 读全文 → split → filter → map:
content.split("\n").filter((l) => l.trim() !== "").map((l) => JSON.parse(l) as ChatMessage)
文件不存在返回空数组,视为新会话。文件末尾有空行(\n 结尾导致)被 filter 掉,不影响解析。
第 3 块:持久化点 —— 在 agentLoop 里插桩。 s01 的循环只做 push,现在每次 push 后紧跟一次 save:
assistant 消息 push → saveMessages([assistantMsg]) // 持久化点 1
tool_result push → saveMessages([toolMsg]) // 持久化点 2
用户输入在 main 里也持久化。这样文件里的消息顺序 = 内存数组的顺序 = 模型看到的对话顺序,三者始终一致。
resume 时,main 调 loadMessages 拿到历史数组,直接喂给 agentLoop,模型看到的上下文和上次退出时一模一样。
运行
export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
export ANTHROPIC_API_KEY=sk-deepseek-...
export MODEL_ID=deepseek-chat
node s05_session/code.ts
试这个流程观察 resume:
- 第一轮输入
列出当前目录的文件,等 agent 跑完 - 输入
q退出 cat session.jsonl看看文件内容,每行一条消息- 再次
node s05_session/code.ts,看到已恢复 N 条消息 - 输入
刚才我让你做什么了,agent 应该能回答(证明历史已恢复)
想清空会话:rm session.jsonl,下次启动就是新会话。
前置概念清单
本章只引入五个新概念:
- JSONL (JSON Lines):每行一个独立 JSON 对象的文本格式。比 JSON 数组更适合日志:可流式追加、可按行处理、单行损坏不影响其他行
- append-only:只追加不修改不删除。文件只往长了长,永不回头改历史。这是 Event Sourcing 的写入约束
- Event Sourcing:状态 = 事件序列的重放结果。不存”当前状态”,只存”发生过什么”,需要状态时回放整条日志
- resume(断点续传):重启后从持久化的事件日志恢复状态,从断点继续执行,对用户透明
- 持久化点(persistence point):在循环中精心选择”何时落盘”的位置。选太少会丢数据,选太多会拖慢循环。本章选在每条消息 push 后立即 save
源码锚点
mini-pi 的两个函数,在pi 里对应一整套 session 子系统:packages/agent/src/harness/session/。先读 jsonl-storage.ts,再回答三个问题:
jsonl-storage.ts第 191-213 行的create方法:pi 创建会话时先写一行 header(type: "session"),然后才写 entry。为什么需要 header?mini-pi 没有 header 会怎样?(提示:想想多会话和 fork)- 第 250-259 行的
appendEntry:pi 的每条 entry 都有id、parentId、timestamp,构成一棵树。mini-pi 的消息没有 ID,只有数组顺序。树结构比线性数组多了什么能力?(提示:看getPathToRootOrCompaction和fork) session.ts第 78 行起的Session类(如第 148-200 行的appendModelChange/appendCompaction等方法):pi 的 session 不只存消息,还存thinking_level_change、model_change、compaction、label等多种 entry 类型。为什么不全塞进消息的 content?(提示:哪些 entry 需要被模型看到,哪些只是元数据)
动手任务(改 mini-pi):给 loadMessages 加一个容错——如果某行 JSON.parse 失败,跳过这行并打印警告,而不是整个崩溃。想想:生产环境里为什么不能因为一条坏行就丢掉整个会话?
妥协清单
mini-pi 比pi 少做了什么,以及为什么省略是安全:
| 省略项 | pi 的做法 | 为什么本章可以省 |
|---|---|---|
| 原子写保证 | fsync + tmp 文件 + rename | appendFileSync 单次小写入在 POSIX 上近乎原子;教学场景崩溃可接受丢最后一条 |
| 会话 header | 首行写 type:"session" 元数据(id/cwd/timestamp) | 单文件单会话,路径本身就是标识,不需要 header |
| entry 树结构 | 每条 entry 有 id/parentId,支持 fork 和分支 | 线性数组够用;fork 是 s06+ 的事 |
| 多 entry 类型 | message/compaction/label/model_change… | 只存消息一种;其他类型在需要时再加 |
| 多会话管理 | JsonlSessionRepo 按 cwd 分目录,list/fork/delete | 一个文件一个会话;想换会话改 CONFIG.sessionFile |
| 滚动/轮转 | 大文件 compaction + 新 session 文件 | s06 专门讲 compaction;本章文件小到可以全量读 |
| 并发写保护 | 文件锁 / 单 writer | REPL 单进程,不存在并发写 |
| 写缓冲/批写 | 批量 append 减少 syscall | 每轮只写 1-2 条,即时落盘更重要 |
| leafId 指针机制 | JsonlSessionStorage.setLeafId 追加 type:"leaf" entry 持久化指针,moveTo 改 leaf 不删消息 | 教学版 leafId 是内存字段,重启后回到文件末尾;s14 会展开 leaf entry 机制 |
| resume 取路径 | getPathToRoot(leafId) 从 leaf 沿 parentId 回溯到根,只取路径上的 entry | 教学版全量回放所有消息;fork/分支场景下行为完全不同,s14 会展开 |
| 持久化格式与 LLM 格式分离 | SessionTreeEntry(含 message/compaction/label 等多种类型)与 AgentMessage[] 不同,需 buildSessionContext 转换 | 教学版直接持久化 LLM 格式,文件里存的 = 模型看到的;pi 的转换在 s06/s14 显现 |
这张表是本章的地图:每一行省略,都是后续章节或生产化时要补回来的洞。
默写验收
合上 code.ts 和本 README,打开 practice.ts,凭记忆补全三个函数体:saveMessages、loadMessages、agentLoop(带持久化点)。
通过标准:
node practice.ts跑起来,能正常对话、能调 bash- 退出后
cat session.jsonl能看到每行一条 JSON 消息 - 再次启动,提示
已恢复 N 条消息,能接着上轮追问
写不出 saveMessages 说明 append-only 没进脑子,回到「工作原理」第 1 块重读。写不出 loadMessages 的 filter/map 链说明回放逻辑没理清,回到第 2 块。agentLoop 忘了插持久化点,说明你把 s01 抄了一遍而不是在 s05 上改进——两个 save 调用是本章的全部增量。