07|记忆:从 JSONL 持久化到 Dream 后台学习

0 阅读18分钟

这是《Agent全栈开发实战》的第 7 篇。整个系列以 catbuddy(一个本地优先的 AI 编程助手,约 3.6 万行 TypeScript)为案例,由浅入深拆 harness 的设计。

上一篇聊了「眼睛」——LLM 每一轮能看到什么,以及上下文快爆了时的三层防线。但那三层防线全是临场救火,只管「这次会话内」。你把 catbuddy 关掉再开,它就把你忘得一干二净了。这一篇我们补上 harness 的「记忆」器官,按三步走:对话先存哪(JSONL)→ 怎么把对话熬成长期记忆(Dream)→ 云端那份缓存怎么对齐这份记忆(MySQL) 。每一步都能独立读,但连起来你会看到一条很清楚的主线:本地那份纯文本文件,才是整个系统的真相之源。


0. 先想清楚:记忆有两种,存法完全不同

聊「记忆」之前,得先把两个被混在一起的东西拆开。

第一种是对话历史——你和 Agent 这一来一回的原始记录,逐字逐句、带工具调用、带 token 用量。它是流水账,要的是完整、可回放

第二种是长期记忆——从一堆对话里提炼出来的「事实」:你习惯用 pnpm 不用 npm、这个项目用 Compound Pattern 拆组件、你讨厌注释写中文。它是结论,要的是精炼、能复用,下次换个会话也得记得。

这两种东西,catbuddy 用完全不同的方式存: image.png

这一篇就顺着这三个框讲:原始对话怎么落到 session.jsonl、Dream 怎么趁你不注意把对话熬成 MEMORY.md、以及本地这份 JSONL 怎么和云端 MySQL 对齐成同一份真相。走起。


1. 对话存哪:JSONL,而不是 SQLite

「你们为什么不用 SQLite?」——这是 catbuddy 架构里被问得最多的一个问题。几乎每个后端背景的人看到 catbuddy 拿一堆 .jsonl 文件存聊天记录,第一反应都是:你认真的?

JSONL(JSON Lines):每行是一个独立的 JSON 对象的纯文本文件。和普通 JSON 数组不同,它没有外层的 [ ],行与行之间互不依赖——你可以一行行追加,也可以一行行读。

确实,论功能 SQLite 全方位吊打:ACID 事务、B-tree 索引、WAL、SQL 查询,随便拿一条都比纯文本「高级」。但 catbuddy 桌面端就是选了 JSONL,跑了一年多、300 多个用户,没出过一次数据一致性问题。原因不是 JSONL 更强,而是一句话:对于桌面端单用户的 Agent 聊天,SQLite 的能力你 80% 用不上,可它的复杂度你一分不少地得付。

1.1 选存储,先看访问模式

挑存储方案,第一步不该比技术参数,而该问:这个场景到底怎么读写数据? 把 Agent 聊天的访问模式列出来,特别朴素:

操作频率数据量
追加新消息每轮一次1~10 条
读当前会话历史每轮一次最近一两百条
列出所有会话切会话时只看每个文件第一行
随机访问某条消息几乎没有——

看出来了吗?没有复杂查询、没有多表 JOIN、没有条件过滤、没有多个写者并发。 SQLite 最擅长的那些场景,在这儿一个都不发生。

那 JSONL 最被人诟病的「随机访问慢」呢?不好意思——聊天记录的访问模式是严格的顺序读写:读历史从头到尾,写新消息追加到末尾。这正好踩在 JSONL 的甜蜜区上。我把这场「错配」画出来你就懂了:

image.png

SQLite 那几个 ✅ 你根本碰不到,但底下两个 ⚠️ ——原生模块编译、二进制格式——你躲不掉。catbuddy 早期真用过 SQLite(better-sqlite3),换掉它就是被这两个 ⚠️ 逼的:

  • 原生模块编译better-sqlite3 要 C++ 编译,Electron 的 native addon 在 Win/macOS/Linux 三平台各有一套坑。catbuddy 的目标是「下载双击就能用」,结果每升一次 Node 或 Electron,重新编译 addon 就是一次 CI 事故。
  • 二进制格式不可观测:用户报「我昨天的对话丢了」,SQLite 时代你得让他把 sessions.db 发过来、用 sqlite3 CLI 打开、SELECT 半天。

