第六章 · 让她记得你
数据库与记忆:「记住你说过的事」是怎么实现的
你已经在 QQ 里见过她记得你——她会在某一轮对话里忽然提起「你上次说最近睡不好」。但你能看到的只有这句话,看不到它从哪里来。这一章要做的事,是把「记得」这件事从一句温柔的错觉,拆成一条你能画出来、能改、能修、也能亲手关掉的数据流。
0. 先看地图:这一章要带你去哪
上一章结束的时候,你已经能让一个 Node.js 程序 24 小时醒着、等别人发消息、再调一次 DeepSeek 的接口把答案发回去。那个程序有一个致命的特点:它每一句话都像第一次见你。你告诉它「我高三」,下一句话它就忘了;你告诉它你昨天考砸了,今天再说一次它还是第一次听说。
回头看第二章(JavaScript 基础)里你学过的对象与数组——那时候它们只是「装东西的盒子」,用来存一个人的名字、一门课的成绩。这一章里,同样是对象和数组,第一次要承担「在断电之后依然存在」的责任。盒子还是那些盒子,但它们现在要住到磁盘上,还要能被别人按条件挑出来。
这一章解决的就是这件事。但在动手之前,我要先给你打一个预防针:这一章会出现相当多的 SQL 语句,而 SQL 是那种「看两眼就能模仿、想半天才能理解」的东西。所以整章有一条铁律:每一条 SQL 都会长在一个具体需求上,比如「用户发来 /忘记,我该在数据库里执行什么?」你不会看到一个为了演示语法而存在的 SELECT。
读完这一章,你应该能回答
- OWL 现在的两套记忆分别存在哪两个文件里、键是什么、值是什么、什么时候过期?我能凭记忆把
history.json的一条记录默写出来吗? - 为什么「存在 JSON 文件里」在某个规模上就不再够用?具体是哪五种病?并且,这个「某个规模」到底在哪里?
- 表、行、列、主键、外键分别是什么?我能为 OWL 画出「谁 — 记得什么 — 在哪个会话里 — 说了什么」这四张表吗?
- 为什么按
user_id查记忆需要索引?索引的代价是什么?什么时候我不该建索引? - 为什么「字符串拼接 SQL」会让人一个字符删掉全库的数据?占位符
?到底做了什么挡住了它? - 一条消息进来,OWL 从数据库里读了什么、又写回了什么?我能不看笔记把这条数据流画出来吗?
- 什么信息不该被记下来?如果记错了,代价由谁承担?
学完这一章,你会做这些事
- 为 OWL 设计并建出一套真正的记忆数据库(表结构 + 迁移脚本)
- 写出安全的 SQL(参数化查询),并一眼认出注入风险
- 实现「短期上下文 + 长期记忆」的完整读写链路
- 为数据设计 TTL、容量上限与备份策略
- 用一句 SQL 回答:这个人被记住了什么
需要的前置:第二章的对象与数组(你要能看懂 { role: "user", content: "..." } 这样的结构)、第四章的 JSON(数据库之外的另一种「存数据」的方式,这一章会拿它当对照组)、第五章的 Node.js 与文件读写(我们要把 fs 换成数据库,但思路是连续的)。
这一章会为后面铺路:第七章(部署与运维)会讲到数据目录、卷挂载与备份——那时候你会发现,这一章设计的那个 .db 文件就是你要保护的核心资产;第八章(Prompt 与 RAG)会接着这里往下走,把「记忆」从「按 key 查一行」升级成「按意思找一堆东西」。
回头看看:在序章(出发之前)里,我把「数据层」放在七层地图的第 5 层,只写了一句话:「数据怎么存、怎么查、活多久」。现在你回来读那句话,它已经开始有重量了——怎么存、怎么查、活多久,就是这一章的三个大问题。
1. 先看现状:OWL 现在是怎么记的
不要先学数据库。先看清楚一个真实的、正在跑的机器人已经在用什么记东西。因为只有当你知道旧方案的边界在哪里,新方案才不是一堆凭空出现的名词。
另外说一件相关的事:这两个文件已经进了版本控制(如果你按第三章的做法给 qq-bot 建了仓库的话),而它们不应该被提交——因为里面是真实用户说过的话。第三章(Git 与版本控制)里讲 .gitignore 时你可能觉得它只是个「忽略 node_modules」的小工具;现在它有了一个更严肃的用途:阻止别人的隐私被 git push 出去。
打开 qq-bot/bot/ 目录,你会看到两个你可能一直没注意过的文件:history.json(约 8 KB)和 memory.json(约 1.5 KB)。它们不是配置,不是日志,它们是 OWL 的记忆。
1.1 第一套记忆:会话上下文(history.json)
先看结构。它的顶层是一个对象,键是会话标识,值是「这条会话最近的几句对话」加一个时间戳:
{
"val:musah18h:只看分数": {
"at": 1791025740819,
"msgs": [
{
"role": "user",
"content": "这次月考我考了全班第 15 名,比上次退了 5 名,我是不是很失败"
},
{
"role": "assistant",
"content": "小夏,退5名确实会让人心里堵一下,但第15名不是失败。…"
}
]
}
}
▲ 上面是 qq-bot/bot/history.json 里一条真实记录的简化摘录(原文是一整行没有换行的 JSON,我只调整了缩进,并把过长的回复截断)
逐字段读一遍,这四个字段缺一不可:
| 字段 | 它的值是什么 | 为什么必须是它 |
|---|---|---|
| 顶层键(会话标识) | 由「来源 + 谁」拼成的字符串 | 这是「按谁分开记」的唯一依据。群里是共享的,私聊是独立的,这个键决定了它们不会串味 |
at | 一个毫秒时间戳,如 1791025740819 | 用来判断这段上下文过没过期。没有它,一小时的规则就没法执行 |
msgs | 一个数组,每个元素是 { role, content } | 这正是要原封不动发给大模型的对话历史格式,存在这里就省掉了转换 |
role | "user" 或 "assistant" | 模型必须知道哪句话是你说的、哪句是它说的,否则它会把两个角色混起来「自问自答」 |
这五个字段其实回答了一个问题:「这个人刚才在聊什么?」注意问的是「刚才」。它是一段临时记忆,跟你的名字、年级、困扰都无关——它只在乎连续对话的感觉。
要点上面这条记录里,val:musah18h 不是 QQ 号。它是本项目验证脚本跑测试时用的模拟会话标识,-b 那种后缀表示「同一句话的第二版回复」。用真实的测试数据当教材有一个好处:里面没有一个真实高中生的隐私,你可以放心地逐字对照。但它同时也暴露了一个后面要修的问题——同一个人的记忆被拆到了好几个键里,这一点我们在第 1.4 节会回来算这笔账。
1.2 第二套记忆:用户画像(memory.json)
另一套记忆长得完全不一样。它的键是用户,值是一个数组,数组里每一条是「关于这个人的一条稳定事实」:
{
"val:musah18h:有用吗": [
{
"key": "subject",
"label": "学科",
"snippet": "学历史到底有什么用?又不能加分,我觉得不如",
"at": 1791025726804
},
{
"key": "values",
"label": "在意的价值问题",
"snippet": "学历史到底有什么用?又不能加分,我觉得不如去刷题",
"at": 1791025726804
}
]
}
▲ 摘自 qq-bot/bot/memory.json(原文用单个空格缩进,我为了排版调了一下)
这里最值得注意的是 snippet:它是「命中词周围的一小段原文」,而不是一句被概括好的结论。为什么这么设计?因为概括会失真,原文不会。如果你让程序把「学历史有什么用」概括成「用户对历史学科价值存疑」,那句话听起来更整齐,但它抹掉了这个人说话的语气——而语气恰恰是 OWL 最需要的东西。
代价也很清楚:snippet 会把原文(包括可能的隐私)原样留下来,而且它只有 30 个字符左右,经常是半句话。你以后会看到这个半句话被直接塞进提示词里,效果是「有线索,但不完整」。
1.3 两套记忆的分工:短期与长期
现在把两者并排放在一起,你会看到它们是两个完全不同的问题被两个不同的数据结构回答了:
| 对比项 | 会话上下文 history.json | 用户画像 memory.json |
|---|---|---|
| 存的是 | 最近的对话原文 | 关于这个人的稳定特征 |
| 按什么分组 | 会话(群 / 私聊) | 用户 |
| 活多久 | 会过期(有 TTL) | 不过期,靠条数上限淘汰 |
| 谁写进去的 | 每轮对话结束后必然写 | 只有被规则命中时才写 |
| 怎么用 | 整段拼进发给模型的消息列表 | 先压成一段说明,再拼进系统提示词 |
| 量级 | 会话数 × 轮数 | 人数 × 每人最多 8 条 |
为什么必须分成两套?设想只留一套的两种情况。
只留会话上下文:那个人今天说「我高三」,明天你们从零开始聊,它再也不提你的年级。听起来不算太糟,但「被记住」的感觉会消失——而这是 OWL 存在的理由之一。
只留用户画像:它知道你高三、知道你历史学得烦,但不知道你上一句话在问什么。「你高三」和「你刚才问我历史有什么用」是两种完全不同的事实,前一个是关于你的,后一个是关于这场对话的。把它们塞进同一个结构里,你迟早会写出一个既不能查、也不能删的东西。
想一想你自己手机里的东西,哪些属于「短期上下文」、哪些属于「长期画像」?相册、聊天记录、备忘录、通讯录分别更像哪一种?想清楚之后,再回来想一个问题:「临时」和「永久」这两类数据,混在一个文件里会坏在哪里?
1.4 真实配置值,以及它们带来的一个隐藏缺陷
两套记忆各有一组控制它的配置。这是 qq-bot/bot/config.json 里真实的两段(systemPrompt 太长,我略过了):
{
"llm": {
"history": {
"maxTurns": 8,
"ttlMinutes": 180
},
"memory": {
"enable": true,
"maxPerUser": 8
}
}
}
▲ 摘自 qq-bot/bot/config.json。项目文档 AI-SETUP.md 里写的示例值是 maxTurns: 6 / ttlMinutes: 60,而这份正在跑的配置是 8 / 180——文档和真实值不一致,是教程类项目里最常见的坑,以后你改配置前一定要打开文件自己看一眼,而不是相信文档里的数字。
这几个数字的含义,以及它们在代码里被怎么用:
maxTurns: 8——记住最近 8 轮(约 16 条消息)。代码里写的是prev.slice(-maxTurns * 2),因为「一轮」包含一问一答两条,所以乘 2。ttlMinutes: 180——三个小时不说话的会话,启动时会被直接丢掉。代码是now - (v.at ?? 0) < ttlMs,注意那个<:超过三小时的,连读都不读进来。maxPerUser: 8——每个人最多留 8 条印象。代码是list.slice(-max),意思是留下最新的 8 条,老的自动挤掉。
现在来看那个隐藏缺陷,它值得我们停下来想清楚,因为这正是这一章后半段要动手解决的问题。
在 llm.mjs 里,长期记忆的键是 userKey,也就是用户的 QQ 号字符串(bot.js 里传的是 String(event.user_id))。按这个设计,一个人的记忆应该集中在一个键下面。但你看真实的 memory.json,键是 val:musah18h:有用吗、val:musah18h:内卷 这种形状——它看起来更像会话标识,而不是用户号。
这说明什么?我不能凭猜测给你一个确定的结论。但它至少提示了两个方向,而这两个方向都值得你自己去验证:
- 可能是测试脚本自己构造的键。验证脚本可能刻意用「会话」当用户键,好让每一轮测试互不干扰。
- 也可能是真的存在混用。也就是说,某个调用路径把会话标识当成了用户标识传进去。如果真发生这种事,后果是:同一个人在群里和私聊里的记忆会分裂成两份,谁也看不到谁。
警告这就是「用 JSON 文件当数据库」的第一种症状:键的语义没有地方被声明,也没有任何东西会阻止你写错。在 JSON 里,"user:123" 和 "group:456" 都是合法的键,没有任何机制在你写错时报错。等你换成数据库,这种错误会被 users 表的主键挡在门外——这是下一节的主题,也是我们建表的第一个理由。
1.5 一条消息进来,到底读了什么、写了什么
把上面所有东西串起来。这是本章最重要的一张图,你要能在纸上默画出来:
有人私聊 OWL 一句:「作业太多了写不完,我打算抄一下」
┌─ 1. 收 ────────────────────────────────────────────────┐
│ NapCat 把 QQ 消息转成 JSON,经反向 WebSocket 推到本体 │
│ event.user_id = 1001 event.message_type = "private" │
│ sessionKey = "private:1001" userKey = "1001" │
└──────────────────────────┬───────────────────────────────┘
▼
┌─ 2. 查(读)────────────────────────────────────────────┐
│ 读 history.json[sessionKey] → 最近 8 轮对话原文 │
│ 读 memory.json[userKey] → 关于他的稳定事实 │
│ 读 config.json → 人设、温度、上限 │
└──────────────────────────┬───────────────────────────────┘
▼
┌─ 3. 拼(组装 prompt)──────────────────────────────────┐
│ system = 人设 + 记忆说明 + 价值观提示 + 安全指令 │
│ messages = [system, ...最近16条, 这一句新的] │
└──────────────────────────┬───────────────────────────────┘
▼
┌─ 4. 问(HTTPS)────────────────────────────────────────┐
│ POST api.deepseek.com → 约 1 秒后拿回一段文字 │
└──────────────────────────┬───────────────────────────────┘
▼
┌─ 5. 写(回存)─────────────────────────────────────────┐
│ 把这句 user 和它的 assistant 回复 push 进会话数组 │
│ 只留最后 16 条,写回 history.json │
│ 用 9 条正则规则扫这句话,命中就抽成记忆 │
│ 按 key 覆盖或追加,只留最后 8 条,写回 memory.json │
└─────────────────────────────────────────────────────────┘
▼
回复发回 QQ
▲ OWL 当前的读写数据流(依据 llm.mjs 与 bot.js 的真实实现整理)
请特别注意第 5 步里的两件事,它们决定了这一章后面的走向。
第一,每次回复结束,都要把整个文件重新写一遍。注意 saveHistory() 的写法:它新建一个空对象,把内存里所有会话遍历一遍塞进去,然后 fs.writeFileSync 整个覆盖。你只为一个人更新了两条消息,但磁盘上写的是所有人的上下文。这就是下一节要算的那笔账。
第二,记忆是「先想起,再记下」。看 chat() 里的顺序:先 #recall 读出旧记忆、拼进 memoryBlock,然后才 #remember 抽取新记忆。顺序不能反——如果先记再想,这一句话里刚说出的信息会立刻出现在「你记得他的事」里,回复会显得非常诡异(刚说完就被当成旧事提起)。这类「顺序错了功能就怪」的细节,是读源码最值钱的部分。
2. 为什么仅靠 JSON 文件会走到尽头
我必须先把话说公道:用 JSON 文件存状态,是一个非常好、非常正确、而且被严重低估的做法。OWL 用它跑了很久,你读这一节的时候它很可能还在跑,而且没出过大问题。真正的问题不是「JSON 很笨」,而是「你在用它做一件它没有承诺要做的事」。
下面五种病,是文件方案在规模上来之后必然会遇到的。请注意,每一条我都会同时告诉你:它在什么规模下才真的开始疼。因为不懂边界的人会过早地把项目搞复杂,那是另一种失败。
2.1 病一:并发写会互相覆盖
这是最致命的一条,而且在单进程、低流量下你根本不会发现它。
看 saveHistory():它读的是内存里的 this.history(Map),然后整个文件覆盖写。这意味着两件事:
- 如果同时有第二个进程(比如你为了调试开了两个机器人实例,或者你写了个脚本去读记忆并顺手清理)也在写同一个文件,那么后写的那一个会把先写的整个盖掉——不是合并,是覆盖。用户 A 刚存进去的话就这样消失了。
- 更隐蔽的是「读—改—写」的窗口。如果外部脚本在 OWL 正在写文件的瞬间读到了半个文件,它会读到一段截断的 JSON,解析直接失败。你会在日志里看到
⚠️ 历史记录读取失败: Unexpected end of JSON input——然后 OWL 会带着空的记忆继续跑,因为它把这个异常吞掉了。
什么时候开始疼?只要你有第二个写者就疼。一个进程单独跑,这个病不会发作。
2.2 病二:每次操作都是全量读写
来算一笔真实的账。现在 memory.json 是 1518 字节,history.json 是 8339 字节,加起来不到 10 KB。每回复一句话就全量重写 10 KB,在一个 2 核 1.6G 的服务器上,这大约是零点几毫秒的事,完全不值一提。
但规模是会长的。假设 OWL 服务 200 个人,每人平均 4 个活跃会话,每个会话留 16 条消息,一条消息平均 120 个汉字:
200 × 4 × 16 × 120 ≈ 150 万字。UTF-8 下中文一字 3 字节,再加上 JSON 的结构开销,这个文件大概在 5 MB 上下。于是,每次有人只说了一句「嗯」,OWL 都要把 5 MB 序列化成字符串、再整块写进磁盘。
这里还有一个更贵的隐藏成本:JSON.stringify 是 CPU 密集操作,而 Node.js 是单线程的——第五章你学过事件循环。对一个只做 I/O 的服务,单线程完全够用;但当你把一个 5 MB 的对象序列化时,那一两百毫秒里整台机器人是「卡住」的,别人发来的消息只能排队。这不是理论,这是「打字很快的人发现机器人偶尔变慢」这类玄学问题的真实来源。
什么时候开始疼?文件到几百 KB 时开始有感;到几 MB 时,每次交互都要付一次可感知的代价。
2.3 病三:查询只能靠循环
这是最容易被忽略、但对你作为开发者影响最大的一条。
假设你突然想知道一件很简单的事:「昨天一共被记住了多少次?」——你想拿这个数字做个每日播报,或者只想看看有多少人在被她记住。
用现在的 memory.json,你只能这么干:
// 这是为了演示「查询有多笨」而写的代码,不是项目里的真实代码
const all = JSON.parse(fs.readFileSync("memory.json", "utf8"));
const today = [];
for (const [userKey, list] of Object.entries(all)) {
for (const m of list) {
if (m.at >= startOfToday && m.at < endOfToday) today.push({ userKey, m });
}
}
console.log(`今天被记住了 ${today.length} 次`);
// 想再加一句「其中多少人今天第一次被记住」?再写一个循环 + 一个 Set。
▲ 一个「统计今天被记住多少次」的需求,在文件方案里必须写成两层循环
这段代码能跑,也不是很难写。但它有三个问题:
- 每次都要把所有数据读进内存。你为了数一个数字,把 5 MB 全加载了一遍。
- 它不可组合。「今天被记住最多的是谁」「既在今天被记住、又在昨天被记住的人」——每换一个问法,你就要重写一次循环。
- 它没有复用。数据库里同样的需求是一句话,而那句话说出去几乎不用想:
SELECT COUNT(*) FROM memories WHERE at >= ?。
要点数据库真正卖给你的东西,不是「存得下」,而是一种描述「我要什么样的数据」的语言。你描述结果,不描述过程;至于它怎么找、要不要翻索引、要不要并发去读,那是数据库的事。这就是「声明式」的含义,也是这一章最核心的一次思维转换。
什么时候开始疼?当你第二次为同一个集合写不同问法的循环时,就该疼了。
2.4 病四:没有类型与约束
文件里的 at 字段,你现在可以往里面写任何东西:
// 三个都能被成功写进 JSON,没有一处在阻止你
{ "key": "exam", "at": 1791025724036 } // 正确的写法
{ "key": "exam", "at": "1791025724036" } // 字符串,之后的比较会出错
{ "key": "exam", "at": "2026-10-03" } // 人类可读,但排序全乱
{ "at": 1791025724036 } // key 没了,这条记忆没法去重
▲ JSON 对「一条记忆长什么样」没有任何意见
这种错误会在什么时候爆?几个星期以后。那时你会花一个下午去查「为什么记忆的排序不对」,最后发现是某一次重构往 at 里写了一个字符串,而 JavaScript 的 string > number 不报错、只是给你一个荒谬的 false。
数据库把这件事变成了写不进去。你声明 at INTEGER NOT NULL,那么往里面写 "2026-10-03" 会立刻报错——在错误发生的那一刻报错,而不是在三个星期后。这一条的价值,远远超过它听起来的分量:约束把「未来的一个下午」变成了「现在的三秒钟」。
什么时候开始疼?从项目第二个人加入,或者从你隔一个月回来看自己代码的那一刻起。
2.5 病五:没有事务,没有回滚,坏了没法救
事务(transaction)这个词听起来很学术,但它的意思非常朴素:把几件事打包成一个「要么全成、要么全不成」的单位。
看 /忘记 这条指令在 bot.js 里的真实实现:先 clearHistory(sessionKey),再 clearMemory(userKey)。这两步各自都会写一次文件。现在考虑一个很小的意外:
- 第一步写
history.json成功了; - 就在此刻,服务器的磁盘满了(这在 40 G 磁盘、日志不断增长的云服务器上非常常见,第七章会专门讲);
- 第二步写
memory.json失败。而saveMemory()里是catch { /* 忽略 */ }——异常被吞掉了。
结果是:会话上下文清了,长期记忆还在。用户以为自己被「忘记」了,实际上他关于自己的那些印象还在文件里躺着,下一次对话还会被重新注入给模型。这是一个真实存在的隐私事故,而且它失败得非常安静——没有任何日志告诉你出了事。
再想一步:如果文件在写入过程中断电,你会得到一个被截断的 JSON。这时候你能做什么?你只能去备份里找,或者手工去修那个文件的括号。没有回滚,没有校验,没有「上一个可用的状态」。
什么时候开始疼?当「写一半失败」这件事的后果涉及隐私或金钱时,它就不能再被 catch {} 吞掉了。
2.6 反过来说:什么时候 JSON 文件就够了
这是这一节最重要的部分,也是整本书里「权衡」的第一次真实练习。因为如果你读完上面五条就决定「以后所有东西都用数据库」,那你只是把一种迷信换成了另一种。
下面这些场景里,用 JSON 文件是对的,用数据库反而是错的:
| 场景 | 为什么文件更好 |
|---|---|
| 只有一个写者(一个进程,且没有外部脚本) | 「并发覆盖」这个最严重的病根本不会发作 |
| 数据量在几十 KB 到一两百 KB | 全量读写的成本远低于它的好处:你 cat 一下就能看懂全部数据 |
| 人要看、要手改 | 配置文件是最好的例子。config.json 用数据库存会是个笑话 |
| 数据是整体使用的,从不单独查询某一项 | 比如人设、指令表、关键词表——每次都是整份读进来 |
| 它是导出/交换格式,或者需要进 Git | JSON 是纯文本,git diff 能一行行看出你改了什么;二进制数据库文件做不到 |
| 你在做原型,还没想清楚数据长什么样 | 加一个字段的成本是零。数据库虽然也能改表,但心理成本高得多 |
而且还有一条更实用的判断标准,我建议你用一整年:
判断标准:你需要的是一次「读写整个东西」,还是「在一堆东西里挑出符合条件的一部分」?
前者用文件。一旦你的问题里出现了「符合…条件的」「按…排序的前 N 个」「统计…有多少」,你就已经进入了数据库的地盘。
OWL 的两套记忆恰好一个在左、一个在右:会话上下文是「整段拿出来发走」,文件很合适;用户画像是「挑这个人的、按时间排、最多 8 条」,这已经是查询了。而随着你想做的事变多(谁被记住最多、某个 key 的记忆分布、三个月没来的人),文件方案的每一步都会变得更勉强。
想一想请你自己举三个例子:一个「必须用文件」、一个「必须用数据库」、一个「两者都行但你选文件」的场景。第三个例子最难,也最有价值。想完之后再回答:如果你现在要给 OWL 加一个「群成员积分排行」,你会用哪种?为什么?
3. 关系模型与 SQL:把「谁记得什么」变成表格
数据库有很多种。这一章只讲最主流的那一种:关系模型。关系型数据库(relational database)的核心思想简单到近乎朴素——所有数据都是表格,表格之间可以互相指向。
你其实早就会这个了。ExCEL 表格、成绩单、课程表,都是「行 × 列」。关系型数据库只是往这件事上加了三样东西:严格的类型(这一列只能是数字)、唯一标识(每一行必须有办法被精确指认)、一个问它问题的语言(SQL)。
3.1 表、行、列:先用 OWL 的数据看一遍
先把 memory.json 里的那条记忆「摊平」成一张表,你会立刻看出区别。
现在的形状是「一个键 → 一个数组」:"val:musah18h:有用吗" 对应两条记录。摊平成表格之后,它是这样的:
| id | user_id | key | label | snippet | at |
|---|---|---|---|---|---|
| 1 | val:musah18h:有用吗 | subject | 学科 | 学历史到底有什么用?又不能加分,我觉得不如 | 1791025726804 |
| 2 | val:musah18h:有用吗 | values | 在意的价值问题 | 学历史到底有什么用?又不能加分,我觉得不如去刷题 | 1791025726804 |
四个词,各有一个精确的含义:
- 表(table)——一整类东西。这里这张表叫
memories,装的是「所有关于人的记忆条目」。一张表只描述一种东西,这是设计时最重要的一条纪律。 - 行(row)——表里的一条记录。上表有两行,就是两条记忆。数据库的行还有个别名:记录(record)。
- 列(column)——这一类东西共有的一个属性。就叫
label、snippet、at。 - 字段(field)——常被当作「列」的同义词。更准确地说,字段指「列的定义」(它叫什么、什么类型),而列有时也指「某一行的这个值」。日常说话里混用没关系。
这里有一个思维方式的关键转折,我要把它说明白:
在 JSON 里,"val:musah18h:有用吗" 是结构的一部分——它是键,是路径,你要拿到里面的东西必须先知道这个键。而在表格里,user_id 只是某一列的值。这个区别带来的后果非常实际:
在 JSON 里,「谁」是骨架;在表格里,「谁」是数据。
骨架是没法查询的——你没法写「找出所有键里含有 musah18h 的记录」,除非把它们全拿出来做字符串匹配。而数据是可以查询的,因为数据库会为它建索引(第 4 节)。
这就是为什么把所有用户塞进一个 JSON 对象、用用户 ID 当键,在张数一多之后会越来越难受:你把最需要被查询的那个维度,放到了最难被查询的位置上。
3.2 数据类型:为什么 at 不该是字符串
建表的时候,每一列都要声明类型。SQLite 的类型系统比 MySQL 宽松,常用的就这么几种:
| 类型 | 装什么 | 在 OWL 里对应什么 |
|---|---|---|
INTEGER | 整数 | 时间戳 at、自增主键 id |
TEXT | 文本(UTF-8,中文没问题) | QQ 号 user_id、记忆 snippet、昵称 |
REAL | 小数 | 这一章用不到,但比如「情绪打分 0.73」会用 |
BLOB | 二进制数据 | 存图片、音频的原始字节 |
NULL | 「没有值」 | nickname 未知时就是它 |
关于 QQ 号为什么用 TEXT 而不是 INTEGER,这是一个值得单独说的小判断。因为它虽然长得像数字,但它不是用来做算术的。你不会把两个 QQ 号相加,也不需要它的「大小」。而如果有一天你把机器人从 QQ 迁到别的平台,那个平台上的用户 ID 很可能是 u_8f3a2b 这种带字母的字符串。用 TEXT 存,这种迁移只需要换数据、不需要改表结构。「看起来像数字」和「是数字」是两件事,这是数据建模里最常见的一次误判。
技巧SQLite 允许你在建表时加一个 STRICT 关键字,让类型检查变成真的强制。用了它,往 INTEGER 列里写字符串会直接报错:cannot store TEXT value in INTEGER column。这是我在这一章的例子里默认开启的写法,因为你现在正是最需要「错了就立刻报错」的阶段。注意它在较老的 SQLite 版本上不支持,用之前先确认你的运行环境。
3.3 主键:每一行都要有一个「身份证」
要让数据库能精确指认某一行,这张表必须有一列(或几列的组合)是永不重复的。这叫主键(primary key)。
主键有两条硬规则:不能重复,不能为空。数据库会自动为它建一个索引(下一节讲),所以「按主键找一行」是最快的查询。
在 OWL 的设计里,哪些东西适合当主键?我们有两种选择,各有取舍:
- 用业务字段当主键——比如
users表直接用user_id(QQ 号)。好处是天然唯一、可读;坏处是它由外部系统决定,如果有一天 QQ 号变了,你就得改所有引用它的地方。 - 用一个自增的数字当主键——
id INTEGER PRIMARY KEY AUTOINCREMENT。好处是完全由数据库控制,稳定、紧凑、加索引便宜;坏处是它对你没有意义,你还得同时保留业务字段并给它加唯一约束。
我的建议在这里很明确:users 表用 QQ 号当主键,memories 和 messages 表用自增 id 当主键。理由是:用户是「外部世界的实体」,它的身份本来就由 QQ 决定,用外部身份当键最诚实;而记忆和消息是「我们自己生成的记录」,一笔一笔地发生,用自增号最自然,也最省事。
警告一个真实会踩的坑:SQLite 里写 INTEGER PRIMARY KEY 时,这一列会成为「行号别名」,删除数据后新插入的行可能复用被删掉的号。加上 AUTOINCREMENT 关键字可以保证号只增不复用。对聊天记录这类要追加的东西,我建议加上它——因为「第 1001 条消息的 id 过两个月变成了另一条消息」这种事,会让你的日志和备份对不上账。
3.4 外键与关联:为什么不该把用户信息塞进记忆表
现在来面对一个设计问题,它是这一节所有的关键:memories 表里要不要存昵称?
直觉上「存下来更方便」——查记忆的时候顺便就把名字拿到了,不用查两次。但这么做会带来三个后果:
- 重复。同一个人有 8 条记忆,昵称就存了 8 份。
- 不一致。他改了昵称,你得把 8 行都更新一遍。漏掉一行,你就有了两个名字的同一个他。
- 无处安放。有些关于用户的信息跟记忆无关(比如「第一次见到他是哪天」),硬塞进记忆表就变成了「为了存而存」。
正确的做法是拆成两张表,然后用外键(foreign key)把它们连起来。外键的意思是:这一列的值,必须是另一张表里某个主键的值。
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, -- QQ 号,用户唯一的身份证
nickname TEXT, -- 最后一次见到的昵称,可能没有
first_seen INTEGER NOT NULL, -- 第一次和他说话的时间戳
last_seen INTEGER NOT NULL -- 最近一次互动的时间戳
) STRICT;
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL
REFERENCES users(user_id) -- 外键:这个值必须存在于 users 里
ON DELETE CASCADE, -- 用户被删掉时,他的记忆自动跟着删
key TEXT NOT NULL, -- grade / exam / stress …(去重靠它)
label TEXT NOT NULL, -- 「年级」「考试相关」(给人看的)
snippet TEXT NOT NULL, -- 命中词周围的一小段原文
at INTEGER NOT NULL, -- 这条记忆最后一次更新的时间
UNIQUE (user_id, key) -- 同一个人同一个 key 只能有一条
) STRICT;
CREATE INDEX IF NOT EXISTS idx_mem_user_at ON memories(user_id, at DESC);
▲ users 与 memories 两张表的建表语句(可运行,第 6 节会讲完整四张表)
逐条读一遍这几行,它们每一行都在解决一个具体问题:
REFERENCES users(user_id)——这句就是外键。它让数据库帮你守着「不能有属于不存在的用户的记忆」。没有它,你哪天手工删了一个用户,他的记忆会变成孤儿数据,永远查不出来也永远删不掉。ON DELETE CASCADE——级联删除。它的意思是「父行没了,子行自动跟着没」。这一条直接决定了/忘记的实现能不能写对:只要删掉users里那一行,他的所有记忆会被数据库自动清理干净,不需要你在代码里记得删每一张表。UNIQUE (user_id, key)——联合唯一约束。它把「同一个人同一个 key 只能有一条记忆」这件事写进了数据结构,而不是写在某段 if 里。这是本章最重要的一个技巧,第 8 节会用它来实现「按 key 覆盖」。idx_mem_user_at——索引,第 4 节的主题。
要点注意 ON DELETE CASCADE 是有代价的:它让删除变得很容易,包括「手滑删了一个用户,结果他三年的记忆一起没了」。所以在真实项目里,破坏性的操作要配合两件事:一是备份,二是先在一个事务里写一条操作日志(第 10 节)。方便和危险经常是同一件事的两面。
3.5 增删改查:只有四件事,但要长在需求上
SQL 的日常操作只有四个动词,缩写是 CRUD(Create / Read / Update / Delete,增、查、改、删):
| 动作 | SQL 语句 | 在 OWL 里的真实需求 |
|---|---|---|
| 增 | INSERT | 第一次见到某个人,往 users 里插一行 |
| 查 | SELECT | 想起一个人:取出他最近 8 条记忆 |
| 改 | UPDATE | 他改了昵称,更新 users.nickname |
| 删 | DELETE | 发来 /忘记,把他的一切清掉 |
下面每一条都写出来。请注意每一句前面那个「因为什么需求」——那才是它存在的理由。
3.6 查:SELECT 与它的四个搭档
需求:想起一个人。取 user_id = '1001' 最近更新的 8 条记忆,最新的排在前面。这是 #recall() 在数据库里的版本:
SELECT key, label, snippet, at
FROM memories
WHERE user_id = '1001'
ORDER BY at DESC
LIMIT 8;
▲ 一句 SELECT 里出现了四个关键字,下面逐个拆开
SELECT key, label, snippet, at——要哪几列。别用SELECT *(星号表示所有列)。理由不是「性能」,而是「明确」:你写清楚要哪几列,三个月后你加了一列存私密备注时,不会因为它被无意间查出来塞进日志。这是最小权限原则在数据层的体现。FROM memories——从哪张表里找。WHERE user_id = '1001'——筛选条件,只保留满足这个条件的行。SQL 里字符串用单引号,不是双引号。双引号在标准 SQL 里是「标识符」(表名、列名)用的,写错了某些数据库会直接报错,SQLite 则宽容一点但同样不推荐。ORDER BY at DESC——排序。DESC是从大到小(最新的在前)。想要从小到大就是ASC,而ASC是默认值,可以不写。LIMIT 8——最多返回 8 行。
LIMIT 为什么在这里特别重要?因为它把「最多多少条」这个决定从代码搬到了查询里。在 JSON 方案里,限制条数是 list.slice(-max)——你先把所有记忆都拿到了内存里,然后再切掉。而 LIMIT 8 是让数据库只给你 8 条。当一个人有 8 条记忆时没区别;当他们有几万条时,这就是「传 8 条」和「传几万条」的区别。
WHERE 可以组合条件。三个常用运算符:
-- 需求:找出「考试相关」或「压力/睡眠」的记忆(两个条件满足一个就行)
SELECT * FROM memories WHERE key = 'exam' OR key = 'stress';
-- 需求:找出今天之后更新的、并且不是「兴趣」的记忆(两个条件都要满足)
SELECT * FROM memories WHERE at >= 1791024000000 AND key <> 'interest';
-- 需求:找出所有「压力/睡眠」相关的记忆,不管是什么时候(用 IN 更整齐)
SELECT * FROM memories WHERE key IN ('stress', 'peer', 'family');
▲ AND 要求两边都成立,OR 要求至少一边成立;<> 是「不等于」,也可以写成 !=;IN 是「在这一串里」的简写
警告HTML 里 < 和 > 必须转义(写成 < / >),这一点在写 SQL 的时候特别容易忘——因为 < 在 SQL 里太常见了。这不是数据库的限制,是网页的限制:浏览器看到一个裸的 < 会以为标签开始了,后面的内容就全乱了。你如果在自己写教程页面,这一条会让你浪费一个下午。
下面的 GROUP BY 与聚合函数,是把「多行压成一个数字」的工具。四个常用的聚合函数是 COUNT(数个数)、SUM(求和)、AVG(求平均)、MAX / MIN(最大最小)。
需求:统计今天被记住了多少次。这就是 2.3 节里那个要写两层循环的需求,在 SQL 里是一句话:
SELECT COUNT(*) AS 次数
FROM memories
WHERE at >= 1791014400000; -- 今天的零点的毫秒时间戳
-- 再进一步:按 key 分组,看今天记住的都是哪一类事
SELECT key, COUNT(*) AS 次数
FROM memories
WHERE at >= 1791014400000
GROUP BY key
ORDER BY 次数 DESC;
▲ AS 给结果列起个别名,纯粹为了好读;GROUP BY key 的意思是「把 key 相同的行归成一堆,每堆算一次 COUNT」
这里有一个新手最容易踩的坑:WHERE 和 GROUP BY 的顺序与作用范围。记住这条规则就够了:
WHERE 在分组之前筛掉行;HAVING 在分组之后筛掉组。
所以「只要今天记录数超过 5 条的 key」必须用 HAVING COUNT(*) > 5,不能写进 WHERE——因为在 WHERE 执行的时候,COUNT(*) 这个数还不存在。
3.7 改与删:UPDATE 和 DELETE 里那个致命的遗漏
需求:他改了昵称。
UPDATE users
SET nickname = '小夏',
last_seen = 1791025740819
WHERE user_id = '1001';
▲ UPDATE 用 SET 指定要改哪几列,用 WHERE 指定改哪几行
需求:清空某个人的记忆。
DELETE FROM memories WHERE user_id = '1001';
▲ 注意:这里是删「记忆」,不删「用户」。删用户的写法在 3.8 节
警告忘写 WHERE 是数据库里唯一一个「一秒钟毁掉一切」的操作。DELETE FROM memories; 会把这张表清空;UPDATE users SET nickname = 'x'; 会把所有人的昵称改成 x。数据库不会拦你,因为在它看来这是一个完全合法的请求——你说「更新 users 表」,它就更新了整张表。
两个真实的防护办法:(1)写 DELETE 或 UPDATE 之前,先把同样的 WHERE 拿去做一次 SELECT COUNT(*),确认数出来的行数是你期望的;(2)在生产库里养成习惯:先 BEGIN,执行完看一眼影响行数,确认了再 COMMIT,不对就 ROLLBACK。这两条都是习惯,不是技术,而习惯比技术可靠。
3.8 为什么需要 JOIN:把拆开的两张表问回一起
现在兑现 3.4 节的那个决定:我们为了不重复、不一致,把用户和记忆拆成了两张表。代价是——想知道「小夏的年级」时,昵称在 users 里,记忆在 memories 里,数据被分开了。
JOIN 就是把它们按关联关系重新拼起来的东西。
需求:打印一张「谁 — 记得什么」的清单,要带上昵称。
SELECT u.nickname, m.key, m.label, m.snippet, m.at
FROM memories AS m
JOIN users AS u ON u.user_id = m.user_id
ORDER BY m.at DESC
LIMIT 20;
▲ JOIN … ON 后面的条件就是「怎么把两行配对」;AS m / AS u 是给表起短别名,否则你得写一长串 memories.key
读这句 SQL 的正确方式是从中间往两边看:
FROM memories AS m——先确定「结果的主体是记忆」。JOIN users AS u ON u.user_id = m.user_id——对每一条记忆,去users里找出user_id相等的那一行,把它的列接在后面。- 配对失败的怎么办?默认的
JOIN(也叫INNER JOIN)会丢掉它们。如果你想「保留所有记忆,即使查不到用户也显示出来」,要用LEFT JOIN——它保留左边的表(memories),右边没配上的部分补NULL。
想一想如果我们在 memories 表里加一个外键 REFERENCES users(user_id),那么「查不到用户的记忆」在理论上不可能存在——那 LEFT JOIN 还有意义吗?它为什么仍然值得写?提示:想一想备份恢复的顺序,或者想一想你在手工调试时往表里插的测试数据。
JOIN 这一节我不打算讲完——它有十几种变体,而这本教程里你只需要两种:JOIN 和 LEFT JOIN。剩下的等你真的遇到那个需求时再去查,记忆会牢得多。
4. 索引:为什么「按 user_id 查记忆」会越来越慢
这一节讲一个概念,它决定了你的数据库在数据变多之后是「依然很快」还是「突然卡死」。而且它有一个非常反直觉的性质:加索引能让读变快,但会让写变慢。
4.1 没有索引时,数据库在做什么
假设 memories 表里有 10 万行,你要执行:
SELECT key, label, snippet, at
FROM memories
WHERE user_id = '1001';
如果没有索引,SQLite 只能一行一行地读,把每行的 user_id 拿出来跟 '1001' 比。这叫全表扫描(full table scan)。要找的那个人可能在第 1 行,也可能在第 99999 行,所以平均要读一半的表。10 万行的情况下,这是一次肉眼可感的卡顿——而它发生在每一次有人跟机器人说话的时候。
更糟的是,它会随着数据增长而线性变差。今天 10 万行要 20 毫秒,明年 1000 万行就要 2 秒。而且它不只是慢——它占用的是单线程 Node.js 的主线程时间,所以整台机器人跟着卡。
4.2 索引是什么:一本书的目录
索引(index)是一棵额外的数据结构,它把某一列的值排好序,并记住每个值对应哪一行。就像一本书后面的索引页:想知道「索引」这个词出现在哪些页,你不用从第 1 页翻到最后,先查索引页就行了。
SQLite 用的是 B 树(B-tree)。你不需要懂它的实现,只需要记住它的一个性质:在排好序的数据里找一个值,需要比较的次数大致是「数据量的对数」,而不是数据量本身。10 万行用二分查找大约只需 17 次比较。
建索引的写法就一行:
-- 需求:OWL 每次说话都要「按人取最近的记忆」
CREATE INDEX IF NOT EXISTS idx_mem_user_at
ON memories(user_id, at DESC);
▲ 这是一条「联合索引」——把 user_id 和 at 一起放进索引,顺序很重要
为什么把两列放在一个索引里?因为 #recall() 真实的查询是「WHERE user_id = ? ORDER BY at DESC LIMIT 8」——它既按人筛,又按时间排。如果索引里已经按 (user_id, at) 排好了,数据库就能直奔那个人的那一段、并且按时间顺序直接拿到前 8 条,连排序都不用做。
要点联合索引有一个容易吃亏的规则:它只能从头开始用。(user_id, at) 这个索引能加速「按 user_id 查」,也能加速「按 user_id 查并按 at 排序」,但几乎不能加速「只按 at 查」——因为索引是按 user_id 先分块的,at 在每块内部有序、跨块无序。你把索引想成一本「先按姓氏、再按名字」排的电话簿,就能理解这件事。
4.3 索引的代价:为什么不能每列都建
初学者最容易犯的错误是「那我给每一列都建个索引不就快了」。这么做的后果是:
- 写入变慢。每次
INSERT一行,数据库不只是加一行,还要把所有相关索引都更新一遍。你有 5 个索引,一次插入就是 6 次写操作。而 OWL 最频繁的操作恰恰是写消息——每轮对话写两条。 - 占空间。索引本身也要存。极端情况下,索引的总大小可能超过数据本身。你在一台 40 G 磁盘的服务器上,这不是理论问题。
- 让查询计划变复杂。索引多了,数据库选择走哪条路的成本也变高,偶尔还会选错——你以为加了索引会变快,结果反而慢了。
所以判断标准是:只给「经常出现在 WHERE 或 ORDER BY 里的列」建索引。对 OWL 这个项目,索引清单应该短得能背下来:
| 表 | 索引 | 它服务的那个查询 |
|---|---|---|
memories | (user_id, at DESC) | 想起一个人:取他最近 8 条记忆 |
messages | (session_key, at DESC) | 取出某个会话最近的 N 条消息 |
users | 主键 user_id(自动有) | 按人查昵称、更新时间 |
sessions | 主键 session_key(自动有) | 按会话标识找记录 |
就这些。四张表,两个额外索引。「不该建」这件事和「该建」一样重要:label、snippet、role 这些列都不需要索引,因为你从来不会「按短语查记忆」——那是第八章 RAG 要解决的问题,而它的答案不是 B 树索引。
4.4 EXPLAIN:让数据库告诉你它打算怎么查
你不需要猜。SQLite 提供了一个命令,让数据库把「我打算怎么执行这句查询」说出来:
EXPLAIN QUERY PLAN
SELECT key, label, snippet, at
FROM memories
WHERE user_id = '1001'
ORDER BY at DESC
LIMIT 8;
▲ EXPLAIN QUERY PLAN 不执行查询,只输出执行计划
你在 Node 里可以直接拿它的结果(这是我实际跑出来的输出,环境是 Node 22 自带的 SQLite 3.50):
const rows = db.prepare(
"EXPLAIN QUERY PLAN SELECT * FROM memories WHERE user_id = ? ORDER BY at DESC LIMIT 8"
).all("1001");
console.log(rows);
// [
// { id: 3, parent: 0, notused: 39,
// detail: 'SEARCH memories USING INDEX idx_mem_user_at (user_id=?)' }
// ]
▲ USING INDEX 就是你要看到的那两个词
怎么读这个结果?你只需要抓住关键词:
| 看到什么 | 意思 | 你该做什么 |
|---|---|---|
SEARCH … USING INDEX … | 走了索引,直接定位 | 好事。注意括号里的 (user_id=?) 说明这个条件真的用上了索引 |
SCAN … | 全表扫描 | 如果这张表很大、而这句查询又很频繁,就该考虑建索引了 |
USE TEMP B-TREE FOR ORDER BY | 为了排序,临时建了一张表 | 说明索引没覆盖到排序。把排序列加进联合索引(像我们那样)可以消掉它 |
把「看执行计划」变成习惯,是这一章里最值钱的一个技能。因为关于性能的猜测,绝大多数是错的——你以为慢在 A,实际慢在 B。你自己测出来的结论,比任何一篇博客都可靠。
想一想假设你在 messages 表上建了索引 (session_key, at DESC),然后你写了一句 SELECT * FROM messages WHERE content LIKE '%睡不着%' 想找出所有提到「睡不着」的消息。这个索引帮得上忙吗?为什么?如果不能,那要解决「按内容找消息」这个需求,你该往哪个方向想?(第八章会给一个答案。)
5. SQLite 深入:为什么一个机器人配一个文件就够了
数据库软件有一大堆:MySQL、PostgreSQL、SQL Server、Oracle、MongoDB、Redis……看到这个列表,初学者的第一反应是「我该选哪个」。而我的回答是:对你现在要做的事,这个问题的答案不需要犹豫——SQLite。但你得知道为什么,否则你只是在听我的话,而不是在判断。
5.1 SQLite 是什么,以及它不是什么
SQLite 是一个嵌入式数据库。这四个字是这个项目里最重要的技术判断,值得拆开说:
- 没有服务器进程。MySQL 要先
mysqld跑起来,你的程序通过网络连上它,把 SQL 发过去,等它把结果发回来。SQLite 没有这个东西——它就是一个库文件,你的程序直接把它加载进自己的进程,调用里面的函数。 - 整个数据库是一个文件。你建的那个
owl.db文件,就是这个数据库的全部。复制它就是备份,删除它就是删库,把它挂到 Docker 卷里它就活着,代码更新也不丢。 - 零运维。不用装服务、不用开端口、不用配账号密码、不用设权限。这句话对你这台 2 核 1.6G 的服务器意味着:它不占一份常驻内存,也不需要你为它学一整套运维知识。
- 它不支持并发写。这是最大的限制,也是下面 5.3 节的主题。
一个必须记住的事实:SQLite 是世界上部署最广的数据库。你手机里几乎每一个 App、每一台手机、每一个浏览器、每一架飞机上的娱乐系统里都有它。它不是「小玩具」,它是「被设计来嵌进程序里的数据库」,而这恰好就是你现在的场景。
5.2 事务与 WAL:两个词,一句话
事务(transaction)我们在 2.5 节见过朴素版本:把几件事打包成「要么全成、要么全不成」。在 SQL 里它长这样:
BEGIN;
DELETE FROM messages WHERE user_id = '1001';
DELETE FROM memories WHERE user_id = '1001';
DELETE FROM users WHERE user_id = '1001';
COMMIT;
▲ BEGIN 开始,COMMIT 提交。中间任何一步出错,你都可以 ROLLBACK 全部撤销
这个「打包」的性质有一个专门的名字:原子性(atomicity)——要么全做完,要么像没发生过一样。它是 2.5 节那个「/忘记 只成功了一半」的病的解药。你会发现这两个词在这本教程里反复出现:事务和原子性,一个说的是「边界」,一个说的是「边界内的保证」。
WAL(Write-Ahead Logging,预写式日志)是一个模式开关,这里我只讲一句话:它让「读」和「写」不再互相阻塞。默认模式下,一个人在写数据库时,其他人的读会等待;打开 WAL 之后,读的人看到的是修改之前的快照,不用等。对 OWL 这种「写多、读也多、但都很少」的场景,它几乎总是值得打开:
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA busy_timeout = 5000;"); -- 遇到锁时最多等 5 秒,而不是立刻报错
▲ 我在本地实测过:默认的 journal_mode 是 delete,执行这一句之后变成 wal;而 busy_timeout 默认是 0,意思是「一遇到锁就失败」
警告打开 WAL 之后,你的目录里会多出 owl.db-wal 和 owl.db-shm 两个文件。这不是垃圾,这是数据的一部分。备份时必须三个一起备份,或者在备份前先执行一次「检查点」(PRAGMA wal_checkpoint(TRUNCATE);)。只复制 .db 而丢掉 -wal,你可能会丢最近的一部分写入——这个坑我们会在第 10 节和第 7 章再提一次。
5.3 并发写的限制:为什么「一个机器人一个库」是对的
SQLite 在同一时刻只允许一个写者。注意措辞:不是「只允许一个连接」,是「只允许一个写事务」。别人想写的时候,得等前一个写完。
这个限制听起来很严重,但算一下你的场景:OWL 每次回复大约 1 秒(因为要等模型),而这 1 秒里数据库要做的事是——读 16 条消息、读 8 条记忆、写 2 条消息、写最多 4 条记忆。这些操作加起来大概几毫秒。也就是说,数据库的空闲率超过 99%。
所以对这个项目,结论很明确:一个机器人进程、一个 SQLite 文件,完全够用,而且是对的选择。你不需要为了「并发」去引入一个需要独立运维的数据库服务器。
但你必须知道这个限制在什么时候会咬人:
- 你开了两个机器人进程。比如你为了测试副本、为了灰度上线,起了第二个实例连同一个文件。这时两个进程会争抢写锁,表现是随机的
SQLITE_BUSY错误——而它的随机性会让排查非常痛苦。 - 你写了一个每小时的统计脚本。如果它的事务很长(比如在 Node 里
await了别的东西),它会长时间持有写锁,把机器人卡住。 - 你把数据库放在网络盘(NFS、SMB)上。SQLite 的文件锁依赖本地文件系统的正确实现,放在网络盘上会出现无法解释的损坏。这一条是官方明确警告的。
所以「一个机器人一个库」这句话背后真正的纪律是:这个文件只应该有唯一一个进程在写它。备份脚本、统计脚本要用它,就设计成「只读」,或者用官方提供的在线备份接口(下面 5.5 节会提到)。
5.4 与 MySQL / PostgreSQL 的选型对照
这张表不是让你背参数,而是让你以后在别人说「你为什么不用 MySQL」时,能给出一个具体的答案:
| 你关心的 | SQLite | MySQL / PostgreSQL |
|---|---|---|
| 部署 | 一个文件,在程序里 | 独立的服务进程,要装、要启动、要连 |
| 运维成本 | 几乎为零 | 要管账号、端口、备份策略、版本升级、内存调参 |
| 内存占用 | 跟着程序走,很省 | 常驻几百 MB 起,对 1.6 G 的机器是真实压力 |
| 同时写入 | 一次只能一个写者 | 真正的高并发写 |
| 网络访问 | 不行(它不是一个服务) | 天生支持,多个程序、多台机器共享 |
| 备份 | 复制文件(注意 -wal) | 要用专门的导出工具 |
| 什么时候该换 | 当你需要「多个进程/多台机器同时写同一个库」,或者单机写入量大到写锁成为瓶颈时,就该考虑迁移 | |
我还想加一条我个人认为最重要的判断:你在这台 1.6 G 内存的服务器上装 MySQL,为了让一个记 8 条印象的机器人多花几百 MB 内存,是一笔很差的交易。技术的选择要放进它真实的约束里看——你的约束是内存、是运维人力、是一个人的时间,不是「大厂用什么」。
要点好的架构不是「用了更强的组件」,而是「组件的能力刚好覆盖需求,且没有多余的部分」。SQLite 在这里不是权宜之计,它是正确答案。以后你做的项目变大了,你会自然而然地遇到需要换的那一天,而到那天你会发现:因为你写的是标准 SQL,迁移的工作量主要在于装环境、改连接方式,而不是重写业务逻辑。
5.5 在 Node 里怎么用:两种主流方案
现在到了我必须小心的地方。Node.js 操作 SQLite 的方式在过去两年里正在发生变化,所以我不给你一个「唯一正确写法」,而是给你两条路和它们各自的取舍。请你在动手之前,先在自己的机器上跑一句 node -v 确认版本。
方案 A:node:sqlite(Node.js 官方内置模块)
这是 Node.js 官方在较新的版本里加进标准库的 SQLite 支持。它不需要安装任何依赖,直接 import:
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("owl.db"); // 文件不存在会创建
db.exec("PRAGMA journal_mode = WAL;");
const row = db.prepare("SELECT COUNT(*) AS n FROM memories").get();
console.log(row.n);
▲ 官方内置模块的最小用法。DatabaseSync 这个名字里的 Sync 说明了一件事:它是同步 API,返回给你的那个对象就是一条数据库连接
- 好在哪:零依赖(不用
npm install,不用管编译)、API 简洁、和 Node 一起升级。对「只有几百行代码、只有一个第三方依赖」的 OWL 来说,这是很有吸引力的。 - 要注意什么:这个模块加入得比较晚。它在 Node 22.5 首次出现,之后一段时间里需要加一个实验性开关才能用,后来才默认可用,而稳定性等级在近期才提升到「发布候选」(Release Candidate)——也就是说,官方仍然认为它的 API 可能发生调整。另外,它不在 Node 20 上可用,而 OWL 服务器上装的正是 Node 20(
bootstrap.sh里写的是setup_20.x)。
方案 B:better-sqlite3(社区第三方库)
这是过去几年里 Node 生态中最主流的 SQLite 绑定,也是你搜教程时最可能遇到的:
import Database from "better-sqlite3";
const db = new Database("owl.db");
db.pragma("journal_mode = WAL");
const row = db.prepare("SELECT COUNT(*) AS n FROM memories").get();
console.log(row.n);
▲ better-sqlite3 的最小用法。注意 pragma() 是一个方法,而不是手写 SQL 字符串
- 好在哪:成熟、文档和示例极多、性能好、支持 worker 线程。它的 API 风格(
prepare/run/get/all)和node:sqlite高度相似——这不是巧合,官方内置模块明显参考了它。 - 代价:它是一个原生模块(需要编译 C++ 代码)。虽然通常有预编译好的二进制可以直接下载,但当你的 Node 版本太新或平台太冷门时,它会退化成「在你机器上现场编译」,于是你需要一套编译工具链——这对刚学会用 Linux 的人来说是一个很痛的门槛。
警告上面这些版本和状态信息会变。我在写这一章时核对过官方文档,但等你读到它的时候,node:sqlite 可能已经稳定、也可能改了几个方法名。所以正确的做法不是记住我这段话,而是记住一个动作:动手前去看一眼官方文档那一页(链接在第 19 节),确认它是实验性还是稳定、以及它需要的最低 Node 版本。
那该怎么选?我给你一条可执行的判断,而不是一个答案:
如果你能(也愿意)把服务器上的 Node 升到 22 或更高——用 node:sqlite。它让 OWL 的依赖列表保持极短,符合这个项目「清晰胜于复杂」的气质。
如果你不想动服务器上的 Node 20——用 better-sqlite3。它是成熟方案,安装顺利的话五分钟就能跑起来。而 Node 20 本身已经进入生命周期尾声,这件事我建议你在第七章部署运维时顺手解决。
不要纠结太久。这两个方案的 API 几乎一样,意味着你写的业务代码(建表、查询、增删改查)换一个只改几行 import 和初始化。这就是「把不稳定因素隔离在一层薄薄的适配里」的工程思路,你在第九章还会见到它。
技巧node:sqlite 还提供了一个官方备份接口 sqlite.backup(),可以在数据库正在被使用的时候安全地复制一份,而不用手工去处理 -wal 文件。better-sqlite3 也有对应的 db.backup()。等你到第 10 节做备份脚本时,优先用这个而不是 cp。
6. 动手:为 OWL 设计一个真正的记忆库
这是本章的重头戏。我们要从需求出发,一步一步把 OWL 的记忆从两个 JSON 文件变成四张表,把旧数据搬进去,然后把 /忘记 这类功能真正实现出来。
但我要先给你一条纪律,它比这四张表本身更重要:
永远不要从「表长什么样」开始设计,要从「我要回答什么问题」开始。
因为表的形状是由问题决定的。你换一个问题,可能就要换一种结构;而如果你先画好了表再去想问题,你会开始为了迁就表结构而放弃一些需求——这是数据建模中最常见的失败方式,而且它发生得悄无声息。
6.1 第一步:先列出问题,不列表
把 OWL 关于「记忆」要做的事,用最土的话写下来。不要用任何数据库词汇:
- 有人跟我说话时,我要知道他是谁(至少要有个稳定的标识),并且记得他叫什么、第一次和最近一次说话是什么时候。
- 我要能取出某个人最近 8 条关于他的稳定事实,用来拼进提示词。
- 当一句话里出现了新的稳定事实时,我要把它存下来;如果同一个类别(比如「年级」)之前已经有了一条,就覆盖它,而不是两条都留着。
- 我要能取出某个会话最近的 N 条对话原文,而且要能判断这个会话是不是超过三小时没动静了。
- 有人发
/重置时,我要只清掉当前这个会话的上下文,不动别人的、也不动长期记忆。 - 有人发
/忘记时,我要把他的一切清干净:他的记忆、他说过的话、他的会话上下文。 - 我想能统计:今天一共被记住了多少次?哪些 key 最常出现?有多少人已经三个月没来了?
这七条里有四条是「取出一部分」,两条是「写入与覆盖」,一条是「统计」。现在再回头看第 2 节那个判断标准——它们全部落在「数据库」这一边。这就是我们要动手的依据,而不是「因为数据库比较高级」。
6.2 第二步:找出实体,划清边界
接下来要做的,是把上面的话里反复出现的名词圈出来。这些名词就是「实体」(entity),每个实体大概率对应一张表。
圈出来的结果是四个:人、关于人的事实、会话、消息。
现在到了最关键的一步——判断哪两个词其实是同一个东西,哪两个看起来一样但必须分开。这里有三组最容易被混淆的,我一个一个说。
混淆一:「人」和「会话」。在私聊里它们看起来是一对一的(一个人开一个私聊会话),所以初学者经常把它们合成一张表。但群聊立刻打破了这一点:一个群里有很多人,一个会话里有很多人。这是「多对多」的关系,必须拆成两个实体。而且群聊的会话是共享的——这是第 7 节要展开的一个真实风险。
混淆二:「关于人的事实」和「消息」。「他高三」是一条事实,「我高三了,数学还是跟不上」是一条消息。事实是从消息里抽出来的,会覆盖、会过期、有上限;消息是流水,只增不改。把事实当作「特殊的消息」来存,会让你无法回答「他的年级是什么」——因为你只能扫所有历史消息,还得猜哪一条是最新的。提取过的摘要和原始记录必须分开存,这是记忆系统里最根本的一条设计。
混淆三:「会话」和「消息」。会话是容器,消息是内容。严格来说,只靠 messages 表也能推断出有哪些会话(把它们 GROUP BY 一下),那为什么还要单独一张 sessions 表?因为会话本身有只属于它自己的属性:它是群聊还是私聊、它的更新时间、以后可能还有「这个群要不要开 AI 回复」。这些属性和任何一条具体消息都无关。当一类东西有了自己的属性,它就该有自己的表。
6.3 第三步:给每个实体列属性
现在给四个实体列属性。列的时候问一个问题:「这个属性是它自己的,还是别人的?」如果属于别人,它就不该在这里,而应该变成一个外键。
| 实体 | 它自己的属性 | 它引用的别人 |
|---|---|---|
| 人(users) | 昵称、第一次见到的时间、最近一次互动的时间 | 无(它就是被引用的那一端) |
| 关于人的事实(memories) | 类别 key、给人看的标签 label、原文片段 snippet、更新时间 at | user_id → users |
| 会话(sessions) | 是群还是私聊、更新时间 | 无 |
| 消息(messages) | 角色(谁说的)、内容、时间 | session_key → sessions;user_id → users(可空) |
注意 memories 里没有任何一个字段叫「昵称」。这就是 3.4 节那条纪律在起作用:昵称属于 users,记忆要它就去 JOIN。
6.4 第四步:写出建表 SQL,并逐行解释
想清楚了再写。下面是我为 OWL 设计的四张表,完整可运行:
-- 初始化参数(每个连接打开时都该执行)
PRAGMA journal_mode = WAL; -- 读写不互相阻塞
PRAGMA foreign_keys = ON; -- 打开外键约束(默认可能不开!)
PRAGMA busy_timeout = 5000; -- 遇到锁最多等 5 秒
-- 1. 人:谁
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, -- QQ 号字符串,用户的唯一身份证
nickname TEXT, -- 最近一次见到的昵称,可能为空
first_seen INTEGER NOT NULL, -- 第一次和他说话的时间(毫秒时间戳)
last_seen INTEGER NOT NULL -- 最近一次互动的时间
) STRICT;
-- 2. 关于人的稳定事实:他是什么样的人
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL
REFERENCES users(user_id) ON DELETE CASCADE,
key TEXT NOT NULL, -- grade / exam / stress …(去重全靠它)
label TEXT NOT NULL, -- 「年级」「考试相关」(给人看的)
snippet TEXT NOT NULL, -- 命中词周围的一小段原文
at INTEGER NOT NULL, -- 这条事实最后一次被确认的时间
UNIQUE (user_id, key) -- 同一个人同一个 key 只能一行
) STRICT;
CREATE INDEX IF NOT EXISTS idx_mem_user_at ON memories(user_id, at DESC);
-- 3. 会话:在哪儿发生的
CREATE TABLE IF NOT EXISTS sessions (
session_key TEXT PRIMARY KEY, -- 'private:1001' 或 'group:987654'
kind TEXT NOT NULL
CHECK (kind IN ('private', 'group')),
updated_at INTEGER NOT NULL
) STRICT;
-- 4. 消息:说过什么(流水,只增不改)
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_key TEXT NOT NULL
REFERENCES sessions(session_key) ON DELETE CASCADE,
user_id TEXT
REFERENCES users(user_id) ON DELETE SET NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_msg_sess_at ON messages(session_key, at DESC);
▲ 四张表 + 两个额外索引。这是完整可运行的初始化脚本(我本地在 Node 22 自带的 SQLite 上逐条跑过)
现在逐块解释,重点是每一个决定在做哪个取舍。
为什么 memories.user_id 是 ON DELETE CASCADE,而 messages.user_id 是 ON DELETE SET NULL?
这是一个很细但很真实的判断。一个人发 /忘记 时,他的「稳定事实」必须消失——那是关于他的画像,留着就是隐私事故。但会话里的消息呢?如果这些消息在一个群聊里,直接删掉会破坏群聊其他人的上下文(他们那边的对话会突然出现空洞)。所以更稳妥的处理是:把他自己的记忆和私聊痕迹删干净,而群聊里的消息保留内容但把他的身份置空——这就是 ON DELETE SET NULL。
我得诚实地说:这是一个有争议的决定。「保留内容但置空身份」在技术上不能算完全匿名(内容里可能还有「我是高三的」),所以更彻底的方案是「连内容一起删」。哪一种对,取决于你怎么看「群里别人的上下文」和「他的被遗忘权」哪个优先。我倾向于前者是错的——如果你也想不清楚,那就选更彻底的那一种,因为在隐私问题上,「多删一点」的代价永远比「少删一点」小。
为什么 messages 表不设条数上限、而 memories 设 8 条?
因为消息是「日志」,它对你有别的价值:统计、排查、看看哪一句把模型逼出了胡说八道。而记忆是「要被塞进每一轮提示词的东西」——它直接花 token,直接稀释注意力。所以 memories 有 maxPerUser = 8,messages 没有。上限不是加在「存」上的,是加在「用」上的。这句话值得单独记住。
为什么需要 sessions 表。三个具体理由:一是 messages.session_key 需要有一个东西担保它是合法的;二是「这个会话是群还是私聊」在统计和管理时很有用;三是 /重置 可以简单地用「删掉这个会话的所有消息」来实现,而 ON DELETE CASCADE 保证了删会话时消息跟着走。
想一想如果你不建 sessions 表,把「群还是私聊」这个信息塞进 session_key 字符串的前缀里(就像现在 bot.js 的 sessionKeyOf() 那样),哪些需求会变得难受?请举出至少两个。反过来说,现在这个前缀设计有什么好处?
6.5 第五步:把旧数据搬进去(迁移脚本)
设计完表之后,你不能让新代码从空的数据库开始跑——现有的 memory.json 和 history.json 里是真数据。所以需要一个脚本:读旧格式,写新格式。这个动作在工程上有个专门的词,叫数据迁移(migration)。
迁移脚本有三条铁律,我从一个真实的坑开始讲:
警告迁移脚本绝对不能修改或删除原始文件。它应该只读 memory.json,把数据写进 owl.db,然后什么都不做。因为迁移脚本必然有 bug,而如果你在迁移的过程中删掉了源文件,你就没有第二次机会了。正确顺序永远是:写脚本 → 跑 → 对比两边数量 → 确认无误 → 隔几天再手工删旧文件(或者干脆不删,只是重命名成 memory.json.bak)。
脚本的核心思路只有三步:遍历旧的每个键 → 保证 users 里有这个人 → 把记录一条条 upsert 进 memories。下面是关键代码(简化版,去掉了日志):
import fs from "node:fs";
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("owl.db");
db.exec("PRAGMA foreign_keys = ON;");
// 准备两个语句,循环里反复用它们(不要每次重新 prepare)
const ensureUser = db.prepare(
"INSERT INTO users (user_id, nickname, first_seen, last_seen) " +
"VALUES (?, NULL, ?, ?) " +
"ON CONFLICT(user_id) DO UPDATE SET last_seen = excluded.last_seen"
);
const putMemory = db.prepare(
"INSERT INTO memories (user_id, key, label, snippet, at) VALUES (?, ?, ?, ?, ?) " +
"ON CONFLICT(user_id, key) DO UPDATE SET " +
" label = excluded.label, snippet = excluded.snippet, at = excluded.at"
);
// memory.json 的形状是 { userKey: [ {key,label,snippet,at}, … ] }
const mem = JSON.parse(fs.readFileSync("memory.json", "utf8").replace(/^\uFEFF/, ""));
let userCount = 0, memCount = 0;
db.exec("BEGIN"); // 整个迁移放进一个事务
try {
for (const [userKey, list] of Object.entries(mem)) {
if (!Array.isArray(list)) continue;
const at = Date.now();
ensureUser.run(userKey, at, at);
userCount++;
for (const m of list) {
putMemory.run(userKey, String(m.key), String(m.label), String(m.snippet), Number(m.at) || at);
memCount++;
}
}
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK"); // 出任何错就整体回滚,不留下半迁移的库
throw e;
}
console.log("迁移完成:" + userCount + " 个人," + memCount + " 条记忆");
▲ 长期记忆的迁移思路。ON CONFLICT … DO UPDATE 是 SQLite 的 upsert 语法,意思是「冲突了就改成更新」
这段代码里有四个细节,每一个都值得你停下来看一眼:
- 整个迁移在一个事务里。如果第 500 个人那里出错,前面 499 个也不会留下——你不会得到一个「迁移了一半」的数据库。这比「跑到哪算哪」安全得多。
ensureUser用 upsert。因为同一个人可能出现在多条记录里,第一次插入、后面更新,不用在代码里判断「他存在吗」。Number(m.at) || at。这是防 2.4 节那个病的:万一旧数据里的at是字符串或被写坏了,这里会兜底成当前时间,而不是把脏数据带进新库。迁移是唯一一次「你有机会清洗历史脏数据」的时刻,别浪费它。- 语句只 prepare 一次,循环里反复
run。这是better-sqlite3和node:sqlite都推荐的用法:prepare(准备/编译 SQL)是一次相对昂贵的操作,而 run(绑定参数并执行)很便宜。把 prepare 写进循环里,是一个真实存在、但不容易被发现的性能错误。
history.json 的迁移更绕一点,因为要先建会话、再插消息。说一下思路(不是完整代码):遍历每个会话键,先 INSERT OR IGNORE 一行 sessions(键是主键,重复插入会失败,用 OR IGNORE 让它安静跳过),然后遍历 msgs 数组,把每一条插进 messages,其中只有 role = "user" 的那些才带 user_id——因为 assistant 的那些话是 OWL 说的,不属于任何人。
迁移完之后,你必须做一件事:对账。
-- 旧的 memory.json 里有 5 个人 → 新库的 users 应该也是 5
SELECT COUNT(*) AS 人数 FROM users;
SELECT COUNT(*) AS 记忆条数 FROM memories;
SELECT COUNT(DISTINCT user_id) AS 有人数的人 FROM memories;
-- 找出「有记忆但没有用户」的孤儿(正常情况下必须是 0 行)
SELECT m.user_id, COUNT(*) FROM memories AS m
LEFT JOIN users AS u ON u.user_id = m.user_id
WHERE u.user_id IS NULL
GROUP BY m.user_id;
▲ 迁移之后的四个对账查询。第四个如果返回了任何一行,说明你的迁移有 bug
6.6 CRUD 实战:四个真实需求,四组 SQL
现在把第 6.1 节列的问题,一条条变成 SQL。
需求一:记住一件事(有就覆盖,没有就插入)。
这是整个记忆系统里最重要的一句 SQL。它要同时满足:同一个人的同一个 key 只保留最新的一条。UNIQUE (user_id, key) 这个约束是我们能写出它的前提:
INSERT INTO memories (user_id, key, label, snippet, at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET
label = excluded.label,
snippet = excluded.snippet,
at = excluded.at;
▲ excluded 是 SQLite 的关键字,代表「这次本来想插入的那一行」
读法:先尝试插入一行;如果撞上了 (user_id, key) 这个唯一约束,就把已有那一行的三个字段改成「这次想插入的值」。
为什么用这种方式,而不是在 JavaScript 里先查再决定插还是改?因为「先查再改」是两个操作,中间有窗口。在那一瞬间,另一个写者可能刚好插入了同一个 key,于是你就有了两条。而 upsert 是一个原子操作,数据库自己保证「查与改之间没人插进来」。这就是数据库给你的、语言层面给不了的东西。
注意 at 被更新成了新时间。这个细节有后果:重新确认过的旧事实会跑到列表最前面。比如三个月前记下的「兴趣:打篮球」,今天他又提到打篮球,这条记忆就刷新到最新。LIMIT 8 按 at 倒序取,所以它不会被淘汰掉。这就是「按 key 覆盖 + 按时间淘汰」这一对机制真正的含义:常被提及的事情活下来,一次性的旧事被挤出去。它是一个朴素的「遗忘曲线」。
需求二:想起一个人(取最近 8 条)。
SELECT key, label, snippet, at
FROM memories
WHERE user_id = ?
ORDER BY at DESC
LIMIT 8;
▲ 这就是 #recall() 的数据库版本,一句顶掉了 this.memory.get(userKey) ?? []
但这里有一个新增的麻烦:旧的 #recall() 是「纯读」的,而现在你可能还想顺手更新 users.last_seen。这是两个操作,所以应该放进一个事务:
db.exec("BEGIN");
try {
const rows = recallStmt.all(userId); // 取记忆
touchUserStmt.run(Date.now(), userId); // 更新 last_seen
db.exec("COMMIT");
console.log(rows);
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
▲ 事务的最小用法。注意 COMMIT 必须在 try 里、ROLLBACK 在 catch 里
需求三:清空某个人的记忆(/忘记)。
这一条我们要认真写,因为它是隐私承诺的兑现方式。回顾第 6.1 节的第 6 条:要「把他的一切清干净」。和 3.8 节一样,我们靠外键的级联来做:
-- 只清记忆、保留用户(比如以后想加一个「只忘内容不忘人」的指令)
DELETE FROM memories WHERE user_id = ?;
-- 彻底忘记一个人:删掉 users 那一行,他的记忆会自动跟着消失
DELETE FROM users WHERE user_id = ?;
-- 只清当前这个会话的上下文(/重置)
DELETE FROM messages WHERE session_key = ?;
▲ 三条删除语句,对应三个不同的指令。第二条依赖 ON DELETE CASCADE 才有效
注意 PRAGMA foreign_keys = ON 这一句。这是这一节最容易踩的坑:SQLite 里外键约束默认可能是关闭的,取决于编译时的设置和连接选项。如果它是关的,那 DELETE FROM users 会成功删除用户,但他的记忆会留在 memories 里,变成永远查不到的孤儿数据。于是用户以为自己被忘记了,实际上没有——这正是 2.5 节那个 bug 在数据库方案里的翻版。
技巧最稳的做法是两层保险:在打开连接时执行 PRAGMA foreign_keys = ON,同时在 /忘记 的实现里显式地把三张表都删一遍(放在一个事务里)。这不是冗余,这是「不把隐私的实现完全押在一个 pragma 上」。你可以在每次打开数据库后立刻跑一句 PRAGMA foreign_keys; 确认它真的开了,返回值是 1 才算数。
需求四:统计今天被记住了多少次。这就是 2.3 节那个两层循环在 SQL 里的样子,也顺便看看「聚合」能做什么:
-- 今天一共记住了多少次
SELECT COUNT(*) AS 次数 FROM memories WHERE at >= ?;
-- 按类别分组,看今天记住的都是哪一类事
SELECT key, COUNT(*) AS 次数
FROM memories
WHERE at >= ?
GROUP BY key
ORDER BY 次数 DESC;
-- 今天有新印象的人有几个(去重计数)
SELECT COUNT(DISTINCT user_id) AS 人数 FROM memories WHERE at >= ?;
-- 三个月没来的人
SELECT u.user_id, u.nickname, u.last_seen
FROM users AS u
WHERE u.last_seen < ?
ORDER BY u.last_seen ASC
LIMIT 20;
▲ 四个统计查询。前三个在旧的 JSON 方案里都要写循环,第四个几乎写不出来
6.7 参数化查询:一个字符删掉全库的故事
上面所有 SQL 里,你都看到一个 ?。这不是省略号,这是本章最重要的一个安全机制,叫参数化查询(parameterized query),也叫占位符、绑定参数(bound parameters)。
要讲清它为什么必须存在,最好的方式是先写错的版本。
假设你在实现 /忘记,而且你「很自然地」用字符串拼接写了一句 SQL:
// ❌ 千万别这样写
const uid = "1001"; // 这个值来自 QQ 消息
const sql = "DELETE FROM memories WHERE user_id = '" + uid + "'";
db.prepare(sql).run();
// 执行的是:DELETE FROM memories WHERE user_id = '1001'
▲ 字符串拼接。看起来完全能工作——这就是它危险的地方
它确实能工作。问题是 uid 不是你自己写的字面量,它来自一条 QQ 消息,是一个你能控制的字符串。我们换一个真实的场景:假设你在做一个「按昵称查记忆」的后台命令:
// ❌ 攻击者把自己的昵称写成这样:
const nickname = "1001' OR '1'='1";
const sql = "DELETE FROM memories WHERE user_id = '" + nickname + "'";
// 拼出来的 SQL 变成了:
// DELETE FROM memories WHERE user_id = '1001' OR '1'='1'
//
// 而 '1'='1' 永远成立,所以这一句的实际含义是:
// DELETE FROM memories; —— 删掉所有人的所有记忆
▲ 这就是 SQL 注入(SQL injection)。我在本地真的跑过这个例子:正常调用删 2 行,注入后删掉了全部 3 行
请仔细看这件事的本质,它不是「有人输入了奇怪的字符」,而是:
字符串拼接把「数据」和「代码」混在了一起。
'1001' 本来应该是数据(一个用户号),但因为它是用字符串拼进 SQL 文本的,它身边的那个单引号是代码(SQL 语法的一部分)。攻击者只要在自己的输入里放一个单引号,就能提前结束数据、开始写代码。
SQL 注入不是「SQL 的漏洞」,是「你把用户的输入当成了程序的一部分」的漏洞。同样的病存在于所有「把输入拼成代码」的地方——这也是为什么第九章讲安全时会反复回到这个模式。
参数化查询从根上解决了它。? 的位置不是文本替换,而是一个真正的参数槽:你把 SQL 的骨架先交给数据库编译好,然后单独把值递进去。值永远只能是值,它不可能变成语法:
// ✅ 正确写法
const stmt = db.prepare("DELETE FROM memories WHERE user_id = ?");
stmt.run(uid); // 无论 uid 里有什么字符,它都只是一个字符串
// 需要多个参数就按顺序多写几个 ?
db.prepare("INSERT INTO memories (user_id, key, label, snippet, at) VALUES (?,?,?,?,?)")
.run(userId, key, label, snippet, Date.now());
// 也可以用具名参数,可读性更好(node:sqlite 支持 :name 和 $name 两种前缀)
db.prepare("SELECT * FROM memories WHERE user_id = :uid ORDER BY at DESC LIMIT :n")
.all({ uid: "1001", n: 8 });
▲ 占位符的两种写法。左边是位置参数,右边是具名参数——参数越多,具名参数越不容易出错
用一个比喻收束这一段,然后立刻把它拆掉:字符串拼接像把用户的输入当成一句「台词」,念进了剧本里;参数化查询像把用户的输入当成一个「道具」,递到演员手上——台词是他念的,道具是死的。这个比喻的局限在于,它容易让人以为数据库在「检查」你的输入。它没有检查。它做的是更彻底的事:让值根本不经过「被解析为语法」那条路。
警告有一个地方你没法用占位符,必须特别小心:表名、列名、以及 LIMIT 后面想动态指定的方向(ASC/DESC)。因为 ? 只能替换值,不能替换语法结构。如果你真的需要一个「用户可选的排序字段」,唯一的正确做法是在代码里写一张白名单:把允许的列名硬编码成一个数组,用户传来的值必须在这个数组里才允许拼接。任何「直接拼」的做法都是注入漏洞。
想一想下面这段代码是安全的吗?
db.prepare("SELECT * FROM memories WHERE snippet LIKE ? ").all("%" + keyword + "%")
提示:这里没有 SQL 注入(值确实被绑定了)。但还有另一个问题——% 和 _ 在 LIKE 里是通配符。如果用户输入的 keyword 里含有这两个字符,会发生什么?这是「注入」的另一个层次,第九章会细讲,但你现在就可以想想它属于哪一类问题。
7. 上下文的生命周期:为什么是 8 轮、3 小时
第 1 节我们看到两个真实的数字:maxTurns: 8 和 ttlMinutes: 180。这一节要回答的不是「它们是多少」,而是「凭什么定这几个数」。因为你会需要自己改它们,而如果你不知道为什么,你只会在两个错误之间来回摇摆:改成 50 觉得很爽,然后账单来了;改成 2 觉得省钱,然后她开始答非所问。
7.1 为什么不能无限记住
有两个原因,第二个比第一个重要得多。
原因一:成本。每一次调用模型,你都要把「系统提示词 + 全部历史 + 这一句新消息」一起发过去。也就是说,历史不是「免费存着」,而是每一轮都要重新付一次钱。这是初学者最容易误解的一点:他们以为历史像数据库一样「存着不花钱」,实际上历史像每次都要复印一遍带进考场的小抄。
算一笔账:OWL 的人设提示词大约 1500 个汉字,一轮对话的一问一答大约 200 字。maxTurns: 8 意味着每一次请求大约要发 1500 + 8×200 ≈ 3100 字;如果改成 maxTurns: 30,就是 1500 + 30×200 ≈ 7500 字——翻了不止一倍,而用户感受到的差别可能只是「她记得我十分钟前说的话」。顺便说,把汉字换算成 token 的准确关系属于第八章的内容,但你先有一个直觉:字多了,钱就多,而且不是线性地多。
原因二:注意力会被稀释。这是更本质的一条。模型在回答你这句话的时候,它的「注意力」要分配给它看到的全部内容。历史越长,这一句新消息在所有内容里的比例越小。表现就是:她开始答得有点飘,会回应你三轮前说过的话,或者忽略你这句里的关键信息。
所以「历史越长越好」是一个直觉上的错误。正确的说法是:历史的价值在衰减,而它的成本在累积。两条曲线交叉的地方,就是你该设的 maxTurns。对「陪高中生聊天」这件事,交叉点大概就在 6 到 10 轮之间——因为这类对话的连续性以「这几分钟」为单位,而不是以「今天下午」为单位。
7.2 为什么是 3 小时(而不是 5 分钟或 3 天)
ttlMinutes 回答的是另一个问题:一个会话「多久没动静」之后,它的上下文就该被忘掉?
这个数字背后是一个关于人的判断,而不是技术判断:
- 如果设成 5 分钟:他上个厕所回来,跟她说「接着说」,她会问「说什么?」——体验崩了。
- 如果设成 3 天:他昨天深夜倾诉完,今天中午随便问了句「在吗」,她会带着昨晚那段沉重的上下文回应他——这比忘记更糟,因为它看起来像「她还在惦记那件事」,而人家可能已经过去了。
所以 3 小时是一个关于「一场对话的边界」的估计:超过 3 小时,人们通常已经不认为自己在同一场对话里了。注意这句话是可以用实验验证的——你完全可以去问几个真实用户,或者看看自己的聊天记录里「同一场对话」的间隔分布。这类数字不该靠猜,但也不该指望有标准答案。
想一想如果你把 OWL 改成面向成年人、讨论工作的助手,ttlMinutes 应该变大还是变小?maxTurns 呢?请给出你的数字和理由,然后想一个更狠的问题:如果不同的人需要不同的值,你该怎么设计?
7.3 过期是怎么真正发生的
这里有一个容易被忽略的实现细节,而它会造成一个真实的 bug。
在 llm.mjs 里,TTL 的检查发生在 #loadHistory()——也就是程序启动时。它读文件,把没超过 3 小时的会话装进内存,然后……就没有第二次检查了。之后的每一次 chat() 都直接从内存的 Map 里取,不再检查它的 at 是不是过期了。
后果是:只要机器人一直不重启,一个会话可能在内存里活好几天。它的内容确实会被 maxTurns × 2 截断(只留最近 16 条),所以不会无限增长,但「三小时」这个规则实际上只在重启时生效。
这就是我要你记住的那类坑:配置里写了不等于代码里做了。你看到 ttlMinutes 这个配置项,很容易以为它一直在起作用。验证它的唯一方法是去读那段代码——或者做实验:改成一个很小的值(比如 0.1),然后不重启程序,看它会不会「忘」。
警告用数据库之后,这个 bug 会自然消失一部分——因为你可以把「过期」写进查询本身:SELECT … WHERE session_key = ? AND at >= ?。注意是「一部分」:你仍然要记得在任何读取路径上都带上这个条件。把规则写进数据结构(索引、约束)比写进代码要可靠,但没有任何办法能代替「读一遍自己的代码」。
7.4 群聊的会话键:共享上下文带来什么
现在来看一个 OWL 真实存在、而且相当微妙的设计。看 bot.js 里的这一行:
function sessionKeyOf(event) {
return event.message_type === "group"
? "group:" + event.group_id
: "private:" + event.user_id;
}
▲ 群聊的上下文按键是「群」,私聊的按键是「人」——这一行决定了群里的对话体验
它带来的直接效果是:在群里,所有人共享同一份上下文。A 说「我这次月考砸了」,B 接着说「那你数学多少分」,此时 OWL 看到的是一段连续的对话,它会自然地接上——这在体验上很好,像个真人参与群聊。
但它同时带来三个真实的风险,你要能一条条说出来:
- 串味。B 问「你上次说的那本书叫什么」,OWL 可能会把 A 说的书当成 B 说的。因为在它的上下文里,这些消息没有区分「是谁说的」——
msgs里只有role: "user",没有 user_id。 - 隐私外溢。A 在群里说了「我爸妈要离婚了」,这条信息进入了共享上下文。之后任何人在这个群里接着聊,模型看到的都包含这句话。更糟的是,如果 A 同时私下跟 OWL 聊过,那两套上下文是分开的,但长期记忆不是——按
userKey存的记忆是跨会话的。 - 被劫持。如果有人刻意在群里连续说话,把上下文刷满,那么 A 的信息就会被挤出去(因为只留 16 条),而刷屏者可以借此让 OWL「忘掉」刚才的话题,或者诱导它把上下文带向别处。
现在把我们第 6 节设计的那张 messages 表拿出来对照一下——它每一行都带 user_id。这不是为了查询方便,而是为了让「谁说的」这件事可被还原。有了它,你才有可能做一件现在做不到的事:把上下文按发言人重新组织,比如给每条历史加上「(这是 A 说的)」这段前缀。
这里有一条能迁移到所有系统的原则:凡是你可能需要在未来区分的东西,今天就要把它当字段存下来。
「群聊里的一句话是谁说的」在 V1 时看起来毫无必要——反正都是一段对话。但 V2 想按人区分时,你只能回去翻日志(如果你有日志的话)。存下来的成本是几字节,事后补不回来的成本是几十小时。
8. 长期记忆的设计哲学:什么该记、怎么用、怎么删
这是本章最值得深挖的一节。因为它讨论的问题没有标准答案,只有取舍——而 OWL 现有的实现已经做了一组取舍,其中有些很聪明,有些我认为是错的。
8.1 抽取:九条正则怎么决定「记住什么」
长期记忆的第一道关口是抽取(extraction):从这一句话里,我要不要把某些东西留下来。OWL 用的是最朴素的办法——九条正则表达式,每条对应一个类别。这是 safety.mjs 里真实的规则表(节选):
const MEMORY_RULES = [
{ key: "grade", re: /(高一|高二|高三|初一|初二|初三|高[一二三]|复读)/, label: "年级" },
{ key: "subject", re: /(理科|文科|物化生|史政地|选科|物理|化学|生物|历史|政治|地理|数学|英语|语文)/, label: "学科" },
{ key: "exam", re: /(高考|中考|期末|月考|模考|一模|二模|保送|艺考|竞赛|强基|想考|考上)/, label: "考试相关" },
{ key: "stress", re: /(失眠|睡不着|熬夜|焦虑|压力|考砸|崩溃|好累|太累|太差|学不好|跟不上)/, label: "压力/睡眠" },
// …另外五条:peer 人际关系、family 家庭、interest 兴趣、goal 目标、values 在意的价值问题
];
▲ 摘自 qq-bot/bot/safety.mjs。注意 key 是给机器去重用的,label 是给人看的
真正的抽取函数还做了两件值得学的事:
- 只扫前 500 个字符(
String(text).slice(0, 500))。这是一个成本与干扰的权衡:太长的输入里,命中很可能来自无关的引用或复述。 - 只取命中词前后各一小段(前 12 字、后 18 字)作为
snippet,最多留 4 条。它不是存整段对话,而是存一个能提醒「当时在说什么」的碎片。
这个设计的取舍非常清楚,而且 safety.mjs 的注释里自己写明了:「刻意保守:宁可漏,不要错记。」这句话是整节的核心。为什么宁可漏?因为两类错误的代价完全不同:
| 错误 | 后果 | 可恢复吗 |
|---|---|---|
| 漏记(他高三,但没记下来) | 下次他不知道你高三,可能会问「你高几」 | 可以。多聊两句就补回来了 |
| 错记(把「我表弟高三」记成「他高三」) | 她每次都用错误的身份跟你说话,而且你会开始怀疑她还记错了什么 | 很难。信任一旦裂了,不是改一条数据能修的 |
这个判断可以推广成一句更狠的话:在涉及「关于一个人的事实」的系统里,不确定就不要下结论。它不是技术约束,它是伦理约束——尤其当你面对的是高中生时。
8.2 去重与覆盖:为什么要有 key
抽取出来的东西不能无脑追加,否则「高三」这个事实会被存几十遍。所以每条规则都带一个 key——key 的作用只有一个:标识「这是一类事实」。
现有实现的逻辑在 #remember() 里:找到同 key 的那一条,覆盖它;找不到就追加一条;最后 slice(-max) 只留最近 8 条。
这套机制有一个很聪明的副作用,也有一个真实的缺陷。
聪明的副作用:key 是「有损的」,这反而保护了隐私。想一想:如果 OWL 不分类,把每条记忆都单独存着,那么「grade」这个类别下可能积累十几条不同时间点说的话,其中可能包含「我爸是老师」这种更细的信息。而按 key 覆盖之后,一个人在一个类别下永远只有一条,而且是最近的一条。这让记忆的总量可控、也让「被忘记」这件事变得更容易兑现。
真实的缺陷:颗粒度太粗。「stress」这一个 key 现在装的是「失眠」「焦虑」「考砸」「跟不上」所有东西。如果他上周因为考试崩溃、这周因为家里的事崩溃,覆盖之后前一件事就消失了。而这两件事在他生命里可能完全不是一回事。
想一想如果要修这个缺陷,你有三个方向:(1)把 key 拆细(stress_exam / stress_family);(2)每个 key 允许保留多版本(加一列 prev_snippet 存上一次);(3)不再按 key 去重,改用别的方式控制总量。分别说出每个方向的代价。特别注意第二个方向:它会让「被遗忘权」变得更难兑现还是更容易?
8.3 maxPerUser = 8:为什么是 8,淘汰的规则公平吗
8 这个数字不是算出来的,它是一个容量预算——你要在她的「记忆里」给每个人留多少位置。
先说上限是怎么执行的:list.slice(-max)。因为它按插入顺序保留最后 8 条,而不是按 at 排序,所以这里的规则其实是「最后被写进来的 8 条」。这个区别很重要:一条三个月前记下的记忆,只要你今天又提到它(触发同 key 覆盖),它会被重新 push 到列表末尾,于是它不会被淘汰。这就是它朴素的「遗忘曲线」:常被提起的事情留下来。
但这个「公平」是有代价的,有两个真实的偏斜:
- 新事实挤掉旧事实。一个高三学生连着聊了三次考试、两次压力、一次人际、一次家庭、一次目标、一次兴趣——正好 8 条。第二天他随口提了一句「我最近在听某个乐队的歌」,它会把最早的那条(可能正是「家庭」那条最需要被记住的)挤出去。频率高的类别会挤走频率低的类别。
- 重要的东西不一定被提得多。「他有自伤倾向」这件事可能只说过一次,但它比「他喜欢打篮球」重要得多。现在这条清单里,它们的权重是一样的。
如果要改,最直接的方向是给记忆加一个 weight(权重)或 pinned(钉住)字段,淘汰时按权重而不是按时间。但这会引入一个新问题:谁来定权重?让模型定,它会犯错;让规则定,你会写出越来越复杂的规则。这就是「设计取舍的味道」——每一个修法都会带来一个新的问题,而好设计不是没有问题的设计,是你清楚知道自己在承担哪个问题的设计。
8.4 注入:记忆不是「存着」就有用
这是这一节最重要的技术点,也是初学者最常忽略的一步。
记忆存在文件里或者数据库里,它对模型完全不可见。模型看不到你的数据库,看不到你的文件,它只能看到你这一次发给它的那段文字。所以「记住」这件事,必须由两个动作共同完成:
- 存储:把事实写进
memories表。 - 注入(injection):在每一轮对话时,把它挑出来、写成一段文字、塞进 system 消息里。
只有第一步而没做第二步,效果等于没记。而第二步做得怎么样,直接决定了记忆「读起来像不像记得」。看 safety.mjs 里真实的注入模板(这就是 memoryInstruction):
export function memoryInstruction(memories) {
if (!memories || !memories.length) return "";
const lines = memories.map((m) => "- " + m.label + ":…" + m.snippet + "…").join("\n");
return [
"",
"【你记得这个人的事】以下是你之前了解到的情况,必要时自然地带一句(例如“你上次说最近睡不好”),",
"但绝对不要生硬地罗列,也不要说“根据我的记录”。如果跟当前话题无关就别提:",
lines,
].join("\n");
}
▲ 摘自 qq-bot/bot/safety.mjs。它做了三件事:挑出内容、规定语气、规定边界
把这段文字拆开看,它其实在回答四个问题,而每一个都会影响输出:
| 问题 | OWL 的答案 | 如果换个答法会怎样 |
|---|---|---|
| 注入什么 | 最近 8 条记忆的 label + snippet | 注入全部记忆 → 长、贵、而且会提到尴尬的旧事 |
| 怎么表述 | 用「…」包住片段,标明是回忆不是原文 | 不加包裹,模型可能把片段当成用户刚刚说的话,直接回答它 |
| 什么语气 | 「自然地带一句」,并给了例句 | 只写「这是你的记忆」,它会说「根据我的记录,你高三」——像客服系统 |
| 什么时候别提 | 「如果跟当前话题无关就别提」 | 不说这句,她会在聊天气的时候突然问「你上次说的家庭问题怎么样了」 |
最后一行是这一节我最想让你记住的。「不提」和「不提」是两种能力。初学者以为记忆系统越强越好,实际上一个不会在该沉默时沉默的记忆系统,比没有记忆更让人不舒服。想想现实里那个总提你不想提的事的人——他不是记性差,他是不会读空气。
要点把记忆注入 system 消息,而不是混进对话历史里,是一个重要选择。原因是权威性不同:system 消息是「你的背景知识」,历史消息是「已经发生过的对话」。如果你把记忆伪装成一条历史消息,模型会更倾向于「回应」它,而不是「使用」它。这个区别你在第八章写提示词时会反复用到。
8.5 什么时候不该记:隐私与误记的代价
现在到了这一节最需要判断力的地方。前面讲的是「怎么记」,这里讲的是「什么时候决意不记」——而后者更能看出一个开发者的成熟度。
我把「不该记」分成四类,每类给一个具体判断:
第一类:敏感的第三方信息。「我同桌偷东西被抓住了」「我妈打我」——这些话里包含了不属于说话人的隐私。你记下来,等于替一个未经同意的第三方建了档案。原则:凡是涉及具体他人的负面信息,默认不记。现有那九条规则里的 family、peer 两条,恰恰最容易踩到这里。
第二类:可能变化的事实,却用永久的方式存。「我数学考了 60 分」——这是一次事件,不是稳定特征。存成记忆之后,三个月后她还可能提「你数学不是只有 60 分吗」,而这期间他可能已经考了 90。原则:只记「变化缓慢或不会变化」的东西(年级、选科、家庭结构),不记「一次性状态」(今天的心情、这次的分数)。
第三类:健康与身份相关的敏感信息。现有的 stress 规则会把「失眠」「焦虑」「抑郁」都存下来。这类信息一旦被记,就成了这个人的标签。OWL 只是一个聊天机器人,它没有能力保护这类数据,也没有资质去持有它。我个人的判断是:危机信号应该走「即时处理」的通道(detectCrisis),而不是走「长期记忆」的通道。这两条路现在在 llm.mjs 里是分开的(detectCrisis 和 extractMemories),这是正确的设计,但它需要被守住——不要让 stress 规则膨胀到覆盖更多东西。
第四类:你不需要的信息。最后一条是纪律性的:每加一条记忆规则之前,问一句「知道这个,她下一次能明显做得更好吗?」如果答案含糊,就别加。因为每一条记忆都是一份责任,而不是一份资产。
警告还有一个技术上的「不该记」:不要把记忆写进日志。看 llm.mjs 里这行日志:this.log("💭 记住: " + newlyMemorized.map((m) => m.label).join("、"))。它只打 label(「年级」「考试相关」),没有打 snippet。这是一个刻意的好设计:日志会被复制、会被上传、会被留在服务器上,而 snippet 里有原文。哪天你想加日志方便调试,请一定守住这条线。第 10 节还会回来讲这件事。
8.6 /忘记 的实现:删干净到底意味着什么
用户在 QQ 里发一个 /忘记,他的理解是「她把我忘了」。而你要保证的是:在每一个数据存在的地方,关于他的东西都不在了。这两件事之间的距离,比初学者想的大得多。
真实实现在 bot.js 里是这样的:
if (cmd.reply === "__RESET_HISTORY__") {
llm.clearHistory(sessionKey);
}
if (cmd.reply === "__FORGET_ME__") {
llm.clearHistory(sessionKey);
llm.clearMemory(String(event.user_id));
}
▲ 摘自 qq-bot/bot/bot.js。/忘记 做两件事:清会话上下文、清长期记忆
而 clearMemory() 是 this.memory.delete(userKey) 加 saveMemory()。到这里为止,从「程序下次读到的数据」这个角度看,它是对的。但如果你把「删干净」这四个字认真对待,你会发现它只做到了第一层。下面是一张完整的清单:
| 数据在哪里 | 现在的实现删了吗 | 怎么才能删干净 |
|---|---|---|
内存里的 Map | ✅ 删了(delete) | — |
memory.json / history.json | ✅ 重写了(不含他的键) | 换数据库后:DELETE FROM users WHERE user_id = ? |
| 数据库(如果换了 SQLite) | — | 记得 PRAGMA foreign_keys = ON,否则记忆会变成孤儿 |
SQLite 的 -wal 文件 | — | 删除之后执行一次 PRAGMA wal_checkpoint(TRUNCATE); 把日志合并回主文件 |
| 被删除数据的磁盘残留 | ❌ 没处理 | SQLite 删除只是把页标记为空闲,字节还在文件里。VACUUM 会重建整个文件 |
| 日志 | ❌ 没处理 | 如果日志里打过他的原文,你要么改掉日志、要么提供轮转与清理(第七章) |
| 备份 | ❌ 完全没处理 | 这是最容易被忽略的一层,见第 10 节 |
| 模型服务商那一侧 | ❌ 你控制不了 | 你发出去的每一句话都属于「数据传输」。这件事你只能在隐私说明里讲清楚 |
这张表里有三行是「现在的 OWL 做不到的」,我要把话说直白:这不是 OWL 的失败,这是所有真实系统的常态。没有任何一个系统能做到「绝对删除」,因为数据会以副本、日志、备份、缓存的形式扩散。工程上的正确态度是两件事:
第一,在自己的能力范围内做到最好,并且知道自己做到了哪一层。「删了数据库那一行」和「删了数据库那一行 + 做了 checkpoint + VACUUM + 轮转日志」是两个不同等级的回答。你要能说出自己做的是哪一个。
第二,绝不承诺你做不到的事。OWL 的人设里写着「不承诺保密」——这不是一句免责声明,这是一个准确的工程判断。她的数据会经过 QQ、经过你的服务器、经过模型服务商。一个人对自己能力的边界诚实,比一个漂亮的承诺更值得信任。
想一想如果用户在发 /忘记 的当天,你的服务器刚好做了一次自动备份,那么这份备份里还有他的数据。你会怎么设计备份策略,让「被遗忘权」和「数据可靠性」这两个需求不打起来?(提示:想一想「备份保留多久」「备份能不能加密」「要不要记录一份处理请求的清单」。)第 10 节和第 7 章会给你一些材料,但答案要你自己写。
9. 三种记忆路线的对照:文件、数据库、向量检索
现在我们手上已经有两套方案了,而第八章会给你第三套。把它们放在一起看清楚,你以后做任何「存数据」的决定都能用同一条思路。
9.1 先看三条路各自在做什么
路线一:JSON 文件。数据是一个 JavaScript 对象,用 JSON.stringify 变成文本写进磁盘。查询 = 自己写循环。这是 OWL 现在的样子。
路线二:关系型数据库。数据是表和行,用 SQL 描述「我要什么」。精确匹配、范围筛选、排序、分组、跨表关联都是它的强项。这是第 6 节设计出来的东西。
路线三:向量检索。把一段文字交给一个「嵌入模型」,它会输出一串几百个数字(叫向量),这串数字代表这段文字的「意思」。找东西时不比较字符串,而是比较两串数字的距离——距离近的意思是「意思相近」。这条路线也常被称为语义检索,它是第八章 RAG 的核心。
9.2 对照表
| 你关心的 | JSON 文件 | 关系型数据库(SQLite) | 向量检索 |
|---|---|---|---|
| 适用规模 | 几十 KB 到几百 KB | 几万到几千万行,单机绰绰有余 | 几百到几万条「知识片段」是常见区间 |
| 能做什么查询 | 只能全量读进内存自己筛 | 精确匹配、范围、排序、分组、关联、事务 | 「意思相近的 N 条」,做不了精确匹配 |
| 能否回答「他的年级是什么」 | 能,但要写循环 | 能,一句话,还很快 | 不可靠。它只会给你「意思接近」的东西,而不是事实 |
| 能否回答「谁提到过类似的困扰」 | 几乎不能 | 不能(除非用全文检索) | 能,这是它的主场 |
| 实现成本 | 最低,半小时 | 中,一天到一周 | 高:要切片、要调嵌入接口、要存向量、要算距离 |
| 运行成本 | 零 | 零(就是一点磁盘) | 每一条入库都要调一次付费接口 |
| 典型失败模式 | 并发覆盖、写入截断、全表循环 | 忘写 WHERE、忘开外键、缺索引导致慢查询 | 检索到「意思像但事实错」的片段,然后模型用它编出一个自信的答案 |
| 数据会过期吗 | 要自己判断 | 要自己判断(可以用时间列 + 查询条件) | 最容易失控:改名之前的旧片段会一直被检索出来 |
9.3 什么时候该升级:一条可执行的判断线
把上面那张表压缩成三个信号。当你观察到其中任意一个持续出现时,就该换路线了:
信号一(文件 → 数据库):你开始写第二个「遍历所有数据找符合条件的记录」的循环。第一个循环是合理的权宜之计,第二个循环说明「查询」已经成了你的日常需求。
信号二(文件 → 数据库):出现了第二个写者,或者你开始担心「写一半失败」。一旦数据涉及隐私或金钱,事务就从「锦上添花」变成了「必须」。
信号三(数据库 → 向量检索):你需要回答的问题里出现了「相似」「相关」「提到过类似的」,而不是「等于」。SQL 的 WHERE key = 'stress' 能精确找出被标为「压力」的记录,但它找不出「一句没有被规则命中、意思却很像」的话。这正是向量检索补上的那一块。
反过来说,也有不该升级的信号,同样重要:
- 「因为大家都在用」。看到别人的项目用 PostgreSQL、用向量库,就觉得自己也该用——这是最贵的理由。
- 「数据量感觉会很大」。你现在的两套记忆加起来 10 KB。不要为想象中一百万个用户设计。
- 「想学一个新东西」。这是最诚实的理由,但它不是升级生产系统的理由。想学就去另开一个项目学,别拿正在给人用的机器人练手。
要点注意这三条路线不是替代关系,而是叠加关系。第八章你要做的 RAG,不是在数据库和向量库之间二选一:原文、元数据、权限、时间戳这些「确定性」的东西仍然放在 SQLite 里,向量库只负责「找出意思相近的几条」,然后由 SQL 去取它们的完整内容。真实系统几乎都是组合起来的,而不是某一条路线独占一切。
9.4 一个预告:向量检索不能替代这一章
我提前说一个第八章会展开的判断,免得你学完 RAG 之后把它当万能药:向量检索解决的是「找不到」,而不是「记不准」。
「他的年级是高三」这件事,是一个确定的事实。用向量检索去存它,你要接受两个后果:一是它可能检索到「我表弟高三」这条更相似的片段;二是它没有任何机制保证「同一个人同一个类别只保留最新的一条」——你需要 UNIQUE (user_id, key) 这种约束,而向量库不提供它。
所以正确的分法很清楚:事实用表,知识用向量。「他高三」是事实,「学校的高考政策是怎么规定的」是知识。把事实塞进向量库,是在用一件擅长模糊匹配的工具,去做一件要求精确的事。
10. 数据安全与可靠性:备份、原子写与自救
这一节很短,但它的内容是你未来最后悔没早学的那一类。因为前面所有章节的努力,最终都汇聚成一个文件——owl.db。那个文件没了,「她记得你」就没了。
10.1 备份的三个问题
很多人说自己「有备份」。要检验这句话,只有三个问题:
- 多久备一次?如果答案是「想起来就备」,那你实际拥有的备份是「零次」,只是在时间轴上随机分布着几个点。
- 多久能恢复?如果恢复要三小时,而你在深夜两点被叫醒处理事故,那三小时里机器人是死的。恢复时间是一个必须提前算出来的数字,不是事故现场才想的。
- 验证过吗?这一条最重要。没验证过的备份不算备份,算一种心理安慰。验证的意思是:你真的从备份里恢复过一次,并且对比过数据条数、随机抽过几条记录。
对 OWL 这个规模,我建议的策略简单到可以今天就做:每天一次定时备份,保留最近 7 天,文件名带日期(第七章讲 systemd timer 和 cron 时会给你具体写法)。7 这个数字的意思是「一个星期之内的问题,我都能回到发生之前」。
-- 备份时不要直接 cp,用官方的备份接口(可以在数据库使用中安全执行)
-- 在 Node 里(node:sqlite):
import { backup } from "node:sqlite";
await backup(db, "backup/owl-" + new Date().toISOString().slice(0, 10) + ".db");
-- 如果一定要用命令行,先做一次检查点,把 -wal 合并回主文件
-- sqlite3 owl.db "PRAGMA wal_checkpoint(TRUNCATE);"
-- cp owl.db backup/owl-$(date +%F).db
▲ 备份的正确姿势。注意第二种方式的两个前提:先 checkpoint,用 $(date) 命名
警告备份还有三件事必须做对,否则等于没做:(1)备份文件不要和数据库放在同一个磁盘上——磁盘坏了两个一起没;(2)备份不要放进 Git 仓库——git push 一下,全世界的隐私就上了 GitHub(第三章讲过 .gitignore,这是它最重要的用途之一);(3)给备份加上「多久之后自动删除」——不然磁盘会被历史备份一点点吃掉,而你在第七章会见到 40 G 磁盘被日志写满这类真实事故。
10.2 原子写:给文件方案的最后一道保险
如果你暂时还不打算换数据库,或者你有别的文本文件要写(配置、导出结果),那么至少要学会原子写(atomic write):先写一个临时文件,写成功了再改名。
import fs from "node:fs";
// 把内容安全地写到目标文件:要么旧内容完整,要么新内容完整,不存在「写了一半」
function writeFileAtomic(target, content) {
const tmp = target + ".tmp";
fs.writeFileSync(tmp, content); // 1. 全部写到临时文件
fs.renameSync(tmp, target); // 2. 改名是原子操作,一瞬间完成
}
▲ 二十行不到,但它消灭了「文件被截断」这一整类事故
为什么它有用?因为 rename(改名)在操作系统层面是一个原子操作——它要么完成了,要么没完成,不存在「改了一半」。所以任何时刻,target 这个路径上都是一个完整的老版本或完整的新版本,读它的人永远不会读到残缺内容。
顺便说一句:OWL 现在用的是 fs.writeFileSync 直接覆盖。这就是 2.1 节那个「读到半个文件」风险的来源。换成上面这四行,就能消掉它。这是一个「很小的改动、很大的收益」的例子,也是我最推荐你现在就去改的一处代码。
10.3 数据损坏了怎么自救
SQLite 有一个内置的体检工具,遇到「数据库文件似乎坏了」的时候第一时间用它,而不是去搜「SQLite 损坏怎么修复」然后下载某个来路不明的工具:
-- 1. 先体检(只读,不会改数据)
PRAGMA integrity_check;
-- 2. 如果报错,尝试把数据导出成 SQL 文本(能导出多少算多少)
-- sqlite3 owl.db ".dump" > rescue.sql
-- 3. 建一个全新的库,把导出成功的部分导回去
-- sqlite3 owl-new.db < rescue.sql
-- 4. 顺手把空间收回来(VACUUM 会重建整个文件)
VACUUM;
▲ SQLite 损坏时的标准自救流程。第 2 步是最有价值的:只要 .dump 能跑出来,你的数据基本就救回来了
还有一件事:大部分「数据库损坏」其实是「程序写坏了」,而不是「磁盘坏了」。回顾 2.1 节和 2.5 节——多个进程同写、写入过程被打断、把数据库放在网络盘上,这三件事是 SQLite 损坏最常见的三个原因。先排查这三个,再去怀疑硬件。
10.4 日志里不要打印用户的隐私
这条是纪律,我把它单独列成一小节,因为它最容易被「为了方便调试」破掉。
我们在 8.5 节看到,OWL 记日志时只打 label,不打 snippet。这是对的。现在把这条纪律写清楚,你以后可以拿它当检查清单:
| 可以进日志 | 不要进日志 |
|---|---|
| 用户 ID(数字)、群 ID | 消息原文、记忆的 snippet |
| 「危机信号命中:high(累计 3 次)」 | 「他说他想自杀」 |
| 「记住: 年级、考试相关」 | 「记住: 他说他爸妈在办离婚」 |
| HTTP 状态码、耗时、错误类型 | 请求体全文、API Key |
| 「记忆写入失败: SQLITE_BUSY」 | 整条 SQL 带上参数值 |
为什么这条纪律比它看起来重要?因为日志的生命周期比你想象的长。数据库里的数据是「管理」的,你知道它在哪里、能删它;日志是「流」的,它会被轮转、被压缩、被上传、被复制到别人的电脑上排查问题。你以为你在调试,其实你在不断地、无意识地制造一份未经保护的隐私副本。
想一想现在检查一遍 OWL 的日志(bot.js 和 llm.mjs 里的 this.log(...))。有没有哪一条会打出用户的原话?如果有,你会怎么改——改成什么、还能不能调试?如果你觉得「不打原文就没法排查」,那说明你需要的是另一种工具(比如只对某一个人、某一段时间打开详细日志),而不是把所有人的原文都留在磁盘上。
11. 动手项目:把第五章的假机器人升级成有记忆的版本
第五章你应该写过一个「能跑起来」的最小机器人:收到消息就调一次接口,把答案打出来。它每次对话都像第一次见你。现在把它升级。
这个项目分四步,前两步大约一个晚上,后两步各一小时。不要跳过第 3 步,因为那一步是整章的落点。
11.1 第一步:建库与建表
新建一个 store.mjs,把第 6.4 节的五条 CREATE(四张表加一个索引)放进去,写成一个 init(dbPath) 函数。要求:
- 打开数据库后立刻执行三条
PRAGMA(WAL、foreign_keys、busy_timeout); - 用
IF NOT EXISTS,保证这个函数可以被反复调用而不会报错; - 在函数结束时跑一句
PRAGMA foreign_keys;,如果返回不是 1,就抛一个错误——让「外键没开」这件事在你开发的时候炸掉,而不是在用户发/忘记的时候。
11.2 第二步:把读写换成 SQL
在 store.mjs 里实现五个函数,每个都只用一条(或一组)SQL:
touchUser(userId, nickname)——upsert 一行users,更新时间。appendMessage(sessionKey, userId, role, content)——插一条消息,同时 upsertsessions。recentMessages(sessionKey, limit)——取出最近 N 条,注意要ORDER BY at DESC LIMIT ?之后再在 JavaScript 里reverse(),因为对话要按时间正序发给模型。remember(userId, key, label, snippet)——第 6.7 节那一句 upsert。recall(userId, limit)——取最近 N 条记忆。
第 3 条里的那个「倒序取出、再反过来」是个真实的细节,值得你自己踩一次:如果直接按 ASC 取前 N 条,你取到的是「最早的 N 条」,而不是「最近的 N 条」。正确做法是 DESC + LIMIT 拿到最近的,再反过来排。
11.3 第三步:实现 /重置、/忘记,以及「自然提起上次的事」
前两个指令就是三条 DELETE。但真正的重点在第三件事,也是这个项目里最重要的设计动作:
要求:把「注入给模型的那段提示词」原样打印出来。
不是打印「我注入了 3 条记忆」这种摘要,而是把最终那段 system 内容完整打出来——包括人设、记忆说明、以及那三条 - 年级:… 的片段。你要亲眼看一次记忆是怎么被用起来的。
为什么这一步不能省?因为「记忆有效」这件事在界面上是看不见的。你只看到她的回复里提了一句「你上次说睡不好」,你会把功劳归给「模型聪明」。只有当那段提示词在你眼前展开,你才会真正意识到:她不是「记得」,她是「刚被告知」。
这句话听起来有点扫兴,但它包含一个非常正面的结论:既然记忆是「被注入的文字」,那么它就完全在你的控制之下。注入几条、注入什么措辞、什么时候不注入——全部是你写的代码,不是模型的脾气。这就是你能开始「设计她的记性」的时刻。
顺带,这一步会立刻帮你发现两个问题:(1)记忆的 snippet 常常是半句话,读起来很突兀;(2)如果不加「跟当前话题无关就别提」那句约束,她会频繁地硬提记忆。这两个都是真实的调优起点。
11.4 第四步:验收清单
做完之后,用这五条验收。每一条都要你真的做一遍,不能靠读代码判断:
- 跟它说「我高三,最近老失眠」,重开程序,再说「在吗」。她应该能自然提起上次的事。
- 发
/重置,然后问「我刚才跟你说什么了」。它应该不知道刚才的对话,但仍然知道你的年级。(这一条检验「会话上下文」和「长期记忆」是分开的。) - 发
/忘记,然后去数据库里查:SELECT COUNT(*) FROM users WHERE user_id = ?;和SELECT COUNT(*) FROM memories WHERE user_id = ?;。两个都必须是 0。 - 故意把
PRAGMA foreign_keys注释掉,再重复第 3 条。观察memories里是不是留下了孤儿数据。这是这一章最重要的一次亲手验证。 - 把注入的提示词打印出来看一眼:如果这个人的记忆里有关于别人隐私的内容,你会希望它出现在这里吗?
第 1 条里那个「重开程序」的要求,是刻意加的。因为你要验证的是「记忆真的落盘了」,而不是「还留在内存里」。不重启的测试,什么都测不出来。
自查:你是不是真的懂了
先自己回答,再展开参考答案。凡是你只能靠「感觉」回答的,说明还要再读一遍。第 2 题和第 8 题请你真的动笔写 SQL,不要只在脑子里想。
-
用你自己的话说清:OWL 现在有哪两套「记忆」?它们分别存在哪个文件里、按什么分组、什么时候过期?为什么它们必须分开?
参考答案
(1)会话上下文存在
history.json,按键是会话(群聊是group:群号、私聊是private:QQ号),值包含at(毫秒时间戳)和msgs({role, content}的数组),受maxTurns: 8(只留最近 16 条)和ttlMinutes: 180控制。(2)用户画像存在memory.json,按键是用户,值是{key, label, snippet, at}的数组,受maxPerUser: 8控制,不过期。必须分开的原因:它们回答的是两个不同的问题——「这场对话刚才在聊什么」和「这个人是什么样的人」。前者的生命周期以分钟计、以会话为单位;后者以月计、以人为单位。混在一起会导致:删除语义不清(清上下文会不会连带清画像?)、群聊里「人」和「会话」无法区分、以及无法为两者设定不同的上限。 -
建模题。现在 OWL 要新增一个需求:记录「每次有人使用
/忘记的时间和处理结果」,用于审计与故障排查。请写出这张表的建表 SQL,并说明:它要不要外键?要不要索引?外键删用户时应该是什么行为?参考答案
合理答案示例:
CREATE TABLE IF NOT EXISTS forget_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
at INTEGER NOT NULL,
ok INTEGER NOT NULL, -- 1 成功 / 0 失败
note TEXT
) STRICT;关键判断:外键这里不该加
REFERENCES users,或者即使加也必须用ON DELETE NO ACTION(默认),绝不能用CASCADE。因为审计日志的价值恰恰在于「用户已经被删了,但我还要知道这件事发生过」。如果用CASCADE,删用户时日志一起消失——你唯一用来证明「我确实处理过这个请求」的证据就没了。这一点和memories表的需求正好相反,值得对照记忆。user_id上可以建索引(未来会按人查),at上也常建(按时间范围统计)。但注意别一次建太多,见第 4.3 节。另外必须加一句纪律:note里绝不能写用户的原文。审计日志里出现隐私,是隐私事故的常见形式。 -
SQL 编写题。写出三条 SQL:(a)把
user_id = '1001'的「年级」(key 是grade)记忆更新为「高三」,如果原本没有就插入;(b)统计memories表里每个 key 各有多少条,按数量从多到少排;(c)删掉所有「超过 90 天没有被更新过」的记忆(at存的是毫秒时间戳)。参考答案
(a)用 upsert,依赖
UNIQUE (user_id, key)这个约束:INSERT INTO memories (user_id, key, label, snippet, at)
VALUES ('1001', 'grade', '年级', '我高三了', ?)
ON CONFLICT(user_id, key) DO UPDATE SET
label = excluded.label, snippet = excluded.snippet, at = excluded.at;(b)
SELECT key, COUNT(*) AS 条数 FROM memories GROUP BY key ORDER BY 条数 DESC;——注意不能用WHERE COUNT(*),那是HAVING的活。(c)
DELETE FROM memories WHERE at < ?;,参数传Date.now() - 90 * 24 * 3600 * 1000。这一步必须先备份、先用同样的 WHERE 做一次SELECT COUNT(*)确认范围,然后再删。忘了WHERE就是清空整张表。 -
排查题一。用户发
/忘记之后,你查数据库发现users里那一行没了,但memories里他的记录还在。最可能的原因是什么?怎么验证?怎么修?参考答案
最可能的原因:外键约束没有生效。SQLite 的外键默认可能不开,需要
PRAGMA foreign_keys = ON(或者打开连接时传enableForeignKeyConstraints: true)。没有它,DELETE FROM users只会删父行,子行变成孤儿数据留在原地。验证:打开连接后执行
PRAGMA foreign_keys;,返回 1 才是开了。再查一遍「有没有查不到用户的记忆」:SELECT m.user_id, COUNT(*) FROM memories AS m LEFT JOIN users AS u ON u.user_id = m.user_id WHERE u.user_id IS NULL GROUP BY m.user_id;——如果这里能查出东西,就说明历史上已经产生过孤儿数据。修:一是打开 pragma,并在
/忘记的实现里显式地把各张表都删一遍(放在一个事务里),不要完全依赖级联;二是清理已有的孤儿数据(DELETE FROM memories WHERE user_id NOT IN (SELECT user_id FROM users);,同样先查后删)。这个 bug 最危险的地方是它「看起来很成功」——用户以为被忘记了,其实没有。 -
排查题二。有一段时间你发现机器人回复变慢了,日志里偶尔出现「历史记录读取失败: Unexpected end of JSON input」。请说出至少两个可能的原因,以及各自的修法。
参考答案
(1)读到了写了一半的文件。
saveHistory()用fs.writeFileSync直接覆盖目标文件,写入过程中(尤其是文件变大之后)外部读到的是截断的 JSON。修法:改成原子写——先写.tmp,再fs.renameSync改名;或者直接迁移到 SQLite。(2)多进程同时写同一个文件。比如你开了两个机器人实例,或者有个统计脚本也在写。后写者会覆盖先写者,并且可能留下不完整内容。修法:保证只有一个写者;或者换数据库,用事务与锁来解决。(3)还有一种情况值得注意:文件本身被写坏过一次,而且一直没修。读失败被
catch吞掉了(你在代码里看到的是} catch { /* 写不进去不影响聊天 */ }这类写法),于是程序带着空记忆继续运行,表现就是「她突然失忆了」。修法:至少把这个异常打进日志,别静默吞掉——吞异常是让 bug 变成玄学的最快方式。 -
辨析题。「外键」和「索引」都会让数据库变慢——这句话对不对?请分别说明它们各自让什么变慢、为什么还值得用。
参考答案
不对,两者性质完全不同。
索引确实会让写变慢(每次插入/更新都要同步维护索引),并占用额外空间;它让读变快。值得用的理由:你的读远多于写,而且慢的读是用户直接感知到的。
外键让写略微变慢(插入子行时要检查父行存在;删除父行时要处理子行),但它换来的不是性能而是正确性:它让「属于不存在的人的记录」在结构上不可能存在。它不是优化手段,是约束手段。去掉它你可能会快千分之一,但会失去「
/忘记真的删干净了」这个保证。所以正确的心智模型是:索引是「用写入换读取」的交易;外键是「用一点点写入换正确性」的保险。把两者混为一谈,会导致你在该加外键的地方纠结性能,在该权衡索引的地方以为自己在做正确性。
-
为什么给
memories建索引时要写成(user_id, at DESC)而不是只写(user_id)?再问一句:这个索引能不能加速「按at找出最近被记住的 10 条」这个全局查询?参考答案
写成联合索引是因为真实的查询同时有「按人筛」和「按时间排」两个动作:
WHERE user_id = ? ORDER BY at DESC LIMIT 8。索引里已经按(user_id, at)排好,数据库既可以直接定位到那个人、又可以直接按顺序拿前 8 条,连临时排序都省了。只用(user_id)的话,定位很快,但取到该用户的所有行之后还要再排一次序。第二个问题的答案是:不能,或者只能在很有限的情况下用上。因为联合索引是先按
user_id分块、块内才按at有序;跨块的at是无序的。所以「全表按 at 找最近的 10 条」用不上它,会做全表扫描。如果真的需要这个查询,得另建一个(at DESC)的单列索引——但先问自己:这个查询够频繁、够重要吗? -
下面这段代码安全吗?请指出问题并给出正确写法。
db.prepare("DELETE FROM memories WHERE user_id = '" + userId + "'").run();参考答案
不安全,这是典型的 SQL 注入。因为
userId最终来自 QQ 消息,是外部输入;字符串拼接让它从「数据」变成了「SQL 语法的一部分」。攻击者只要让自己的输入里带一个单引号,就能提前闭合字符串并追加自己的条件,例如1001' OR '1'='1会让这句 SQL 变成DELETE FROM memories WHERE user_id = '1001' OR '1'='1'——清空整张表。正确写法:
db.prepare("DELETE FROM memories WHERE user_id = ?").run(userId);。占位符不是文本替换,而是把值单独绑定到编译好的语句上,值永远不可能被解释成语法。还要能说出一句更重要的判断:注入的根源不是「输入里有单引号」,而是「你把输入拼进了代码」。所以修法也不该是「过滤掉单引号」——那种黑名单做法永远会漏(不同的数据库有不同的转义规则、编码问题、注释符
--也要考虑)。唯一可靠的修法是参数化。顺带一个正确的补充:表名、列名、排序方向不能用占位符,那些位置需要代码里的白名单。
-
隐私判断题。OWL 的
MEMORY_RULES里有一条peer(人际关系),正则是/(霸凌|欺凌|孤立|排挤|没朋友|孤独|被嘲笑|闹掰)/。假设一个用户说「我同桌老是被班里的人排挤,我替他觉得难受」。这条规则会命中什么?它应该被记下来吗?如果记了,会有什么后果?参考答案
规则会命中「排挤」,于是抽出一条
key = "peer"、label = "人际关系"的记忆,snippet是「…我同桌老是被班里的人排挤…」。这是一次典型的「不该记」,而且是双重问题。第一,这条信息的主体是第三方——被排挤的是他同桌,而同桌从未同意被一个机器人记录。你等于替一个不在场的人建了一份关于「被霸凌」的档案。第二,语者本人的状态被误读:他说的是「替他觉得难受」,但抽出来的记忆贴在他自己头上,下次她可能会问他「你上次说的被排挤的事怎么样了」——而他自己并没有被排挤。这就是 8.1 节说的「错记」,而错记的代价比漏记大得多。
正确处理方向:一是把「说话人自己的经历」和「转述他人的事」区分开(这很难,可以保守地一律不记);二是对涉及霸凌、家庭暴力这类高敏感类别,默认只做即时处理,不做长期存储;三是在隐私说明里写清楚记住什么、记住多久、怎么删。反过来说,如果一个人说「我被同桌孤立了」,那仍然是敏感信息,但至少主体是他本人——这时候要不要记,取决于你是否能保证它被安全地保管、并且真的对他有帮助。保守的答案是:不记,只在这一轮里接住他。
-
动手题。在你自己的机器上,用
node:sqlite或better-sqlite3建一个内存数据库,建一张t(id INTEGER PRIMARY KEY, v TEXT),插入三行,然后分别用字符串拼接和占位符写一句DELETE … WHERE v = ?,把v设成一个带单引号的字符串,观察两次的结果有什么不同。把两次的结果写下来。参考答案
要观察到的核心现象:用拼接的那次,行为会随着输入里的单引号而改变——如果输入是
a' OR '1'='1,拼接出来的 SQL 里多了一个恒真的条件,于是它删掉的不是「v 等于某个字符串的那些行」,而是「所有行」。而用占位符的那次,无论输入里有多少单引号、引号、分号、--,它都只会删掉字面上等于那个字符串的行——通常是 0 行,因为表里没有这样的值。除了结果,还要注意两件事:(1)拼接版本可能不报错。这是它最危险的地方——它「成功地」执行了一个你没想让它执行的操作。(2)用
prepare的时候,如果 SQL 骨架里已经写好了?,那么拼接根本无从下手,这不是因为你更小心了,而是因为结构上不允许。做完这个实验,你就应该有一个自己的结论:参数化不是「一种更安全的写法」,而是「唯一一种把数据和代码分开的写法」。凡是你能用占位符的地方,用拼接就是错的。
自问自答:把知识变成你自己的
这些问题没有标准答案。请不要在页面上浏览,拿一张纸写下来。
- 「记住」和「被记住」是同一件事吗?从数据流的角度看,OWL 从来没有「记住」过任何东西——她只是每次把几行文字重新读一遍、塞进提示词。那么当你觉得「她记得我」时,你感受到的到底是什么?
- 如果我把
maxPerUser从 8 改成 200,会发生什么?请分别从钱、回答质量、隐私风险三个角度想,然后再问自己:我凭什么认为 8 比 200 好? - 你自己希望被一个机器人记住哪些事?把它们列出来。然后列出你不希望被记住的事。这两个清单的长度比是多少?这个比例说明了什么?
- 群聊共享上下文这个设计,如果让你重新做,你会改吗?改成「按人独立」会失去什么、得到什么?有没有一种「两者都要」的做法?
- 一个三个月没来的人回来了,她应该记得他,还是应该装作第一次见?哪种更体贴?你的答案取决于你怎么理解这个机器人是什么——那么你希望她是什么?
- 从「JSON 文件」到「数据库」,你付出的成本是「多学了一整套东西」。这值得吗?请给出你自己的判断,并说出它在什么条件下会变成「不值得」。
- 技术上的「删除」到底意味着什么?如果你发现你删除的每一条数据其实都还留在某块磁盘的某个扇区上,你会因此改变对「
/忘记」这个功能的承诺方式吗? - 如果有一天你要把 OWL 交给另一个人维护,你希望他第一眼看到的是两个 JSON 文件,还是一个 .db 文件加一份建表脚本?为什么?这个答案对你今天怎么整理代码有什么指导?
- 第 10 节那张「隐私检查清单」里,有哪一条是你现在就在违反的?你打算什么时候改?如果答案是「以后」,那个「以后」是什么时候?
- 回顾第 2 节的那句判断标准——「一次读写整个东西」用文件,「挑出符合条件的一部分」用数据库。请用它重新审视你自己手机/电脑里的三个东西(比如浏览器书签、笔记、微信记录),判断它们各自适合哪种方案,以及它们实际上用的是哪种。
小结
这一章说了三件事。
一、「记忆」不是一个功能,而是两套生命周期完全不同的数据。会话上下文(history.json)按会话分、三小时过期、只留最近 8 轮;用户画像(memory.json)按人分、长期保留、每人最多 8 条。前者回答「这场对话在聊什么」,后者回答「这个人是什么样的人」。而两套记忆都必须经过同一个流程才能生效:存入 → 挑出 → 注入。少了最后一步,记忆等于不存在。
二、从文件到数据库,换的不是「容量」而是「能力」。五种病(并发覆盖、全量读写、只能循环、没有约束、无法回滚)中,最致命的是「没有约束」和「无法回滚」,因为它们让错误变得安静。而数据库真正卖给你的是三样东西:一个描述结果的语言(SQL)、一套让错误无法发生的规则(类型、主键、外键、唯一约束)、一个「要么全成要么全不成」的边界(事务)。这三样东西的共同点是:它们把「未来的一个下午」变成了「现在的三秒钟」。
三、数据模型的形状由问题决定,而记忆系统的每一处都在做伦理取舍。我们从「谁、记得什么、在哪儿、说了什么」四个问题推出四张表;从「怕并发」推出单进程单库;从「怕记错」推出保守的抽取;从「怕被遗忘权落空」推出级联删除和两层保险。每一个技术决定背后都站着一个关于人的判断,而写代码的人要能说出那个判断是什么。
最后我想说一件稍微超出技术的话。
这一章从头到尾,我们都在做同一件事:把「记得」这个词,翻译成可以检查、可以修改、可以删除的东西。这个过程有一种奇怪的诚实——你原本以为的温柔,被拆开之后是几条正则、一个唯一约束、和一段拼进系统提示词的文字。
但你有没有发现,拆开之后,那件事并没有变得不温柔?
因为决定「什么该记、什么不该记、记错了怎么办」的,从来不是数据库。数据库只回答「能不能」。而「该不该」这三个字,从序章到第九章,一直在你自己手上。你设 maxPerUser = 8 而不是 800,你决定不记录别人的隐私,你在用户发 /忘记 的时候真的把它删干净——这些才是「她记得你」这句话里,属于你的那一部分。
延伸:可以去哪里继续
网站
- SQLite 官方文档——唯一的权威。你以后遇到「SQLite 到底支持不支持某个语法」这类问题,应该先来这里,而不是先搜博客。特别推荐两页:
lang.html(SQL 语法总览)和pragma.html(各类 PRAGMA 开关,包括我们用的foreign_keys、journal_mode)。 - SQLBolt——一个在浏览器里直接做题的交互式 SQL 教程,每课都配一个可以立刻运行的练习。它覆盖的正好是这一章讲的
SELECT/WHERE/JOIN/GROUP BY/ 增删改。学 SQL 只看不练是学不会的,这个网站就是给你练的。我建议你现在就把它的第 1–6 课做完,大约两小时。 - SQLite Tutorial——按主题组织的参考站,讲得比官方文档更贴近「我想知道怎么写」。当你需要查「SQLite 的 upsert 到底怎么写」「AUTOINCREMENT 有什么坑」时来这里。它还有一个专门的「SQLite Node.js」章节,可以和下面那份文档对照着看。
- Node.js 官方文档 · SQLite 章节——如果你用内置的
node:sqlite,这一页就是你的手册。看的时候请注意页面上标注的「稳定性等级」和「Added in: vXX.X.X」——这两处会告诉你它现在能不能用于生产、以及你需要什么版本的 Node。这也正是本章第 5.5 节提醒你要自己核对的原因。 - better-sqlite3 文档(GitHub)——如果你用这个第三方库,README 和它的
docs/目录里有 API 手册、性能说明和故障排查。它的 README 里有一段「什么时候这个库不合适」,非常值得读——那是一份诚实的自我边界说明,也是你学习「怎么评估一个技术选型」的好样本。 - W3School · SQL——最速查的一个。它的价值不在教程深度,而在于「我想知道某个关键字怎么拼」时能三秒查到,并且可以就地改一改试运行。当字典用,别当教材用。
值得读的书(两本,够你用很久)
- 《SQL 基础教程》(MICK)——一本很薄的入门书,把
SELECT到JOIN到建表讲得干净利落,配有可以下载的示例数据库。什么时候读:现在。你刚学完这一章,正好带着 OWL 的四张表去读它,把书里的例子换成自己的数据练一遍——这是本站一直强调的「用真实素材学习」,而这一次你手上终于有真实素材了。 - 《数据密集型应用系统设计》(Martin Kleppmann,常被称为 DDIA)——讲「数据系统为什么是这样」的书:可靠性、可扩展性、一致性、复制、分区、批处理与流处理。它不教你写 SQL,它教你判断。什么时候读:不是现在。这本书对零基础来说太难,你需要先有真实的系统经验——至少等你做完第七章(部署运维)、跑过一段时间的真实用户、并且亲手出过一次数据相关的事故之后再读。把它记在两年后的书单上,而不是这个月。
提醒不要收藏了就算看过。上面六个网站里,这一章只要求你做一件事:去 SQLBolt 把前六课做完(大约两小时)。做完之后,你手上会多一样东西——不是「我知道 SQL 是什么」,而是「我能写出一条 SQL 并立刻看到它跑出来的结果」。这个差别,就是读者和作者之间的差别。