在多轮对话和连续工具调用的过程中,Agent 会不断产生新的消息。会话引擎需要保存这些消息,维护每次发送给 LLM 的上下文,并在程序崩溃或执行中断后恢复会话,让后续执行能够接上已有进度。
这篇文章围绕一套基于 SQL 数据库的会话引擎设计,讨论消息持久化、上下文压缩、崩溃恢复与断点续聊,以及同一会话内的并发安全。
数据模型
数据库设计
对于每个会话,需要分别管理两套消息列表:
- 原始消息帧:保存系统提示、用户消息、模型输出和工具返回等完整记录,不因上下文压缩而删减。这套列表用于回溯会话的执行过程,是生成 Context 的基础。
- Context 窗口:本次调用 LLM 时实际发送的消息列表。它是原始消息经过摘要或过滤后的投影,用于控制 token 开销,让有限的上下文窗口能够承载更长的会话。
两者的区别在于:原始消息记录整个会话过程,Context 则决定模型在当前调用中能看到哪些信息。压缩会改变 Context 中的消息组织方式,但应保留原始记录。
下图以一个多轮 ReAct 过程为例:左侧保留完整消息帧,右侧将前三轮历史压缩为一条摘要消息,并保留后续消息。原始列表中的多条消息,因此可以对应 Context 中的一条消息。
据此,可以将持久化数据拆成三张表:
- 会话表:保存
session_id、下一条消息的序号next_msg_seq,以及当前上下文压缩版本context_compassion_version等会话状态。 - 原始消息表:按会话保存完整消息帧及其顺序。
- Context 窗口消息表:保存当前上下文中的消息,并通过
original_seqs记录它们与原始消息的对应关系。一条历史摘要可以关联多个原始消息序号。
两张消息表都通过 session_id 归属于会话。对于工具交互,模型消息中的 tool_calls 与工具回复中的 tool_call_id 用于关联一次调用及其返回结果。
运行时会话对象
持久化表保存会话记录,运行时则通过 Session 对象和 Context 对象维护当前会话的工作集。Session 标识当前会话;Context 保存本次发送给 LLM 的消息列表,以及消息序号、当前压缩版本等运行状态。实现中,Context 对应 RequestContext,嵌入在 Session 内,两者共用同一份消息工作集。
加载会话时,根据 session_id 读取会话状态和当前版本的 Context 消息,重建这一工作集。完整原始历史保留在数据库中,按需查询。Context 中的消息携带原始序号映射和工具调用信息,用于追溯摘要来源、关联工具调用与回复。下面的结构图只展示与后续消息更新和恢复有关的对象职责及数据来源。
后面的更新和恢复时序图,围绕这份工作集展开:追加消息或压缩上下文时先准备候选副本,数据库提交成功后再替换当前状态;崩溃恢复时,则从已提交记录重新构建工作集。
数据更新
会话数据的更新主要有两种情况:追加新消息,以及压缩已有上下文。这两种操作都需要同时考虑数据库记录和运行内存中的会话状态。
追加新消息
用户发消息、模型生成输出、工具返回结果时,会话引擎都需要将新消息追加到列表中。一次追加操作涉及三类持久化数据:原始消息表、Context 窗口消息表,以及会话表中的消息序号等状态。此外,运行内存中的会话对象和 Context 对象也需要同步更新。
数据库内的三张表可以通过同一个事务保证一致性。内存对象则需要安排好更新时机:如果先修改正在使用的对象,随后数据库写入失败,就会出现内存已有新消息、数据库却没有对应记录的情况。
这里采用先准备副本、再提交数据库、最后替换内存的顺序:
- 深拷贝会话对象和 Context 对象,在副本上分配消息序号、追加新消息。
- 在同一个数据库事务中写入原始消息表和 Context 窗口消息表,并更新会话表的序号计数。
- 事务提交成功后,用副本替换当前内存对象;如果事务失败,则丢弃副本,保留原有内存状态。
这样,准备写入的数据可以先在内存中完成组装,当前使用的会话对象则在数据库提交成功后才切换到新状态。
上下文压缩
上下文压缩改变的是 Context 的投影,不修改原始消息记录。压缩过程中需要对历史消息进行过滤或摘要,生成新的上下文列表,并更新压缩版本。
它沿用与追加消息相同的更新顺序:
- 深拷贝 Context 和会话对象,在副本上完成消息过滤、摘要和版本更新,保留压缩结果与原始消息的来源映射。
- 在同一个数据库事务中,删除当前会话已有的 Context 记录,插入压缩后的消息列表,并更新会话表中的压缩版本。
- 事务提交成功后,再用副本替换内存中的 Context 和会话对象;失败时保留原有状态。
将 Context 消息列表与会话表中的版本一起提交,可以避免新的压缩结果和旧版本状态混用。
是否保留 Context 历史版本
上述方案只保存当前版本的 Context:每次压缩后,用新投影替换旧投影。另一种选择是保留所有历史版本,压缩时只插入新版本消息,不删除旧版本。
历史版本能够展示同一段原始消息在不同阶段如何被压缩,有助于分析 Agent 的执行轨迹,以及压缩策略保留或丢失了哪些信息。代价是需要额外存储每个版本的 Context 消息列表。
如果采用多版本留存,需要在 Context 窗口消息表中增加 compassion_version 字段。加载会话时,只查询会话当前版本对应的消息,历史版本留作分析使用。
是否保留历史版本可以做成动态开关,按实际需要开启。下图展示了两种策略在同一会话中的区别:仅保存当前版本,或同时保留此前的 Context 投影。
崩溃恢复与断点续聊
恢复会话的基本过程,是根据 session_id 从数据库读取会话状态和 Context 消息,重新构建内存中的会话对象与 Context 对象。
这里有一个需要额外处理的中断状态:程序崩溃或用户主动中断 ReAct 循环时,Agent 可能正在执行工具。此时,模型发出的工具调用已经持久化,对应的工具回复却还没有写入,消息列表末尾会留下一个未配对的调用。
为了让后续模型调用能够接着处理这段会话,需要为缺失的回复构造一条工具消息,并通过 tool_call_id 关联原调用。
这条补写消息应明确说明:执行已中断,工具结果未知,继续执行前应先检查工具状态。缺少回复并不意味着工具没有执行,也不能据此判断它已经成功。工具可能已经产生了副作用,只是结果尚未写入;例如,会议提醒已经发送,但程序在保存回复前崩溃了。
因此,补写的是一条恢复提示。它让模型了解中断状态,而不是替原工具编造一个成功或失败的结果。
重建会话时,先加载持久化记录,检查是否存在未配对的工具调用。如果需要补齐回复,则将补写消息保存到原始消息表和 Context 窗口消息表;数据库提交成功后,再发布重建的内存对象,返回可继续执行的会话。
并发安全
对于以会话为执行边界的 Agent,不同会话的任务通常可以分别运行。仍需处理的是同一个 session_id 内的并发写入,例如:
- 网络异常或抖动时,用户点击重试,前一个请求仍在执行。
- 用户在多个登录态下,同时向同一个会话发送消息。
如果这类冲突频率较低,可以先通过乐观锁处理,避免为每个会话额外维护一套锁机制。
每次追加消息或压缩上下文时,事务都会更新会话表中的 next_msg_seq 或 context_compassion_version。因此,可以把读取到的这两个字段作为预期版本,放入会话表 UPDATE 语句的条件中:只有数据库中的值仍与预期一致,当前请求才能完成更新。
下图展示了两个请求读到相同会话状态的情况。请求 A 先完成条件更新并提交,改变了会话状态;请求 B 随后使用旧值更新时,无法命中记录,说明它基于过期状态准备的数据已经发生冲突。
此时应回滚 B 的整个事务,包括本次消息追加或上下文压缩的变更,并保留原有内存对象。不能只跳过会话表更新,却仍提交消息表中的数据。
如果多登录态同时发消息的情况变得频繁,再考虑引入分布式悲观锁,或从产品设计上限制同一会话的同时发送行为。具体采用哪种方式,可以根据实际冲突频率和交互需求调整。
这套 Session 引擎来自我用 Golang 实现的开源 Agent 项目 laxcode。如果你也在实现 Agent 的会话持久化、中断恢复,欢迎看看源码,也欢迎通过 Issue 交流实现细节和改进建议。