1.2 JSONL 长什么样:第一行是索引,后面是数据

catbuddy 每个会话是一个独立的 .jsonl 文件,放在 ~/.catbuddy/workspace/sessions/ 下。打开一个看:

{"key":"desktop:main","title":"前端重构讨论","preview":"我觉得应该...","created_at":"...","updated_at":"...","last_consolidated":0,"metadata":{}}
{"id":1,"sessionKey":"desktop:main","role":"user","content":"我觉得应该用 Compound Pattern","timestamp":"..."}
{"id":2,"sessionKey":"desktop:main","role":"assistant","content":"同意,但需要注意...","timestamp":"..."}

两个关键设计:

第 1 行是元数据——标题、预览、创建时间。要列会话列表时,每个文件只读第一行就够了,不用解析后面几千条消息。这就是 JSONL 版的「索引」。

第 2 行起是消息——每条一行,sessionKey 冗余存一份。哪怕你把单个文件拷贝走,每行都自带「我属于哪个会话」。

列会话列表的代码也就这么几行,遍历目录、每个文件切一刀拿第一行:

list(): SessionInfo[] {
  const files = fs.readdirSync(this._dir).filter(f => f.endsWith('.jsonl'))
  const infos = files.map(f => {
    const firstLine = fs.readFileSync(path.join(this._dir, f), 'utf-8').split('\n')[0]
    return this._parseInfoLine(firstLine)   // 只解析第一行
  }).filter(Boolean)
  return infos.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
}

500 个会话扫一遍约 30~50ms,SQLite 同样查询大概 5ms。是慢了 25ms,但用户切会话时多等 25ms,换来的是「不用维护一套三平台编译 CI」——这不叫 trade-off,这叫别为省 25ms 给自己挖坑。

1.3 写入的安全:一个 rename 就够了

JSONL 最大的争议是「并发写不安全」。但 catbuddy 的写入是单写者——同一时刻只有一个 Agent Loop 在写一个会话,根本没有「两个人同时发消息」。所以写入策略简单到不像话:不追加,而是整文件重写 + 原子 rename

const lines = [JSON.stringify(meta), ...messages.map(m => JSON.stringify(m))]
const tmp = filePath + '.tmp'
fs.writeFileSync(tmp, lines.join('\n') + '\n', 'utf-8')  // 先写临时文件
fs.renameSync(tmp, filePath)                             // 再原子替换

POSIX 的 rename(2) 是原子操作:读取者要么看到完整的旧文件,要么看到完整的新文件,绝不会撞见写了一半的状态。进程哪怕在 writeFileSync 中途崩了,崩的也是那个 .tmp,原文件毫发无损。这本质上等价于 SQLite 的 rollback journal,但你不用管 WAL 文件、不用 checkpoint、不用 PRAGMA journal_mode

代价是每次写都要重写整个文件。但 2000 条消息约 200KB,本地磁盘上序列化一遍 2~5ms,用户完全无感。

1.4 不让它无限长:2000 条上限 + 摘要压缩

聊天记录最怕无限膨胀。catbuddy 设了个硬上限——每个会话最多 2000 条,超了就从头砍(FIFO):

if (messages.length > MAX_MESSAGES) {           // MAX_MESSAGES = 2000
  messages.splice(0, messages.length - MAX_MESSAGES)  // 丢最旧的
}

2000 条这个数不是拍脑袋——它正好是「JSONL 整文件重写还很快」的甜蜜区上沿。但光砍不行:会话搁置几天、超出 2000 条的旧消息被丢了,Agent 再打开这个会话怎么「回忆」之前聊过啥?

