本章把会话写到磁盘,崩溃后原样恢复,做法是追加式事件日志 + 重放。
本章代码 = s03 的最小 agent 循环 + store.mjs(事件日志与重放)。
agent 干了半小时的活:几十轮对话、二十次工具调用。你按错了 Ctrl+C,或终端误关、笔记本没电——重新打开,它什么都不记得。前几章的 agent 都这样:messages 只存在内存里。
常见的第一反应是"存成 JSON 文件,每次变化整个重写"。这个方案有隐患——demo 场景一真实写磁盘、真实模拟崩溃,展示了这个现场:
━━━ 场景一:全量重写 JSON,崩溃在写到一半时 ━━━
磁盘上留下 124/207 字节的 session.json,尝试恢复:
✗ JSON.parse 失败:Unterminated string in JSON at position 124 (line 7 column 27)
✗ 整个会话报废 —— 不止最后一条消息,之前的历史也一起没了。
落盘的不是状态快照,而是事件:每发生一件事就往 JSONL 文件末尾追加一行,已写下的字节永远不被触碰;恢复时逐行重放事件,把 messages 重新推导出来。崩溃的影响从"整个文件可能写坏"缩小到"最后一行可能写了一半"——跳过那行即可:丢一条消息,不丢整个会话。
免 key 演示:
node notes/s08_persistence/demo.mjs三个场景,全部真实写磁盘、真实模拟崩溃(场景一的输出见上文"问题")。场景三的输出节选:
━━━ 场景三:崩溃把最后一行写了一半 ━━━
往同一个 .jsonl 追加了半行(49/98 字节),再次重放:
✓ 恢复出 4 条消息,跳过 1 行损坏数据
✓ 只丢了崩溃瞬间那一条,之前的全部历史完好。
✓ 恢复后追加两条消息,再次重放全部保留,原有字节未改写。
✓ 新文件、空文件、正常追加和完整 JSON 缺换行的边界断言全部通过。
演示内的断言还会检查恢复后继续写入的结果,防止“第一次能恢复,第二次却丢了新消息”。
接上真实模型,启动时分岔:带 --resume <id> 就重放恢复,否则开新会话并打印 id:
AGENT_API_KEY=sk-xxx node agent.mjs
# 新会话 ses_mr47xdu8_sgf8(落盘于 …/.sessions/ses_mr47xdu8_sgf8.jsonl)
# 下次续上:node agent.mjs --resume ses_mr47xdu8_sgf8
AGENT_API_KEY=sk-xxx node agent.mjs --resume ses_mr47xdu8_sgf8
# 已恢复会话 ses_mr47xdu8_sgf8:14 条消息,5 次工具调用验收:跟它聊两轮、让它读个文件,Ctrl+C 退出,再 --resume 回来问"刚才聊到哪了"——它应该答得上来。s03 看门狗注入的纠偏消息也走 pushMessage(见下文"实现"):恢复出的会话必须和退出前一致。
写文件不是原子操作。writeFileSync(f, bigJson) 在操作系统层面是"打开(清空旧内容)→ 分块写入字节"。崩溃落在中间,磁盘上就是半个 JSON:旧版本已清空,新版本没写完——JSON.parse 失败,整个会话(包括崩溃前完好的部分)一起丢失。演示场景一就是这个现场。
而且越用越危险:会话越长,重写窗口越大,中招概率越高——最有价值的长会话恰恰最容易被写坏。
追加式日志(JSONL:一行一个 JSON)从结构上消除了这个问题:
{"ts":"…","type":"session_meta","meta":{"id":"ses_x","model":"deepseek-chat",…}}
{"ts":"…","type":"message","message":{"role":"user","content":"帮我修一下登录页"}}
{"ts":"…","type":"tool_call","record":{"id":"call_1","name":"read_file","status":"completed",…}}
每发生一件事(用户消息/助手消息/工具结果/压缩边界)就在末尾追加一行,已写下的字节永远不被触碰。崩溃的影响被限制在"最后一行可能写了一半"——恢复时跳过那行即可:丢一条消息,不丢整个会话。这不是额外的容错,是"只追加"结构自带的性质。
落盘的是事件,不是状态。恢复时逐行读事件,把 messages 数组重新推导出来:
for (const line of text.split("\n")) {
if (!line.trim()) continue; // 文件末尾的换行会产生空串,先跳过
let event;
try { event = JSON.parse(line); } catch { skipped++; continue; } // 半截行:跳过
switch (event.type) {
case "session_meta": meta = event.meta; break;
case "message": messages.push(event.message); break;
case "tool_call": /* 按 id upsert 进 toolCalls */ break;
default: break; // 未知类型忽略 —— 老代码也能加载新版本写的日志
}
}这个结构带来两个不显眼但重要的自由度:坏行可以跳过(容错);未知事件类型可以忽略(向前兼容——新版本程序写的日志,旧程序照样能加载它认识的部分)。
恢复后还要继续写。半截尾行通常没有换行,如果直接追加新事件,它们会粘成同一行非法 JSON,下次重放会把新事件一起丢掉。appendEvent 用 a+ 打开文件,只读取最后一个字节;非空文件没有以 \n 结尾时,在新事件前补一个换行。完整 JSON 仅缺换行时也按同样方式处理;空文件直接写第一条事件。所有写入仍然只追加,原有字节保持不变,每次追加也无需扫描整个日志。文件句柄在 finally 中关闭。
恢复会话时,模型配置从哪来?很多实现顺手用当前的环境变量或全局默认。这是错的:用什么模型是这个会话自己的属性,创建时就冻结进第一行 session_meta,恢复时以它为准:
meta = createSession(SESSIONS_DIR, { model: process.env.AGENT_MODEL ?? "deepseek-chat" });
// ……第二天恢复时:
const MODEL = restored.meta.model; // 来自会话记录,不是今天的默认值配置跟着会话走,界面显示和实际请求才不会不一致(真实产品踩过事故,见文末)。
s03 有个临时方案:靠报错文案的开头文字(FAILURE_RE)判断工具调用是否成功。文案是写给模型看的,随时会改——拿它当机器判据太脆。本章移除这个脚手架:成败在执行那一刻确定,作为结构化字段落盘。
约定很简单:handler return = completed,throw = failed;报错文案原样回给模型(错误即信息),但"失败了"这个事实走字段:
try {
return { status: "completed", output: tool.handler(args) };
} catch (err) {
return { status: "failed", output: err.message };
}落盘的 tool_call 事件带着这个 status。从此审计、重放、监督逻辑都读字段,不再解析文案。
agent.mjs 是 s03 的 agent + 落盘。关键改动两处。第一,消息只从一个入口进数组——内存和磁盘一步完成:
function pushMessage(message) {
messages.push(message);
appendEvent(SESSIONS_DIR, meta.id, { type: "message", message });
}第二,启动时分岔:带 --resume <id> 就重放恢复,否则开新会话并打印 id(运行命令见上文"运行")。
- 给
store.mjs加一个compacted事件和对应的重放逻辑:记录"从第 N 条消息之前已被压缩为摘要 S",重放时用摘要替换被压缩的区间。s06 的压缩机制落盘之后,才算完整闭环。 - 思考题:demo 场景三里半截行恰好在文件末尾,跳过它显然安全。但如果坏行出现在文件中间(比如磁盘坏块),跳过一条
message可能让后面的tool消息变成"孤儿"(tool_call_id 对不上助手消息)——API 会拒绝这样的序列。恢复时该怎么检测并修剪这种断链?(提示:s05 处理 Ctrl+C 留下的残缺消息序列用的是同一套办法。)
本章机制对应 Reina(本系列对照的生产级 agent)的 packages/core/src/rollout.ts(参照 openai/codex 的 rollout recorder 建模):每个会话一个 .reina/sessions/<id>.jsonl,每次状态变化 append 一行 { ts, type, ... }。示例版三种事件类型,生产版二十多种(message / tool_call / tool_update / compacted / usage / todos……)。几个值得参考的生产细节:
- 不变量写在注释里:"Writes are
O_APPENDonly. No code path ever rewrites an existing byte"——跨进程的并发写者也无法互相覆盖历史。进程内则常驻一个文件句柄、用 Promise 链串行化所有 append(示例版每次重开文件,崩溃安全性一样,性能差一些)。 - 重放跳过坏行:
loadRolloutAsSession对每行单独JSON.parse,失败("likely a torn final write from a crash")就跳过并告警skipped N malformed line(s)——和本章 demo 场景三相同。 - 工具调用是结构化记录:
packages/protocol/src/index.ts的ToolCallRecord带status: "pending_approval" | "running" | "completed" | "rejected" | "failed"——比示例版的两态多出审批流和运行中;还有outputPath指向.reina/tool_outputs/下的完整输出存档。 - 决定③的真实事故:Reina 曾在加载旧会话时,模型选择器显示新会话的默认值,而不是会话真正在用的(重放恢复出来的)模型——一个绑定了订阅的会话看起来像在用普通 API key,请求 401,用户以为是配置错误,排查了很久。模型配置随会话重放之外,还有一个细节:
config事件对 model 是整体替换而非浅合并——浅合并会让上一个模型的baseUrl泄漏到切换后的模型上,Reina 注释里记着一次真实事故:kimi 切 codex 后残留的 baseUrl 把请求路由到了错误的主机。 - 仅有的"全量重写"出现在迁移旧格式时(
migrateJsonSnapshotToJsonl),而且写法是先写临时文件再rename进位——rename 在同一文件系统上是原子的,崩溃也不会留下半个 jsonl。
另一个可观察的例子:Claude Code 的会话也是 JSONL(~/.claude/projects/<项目>/**.jsonl),--resume 的底层就是同一套重放事件流。
| ← 上一章:Prompt 缓存 | 目录 | 下一章:子代理与看门狗 → |
|---|

