在开发 Agent 应用时,很多开发者都会遇到一个基础但容易被忽略的问题:模型能记住上下文,是因为每一轮请求都把历史消息一起发了过去;可一旦程序退出,这些保存在内存里的历史也会随之消失。重新创建一个 Agent,得到的只能是一个没有任何前文的新会话。
掘金技术社区“从零构建 Agent”系列的第十篇,聚焦的就是这个问题:如何让 Agent 把多轮对话保存到磁盘,并在程序重启后恢复原来的会话。文章以一个读取文件的工具调用流程为例,展示了基于 JSONL 的会话持久化方案,包括消息保存、逐行追加、会话标识管理以及跨进程恢复等实现细节。
为什么连续对话还需要持久化
在前一篇中,Agent 已经可以实现连续对话:第一轮问答保留在 agent.state.messages 中,第二次请求会把这些消息和新问题一起发送给模型,模型才能基于前文作答。
但这种方式依赖当前进程。只要程序持续运行,历史记录就可以继续使用;程序一旦退出,内存中的消息就不复存在。如果希望今天关闭程序,明天打开后还能接着提问,就需要把消息保存到进程之外。
所谓会话持久化,就是将会话内容写入磁盘文件,使其在程序退出后仍然保留。下一次启动时,程序再读取这些内容,新 Agent 就能继续使用原来的历史。模型理解上下文的方式并没有变化,变化的是历史消息可以跨越两次程序运行。
文章沿用了该系列此前的示例:用户要求 Agent 读取 note.txt 第 2 行,模型调用 read 工具取得文件中的验证代号,再回复用户。这段对话通常包含四条消息:
- 用户提出读取要求
- 模型请求调用
read read返回读取结果- 模型给出最终回答
因此,保存的对象不能只是最后显示在终端里的结果,而应当是完整的消息对象及其顺序。工具调用与工具结果之间的对应关系,也需要通过原有的调用标识保留下来。
JSONL 结构:文件头加逐条消息记录
会话会不断产生新消息。如果文件采用 JSONL 格式,每一行保存一个独立的 JSON 对象,就可以在文件末尾追加新记录,而不必每次重写此前的全部内容。
在该示例中,作者使用 pi 的原生 JSONL 会话存储。第一行是文件头,保存会话基本信息;后续行记录会话内容。完成一轮读取后,文件结构可以理解为:
- 第 1 行:文件头,标识这是哪个会话
- 第 2 行:用户提出读取要求
- 第 3 行:模型请求调用
read - 第 4 行:
read返回读取结果 - 第 5 行:模型给出回答
在给出的结构示意中,文件头包含 kind: "header"、version: 4、id: "chapter-10" 和 cwd 等字段。后续记录使用 kind: "entry",并通过 type: "message" 表示该行承载的是一条消息。
每条消息外层还有用于组织会话文件的字段,例如记录标识、父记录标识、顺序编号等。文章特别提醒,要区分两种标识:外层的消息记录标识用于组织会话文件,消息内部的工具调用标识则用于关联一次工具调用及其结果。恢复会话时,这两层关系都需要保留。
保存与恢复的完整路径
按照示例实现,持久化流程被拆成三步:准备会话文件、写入消息、加载消息并交给新 Agent。
在保存侧,应用先通过 JsonlSessionRepo 创建和打开磁盘会话。代码中指定保存目录后,调用 repo.create() 创建会话。该方法会准备目录和文件头,再创建绑定到该文件的 Session 对象。
保存一条完整消息的入口是 session.appendMessage(message)。以工具结果消息为例,消息本身已经包含调用标识和读取到的代号。pi 会为其增加会话记录信息,再将整条记录写入文件。其内部路径包括:
Session将消息包装成记录JsonlSessionStorage.appendEntry()补齐父记录、顺序编号、时间戳等字段appendMutation()将记录写入文件encodeMutation()将记录转为 JSON 文本并在末尾加换行fs.appendFile()将这行文本追加到会话文件
真正把编码结果交给文件系统的语句是:
await this.fs.appendFile(this.metadata.path, encodeMutation(mutation))
先把记录编码成一行,再追加到文件末尾,这就是消息落盘的核心动作。
在恢复侧,新进程需要把文件中的记录读回来,再还原为按对话顺序排列的消息。示例代码给出了三步:
const session = await repo.open(metadata);const entries = await session.findEntriesOnBranch({ order: "oldestFirst" });const { messages } = buildSessionContext(entries);
这三行依次完成从文件到消息的转换。得到的数组仍然包含原来的用户输入、工具调用、工具结果和回答。将其传给新 Agent 的 initialState.messages,Agent 初始化时就会接收这份历史。下一次提问时,历史与新问题一起发给模型,就能接着原来的对话回答。
也就是说,恢复过程可以概括为:文件中的 JSON 文本,变成有序的会话记录,再变成新 Agent 的历史消息。文章也指出,加载文件不会重新执行工具调用,工具结果会直接作为历史内容使用。
跨进程实验验证与常见注意点
为了验证效果,示例程序先后启动两个独立进程。第一个进程读取随机代号并保存会话,退出后,第二个进程加载会话并追问这个代号。
为便于观察,第一个进程保存成功后会删除原始 note.txt。第二个进程收到的新问题不包含代号,需要从恢复的历史消息中取得。
运行 node labs/10-session-persistence.ts 后,预期输出节选显示:
phase: save, pid: 41001restored roles: (empty)file content: "用途:验证会话恢复\n本次验证代号:read-841273"final answer: read-841273saved roles: user -> assistant -> toolResult -> assistantsource file removed; save process will exitphase: resume, pid: 41002restored roles: user -> assistant -> toolResult -> assistantsource file exists: falsefinal answer: read-841273saved roles: user -> assistant -> toolResult -> assistant -> user -> assistant
两个 PID 表明问答发生在不同进程中;restored roles 表明第二个进程已经取得原来的四条消息。对照最初文件内容与第二次回答,代号保持一致。追问结束后,保存的消息从四条增加到六条,说明新的一问一答也进入了同一会话。
文章同时提醒,这些输出用于观察结果,不会自动断言模型回答正确;请求或保存失败时程序会报错,运行结束后,父进程会清理实验创建的临时目录和会话文件。
对于正在搭建 Agent 工具的开发者来说,这个示例的价值在于把会话持久化拆成了几个明确边界:会话基本信息由文件头记录,消息按原始结构保存,追加写入依赖 JSONL,恢复时再还原为有序历史。这样,重启后的 Agent 就能根据之前的用户输入、工具结果和回答继续对话,并将新产生的消息接着保存。
文章还列出了源码核对入口,包括 repo.ts 中的会话创建与打开、session.ts 中的消息包装与查询、codec.ts 中的文件记录编码、storage.ts 中的文件写入与加载,以及 context.ts 中的记录到消息转换。对于希望自行实现 Agent 会话存储的读者,这些模块提供了一个可直接对照的参考路径。
原创文章,作者:点点,如若转载,请注明出处:https://www.dian8dian.com/chong-qi-ye-bu-shi-yi-yong-jsonl-gei-agent-hui-hua-zuo-chi