这就引出了记忆——丢消息之前,先把它们的精华榨出来存好。负责榨汁的有两个角色:一个是上一篇提过的 Consolidator(把空闲会话的旧消息压成摘要),一个是这篇的主角 Dream(把对话里关于「你」和「项目」的事实提炼成长期记忆)。砍掉的是原始消息,留下的是结论。

小结一下第 1 段:JSONL 不是「放弃数据库」,是选对了数据库——catbuddy 选的这个数据库叫「文件系统」,open/write/rename 是它的查询语言,换行符是分隔符。原子 rename 顶替了 ACID,第一行顶替了索引,纯文本换来了 cat/grep/jq 随手可查的可观测性。接下来看,这些 JSONL 怎么变成长期记忆。


2. Dream:猫睡着了,也在偷偷学习

上一篇结尾留了个伏笔:三层上下文防线只管「这次会话内」,跨会话的记忆得另想办法。这个「办法」就是 Dream。

名字很贴切——它是 Agent 在后台「睡着」(空闲)时跑的进程,默默把你最近聊过的内容消化掉、把事实记下来。等你下次找它,它已经悄悄「记住」了更多关于你的事。整套记忆管道是三个组件接力:

image.png

注意分工:AutoCompact 是「什么时候干」的调度器,Dream 和 Consolidator 是「干什么」的引擎,LayeredMemory 是「写到哪」的路由器。 我们一个个看。

2.1 底座:游标驱动的增量读取

所有提炼的输入,都是一份「对话历史条目」流水账 history.jsonl——又是 JSONL,但这次存的不是某个会话的消息,而是「最近发生过哪些值得记的事」。最底层的 MemoryStore 管它的读写,核心是一个游标

appendHistory(entry: string): number {
  this._cursor += 1
  const row = { cursor: this._cursor, content: entry, at: new Date().toISOString() }
  fs.appendFileSync(this.historyFile, JSON.stringify(row) + '\n', 'utf-8')
  fs.writeFileSync(path.join(this.memoryDir, '.cursor'), String(this._cursor))  // 游标落盘
  return this._cursor
}

每写一条,游标自增并持久化到 .cursor 文件。下次 Dream 跑的时候,调 readEntriesSince(已处理游标) 只拿游标之后的新条目——已经提炼过的历史绝不重复处理。这是个很轻量的「断点续传」:进程重启、崩溃都不影响,游标记到哪就从哪继续。

2.2 Dream 的两阶段:提取 + 巩固

Dream.runOnce() 是整条管道的入口,可以被 cron 定时触发,也可以手敲 /dream 立即触发。它分两个阶段,对应「先提取、再巩固」。

Phase 1 · 提取:收集游标之后的新条目,连同现有的 MEMORY.md / USER.md / SOUL.md 一起喂给 LLM,让它提炼事实。

const newEntries = [...this.store.project.readEntriesSince(this._processedCursor)]
if (newEntries.length === 0) return null     // 没新东西,直接跳过

// 游标在调 LLM 之前就推进——哪怕 LLM 挂了,下次也不会重复读这批
this._processedCursor = Math.max(...newEntries.map(e => e.cursor), this._processedCursor)

const prompt = `${extractionGuidance}
Existing MEMORY.md:\n${existingMemory.slice(0, 3000)}
New conversation entries (up to 30):
${newEntries.slice(-30).map(e => `- ${e.content.slice(0, 1000)}`).join('\n')}
${FORMAT_SUFFIX}`

这十几行里藏了几个讲究:

  1. 把现有记忆也喂回去:LLM 不光看新条目,还看到当前的 MEMORY.md,于是它能判断「这是已知事实的更新」还是「全新发现」,而不是机械地堆重复。
  2. 游标先行:游标在调 LLM 之前就推进了。这是「至少一次」语义而非「恰好一次」——宁可偶尔漏提一批,也不要因为重试把同一批反复塞进记忆。
  3. 限流:最多 30 条、每条截断 1000 字符,防止一次喂太多把 token 撑爆。
  4. 格式指令 FORMAT_SUFFIX:要求 LLM 输出两个固定区块——## User Profile(关于用户的事实)和 ## Project Context(关于项目的事实)。这俩头部是后面分层路由的钩子,很关键。

