上一篇结尾我说,我们的 agent 有个毛病------能转、能调工具、能换模型,可这一切全活在内存里,进程一关,什么都没了。这篇就来治它。
先把话说清楚,免得被"关掉就忘"这个说法带偏:这篇讲的是把"这一次对话"存下来、重启还能接着聊 ,这叫持久化 。它跟另一个词------"记忆" ------不是一回事。"记忆"是指 agent 跨越不同对话 ,记住关于你的事实(你的偏好、你定的规矩),哪怕开一个全新对话它也还记得;那是个更大的话题,我单独留了一篇讲。本篇只干一件事:把眼前这摊对话,稳稳存住、还能恢复。
先想清楚:到底要存什么
别急着建表,先问一句:一次对话,本质上是什么?
往简单了说,就是一串按顺序发生的事 + 一点元数据。元数据是"这是谁的会话、什么时候建的、现在什么状态";那串事------你说了句话、模型回了话、它调了个工具、工具返回了结果------才是主体。
那存哪?我选了 SQLite,一个嵌进程序里的小型数据库。理由很朴素:它就是个本地文件,跑在你自己机器上,数据不出门------正好接上第一篇那个"本地优先"的地基。不用起服务、不用连云,程序带着它走。
元数据,别只存"标题"
会话的元数据,新手容易只存个标题、创建时间就完事。但真做起来,有几样你迟早得加:
- 状态:这会话是闲着、正在跑、还是已经结束?后面判断"能不能往里发新消息"全靠它。
- 父子关系:还记得我们会派子 agent 吗(后面专门讲)?子 agent 也是个会话,它得记住"我爸是谁"。删父会话时,得顺着这条线把子会话一起处理掉。
- 心跳:一个正在跑的会话,得时不时"报个平安",更新一个时间戳。万一进程崩了没报平安,下次启动就能发现"这家伙上次没正常收尾",做相应处理。
这些字段当时我都是缺一个补一个、被坑一次加一个。所以会话这张表,大概长这样:
ts
interface Session {
id: string
parentId?: string // 子 agent 会话靠它记住「我爸是谁」,删父时顺藤摸瓜
status: "idle" | "running" | "done" // 状态:决定能不能往里发新消息
lastBeatAt: number // 心跳:正在跑的会话定时更新它,崩了就停在那
createdAt: number
}
一句话:元数据不是"标题 + 时间",它得撑得起状态流转、父子级联、崩溃恢复。 上面每个字段,后面都有一篇会用到它。
🤔 存"消息",还是存"事件"
这是我纠结过、也是最值得讲的一个决定。
最直觉的做法:存"消息列表"。一条用户消息、一条模型消息......界面怎么显示,就怎么存。简单直接。
但我没这么干,而是存"事件流" :把每件发生的事------用户发言、模型回话、发起工具调用、工具返回------按先后,一条条记成只增不改 的事件,落进一张事件表。界面要的那个"消息列表",是从这串事件投影出来的视图。
为什么舍近求远?因为事件流是一本流水账,有几个消息列表给不了的好处:
- 可回放:从头按序放一遍,就能精确重建任何时刻的状态(下面马上用到);
- 可审计:谁在什么时候干了什么,白纸黑字,改不了;
- 能长出多个视图:消息列表是一种投影,统计、时间线、给别的程序看的格式,都能从同一份事件流派生,不用各存一份。
一句话:消息是结论,事件是过程。存过程,你随时能推出结论;只存结论,过程就永远找不回来了。
落到代码上,一条事件就是这么个朴素结构:
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
}
你看,消息列表是「算」出来的,不是「存」出来的------这就是"事件是真相、消息是投影"落到代码里的样子。
进程重启后,怎么把"现场"重建出来
事件流最爽的一点,是重启恢复几乎白送。
进程一关,内存里那些"当前对话长什么样"的状态全没了。但没关系------事件流还在硬盘上躺着。重新启动时,我只要把这个会话的事件从头按序放一遍 ,内存里的现场就精确重建出来了:聊到哪了、调过哪些工具、结果是什么,一点不差。
重放落到代码上,就是一个循环:
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 记住你:跨会话的长期记忆怎么做。