(05)进程一关对话就没了:聊天记录怎么存、重启怎么恢复

上一篇结尾我说,我们的 agent 有个毛病------能转、能调工具、能换模型,可这一切全活在内存里,进程一关,什么都没了。这篇就来治它。

先把话说清楚,免得被"关掉就忘"这个说法带偏:这篇讲的是把"这一次对话"存下来、重启还能接着聊 ,这叫持久化 。它跟另一个词------"记忆" ------不是一回事。"记忆"是指 agent 跨越不同对话 ,记住关于你的事实(你的偏好、你定的规矩),哪怕开一个全新对话它也还记得;那是个更大的话题,我单独留了一篇讲。本篇只干一件事:把眼前这摊对话,稳稳存住、还能恢复。

先想清楚:到底要存什么

别急着建表,先问一句:一次对话,本质上是什么?

往简单了说,就是一串按顺序发生的事 + 一点元数据。元数据是"这是谁的会话、什么时候建的、现在什么状态";那串事------你说了句话、模型回了话、它调了个工具、工具返回了结果------才是主体。

那存哪?我选了 SQLite,一个嵌进程序里的小型数据库。理由很朴素:它就是个本地文件,跑在你自己机器上,数据不出门------正好接上第一篇那个"本地优先"的地基。不用起服务、不用连云,程序带着它走。

元数据,别只存"标题"

会话的元数据,新手容易只存个标题、创建时间就完事。但真做起来,有几样你迟早得加:

  • 状态:这会话是闲着、正在跑、还是已经结束?后面判断"能不能往里发新消息"全靠它。
  • 父子关系:还记得我们会派子 agent 吗(后面专门讲)?子 agent 也是个会话,它得记住"我爸是谁"。删父会话时,得顺着这条线把子会话一起处理掉。
  • 心跳:一个正在跑的会话,得时不时"报个平安",更新一个时间戳。万一进程崩了没报平安,下次启动就能发现"这家伙上次没正常收尾",做相应处理。

这些字段当时我都是缺一个补一个、被坑一次加一个。所以会话这张表,大概长这样:

ts 复制代码
interface Session {
  id: string
  parentId?: string                          // 子 agent 会话靠它记住「我爸是谁」,删父时顺藤摸瓜
  status: "idle" | "running" | "done"        // 状态:决定能不能往里发新消息
  lastBeatAt: number                         // 心跳:正在跑的会话定时更新它,崩了就停在那
  createdAt: number
}

一句话:元数据不是"标题 + 时间",它得撑得起状态流转、父子级联、崩溃恢复。 上面每个字段,后面都有一篇会用到它。

🤔 存"消息",还是存"事件"

这是我纠结过、也是最值得讲的一个决定。

最直觉的做法:存"消息列表"。一条用户消息、一条模型消息......界面怎么显示,就怎么存。简单直接。

但我没这么干,而是存"事件流" :把每件发生的事------用户发言、模型回话、发起工具调用、工具返回------按先后,一条条记成只增不改 的事件,落进一张事件表。界面要的那个"消息列表",是从这串事件投影出来的视图。

graph TD E[事件流 只增不改的流水账] --> V1[投影成 消息列表 给界面看] E --> V2[实时推给前端] E --> V3[回放 重建任意时刻的状态]

为什么舍近求远?因为事件流是一本流水账,有几个消息列表给不了的好处:

  • 可回放:从头按序放一遍,就能精确重建任何时刻的状态(下面马上用到);
  • 可审计:谁在什么时候干了什么,白纸黑字,改不了;
  • 能长出多个视图:消息列表是一种投影,统计、时间线、给别的程序看的格式,都能从同一份事件流派生,不用各存一份。

一句话:消息是结论,事件是过程。存过程,你随时能推出结论;只存结论,过程就永远找不回来了。

落到代码上,一条事件就是这么个朴素结构:

ts 复制代码
// 每件事都记成一条「只增不改」的事件
interface Event {
  seq: number       // 会话内递增序号------断线重连、回放全靠它(第 12 篇细说)
  sessionId: string
  type: string      // "user.said" / "model.replied" / "tool.called" / "tool.result" ...
  payload: object   // 这件事的具体内容(注意:大对象别往这塞,见后文)
  at: number        // 发生时间
}