Phase 2 · 巩固:拿到 LLM 的输出,不是无脑写盘,而是过一道三级防护再落地。这呼应上一篇那句话——「永远不在用户面前崩溃」,只不过这次搬到了后台:

image.png

  • 第一级·验证validateFormat() 查四样——非空、长度够、含 ## User Profile## Project Context 两个头、且每块底下有实质内容(不是 (nothing)n/a 这种占位符)。
  • 第二级·重试:格式不对不直接丢,而是把 LLM 上次的输出附在提示词里,让它「照规矩重写一遍」。只重试一次,不死磕。
  • 第三级·降级:重试还不行?也不丢——用那份「不合规但可能有料」的原始输出,只在内容实在太短时才放弃。

后台任务最怕的就是「格式没对上就静默丢数据」,这三级把「丢」的概率压到了极低。

2.3 LayeredMemory:你的事归你,项目的事归项目

Dream 提炼完,调 appendLayeredMemory() 写 MEMORY.md。但它不是傻追加——而是按 Phase 1 那两个头部,把内容分流到不同文件。

先用正则把 LLM 输出切成两块,再按是否开启分层路由:

export function appendLayeredMemory(opts): void {
  const { userProfile, projectContext } = splitMemorySections(opts.summary)  // 正则切两块
  if (!layered) {                                       // 非分层:整段进全局
    appendToMemoryFile(globalWorkspace, opts.label, opts.summary); return
  }
  if (userProfile)    appendToMemoryFile(globalWorkspace, opts.label, userProfile, "User Profile")
  if (projectContext) appendToMemoryFile(opts.projectWorkspace, opts.label, projectContext, "Project")
  if (!userProfile && !projectContext)                  // 兜底:都没切到就整段进全局
    appendToMemoryFile(globalWorkspace, opts.label, opts.summary.trim(), "User Profile")
}

三个分支,对应三种情况:

  • 关于「你」的事实 → 写全局 ~/.catbuddy/workspace/memory/MEMORY.md。这样你在 A 项目说过「我用 pnpm」,到 B 项目它照样记得。
  • 关于「这个项目」的事实 → 写项目本地 {workspace}/memory/MEMORY.md。换项目就换一份,互不污染。
  • 切不出区块(兜底) → 整段塞进全局,绝不丢。

下次 Agent 拼系统提示词(上一篇的「眼睛」),这两个记忆源都会被注入——所以它既懂「你这个人」,又懂「你手头这个项目」。

2.4 AutoCompact:什么时候学?空闲越久、消息越多,越优先

Consolidator 和 Dream 都是「引擎」,但它们不知道什么时候该发动。AutoCompact 就是那个调度器,定时(cron)或对话前被调一次,扫一遍所有会话,挑出该压缩的,每轮压几个。

它的精髓是一个优先级公式

private _priorityWeight(info): number {
  const idleMinutes = Math.max(1, (Date.now() - Date.parse(info.updatedAt)) / 60_000)
  const msgCount = info.metadata?.msgCount ?? 50
  return idleMinutes * msgCount     // 空闲时间(分钟) × 消息数量
}

优先级 = 空闲分钟数 × 消息条数。 特别符合直觉:一个刚还在聊的会话(空闲 1 分钟 × 100 条 = 100)不急着压;一个三天没动的长对话(4320 分钟 × 200 条 = 86 万)就该优先处理。调度流程是这样:

image.png 两个生产级细节,正是「让后台任务别添乱」的关键:

  • 每轮最多压 3 个maxPerCycle),不会一上来把所有积压会话全丢给 LLM,把你的 API 配额一口气烧光。
  • 失败指数退避:一个会话压缩失败 → 等 5 分钟再试;再失败 → 10 分钟;再失败 → 20 分钟……一旦成功就清零。这样某个「有毒」的会话不会被反复冲击、白白浪费配额。再加上跳过「正在压缩 / 当前活跃」的会话,就保证了不会和你正在进行的对话抢资源。

