04 - 会话持久化
使用 JSONL 会话文件保存和恢复对话。
为什么会话持久化不可或缺?
在前三章中,我们的 Agent 都有一个致命的缺陷:一旦程序退出,所有对话记忆都会消失。你告诉 Agent 你叫什么名字,关掉程序再打开,它就全忘了。
这是因为 LLM(大语言模型)本身是无状态的。每次你调用 AI 模型的 API,它都像是一个全新的个体 —— 不记得之前说过什么、做过什么。我们在前面章节中感受到的"多轮对话"能力,实际上是框架在每次 API 调用时把完整的对话历史作为上下文一起发送给模型。
用一个类比来说:LLM 就像一个"失忆症患者",每次醒来都不记得之前的事。而会话持久化就是它的"日记本" —— 每次对话都记录下来,下次醒来先翻翻日记,就能"想起"之前发生了什么。
在这一章中,你将学会如何使用 SessionManager 的持久化功能,将对话历史保存到磁盘上的 JSONL 文件中,并在程序重启后恢复。
你将学到
SessionManager.create() —— 创建新的持久化会话
SessionManager.continueRecent() —— 恢复最近的会话
- 会话如何以 JSONL 文件存储在
.sessions/ 目录
- 使用
readline 构建 REPL 循环
- Agent 在恢复会话后能记住上下文
核心概念
会话的三种生命状态
┌─────────────┐ 程序退出 ┌──────────────┐ continueRecent() ┌──────────────┐
│ 创建新会话 │ ──────────────→ │ JSONL 文件 │ ─────────────────────→ │ 恢复旧会话 │
│ (create) │ 自动保存到磁盘 │ 存储在磁盘上 │ 从文件加载消息历史 │ (continue) │
└─────────────┘ └──────────────┘ └──────────────┘
↕ ↕
prompt() prompt()
subscribe() subscribe()
为什么选择 JSONL 格式?
pi-coding-agent 使用 **JSONL(JSON Lines)**格式来存储会话数据 —— 每一行是一个独立的 JSON 对象,代表对话中的一条消息。
为什么不用普通的 JSON 文件或数据库?JSONL 有几个独特的优势:
- 追加友好(append-friendly):新消息直接追加到文件末尾,不需要读取、解析、修改整个文件再写回。这对于实时对话场景非常重要 —— 每条消息都能立即持久化,即使程序崩溃也不会丢失之前的对话
- 流式读取:可以一行一行地读取和解析,不需要将整个文件加载到内存中。对于很长的对话历史,这比解析一个巨大的 JSON 数组要高效得多
- 简单调试:用任何文本编辑器就能查看和理解会话内容,每行就是一条完整的消息
{"role":"system","content":"You are a helpful assistant."}
{"role":"user","content":"My name is Alice"}
{"role":"assistant","content":"Hello Alice! Nice to meet you."}
{"role":"user","content":"What's my name?"}
{"role":"assistant","content":"Your name is Alice!"}
提示
你可以直接用 cat 或任何文本编辑器打开 .sessions/ 目录下的 JSONL 文件来查看对话历史。这对调试 Agent 的行为非常有帮助 —— 你可以看到 AI 实际收到了什么上下文。
完整代码
import * as path from "node:path";
import * as readline from "node:readline";
import {
createAgentSession,
SessionManager,
DefaultResourceLoader,
} from "@mariozechner/pi-coding-agent";
import { createModel } from "../../shared/model";
const SESSION_DIR = path.join(import.meta.dirname, ".sessions");
const model = createModel();
// 根据 CLI 参数决定会话策略
const arg = process.argv[2]; // 'continue' 或 undefined
let sessionManager: SessionManager;
if (arg === "continue") {
sessionManager = SessionManager.continueRecent(process.cwd(), SESSION_DIR);
const ctx = sessionManager.buildSessionContext();
console.log(`📂 已恢复会话(${ctx.messages.length} 条历史消息)`);
console.log(` 会话文件: ${sessionManager.getSessionFile()}\n`);
} else {
sessionManager = SessionManager.create(process.cwd(), SESSION_DIR);
console.log("📝 已创建新会话");
console.log(` 会话文件: ${sessionManager.getSessionFile()}\n`);
}
const resourceLoader = new DefaultResourceLoader({
systemPromptOverride: () =>
"You are a helpful assistant. Be concise. Remember our conversation context.",
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
});
await resourceLoader.reload();
const { session } = await createAgentSession({
model,
tools: [],
customTools: [],
sessionManager,
resourceLoader,
});
// 流式输出
session.subscribe((event) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
// REPL 循环
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("输入你的消息(或 /quit 退出):\n");
const ask = () => {
rl.question("You: ", async (input) => {
const trimmed = input.trim();
if (trimmed === "/quit" || trimmed === "/exit") {
console.log("\n再见!你的会话已保存。");
rl.close();
process.exit(0);
}
if (!trimmed) {
ask();
return;
}
process.stdout.write("\nAgent: ");
await session.prompt(trimmed);
console.log("\n");
ask();
});
};
ask();
逐步解析
1. 会话策略:创建 vs. 恢复
程序启动时,根据命令行参数决定是创建新会话还是恢复已有会话:
// 新会话 —— 创建一个新的 JSONL 文件
sessionManager = SessionManager.create(process.cwd(), SESSION_DIR);
// 恢复 —— 查找最新的会话文件并加载其消息
sessionManager = SessionManager.continueRecent(process.cwd(), SESSION_DIR);
SessionManager.create() 接收两个参数:
cwd(当前工作目录)—— 作为会话的上下文路径,某些内置工具(如文件操作)会使用它
dir(会话目录)—— JSONL 文件将存储在这个目录下
SessionManager.continueRecent() 会在指定目录中找到最新的会话文件并加载它。"最新"的判断依据是文件的修改时间。
2. 查看恢复的上下文
const ctx = sessionManager.buildSessionContext();
console.log(`📂 已恢复会话(${ctx.messages.length} 条历史消息)`);
buildSessionContext() 返回一个包含 messages 数组的对象。这个数组就是从 JSONL 文件中加载的完整对话历史。当你把这个 sessionManager 传给 createAgentSession() 时,框架会自动将这些历史消息作为上下文发送给 AI 模型。
3. REPL 循环
REPL(Read-Eval-Print Loop)是命令行交互程序的经典模式。我们使用 Node.js 内置的 readline 模块来实现:
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ask = () => {
rl.question("You: ", async (input) => {
// 处理输入...
await session.prompt(trimmed);
ask(); // 递归调用,实现循环
});
};
ask();
这里使用了递归模式来实现循环:每次用户输入后,处理完响应再次调用 ask()。这比 while 循环更适合 Node.js 的异步模型。
4. 会话文件的结构
会话以 JSONL(JSON Lines)文件存储在 .sessions/ 目录中。每一行是一个序列化的消息条目,保存完整的对话历史。
chapters/04-session-persistence/.sessions/
├── session-abc123.jsonl
└── session-def456.jsonl
每个会话文件的文件名包含唯一标识符,确保多个会话不会冲突。
底层原理:持久化是如何工作的?
当你通过持久化的 SessionManager 调用 session.prompt() 时,框架会自动执行以下步骤:
- 追加用户消息 —— 将用户的输入作为新的一行写入 JSONL 文件
- 构建完整上下文 —— 从文件中读取所有消息,加上系统提示词,发送给 AI 模型
- 流式接收响应 —— 像之前一样通过事件传递 AI 的回答
- 追加助手消息 —— AI 回答完成后,将完整的回答也追加到 JSONL 文件
整个过程对你来说是透明的 —— 你的代码和前面章节完全一样,只是把 SessionManager.inMemory() 换成了 SessionManager.create()。这就是良好抽象的力量。
┌─────────────────────────────────────────────┐
│ JSONL 文件 │
│ │
│ 第 1 次对话: │
│ {"role":"user","content":"我叫 Alice"} │
│ {"role":"assistant","content":"你好!"} │
│ │
│ 第 2 次对话(续写): │
│ {"role":"user","content":"我叫什么?"} │
│ {"role":"assistant","content":"你叫 Alice!"} │
│ │
│ ← 每条消息即时追加,不需要重写整个文件 │
└─────────────────────────────────────────────┘
常见错误
.sessions/ 目录不存在
SessionManager.create() 通常会自动创建目录,但如果遇到权限问题,你需要手动创建:
mkdir -p chapters/04-session-persistence/.sessions
continueRecent 找不到会话
如果 .sessions/ 目录为空或不存在,continueRecent() 会抛出错误。在生产环境中,你应该先检查是否有可恢复的会话,如果没有则创建新会话:
try {
sessionManager = SessionManager.continueRecent(process.cwd(), SESSION_DIR);
} catch {
console.log("没有找到可恢复的会话,创建新会话...");
sessionManager = SessionManager.create(process.cwd(), SESSION_DIR);
}
上下文窗口溢出
随着对话越来越长,JSONL 文件中的消息越来越多,总 token 数可能超过模型的上下文窗口限制(比如 Claude Sonnet 的 200K token,GPT-4o 的 128K token)。当这种情况发生时,API 调用会失败。
注意
在生产环境中,你需要实现对话截断或摘要策略 —— 当消息总量接近上下文窗口限制时,删除最早的消息或将它们压缩成摘要。pi-coding-agent 框架在底层会处理部分截断逻辑,但对于超长对话,你可能需要自行管理。
生产环境建议
如果你要将会话持久化用于生产环境,以下是一些重要的建议:
-
会话清理策略:定期清理过期的会话文件,避免磁盘空间无限增长。可以基于文件修改时间设置过期策略(如 30 天未活跃的会话自动删除)
-
并发安全:JSONL 的追加写入在单进程中是安全的,但如果多个进程同时写入同一个会话文件,可能会导致数据损坏。确保每个会话文件同一时间只有一个进程在写入
-
敏感信息:会话文件包含完整的对话内容,可能包含用户的敏感信息。确保 .sessions/ 目录有适当的文件系统权限,不要将其纳入版本控制
-
备份:对于重要的会话数据,考虑定期备份到安全的存储位置
提示
在开发阶段,经常查看 .sessions/ 目录下的 JSONL 文件是一个好习惯。它能帮你理解 Agent 实际看到的上下文是什么,对调试 Agent 的异常行为非常有帮助。
运行
# 启动新会话(交互式 REPL)
bun run ch04
# 恢复上一个会话
bun run ch04 continue
试一试
这是一个验证持久化是否正常工作的简单实验:
- 运行
bun run ch04,告诉 Agent:"My name is Alice"
- 等 Agent 回复后,输入
/quit 退出
- 运行
bun run ch04 continue,问:"What's my name?"
- 如果一切正常,Agent 应该回答 "Alice" —— 它"记住"了!
这个实验清楚地展示了会话持久化的效果:即使程序完全退出重启,Agent 仍然能访问之前的对话上下文。
你还可以进一步实验:
- 查看
.sessions/ 目录下生成的 JSONL 文件内容
- 多次恢复同一个会话,观察历史消息数量的增长
- 尝试不带
continue 参数重新运行,验证新会话确实不记得之前的对话
小结
在这一章中,你学到了:
- LLM 是无状态的 —— 它本身不记得任何对话历史,"记忆"是通过每次发送完整上下文实现的
- 会话持久化的本质 —— 将对话历史保存到文件,下次启动时重新加载作为上下文
- JSONL 格式的优势 —— 追加友好、流式读取、易于调试
SessionManager 的两种模式 —— create() 创建新会话,continueRecent() 恢复最近的会话
- REPL 循环的实现 —— 使用
readline + 递归调用实现命令行交互
- 生产环境的注意事项 —— 上下文窗口限制、并发安全、敏感信息保护
会话持久化让 Agent 从"金鱼记忆"进化到了"长期记忆"。但有了记忆和工具的 Agent,如果不加约束,可能会执行一些危险的操作。在下一章中,我们将学习确认模式 —— 让 Agent 在执行关键操作前先征得用户的同意。
下一章
第 05 章:确认模式 —— 在执行危险操作前要求用户确认。