1. 从内存到磁盘:为什么 Agent 需要一个真正的记忆库
做前端出身的人,对“状态”这个词不会陌生。React 里有 useState、Redux 里有 store、Vue 里有 reactive,我们习惯了把数据放在内存里,页面刷新就重置,组件卸载就销毁。这种模式在纯前端场景下完全够用,但当你开始给 Agent 做记忆系统的时候,内存方案会立刻暴露出致命短板。
我一开始做 Agent 对话记忆的时候,用的就是最朴素的方式:一个 JavaScript 数组,每次对话把用户输入和模型回复 push 进去,下次请求时把整个数组塞进上下文。跑个十几轮没问题,但一旦对话轮次上去,或者你关掉进程再重启,所有记忆全部归零。更麻烦的是,当你想做多轮摘要、关键词检索、时间线回溯这些功能时,数组的线性结构根本撑不住。
这就是为什么 Day 16 要专门花时间搞数据库。Agent 的“记忆”本质上和人类记忆有相似之处:有短期的工作记忆(当前对话上下文),也有长期的归档记忆(历史交互记录),还需要能按时间、按主题、按重要性去检索。这些东西用文件读写也能凑合,但一旦涉及并发、查询、索引、事务,文件方案就会变成一团乱麻。
SQLite 在这个场景下几乎是完美的选择。它是一个嵌入式关系型数据库,不需要单独启动服务进程,整个数据库就是一个文件,你的 Node.js 或 Python 程序直接打开就能用。对于前端转过来的开发者来说,SQLite 的学习曲线非常平缓,SQL 语法本身也不复杂,而且你能在本地就把整套记忆系统跑通,不需要配置任何服务器。
我选择 SQLite 而不是其他方案,有几个很实际的考量。第一,零配置部署,一个 .db 文件跟着项目走,复制粘贴就能迁移;第二,支持完整的 SQL 查询能力,做时间范围筛选、关键词模糊匹配、聚合统计都很方便;第三,生态成熟,Node.js 有 better-sqlite3,Python 有内置的 sqlite3 模块,文档和社区案例都非常丰富;第四,性能足够,单机场景下读写几千到几万条记忆记录毫无压力。
注意:SQLite 适合单机、低并发的场景。如果你的 Agent 要部署成多用户在线服务,后面可能需要迁移到 PostgreSQL 或 MySQL,但作为入门和原型验证,SQLite 是最优解。
这一篇我会把整个记忆库的设计思路、表结构、核心操作、踩坑经验全部拆开讲。你跟着走一遍,就能给自己的 Agent 装上一个真正能持久化、能查询、能扩展的记忆系统。不管你是用 Node.js 还是 Python,核心逻辑是通的,我会以 Node.js 为主来演示,因为前端转过来的同学对 JS 生态更熟悉。
2. 记忆库的整体设计与表结构拆解
2.1 先想清楚:Agent 的记忆到底要存什么
很多人一上来就开始建表,结果建完发现字段不够用,或者存了一堆永远查不到的数据。我在动手之前,先花时间梳理了 Agent 记忆的几种类型,这直接决定了表结构的设计。
第一种是对话消息记录,这是最基础的。每条记录包含:谁说的(user 还是 assistant)、说了什么、什么时候说的、属于哪个会话。这是原始记忆,是所有上层功能的数据源。
第二种是会话元信息。一个会话有开始时间、结束时间、标题、摘要、消息数量。你不可能每次都把整个会话的消息全部拉出来,所以需要一个会话级别的索引表。
第三种是长期记忆或事实记忆。Agent 在对话中提取出来的关键信息,比如“用户偏好用 TypeScript”“用户的项目叫某某系统”“用户提到过某个截止日期”。这些是从原始对话中提炼出来的结构化知识,需要单独存储,并且要能按关键词检索。
第四种是记忆的向量或标签。入门阶段可以先不做向量检索,但至少要给记忆打上标签或分类,方便后续按类别查询。比如把记忆分成“偏好”“事实”“任务”“事件”几类。
把这四种类型想清楚之后,表结构就呼之欲出了。我最终设计了三张核心表:sessions(会话表)、messages(消息表)、memories(长期记忆表)。下面逐个拆解。
2.2 sessions 表:会话的容器
sessions 表的作用是给每次对话建立一个容器。字段设计如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY AUTOINCREMENT | 会话唯一标识 |
| title | TEXT | 会话标题,可由首条消息自动生成 |
| summary | TEXT | 会话摘要,后续可由模型生成 |
| created_at | DATETIME | 创建时间,默认当前时间 |
| updated_at | DATETIME | 最后更新时间 |
| message_count | INTEGER | 消息数量,冗余字段方便查询 |
这里有个设计决策值得说一下:message_count 是一个冗余字段。按数据库范式来说,消息数量可以通过 COUNT 查询 messages 表得到,不需要单独存。但在实际使用中,你经常需要在会话列表页展示每个会话有多少条消息,如果每次都去 COUNT,会话多了之后性能会下降。所以我在每次插入消息时同步更新这个字段,用一点写入开销换查询效率。
实操心得:冗余字段在记忆库场景下是值得的,因为读多写少。但一定要保证更新逻辑的一致性,最好把“插入消息”和“更新计数”放在同一个事务里。
2.3 messages 表:原始记忆的存储
messages 表是数据量最大的表,每条对话消息一行。字段设计:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY AUTOINCREMENT | 消息唯一标识 |
| session_id | INTEGER | 外键,关联 sessions 表 |
| role | TEXT | 角色:user / assistant / system |
| content | TEXT | 消息正文 |
| token_count | INTEGER | 估算的 token 数,用于上下文裁剪 |
| created_at | DATETIME | 消息时间 |
session_id 上必须建索引,因为按会话查询消息是最频繁的操作。role 字段也建议建索引,方便统计用户和助手各自发了多少条。token_count 这个字段是我后来加的,一开始觉得没必要,后来做上下文窗口管理时发现非常有用——你需要知道当前会话已经累积了多少 token,快超限时触发摘要压缩。
2.4 memories 表:从对话中提炼的长期记忆
memories 表是让 Agent 真正“记住”东西的关键。它不是原始对话,而是从对话中提取出来的结构化知识。字段设计:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY AUTOINCREMENT | 记忆唯一标识 |
| session_id | INTEGER | 来源会话,可为空表示跨会话 |
| category | TEXT | 分类:preference / fact / task / event |
| key | TEXT | 记忆的键,如“编程语言偏好” |
| value | TEXT | 记忆的值,如“TypeScript” |
| importance | INTEGER | 重要性评分 1-5 |
| created_at | DATETIME | 创建时间 |
| last_accessed_at | DATETIME | 最后访问时间 |
category 和 key 上建联合索引,这样你可以快速查询“所有关于偏好的记忆”或“某个具体键的记忆”。importance 字段用于排序,当记忆太多时优先返回高重要性的。last_accessed_at 用于实现记忆的“衰减”机制——长期不被访问的记忆可以降权或归档。
2.5 三张表的关系与设计取舍
三张表的关系很清晰:一个 session 有多条 messages,一个 session 也可以有多条 memories,但 memories 可以跨 session 存在(session_id 为空)。这种设计让记忆既能追溯到来源,又能独立于单次会话长期存在。
我没有用外键约束(FOREIGN KEY),而是用应用层来保证一致性。原因是 SQLite 的外键约束默认是关闭的,需要每次连接时手动开启,而且级联删除在记忆场景下不一定符合预期——删掉一个会话,不一定想删掉从它提炼出来的长期记忆。所以我在应用层控制删除逻辑,更灵活。
注意:如果你决定用外键,记得在每次打开数据库连接后执行
PRAGMA foreign_keys = ON;,否则约束不会生效。这是 SQLite 的一个经典坑。
3. 核心操作实现:增删改查与上下文管理
3.1 环境搭建与数据库初始化
Node.js 环境下我推荐用 better-sqlite3,它是同步 API,用起来比异步的 sqlite3 包更直观,性能也更好。安装很简单:
npm install better-sqlite3初始化数据库和建表的代码:
const Database = require('better-sqlite3'); const db = new Database('./agent-memory.db'); // 开启 WAL 模式,提升并发读写性能 db.pragma('journal_mode = WAL'); // 建表 db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, summary TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, message_count INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, token_count INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER, category TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, importance INTEGER DEFAULT 3, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_accessed_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_memories_category_key ON memories(category, key); `);这里有个细节:journal_mode = WAL。WAL 是 Write-Ahead Logging 的缩写,开启后读写可以并发进行,不会互相阻塞。对于 Agent 场景,你可能一边在写入新消息,一边在查询历史记忆,WAL 模式能明显减少锁等待。这是我在实际使用中踩过坑之后加的——默认模式下,写操作会阻塞读操作,对话频繁时会出现明显的卡顿。
3.2 创建会话与写入消息
创建会话和写入消息是最基础的操作,但要保证原子性。我用事务把“插入消息”和“更新会话计数”包在一起:
const createSession = db.prepare( 'INSERT INTO sessions (title) VALUES (?)' ); const insertMessage = db.prepare( 'INSERT INTO messages (session_id, role, content, token_count) VALUES (?, ?, ?, ?)' ); const updateSession = db.prepare( 'UPDATE sessions SET updated_at = CURRENT_TIMESTAMP, message_count = message_count + 1 WHERE id = ?' ); const addMessage = db.transaction((sessionId, role, content, tokenCount) => { insertMessage.run(sessionId, role, content, tokenCount); updateSession.run(sessionId); }); // 使用 const sessionId = createSession.run('第一次对话').lastInsertRowid; addMessage(sessionId, 'user', '你好,我想聊聊前端转 AI 的事', 15); addMessage(sessionId, 'assistant', '好的,你想从哪个方向开始?', 12);token_count 的估算我用一个简单的规则:中文按字符数除以 1.5,英文按单词数乘以 1.3。这不是精确值,但用于上下文管理足够了。精确计算需要引入 tokenizer,入门阶段没必要。
实操心得:better-sqlite3 的 prepare 语句可以复用,不要每次操作都重新 prepare,那样会有解析开销。把 prepare 的结果缓存起来,是提升性能的关键。
3.3 按会话拉取上下文并做窗口裁剪
Agent 每次请求模型时,需要把历史消息组装成上下文。但上下文窗口是有限的,不能无限塞。我的做法是:先按时间倒序拉取最近 N 条消息,然后从后往前累加 token,超过预算就停止。
const getRecentMessages = db.prepare( 'SELECT role, content, token_count FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?' ); function buildContext(sessionId, maxTokens = 3000) { const rows = getRecentMessages.all(sessionId, 50); const selected = []; let total = 0; for (const row of rows) { if (total + row.token_count > maxTokens) break; selected.unshift({ role: row.role, content: row.content }); total += row.token_count; } return selected; }这里先取 50 条是一个安全上限,避免一次拉太多。然后从最新的消息往前累加,保证最近的对话一定在上下文里。这个策略比“取前 N 条”更合理,因为最近的对话通常最相关。
如果整个会话的 token 已经远超预算,就需要触发摘要压缩:把早期消息交给模型生成一段摘要,存到 sessions.summary 里,然后上下文里用摘要替代那些早期消息。这个逻辑我在 Day 18 会详细展开,这里先把数据基础打好。
3.4 长期记忆的写入与检索
长期记忆的写入通常发生在对话结束后,或者由模型在对话中实时提取。写入时要注意去重——同一个 key 如果已经有值,应该更新而不是新增。
const upsertMemory = db.prepare(` INSERT INTO memories (session_id, category, key, value, importance) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING `); const findMemory = db.prepare( 'SELECT id FROM memories WHERE category = ? AND key = ?' ); const updateMemory = db.prepare( 'UPDATE memories SET value = ?, importance = ?, last_accessed_at = CURRENT_TIMESTAMP WHERE id = ?' ); function saveMemory(sessionId, category, key, value, importance = 3) { const existing = findMemory.get(category, key); if (existing) { updateMemory.run(value, importance, existing.id); } else { db.prepare( 'INSERT INTO memories (session_id, category, key, value, importance) VALUES (?, ?, ?, ?, ?)' ).run(sessionId, category, key, value, importance); } }检索记忆时,我通常按 category 和 importance 排序:
const getMemoriesByCategory = db.prepare( 'SELECT key, value, importance FROM memories WHERE category = ? ORDER BY importance DESC, last_accessed_at DESC LIMIT ?' );这样当你要给模型注入长期记忆时,可以优先注入高重要性的偏好和事实,控制注入的 token 量。
3.5 关键词模糊检索的实现
除了按 category 精确查询,Agent 还需要能按关键词模糊搜索记忆。SQLite 的 LIKE 操作符可以满足基本需求:
const searchMemories = db.prepare( "SELECT category, key, value FROM memories WHERE key LIKE ? OR value LIKE ? ORDER BY importance DESC LIMIT 10" ); function searchMemory(keyword) { const pattern = `%${keyword}%`; return searchMemories.all(pattern, pattern); }如果记忆量很大,LIKE 的性能会下降。这时候可以上 SQLite 的 FTS5 全文搜索扩展,建一个虚拟表来索引记忆内容。不过入门阶段 LIKE 够用,等数据量上万了再考虑升级。
注意:LIKE 查询默认对大小写不敏感(针对 ASCII),但中文没有大小写问题。如果要做更复杂的匹配,可以考虑用 FTS5 或者把关键词预先分词存储。
4. 常见问题与排查技巧实录
4.1 数据库文件锁与并发写入冲突
这是新手最容易遇到的问题:程序跑着跑着报SQLITE_BUSY: database is locked。原因通常是多个连接同时写,或者一个连接持有事务太久没提交。
我的解决方案有三层。第一层是开启 WAL 模式,让读写不互相阻塞。第二层是设置 busy_timeout,让 SQLite 在遇到锁时自动重试而不是立刻报错:
db.pragma('busy_timeout = 5000');第三层是保证事务尽量短小,不要在事务里做网络请求或耗时计算。我见过有人在事务里调用模型 API,结果事务持有了几秒钟,其他写入全部卡死。记住:事务只包数据库操作,外部调用放在事务外面。
4.2 时间字段的时区问题
SQLite 的 CURRENT_TIMESTAMP 返回的是 UTC 时间,不是本地时间。如果你直接展示给用户,会发现时间差了 8 小时(取决于你的时区)。我一开始就被这个坑了,以为是数据写错了。
解决方案有两种:一是在应用层做时区转换,存储用 UTC,展示时转本地;二是用datetime('now', 'localtime')来存本地时间。我推荐第一种,因为 UTC 存储是更规范的做法,跨时区迁移时不会出问题。
// 查询时转换 const rows = db.prepare( "SELECT id, content, datetime(created_at, 'localtime') as local_time FROM messages WHERE session_id = ?" ).all(sessionId);4.3 消息内容中的特殊字符导致 SQL 错误
如果你用字符串拼接来构造 SQL,消息内容里的单引号、反斜杠会让语句报错,更严重的是会有注入风险。我一开始图省事用过模板字符串拼接,结果用户发了一句 “it's a test”,整个插入就崩了。
正确做法永远是使用参数化查询,也就是 prepare + run 的占位符方式。better-sqlite3 的占位符用?,值作为参数传入,驱动会自动处理转义。这一点没有例外,任何情况下都不要拼接 SQL 字符串。
4.4 数据库文件膨胀与清理策略
跑了一段时间后,你会发现 .db 文件越来越大。原因是消息表只增不减,而且 SQLite 删除数据后不会自动收缩文件。我的清理策略是:定期归档旧会话,把超过 30 天的会话消息导出到备份文件,然后从主库删除,最后执行 VACUUM 收缩空间。
// 删除旧消息 db.prepare( "DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE updated_at < datetime('now', '-30 days'))" ).run(); // 收缩数据库文件 db.exec('VACUUM');VACUUM 会重建整个数据库文件,期间需要额外的磁盘空间,而且会锁库。所以不要在高峰期执行,最好在启动时或定时任务里做。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| SQLITE_BUSY 报错 | 并发写入冲突 | 开启 WAL + busy_timeout |
| 时间显示差 8 小时 | 存储的是 UTC | 查询时用 localtime 转换 |
| 插入报语法错误 | 字符串拼接 SQL | 改用参数化查询 |
| 数据库文件过大 | 数据只增不减 | 定期归档 + VACUUM |
| 查询变慢 | 缺少索引 | 在 session_id、category 上建索引 |
| 重启后数据丢失 | 用了内存数据库 | 确认连接的是文件路径而非 :memory: |
实操心得:
:memory:是 SQLite 的内存模式,数据只存在于进程运行期间。我见过有人调试时用了内存模式,测试通过后忘了改回文件路径,上线后发现数据全丢。检查连接字符串是排查数据丢失问题的第一步。
5. 记忆库的扩展方向与个人实践体会
5.1 从关键词检索到语义检索的演进路径
SQLite 的记忆库跑通之后,下一步很自然是语义检索。用户问“我之前说过喜欢什么语言”,关键词匹配可能找不到,因为记忆里存的是“编程语言偏好:TypeScript”,没有“喜欢”这个词。这时候就需要向量检索。
演进路径我建议分三步走。第一步是当前的关键词 + 分类检索,覆盖大部分明确查询。第二步是引入轻量级向量方案,把每条记忆用 embedding 模型转成向量,存到单独的向量表里,查询时做余弦相似度计算。第三步是混合检索,把关键词得分和向量得分加权融合,兼顾精确匹配和语义匹配。
SQLite 本身不擅长向量计算,但可以存 BLOB 类型的向量,然后在应用层做相似度计算。记忆量在几千条以内时,暴力计算完全可行。超过这个量级再考虑专门的向量数据库。
5.2 记忆的重要性衰减与自动归档
人类记忆会随时间衰减,Agent 记忆也应该有类似的机制。我的做法是给每条记忆一个动态的重要性分数,由初始 importance、访问频率、最后访问时间共同决定。长期不被访问的记忆,分数逐渐降低,低于阈值时自动归档到冷存储。
这个机制的好处是,注入上下文时永远优先选最相关、最活跃的记忆,而不是一股脑全塞进去。实现上可以用一个定时任务,每天跑一次衰减计算,更新 importance 字段。公式可以很简单:new_importance = base_importance * decay_factor ^ days_since_access,decay_factor 取 0.95 左右。
5.3 多 Agent 共享记忆库的隔离设计
如果你有多个 Agent,它们可能需要共享一部分记忆,又需要各自独立的私有记忆。这时候可以在 memories 表加一个 owner 字段,标识记忆属于哪个 Agent,空值表示共享。查询时用WHERE owner = ? OR owner IS NULL来同时获取私有和共享记忆。
sessions 表也可以加 owner 字段,实现会话级别的隔离。这样一套数据库文件就能支撑多个 Agent,不用每个 Agent 单独建库。当然,如果 Agent 之间数据敏感度差异大,还是分开建库更安全。
5.4 我踩过的几个印象深刻的坑
第一个坑是忘了关数据库连接。Node.js 进程退出时,如果没有显式关闭数据库,WAL 文件可能残留,下次打开时会有恢复过程。虽然数据不会丢,但启动会变慢。养成在进程退出钩子里调用db.close()的习惯。
第二个坑是 prepare 语句在数据库重建后失效。如果你删表重建,之前 prepare 的语句会报错,需要重新 prepare。我的做法是把所有 prepare 放在一个初始化函数里,数据库结构变化时统一重新执行。
第三个坑是 token_count 估算偏差太大。我一开始用字符数除以 4 来估算英文 token,结果实际消耗远超预算,导致上下文被截断。后来改成中文按 1.5 字符一个 token、英文按 4 字符一个 token 分别估算,准确度提升了很多。更精确的做法是引入 tiktoken 之类的库,但会增加依赖体积。
5.5 给前端转过来同学的一点建议
前端开发者做数据库,最大的思维转变是从“操作对象”变成“操作集合”。前端习惯了一个对象一个对象地处理,数据库则是批量操作、声明式查询。你要习惯写 SQL 来表达“我要什么”,而不是写循环来表达“我怎么一步步拿到”。
另一个转变是异步思维。前端到处都是 Promise 和 async/await,但 better-sqlite3 是同步的。一开始我总觉得同步操作会阻塞,后来发现对于本地文件数据库,同步反而更简单、更快,因为省去了异步调度的开销。当然,如果你的查询特别重,还是要考虑放到 worker 线程里,避免阻塞主线程。
最后,别怕 SQL。它看起来古老,但表达力极强。一个 JOIN 加 GROUP BY 能顶你写几十行 JS 循环。花一个下午把 SELECT、INSERT、UPDATE、DELETE、JOIN、GROUP BY、索引这几个概念搞明白,后面做任何数据相关的功能都会轻松很多。记忆库只是开始,Agent 的日志、评估、配置管理,底层都是同一套数据库功夫。