// 发生一件事 = 追加一条,绝不去改老的
function append(sessionId, type, payload) {
  const seq = nextSeq(sessionId)
  db.insert("events", { seq, sessionId, type, payload, at: Date.now() })
  bus.emit({ seq, sessionId, type, payload })   // 顺手广播出去,第 12 篇要用
}

界面要的「消息列表」,不另存一份,而是从这串事件投影出来:

ts 复制代码
function projectMessages(sessionId) {
  const events = db.query("events", { sessionId }, { orderBy: "seq" })
  const msgs = []
  for (const e of events) {
    if (e.type === "user.said")     msgs.push({ role: "user", text: e.payload.text })
    if (e.type === "model.replied") msgs.push({ role: "assistant", text: e.payload.text })
    // tool.called + tool.result 折叠成界面上的一张工具卡片......
  }
  return msgs
}

你看,消息列表是「算」出来的,不是「存」出来的------这就是"事件是真相、消息是投影"落到代码里的样子。

进程重启后,怎么把"现场"重建出来

事件流最爽的一点,是重启恢复几乎白送。

进程一关,内存里那些"当前对话长什么样"的状态全没了。但没关系------事件流还在硬盘上躺着。重新启动时,我只要把这个会话的事件从头按序放一遍 ,内存里的现场就精确重建出来了:聊到哪了、调过哪些工具、结果是什么,一点不差。

graph TD Start[进程重启 内存空白] --> Load[从事件表捞出这个会话的全部事件] Load --> Replay[按顺序重放一遍] Replay --> State[现场精确重建 跟没关过一样]

重放落到代码上,就是一个循环:

ts 复制代码
// 进程重启,内存一片空白 ------ 把事件按序重放一遍,现场就回来了
function rebuild(sessionId) {
  let state = emptyState()
  for (const e of db.query("events", { sessionId }, { orderBy: "seq" })) {
    state = apply(state, e)   // 跟当初第一次处理这件事时,一模一样的逻辑
  }
  return state
}

盯着 apply 那行看一眼:重放用的,和实时处理事件用的,是同一套逻辑。这点是关键------只要"处理一件事"的逻辑是确定的,重放就一定能还原出和当时分毫不差的现场,不用另写一套"恢复代码"。

这就是"存过程"的红利:你存的不是某个时刻的快照,而是怎么走到这一步的全过程,所以任何时刻的状态都能推出来。这个"重放"能力,后面讲调试、讲断线重连的时候,还会一次次救我的命。

🤔 同一个会话,被两个地方同时连怎么办

还有个真实场景:你开了两个浏览器标签,都连着同一个会话;或者你手机、电脑同时连。两边要是都往里写,不就乱套了?

我的处理是两条:

  • 心跳 + 单写:一个会话同一时刻只允许一个"正在跑"的主人。谁在跑,谁定时报心跳;别人想接管,得等它心跳超时(判定它挂了)才行。这避免了两个进程同时驱动一个会话、把事件流写乱。
  • 读随便,写排队:看,谁都能看(都从同一份事件流投影);但"发起新一轮"这种写操作,得排队、得检查状态,不能两个请求同时塞进去。

多端同看是福利,多端同写是灾难。读放开,写收紧------这是所有"多人/多端"场景的通用解法。

💥 我第一版就把数据库喂爆了

讲个真摔的跟头。

事件流跑起来后,我图省事,把每件事的内容整个塞进事件里------工具返回了什么,原样存;用户贴了张图,把图片数据(一长串编码)也原样存。

没几天,数据库文件就肿成了一个吓人的大小。更要命的是------还记得第三篇吗------每一轮,整个历史都要重新发给模型。我把几百 KB 的工具输出、图片的编码全塞进了历史,等于每轮都在为这堆庞然大物重复付费。token 哗哗烧,还慢。

根因:我把"大对象"和"对话记录"混在一起存了。几十字的对话,和一张几 MB 的图,根本不该一个待遇。

修法 :定一条死规矩------数据库里只存小东西。存任何 payload 之前,先过一道大小闸:

ts 复制代码
const LIMIT = 100 * 1024   // 100KB,按需调