至于被它调起来的 Consolidator 具体怎么把旧消息压成摘要——保留最近 8 条活跃上下文、每条截断 300 字符喂 LLM、用一把去重锁防止同一会话被并发压两次——和第 06 篇的「上下文压缩」是同一套机制,这里就不展开了。你只需记住:它和 Dream 的产物殊途同归,最后都写进同一份 MEMORY.md。

小结第 2 段:catbuddy 的长期记忆靠一条后台管道——AutoCompact 调度(空闲×消息量排序、每轮 3 个、失败退避)→ Dream 提取 / Consolidator 压缩(两阶段:提炼 + 三级防护巩固)→ LayeredMemory 分层(你的事进全局、项目的事进本地)。猫睡着的时候,它在学习。


3. 云端怎么对齐:JSONL 是真相,MySQL 是缓存

到这儿,本地这台机器上的记忆已经闭环了。但 catbuddy 还有 Web 端——你在手机浏览器里也能看到桌面上的对话。Web 端没有你的本地磁盘,它的数据从哪来?答案是 Gateway 的 MySQL。

于是出现了一个看着「精神分裂」的局面:同一份对话,本地存 JSONL,云端存 MySQL。 两套存储,到底谁说了算?

3.1 两套存储,一套类型

先看它俩长啥样。如果你翻遍 catbuddy 桌面端代码找 CREATE TABLE,一个都找不到——本地纯文件。而 Gateway 端是规规矩矩的 MySQL 三张表:

MySQL 表对应 JSONL 的职责
gateway_users(无)用户认证(email + 密码哈希)
gateway_sessions第一行那条元数据会话元数据(key、title、preview、metadata)
gateway_session_messages第 2 行起的消息消息记录(id、role、content、tool_calls)

注意这个对齐不是巧合——两边共用同一套 TypeScript 类型SessionInfoMessageRecord,都来自 @catbuddy/shared)。MySQL 建表时 session_key 对应 JSONL 的 keymetadata JSON 列就是 JSONL 第一行那个 metadata。一条消息在 JSONL 里是一行 JSON,在 MySQL 里是一行记录,字段一一对上。

更妙的是两个存储类的 API 是镜像的——方法签名长得几乎一样,只是后端不同:

