资讯详情

Agent 记忆库实战:用 SQLite 构建持久化对话记忆系统

📅 2026/10/11 9:09:15 | 华诺云谱 👁 阅读
Agent 记忆库实战:用 SQLite 构建持久化对话记忆系统
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-sqlite3Python 有内置的 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 表的作用是给每次对话建立一个容器。字段设计如下字段名类型说明idINTEGER PRIMARY KEY AUTOINCREMENT会话唯一标识titleTEXT会话标题可由首条消息自动生成summaryTEXT会话摘要后续可由模型生成created_atDATETIME创建时间默认当前时间updated_atDATETIME最后更新时间message_countINTEGER消息数量冗余字段方便查询这里有个设计决策值得说一下message_count 是一个冗余字段。按数据库范式来说消息数量可以通过 COUNT 查询 messages 表得到不需要单独存。但在实际使用中你经常需要在会话列表页展示每个会话有多少条消息如果每次都去 COUNT会话多了之后性能会下降。所以我在每次插入消息时同步更新这个字段用一点写入开销换查询效率。实操心得冗余字段在记忆库场景下是值得的因为读多写少。但一定要保证更新逻辑的一致性最好把“插入消息”和“更新计数”放在同一个事务里。2.3 messages 表原始记忆的存储messages 表是数据量最大的表每条对话消息一行。字段设计字段名类型说明idINTEGER PRIMARY KEY AUTOINCREMENT消息唯一标识session_idINTEGER外键关联 sessions 表roleTEXT角色user / assistant / systemcontentTEXT消息正文token_countINTEGER估算的 token 数用于上下文裁剪created_atDATETIME消息时间session_id 上必须建索引因为按会话查询消息是最频繁的操作。role 字段也建议建索引方便统计用户和助手各自发了多少条。token_count 这个字段是我后来加的一开始觉得没必要后来做上下文窗口管理时发现非常有用——你需要知道当前会话已经累积了多少 token快超限时触发摘要压缩。2.4 memories 表从对话中提炼的长期记忆memories 表是让 Agent 真正“记住”东西的关键。它不是原始对话而是从对话中提取出来的结构化知识。字段设计字段名类型说明idINTEGER PRIMARY KEY AUTOINCREMENT记忆唯一标识session_idINTEGER来源会话可为空表示跨会话categoryTEXT分类preference / fact / task / eventkeyTEXT记忆的键如“编程语言偏好”valueTEXT记忆的值如“TypeScript”importanceINTEGER重要性评分 1-5created_atDATETIME创建时间last_accessed_atDATETIME最后访问时间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消息内容里的单引号、反斜杠会让语句报错更严重的是会有注入风险。我一开始图省事用过模板字符串拼接结果用户发了一句 “its 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_accessdecay_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 的日志、评估、配置管理底层都是同一套数据库功夫。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