27 · Session 持久化
Stage Hello Coding Agent · v27-session-store

第二十六章,我们把多轮对话正式化为 Session——Session { id, context },一场对话有了身份、有了被持有的上下文。转录里两轮对话共享同一个 Session:第一轮修好 factorial 的 bug,第二轮接着加 divide 函数,模型全程记得上一轮改了什么。
但聪明的小伙伴,和容易找到这里的一个坑:Session 只活在内存里。你按下 exit,进程一退出,那个 id 和它怀抱的 context 就一起烟消云散。下一章我们要 --resume 续跑昨天的任务,可昨天的对话压根没留下足迹。
所以这一章,我们要把 Session 写进磁盘——.sessions/session-id.json。接下来就进入正题。
一、上一版存在什么问题?
一般来讲,一个能正经聊天的 Agent,最怕的不是它答不对,而是「聊完就失忆」。回看一下 ch26 的 Session,遗留的问题其实挺明显:
- Session 是易失的:
Session对象只存在于进程内存里,进程退出 = 对话消失——id、context、每一轮修过的东西全部归零; - 没有地方可「找回来」:想继续昨天的对话,没有任何文件、任何索引指向它——你不知道自己有哪些历史会话,更别说怎么恢复;
- 「能导出」但没有「导出到哪」:ch26 给 Session 配了
snapshot()/restore(),但只有内存中的对象对拷,没有落盘手段——数据在,路没有; - 上下文随进程生命周期绑定:
chat()里创建的Session和 CLI 进程同生共死——进程在则对话在,进程亡则对话亡; - 不可审计、不可复盘:一场对话从头到尾发生了什么,没有可查的记录——修了什么文件、跑过什么命令、得出什么结论,退出后无从回溯。
一句话:上一版的 Session 是「过目即忘」的——它有身份、有内容,但没留下任何足迹。它不是一场「对话」,只是一场「短暂的会话」。 简单的说,就是个用完即弃的临时工 😂
二、本篇解决什么问题?
那么问题来了:既然上面对话这么脆弱,我们该怎么让它「活」下来?接下来看下这一章的具体解决姿势:
- 把 Session 落盘:新增
SessionStore——save()把{ id, context }写成.sessions/<session-id>.json,一场对话 = 一个文件; - 每轮对话结束自动保存:
chat()每跑完一轮就store.save(session.snapshot())——不用用户手动操作,对话实时留痕; - 能按 id 取回:
load(id)从磁盘读回完整会话——为 ch28--resume铺好最后一块砖; - 能列出来:
list()返回全部历史会话(按保存时间倒序)——小伙伴可以知道自己有哪些对话可续; - 落盘位置有边界:
.sessions/放在 workspace 根目录内(ch23 的 Workspace 边界)——持久化的数据也遵守「活动范围锁定在显式 workspace」的安全约定。
核心心智模型:
Session 是「对话的运行时形态」,SessionStore 是「对话的持久化形态」。运行时里对话是一个可聊天的对象,磁盘上对话是一份可恢复的 JSON——「能导出」+「有地方存」=「能续跑」的前提。
解决完上面五件事,把线串一下:上一版「Session 只活在内存、没有索引、没有落盘路」这些遗留问题 → 这一章用「SessionStore + .sessions/」解决 → 接下来看一场对话怎么在磁盘上留下完整的脚印。
解决之后,我们收获了什么?
- 对话可以活过进程:
.sessions/72d72bd0-xxx.json躺在硬盘上——退出 CLI,对话依然存在; - 每轮自动留痕:一轮结束自动
save()——不用手动存,也不会漏存; - 能找回来:
load(id)从磁盘恢复完整上下文——「继续昨天的任务」在技术上可行了; - 能看清有什么:
list()按时间倒序列出全部会话——小伙伴对「我有哪些历史对话」心里有数; - 持久化也有边界:
.sessions/落在 workspace 根内,id有格式校验——既不越界,也不怕路径注入。
一句话收个尾(最基本的,手动加强语气):遗留的「Session 易失、无索引、无落盘路」问题被这一章的 SessionStore 解决掉,换来的则是「一场对话 = 一个
.sessions/下的 JSON 文件」——下一章就把文件读回来续跑。
三、先看最终效果
还是 examples/stage-3/25-cli/,跑一场和 ch26 一模一样的两轮对话。唯一的区别:这一版启动时多打印一行 Sessions: 告诉你会存到哪里:
Hello Harness v1.0 · 流式多轮对话(输入 exit 退出,Ctrl+C 取消本轮)
Session : 72d72bd0-c051-47ee-a030-fd4032585c85
Sessions: D:\Workspace\hui\project\hello-harness\examples\stage-3\25-cli\.sessions第一轮(修复 factorial bug,read → edit → npm test pass 3):
你 > 帮我修复这个项目里的 bug:src/calc.mjs 的 factorial 函数结果不对,请先观察、再修改、最后运行 npm test 验证
Step 1 · model → 调用工具:read, bash
Step 4 · model → 调用工具:edit
Step 5 · tool → edit({"path":"src/calc.mjs","oldString":"return n * factorial(n - 2);","newString":"return n * factorial(n - 1);"}) = "已替换 1 处"
Step 11 · model → 调用工具:bash
Step 12 · tool → bash({"command":"npm test"}) → pass 3 / fail 0
Status : completed (finished)第二轮(同一个 Session,加 divide 函数 + 测试,pass 6):
你 > 很好,现在再帮我加一个 divide 函数,并补充一条测试
Step 2 · tool → read({"path":"src/calc.mjs"}) = "... return n * factorial(n - 1); ..." // 记得第一轮
Step 16 · tool → write({"path":"src/calc.mjs","content":"... export function divide(a, b) { ... }"}) = "已写入"
Step 24 · tool → write({"path":"test/calc.test.mjs", ...}) = "已写入"
Step 26 · tool → bash({"command":"npm test"}) → pass 6 / fail 0
Status : completed (finished)退出之后——见证奇迹的时刻来了——磁盘上多了一个文件:
examples/stage-3/25-cli/
.sessions/
72d72bd0-c051-47ee-a030-fd4032585c85.json ← 这一场对话的完整脚印(21KB)打开它,44 条消息全部在(实测结果如下):
{
"id": "72d72bd0-c051-47ee-a030-fd4032585c85",
"context": {
"messages": [
{ "role": "system", "content": "你是一个简洁、直接的中文 Coding Agent。…" },
{ "role": "user", "content": "帮我修复这个项目里的 bug:src/calc.mjs 的 factorial 函数结果不对…" },
{ "role": "assistant", "content": "", "toolCalls": [ { "name": "read", "arguments": { "path": "src/calc.mjs" } } ] },
{ "role": "tool", "toolCallId": "01a0…", "content": "{\"ok\":true,\"value\":\"export function add(a, b) {…\"}" },
{ "role": "assistant", "content": "", "toolCalls": [ { "name": "edit", "arguments": { … } } ] },
{ "role": "tool", "toolCallId": "01a0…", "content": "{\"ok\":true,\"value\":\"已替换 1 处…\"}" },
…
{ "role": "assistant", "content": "全部 6 个测试通过 ✅。…", "toolCalls": [] }
]
},
"savedAt": 1786860624114
}这就是下一章 --resume 的原料:这个 JSON 文件里,system 方法论、每一条 user 指令、每一次工具调用、每一条工具结果、每一轮的回答,按顺序一字不差地躺着。把它读回来塞进 Session.restore(),昨天的对话就活了。
四、架构变化
这一章在 session/ 下加了一个「存储」角色,和 Session 平级:
src/session/
session.ts # 对话的运行时形态:id + context + turn() + snapshot()/restore()
store.ts # 对话的持久化形态:save() / load() / list() ← 新增在 CLI 里的接线:
chat()
├── Session ← 对话本体(内存)
└── SessionStore ← 对话的磁盘落点(.sessions/)
└── workspace.resolve(".sessions/…") ← 复用 ch23 的 Workspace 边界关键设计:Session 负责「对话是什么」,SessionStore 负责「对话存哪里」。 Session 不碰文件系统,Store 不碰对话逻辑——分工明确,各自可测。而且落盘一律经 Workspace.resolve(),持久化的路径也受 workspace 边界保护(请注意,这一道边界不是可有可无的装饰,是安全约定)。
五、核心抽象
这一章新增一个核心概念——SessionStore(会话存储):
interface SessionStore {
save(snapshot: SessionSnapshot): Promise<void>; // 存:一场对话 → 一个 .json
load(id: string): Promise<SessionRecord | null>; // 取:id → 完整会话(找不到返回 null)
list(): Promise<SessionMeta[]>; // 列:全部历史会话(按时间倒序)
}三个设计要点(重点关注这三点,是这一章的精华):
- 一对话一文件:文件名就是
id——.sessions/<id>.json和一场对话一一对应,load(id)就是按文件名取文件; - id 有格式校验:落盘前先正则校验
id(只允许十六进制 + 连字符)——从根上防掉路径注入(../之类进不了文件名); - 记录带时间戳:每条记录额外保存
savedAt——list()按它倒序,小伙伴永远先看到最近的对话。
为什么用 JSON? 因为
context.messages全是「可 JSON 化的普通对象」(role / content / toolCalls / toolCallId)——snapshot() 产出的东西天然就是 JSON,JSON.stringify进文件、JSON.parse回来,零转换成本。最简单的格式配最简单的数据,这就是「最小实现」。 骚操作没有,朴素的道理一条。
六、实现代码
src/session/store.ts:会话存储
下面给出完整的 SessionStore 实现,注释里把关键的边界校验都标出来了:
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { Workspace } from "../workspace/workspace";
import type { SessionSnapshot } from "./session";
export interface SessionMeta {
id: string;
savedAt: number;
}
export interface SessionRecord extends SessionSnapshot {
savedAt: number;
}
export class SessionStore {
constructor(
private readonly workspace: Workspace,
private readonly dirName: string = ".sessions",
) {}
private sessionPath(id: string): string {
// 注意,这里先校验 id 格式,防止路径注入
if (!/^[0-9a-fA-F-]{1,64}$/.test(id)) {
throw new Error(`非法 session id:${id}`);
}
return path.join(this.dirName, `${id}.json`);
}
async save(snapshot: SessionSnapshot): Promise<void> {
const record: SessionRecord = { ...snapshot, savedAt: Date.now() };
await this.workspace.write(this.sessionPath(snapshot.id), JSON.stringify(record, null, 2));
}
async load(id: string): Promise<SessionRecord | null> {
const filePath = this.sessionPath(id);
if (!(await this.workspace.exists(filePath))) return null;
const raw = await this.workspace.read(filePath);
return JSON.parse(raw) as SessionRecord;
}
async list(): Promise<SessionMeta[]> {
const dir = this.workspace.resolve(this.dirName, "访问会话目录");
const entries = await readdir(dir).catch(() => [] as string[]);
const metas: SessionMeta[] = [];
for (const name of entries) {
if (!name.endsWith(".json")) continue;
const raw = await readFile(path.join(dir, name), "utf-8").catch(() => null);
if (!raw) continue;
const parsed = JSON.parse(raw) as SessionRecord;
metas.push({ id: parsed.id, savedAt: parsed.savedAt });
}
return metas.sort((a, b) => b.savedAt - a.savedAt);
}
}重点(最基本,手动加强语气):
save()只用workspace.write()——Workspace负责建目录(mkdir recursive)、写文件、并校验路径不越界——持久化也走 ch23 的边界;load()先exists()再读——找不到返回null而不是抛错——「查无此会话」是一种正常的业务状态,ch28 的 resume 拿到null直接报「没有这个会话」;list()用readdir+ 逐个解析,坏文件跳过(.catch(() => null))——目录里混入非会话文件也不崩。
src/cli/chat.ts:每轮结束自动落盘
改动集中在三处,下面给出关键片段(环境搭建之类的不赘述了):
const store = new SessionStore(workspace); // ① 建 store,绑定 workspace
console.log(`Sessions: ${workspace.root}/.sessions`); // ② 告诉用户存哪里
for (;;) {
const prompt = await ask();
if (prompt === null || prompt.trim() === "" || prompt === "exit" || prompt === "quit") break;
state.stepCount = 0;
state.retryCount = 0;
const run = await session.turn(runtime, prompt);
await store.save(session.snapshot()); // ③ 一轮结束,立刻落盘
printSummary(run, state);
}重点:store.save(session.snapshot()) 放在每轮 turn() 之后——聊一句、存一次。中途 Ctrl+C、断电、崩溃,最多丢当前这一轮,之前的对话全在磁盘上。这就是这一章最实在的使用姿势:不刻意,但稳。
src/cli/index.ts:把 workspace 交给 chat
if (args.chat) {
await chat(model, registry, SYSTEM_PROMPT, workspace, options); // 新增 workspace 参数
}重点:chat() 新增 workspace 参数——store 知道该往哪个 .sessions/ 写(--dir 指定的项目就有它自己的 .sessions/,互不串味)。
.gitignore:会话是本地数据,不是代码
.sessions/重点:.sessions/ 是用户运行产生的本地数据,不是仓库的一部分——加进 .gitignore,让 git 永远不追它们(和 .env 同类待遇)。
七、运行 Demo
跑法一:开一场对话并验证落盘(本章的演示,复现第三节转录):
$ hello --dir examples/stage-3/25-cli --chat
Hello Harness v1.0 · 流式多轮对话(输入 exit 退出,Ctrl+C 取消本轮)
Session : 7898ee33-de63-4dd6-b24d-d6193c241d70
Sessions: D:\Workspace\hui\project\hello-harness\examples\stage-3\25-cli/.sessions
你 > 帮我修复这个项目里的 bug:src/calc.mjs 的 factorial 函数结果不对,请先观察、再修改、最后运行 npm test 验证
…(第一轮:read → edit → npm test,pass 3 / fail 0)
你 > 很好,现在再帮我加一个 divide 函数,并补充一条测试
…(第二轮:write → write → npm test,pass 6 / fail 0)
你 > exit退出后验证磁盘留痕,测试输出如下:
$ dir examples\stage-3\25-cli\.sessions
7898ee33-de63-4dd6-b24d-d6193c241d70.json
$ node -e "const fs=require('fs');const d=JSON.parse(fs.readFileSync('examples/stage-3/25-cli/.sessions/'+fs.readdirSync('examples/stage-3/25-cli/.sessions')[0]));console.log('id:',d.id);console.log('messages:',d.context.messages.length);console.log('savedAt:',d.savedAt);"
id: 7898ee33-de63-4dd6-b24d-d6193c241d70
messages: 44
savedAt: 1786872044291
观察要点:
| 观察点 | 期望 | 实测 |
|---|---|---|
| 落盘位置 | .sessions/<id>.json,在 workspace 根内 | examples/stage-3/25-cli/.sessions/72d72bd0….json |
| 文件名 | 就是 Session id | 7898ee33-de63-4dd6-b24d-d6193c241d70.json ✓ |
| 内容 | 完整 44 条消息(system/user/assistant/tool) | 消息按顺序一字不差 ✓ |
| 保存时机 | 每轮结束自动存 | 退出后文件已存在 ✓ |
| 边界 | 不越出 workspace | dirName 经 workspace.resolve() 校验 ✓ |
这一章同样不做无模型 demo:SessionStore 的价值必须由真实对话来填充——让模型真修一轮 bug,你才能在磁盘上看到「一场对话的完整脚印」,而不是一个空壳 JSON。然后就可以愉快的观察落盘结果了。
八、新架构解决了什么?
- 对话可以活过进程:
.sessions/<id>.json让「这场对话」从内存活到硬盘——退出 CLI,对话还在; - 每轮自动留痕:
turn()后立即save()——聊一句存一次,不用手动、不怕漏存; - 可恢复:
load(id)能从磁盘原样取回完整上下文——ch28--resume的原料已经备好; - 可枚举:
list()倒序列出全部历史会话——「我有哪些对话可续」一目了然; - 持久化也有边界和安全:
.sessions/经Workspace.resolve()落盘、id有正则校验——不越界、防注入; - 可审计可复盘:一场对话的工具调用、文件改动、命令结果全部留档——事后能查「它到底干了什么」。
九、它又引入了什么问题?
接下来再泼盆冷水,看看这一版还留了哪些坑:
- 只有「存」,还没有「取回来用」:
load(id)实现了,但没有任何命令或入口真的调用它——--resume是下一章的事; - 没有会话列表入口:
list()实现了,但小伙伴没有一条命令看到历史会话——「有什么可续」还只能靠翻目录; - 没有并发保护:多个
hello进程同时写同一个.sessions/会互相覆盖——单机单进程够用,但写入不是原子的; - 没有清理/过期策略:
.sessions/只增不减,对话越攒越多——没有「删掉没用的」或「太旧自动清」; - 持久化格式是裸 JSON:如果以后消息结构升级(加字段、改类型),旧文件解析会出兼容性问题——缺一个 schema/版本号;
- 磁盘敏感信息:
.sessions/里躺着工具输出,可能包含路径、命令输出等敏感内容——文件权限和清理都要留意。
十、下一章
.sessions/ 里躺着一场完整的对话,但它现在只是「躺」着——没有任何命令能把这份文件读回来,让 Agent 接着干。下一章我们做 Resume:hello --resume <session-id> 从磁盘读回会话,让 Agent 继续昨天的任务。
持久化给了对话「墓志铭」,Resume 给它「复活术」——一场昨天的对话,明天还能接着干。
上面这些就是 Session 持久化的基本使用姿势了,有啥用、怎么接着玩,留在下一篇 --resume 里逐一展开。
尽信书则不如,以上内容纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现 bug 或者有更好的建议,欢迎批评指正,不吝感激 🙃 欢迎点赞、关注公众号「一灰灰Blog」 我们下章见
微信公众号: 一灰灰Blog