方法JSONL 端(SessionManagerMySQL 端(MysqlSessionStore
getOrCreate查内存 → 查文件 → 内存创建查内存 → SELECTINSERT
addMessage_load → push → 裁剪 → 原子 renameBEGIN → 加载 → INSERT → upsert → COMMIT
list扫目录、读每个文件第一行SELECT ... ORDER BY updated_at DESC
deleteunlinkSyncDELETE(外键 CASCADE)

这是故意的。相同方法签名、不同后端,带来一个巨大的好处:上层业务逻辑在「直连模式(操作 JSONL)」和「远程模式(操作 MySQL)」之间切换时,一行都不用改。对上层来说,类型即接口,存储是实现细节。 这正是第 01 篇反复强调的那条主线——器官只认接口、不认实现。

它俩唯一的实现差异,来自并发模型:JSONL 是单写者(一个 Loop),不用锁,靠原子 rename;MySQL 是多写者(桌面同步 + Web 用户发消息),靠连接池 + 事务的 BEGIN/COMMIT/ROLLBACK。机制不同,但拿到的保证是一样的:要么全成、要么全滚。

3.2 谁是真相之源?时间戳说了算

关键问题来了:本地 JSONL 写一份,云端 MySQL 也写一份,冲突了听谁的?

先明确身份定位:

  • Desktop 的 JSONL 是「真相之源」(source of truth)。 MySQL 里的数据是从桌面推上去的副本。MySQL 哪天挂了,你本地 JSONL 毫发无损。
  • Gateway 的 MySQL 是「分发用的缓存」。 它存在的唯一意义,是让没有本地磁盘的 Web 端也能读到这份对话。

副本和真相之间靠时间戳对齐。Gateway 收到桌面推来的会话时,并不知道这条是新增还是更新,于是用 MySQL 的 ON DUPLICATE KEY UPDATE 做 upsert——以 session_key + message_id 为主键,一条 SQL 搞定「有则更新、无则插入」。而「该不该覆盖」的裁决,就一句话:谁的时间戳更新,谁赢。

image.png

两边代码里各有一道一模一样的判断。桌面侧收到云端数据时:

// 来的数据比本地旧 → 直接忽略
if (local && localUpdated && incomingAt && incomingAt <= localUpdated) {
  return
}

MySQL 侧收到桌面数据时也对称地写着「row.updatedAt > local.updatedAt 才采纳」。这就是 Last-Write-Wins(LWW,后写者胜) ——没有分布式共识算法,没有 CRDT,就是比一下时间戳。对一个单用户的聊天应用来说,LWW 完全够用:反正同一时刻基本只有你一个人在一台设备上敲字,时间戳天然就能排出先后。

(顺带划清边界:消息从桌面到云端再到 Web 具体走什么传输协议、三端怎么实时同步,那是第 14 篇「多端同步」的活儿。这一篇只管存储层这一道 LWW 对齐——谁写赢了听时间戳的,至于消息怎么飞过去的,按下不表。)

小结第 3 段:JSONL 是真相,MySQL 是分发缓存,两边共用一套类型、镜像一套 API,让上层在直连/远程间切换零改动。冲突全靠时间戳裁决——Last-Write-Wins,简单到没有任何共识算法,但对单用户场景刚好够。


4. 复盘:如果重来一次

JSONL 这套唯一让我有点想优化的,是写入策略——每次 addMessage() 都整文件重写。2000 条消息意味着每轮对话都要序列化 200KB。2~5ms 不算慢,但累积起来总归是开销。

更「优雅」的方案大概是混合模式:前 100 条用 append(O(1) 追加),过 100 条再转定期重写,上限和压缩机制不变。

但我们没做。原因很实在:当前方案跑了 300+ 用户、一年多,没出过一次问题。去优化一个从没出过问题的地方,那不叫工程,叫炫技。 把这点力气省下来花在记忆质量上,回报高得多。


这篇讲了什么?

  1. 对话存哪:catbuddy 用 JSONL 而非 SQLite,不是没能力,是看清了访问模式——聊天记录是 append-only + 顺序读 + 单写者,SQLite 的随机查询/多写事务全用不上,可它的原生编译/二进制格式的代价一分不少。JSONL 第一行当索引、原子 rename 顶替 ACID、纯文本换来 cat/grep/jq 的可观测性,再用 2000 条上限 + 摘要压缩防膨胀。
  2. 怎么变成长期记忆:猫睡着(空闲)时,一条后台管道在学习——AutoCompact 按「空闲时间 × 消息量」降序调度、每轮压 3 个、失败指数退避;Dream 两阶段提取(提炼事实 + 三级防护巩固);LayeredMemory 把「关于你」的写进全局 MEMORY.md、「关于项目」的写进项目本地。
  3. 云端怎么对齐:Desktop 的 JSONL 是真相之源,Gateway 的 MySQL 是分发缓存,两边共用 MessageRecord 类型、镜像同一套 API,让上层在直连/远程间切换零改动;冲突靠 session_id + message_id upsert + 时间戳 Last-Write-Wins 裁决,没有共识算法,但单用户场景刚好够。

下一篇预告:记忆稳了,但 harness 还得跟真实世界的混乱打交道——它依赖 Anthropic / OpenAI / DeepSeek 三家 API,格式和流式协议各不相同。下一篇聊韧性:怎么用一套接口把三家差异全封进去、主模型挂了怎么自动故障转移到备选、以及瞬态错误怎么靠三层重试自愈。