DOCS v0.1.0 BUILD: STABLE GitHub ↗
章节目录
S05

Session

jsonl append-only 持久化 + 断点续跑。

前置概念 JSONL (JSON Lines)append-onlyEvent Sourcingresume(断点续传)持久化点(persistence point)

s05: Session / JSONL — append-only 持久化与 resume

消息数组是 agent 的全部状态。把它落盘,就能断点续传。

s01s02s03s04s05s06


问题

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.parsereadFileSync

启动时 loadMessages 恢复历史,每轮结束后 saveMessages 落盘新增消息。进程崩了?重启,读回来,接着写。

这就是 Event Sourcing 的最小形态:状态 = 事件序列的重放结果。不需要数据库,不需要 schema 迁移,一个文本编辑器就能检查”到底发生了什么”。

APPEND-ONLY JSONL
运行中:内存 ↔ 磁盘同步追加
内存消息数组
user列出当前目录的文件
assistanttool_use: bash
usertool_result: a.txt b.txt
3 条
→ flush
appendFileSync
session.jsonl (磁盘)
{"role":"user","content":"列出当前目录的文件"}
{"role":"assistant","content":"tool_use: bash"}
{"role":"user","content":"tool_result: a.txt b.txt"}
3 行 · append-only · 持久化

工作原理

打开 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 时,mainloadMessages 拿到历史数组,直接喂给 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:

  1. 第一轮输入 列出当前目录的文件,等 agent 跑完
  2. 输入 q 退出
  3. cat session.jsonl 看看文件内容,每行一条消息
  4. 再次 node s05_session/code.ts,看到 已恢复 N 条消息
  5. 输入 刚才我让你做什么了,agent 应该能回答(证明历史已恢复)

想清空会话:rm session.jsonl,下次启动就是新会话。


前置概念清单

本章只引入五个新概念:

  1. JSONL (JSON Lines):每行一个独立 JSON 对象的文本格式。比 JSON 数组更适合日志:可流式追加、可按行处理、单行损坏不影响其他行
  2. append-only:只追加不修改不删除。文件只往长了长,永不回头改历史。这是 Event Sourcing 的写入约束
  3. Event Sourcing:状态 = 事件序列的重放结果。不存”当前状态”,只存”发生过什么”,需要状态时回放整条日志
  4. resume(断点续传):重启后从持久化的事件日志恢复状态,从断点继续执行,对用户透明
  5. 持久化点(persistence point):在循环中精心选择”何时落盘”的位置。选太少会丢数据,选太多会拖慢循环。本章选在每条消息 push 后立即 save

源码锚点

mini-pi 的两个函数,在pi 里对应一整套 session 子系统:packages/agent/src/harness/session/。先读 jsonl-storage.ts,再回答三个问题:

  1. jsonl-storage.ts 第 191-213 行的 create 方法:pi 创建会话时先写一行 headertype: "session"),然后才写 entry。为什么需要 header?mini-pi 没有 header 会怎样?(提示:想想多会话和 fork)
  2. 第 250-259 行的 appendEntry:pi 的每条 entry 都有 idparentIdtimestamp,构成一棵树。mini-pi 的消息没有 ID,只有数组顺序。树结构比线性数组多了什么能力?(提示:看 getPathToRootOrCompactionfork
  3. session.ts 第 78 行起的 Session 类(如第 148-200 行的 appendModelChange/appendCompaction 等方法):pi 的 session 不只存消息,还存 thinking_level_changemodel_changecompactionlabel 等多种 entry 类型。为什么不全塞进消息的 content?(提示:哪些 entry 需要被模型看到,哪些只是元数据)

动手任务(改 mini-pi):给 loadMessages 加一个容错——如果某行 JSON.parse 失败,跳过这行并打印警告,而不是整个崩溃。想想:生产环境里为什么不能因为一条坏行就丢掉整个会话?


妥协清单

mini-pi 比pi 少做了什么,以及为什么省略是安全:

省略项pi 的做法为什么本章可以省
原子写保证fsync + tmp 文件 + renameappendFileSync 单次小写入在 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;本章文件小到可以全量读
并发写保护文件锁 / 单 writerREPL 单进程,不存在并发写
写缓冲/批写批量 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,凭记忆补全三个函数体:saveMessagesloadMessagesagentLoop(带持久化点)。

通过标准:

  1. node practice.ts 跑起来,能正常对话、能调 bash
  2. 退出后 cat session.jsonl 能看到每行一条 JSON 消息
  3. 再次启动,提示 已恢复 N 条消息,能接着上轮追问

写不出 saveMessages 说明 append-only 没进脑子,回到「工作原理」第 1 块重读。写不出 loadMessages 的 filter/map 链说明回放逻辑没理清,回到第 2 块。agentLoop 忘了插持久化点,说明你把 s01 抄了一遍而不是在 s05 上改进——两个 save 调用是本章的全部增量。


下一章:s06 compaction — 上下文预算、溢出检测与摘要