function toStorable(content) {
  if (size(content) <= LIMIT) return { inline: content }   // 小的,直接进库
  const ref = writeToDisk(content)                         // 大的,落盘成文件
  return { ref, preview: content.slice(0, 500) }           // 库里只留「引用 + 一小段预览」
}

工具的大输出走这道闸;图片这类二进制更是绝不 把编码塞进库,一律落盘、只留路径引用。库里流转的永远是个轻飘飘的 ref,不是那座山本身。

数据库是"账本",不是"仓库"。大件全部出库落盘,账本上只记一个提货单号。

💥 删了会话,却没删干净

最后这个坑,跟上一篇的"级联取消"遥相呼应,我栽得挺惨。

现象 :我删掉一个会话,数据库里那些行,确实靠着表之间的级联关系,自动连子会话、附件一起删干净了。看起来很完美。可过了一会儿,系统行为开始诡异------好像有个"已经删掉的会话"还在后台活动。

根因 :数据库的级联,只删硬盘上的数据行 。可这个会话如果正在内存里跑 、或者挂着一个等待审批的请求 ,这些都是内存里的状态 ,数据库的级联根本碰不到它们。行删了,内存里的"幽灵"还在。

修法 :删除得删两层------先清内存里的"活物",再删数据库的"行":

ts 复制代码
async function deleteSession(id) {
  // 第一层:先清内存里的「活物」
  await activeRuns.get(id)?.abort()   // 正在跑的循环:停(用上一篇的取消信号)
  approvals.dropPending(id)           // 挂起的审批:清
  bgProcs.killBySession(id)           // 它起的后台服务:收
  // 第二层:这才删数据库行(外键级联自动带走子会话、附件等)
  db.delete("sessions", { id })
}

顺序不能反:先停活物,再删行。反了的话,行没了、内存里的循环却还在引用一个"已经不存在的会话",照样出幽灵。

删除有两层:硬盘上的"行"和内存里的"活物"。数据库的级联只管前者;后者得你亲手去清,否则留下一地幽灵。

这条我后来上升成一个检查项:任何新增的"按会话存在内存里的状态",都得同步在删除流程里加一道清理。 漏一个,就多一个删不干净的幽灵。

小结一下

  • 会话 = 按序发生的事 + 撑得起状态/父子/崩溃恢复的元数据,存进本地 SQLite;
  • 事件流 当唯一真相源(只增不改),消息列表只是投影------过程能推出结论,结论推不回过程;重启时重放事件就能精确重建现场;
  • 多端读放开、写收紧(心跳 + 单写),别让两处同时写乱事件流;
  • 两个大坑:大对象绝不进库 (落盘 + 存引用);删会话要删两层(数据库行 + 内存里的活物),否则留幽灵。

现在这一次对话能存住、能恢复了。但开头我答应过你------"持久化"只是把眼前这摊对话稳住,它还不是真正的"记忆" 。让 agent 跨越一个个全新对话,记住关于你的事实(你是谁、你的偏好、你定的规矩),这是另一件事,也是我留的那一篇。下一篇就讲它:让 Agent 记住你:跨会话的长期记忆怎么做。

相关推荐
Csvn3 小时前
Vue 3 defineModel 翻车实录:多个 v-model 绑定到底怎么写?
前端·vue.js
甲维斯3 小时前
坦克大战测试全翻车了!豆包,DeepSeek,Qwen,GPT,Claude
前端·人工智能·游戏开发
阿里云云原生3 小时前
告别“黑盒进化”:基于阿里云 AgentLoop 实现 AI Agent 全栈自进化闭环
agent
乘风gg4 小时前
还在养虾吗?虾王已诞生:微信龙虾 ClawBot
前端·ai编程·claude
ServBay4 小时前
Laravel Herd MCP 的替代,多语言与跨平台的 AI 本地开发选择
后端·ai编程·mcp
小小小小宇4 小时前
LLM 长期记忆构建
前端
lichenyang4534 小时前
从 Express 老项目到 NestJS + Docker:一次车辆管理系统的渐进式重构
前端
沉默王二5 小时前
讯飞版Codex+GLM-5.2=顶级世界杯AI搭子
agent·ai编程