
1. 从内存到硬盘为什么 Agent 需要一个真正的记忆库做前端出身的人对“状态”这个词不会陌生。React 里有 useState、Redux 里有 store、Vue 里有 reactive我们习惯了把数据放在内存里页面刷新就重置组件卸载就销毁。这种模式在纯前端场景下没什么问题但当你开始给 Agent 做记忆系统的时候内存方案会立刻撞墙。我刚开始做 Agent 对话记忆的时候用的是最朴素的办法一个 JavaScript 数组每次对话往里面 push 一条消息需要的时候把整个数组塞进 prompt。头两天跑得挺顺第三天问题就来了——我关掉终端重新启动程序Agent 完全不记得之前聊过什么。这就像你和一个朋友聊了三个小时他去上了个厕所回来就失忆了体验非常割裂。这就是内存方案的第一个致命伤进程结束数据归零。前端开发者对此应该有天然的理解——localStorage 和 sessionStorage 的区别本质上就是持久化与非持久化的区别。Agent 的记忆也一样它需要一个“硬盘级”的存储层而不是停留在“内存级”。第二个问题是容量瓶颈。内存数组能存多少条消息理论上受限于 Node.js 的堆内存但实际使用中当消息积累到几百条每次把全部历史塞进 prompt 就会导致 token 爆炸。你不可能把三个月的聊天记录每次都完整发给模型成本扛不住上下文窗口也装不下。所以记忆系统必须支持按需检索而不是全量加载。第三个问题是结构化查询。内存数组只能按索引或者简单的 filter 来取数据但 Agent 的记忆需求远比这复杂我想查“上周三用户提到的那本书叫什么”我想查“所有标记为重要且涉及项目进度的对话”我想查“某个用户最近七天的情绪变化趋势”。这些需求用数组 filter 写起来又臭又长而用 SQL 就是一句话的事。所以结论很明确Agent 需要一个真正的数据库。而在我尝试过的所有方案里SQLite 是前端转 AI 这条路上最友好的入门选择。它不需要你单独装一个数据库服务不需要配置用户名密码和端口一个文件就是一个数据库Node.js 里一个 npm 包就能跑起来。对于 Day 16 这个阶段来说先用 SQLite 把记忆系统跑通理解数据库在 Agent 架构中的位置比一上来就折腾 PostgreSQL 或者向量数据库要务实得多。这篇文章我会把整个落地过程拆开讲为什么选 SQLite 而不是别的、表结构怎么设计、Node.js 里怎么接、记忆的写入和检索怎么做、以及我踩过的那些坑。如果你也是前端转 AI 的路上正在给 Agent 做记忆功能这篇应该能帮你省下不少试错时间。2. 方案选型SQLite 凭什么成为 Agent 记忆的入门首选2.1 前端视角下的数据库选型逻辑前端开发者选数据库第一反应往往是“哪个最省事”。我们习惯了 npm install 之后就能用的东西对“先装服务、再配环境、再建连接”这套流程有天然的抵触。这不是懒而是前端生态养成的思维习惯——工具应该开箱即用。按这个标准筛一遍常见的数据库方案方案安装成本是否需要独立服务Node.js 集成难度适用场景SQLite极低否极低单机、嵌入式、原型验证PostgreSQL中等是中等多用户、生产环境MySQL中等是中等传统 Web 应用MongoDB中等是中等文档型数据、灵活 schemaRedis低是低缓存、会话、高速读写向量数据库高视方案而定高语义检索、RAGSQLite 的优势一目了然它就是一个文件不需要独立进程Node.js 里用 better-sqlite3 或者 node:sqlite 模块直接打开就能用。你甚至可以把数据库文件提交到 Git 里团队协作时大家共享同一份记忆数据。但这里有个认知误区需要澄清SQLite 不是“玩具数据库”。它被用在手机 App、桌面软件、嵌入式设备、甚至一些中小型网站的生产环境里。它的单文件设计确实不适合高并发写入但对于 Agent 记忆这种“读多写少、单用户为主”的场景性能绰绰有余。我实测下来单条插入在毫秒级批量插入一万条消息也就几百毫秒完全够用。2.2 SQLite 在 Agent 记忆架构中的定位Agent 的记忆系统通常分三层短期记忆当前对话的上下文窗口、长期记忆跨会话的持久化存储、工作记忆当前任务相关的临时信息。SQLite 主要承担的是长期记忆这一层。具体来说它要解决这几个问题对话历史的持久化每次用户和 Agent 的交互都落库重启程序后还能查到。记忆的检索与召回根据关键词、时间范围、重要性等条件快速找到相关记忆。记忆的元数据管理每条记忆附带时间戳、角色、标签、重要性评分等信息方便后续做筛选和排序。记忆的更新与删除用户可以要求 Agent“忘掉某件事”或者系统自动清理过期记忆。这些需求用 SQL 来表达非常自然。比如“找出最近三天内标记为重要的用户消息”SELECT * FROM memories WHERE role user AND importance 4 AND created_at datetime(now, -3 days) ORDER BY created_at DESC;如果用内存数组来实现同样的逻辑代码量至少翻三倍而且每次都要遍历整个数组。SQL 的声明式查询在这里优势明显。2.3 为什么不是向量数据库你可能会问现在做 Agent 记忆不都用向量数据库做语义检索吗SQLite 能做语义搜索吗这个问题问得好。向量数据库确实在语义检索上有优势但它解决的是“意思相近但用词不同”的匹配问题。比如用户说“我最近压力很大”和“工作让我很焦虑”向量检索能识别出这两句话语义相关而关键词匹配做不到。但向量数据库有几个问题第一它需要额外的 embedding 模型来把文本转成向量这又是一层依赖第二向量检索的结果可解释性差你很难说清楚为什么某条记忆被召回了第三对于入门阶段来说先把结构化检索跑通理解记忆系统的基本运转逻辑比直接上语义检索更重要。我的建议是先用 SQLite 把记忆的写入、查询、更新、删除这套 CRUD 跑通等这套流程稳定了再考虑在 SQLite 基础上叠加向量检索能力。实际上 SQLite 也有向量扩展比如 sqlite-vss可以在同一个文件里同时做结构化查询和向量检索这是后话。3. 表结构设计给 Agent 的记忆搭好骨架3.1 核心表设计messages 表记忆系统的核心是一张 messages 表每条记录代表一次交互。设计字段的时候要考虑几个维度谁说的、什么时候说的、说了什么、这条消息有多重要、属于哪个会话。CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN (user, assistant, system)), content TEXT NOT NULL, importance INTEGER DEFAULT 3 CHECK(importance BETWEEN 1 AND 5), tags TEXT DEFAULT , created_at TEXT DEFAULT (datetime(now)), updated_at TEXT DEFAULT (datetime(now)) ); CREATE INDEX idx_messages_session ON messages(session_id); CREATE INDEX idx_messages_created ON messages(created_at); CREATE INDEX idx_messages_importance ON messages(importance);逐字段解释一下设计意图id自增主键SQLite 的标准做法。用 INTEGER PRIMARY KEY 会自动成为 rowid 的别名查询效率最高。session_id会话标识。Agent 可能同时服务多个用户或多个对话线程用 session_id 区分。类型用 TEXT 而不是 INTEGER因为前端生成的 UUID 更方便不需要依赖数据库自增。role消息角色限定为 user、assistant、system 三种。用 CHECK 约束保证数据干净避免写入脏数据。content消息正文。TEXT 类型在 SQLite 里没有长度限制放心存。importance重要性评分1 到 5。这个字段是记忆系统的关键后面做检索排序时会用到。默认值设为 3表示“普通重要”。tags标签用逗号分隔的字符串存储。比如“项目,进度,截止日期”。SQLite 没有数组类型用字符串存标签是最简单的做法查询时用 LIKE 匹配。created_at / updated_at时间戳。SQLite 没有专门的日期类型用 TEXT 存 ISO 格式的日期字符串配合 datetime 函数做计算。注意SQLite 的 CHECK 约束在插入时会校验但如果你用 ORM 或者手动拼 SQL要确保 role 的值在允许范围内。我踩过一次坑手动插入了一条 role 为 tool 的记录结果查询时被 CHECK 拦住了排查了半天才发现是约束问题。3.2 辅助表设计sessions 表messages 表存的是消息但会话本身也需要管理。比如一个会话什么时候开始的、什么时候结束的、标题是什么、关联了哪个用户。这些信息放在 sessions 表里。CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, title TEXT DEFAULT 未命名会话, user_id TEXT DEFAULT default, created_at TEXT DEFAULT (datetime(now)), last_active_at TEXT DEFAULT (datetime(now)), message_count INTEGER DEFAULT 0 );sessions 表的设计要点id会话 ID和 messages 表的 session_id 对应。用 TEXT 类型前端生成 UUID 后传入。title会话标题。可以自动生成取第一条用户消息的前 20 个字也可以让用户手动设置。user_id用户标识。单用户场景下用默认值就行多用户场景下用来隔离数据。message_count消息计数。每次插入消息时更新这个字段避免每次都去 count messages 表。这是一个典型的反范式设计用少量冗余换查询性能。3.3 索引策略让查询快起来索引是数据库性能的关键。SQLite 默认会为 PRIMARY KEY 建索引但其他字段需要手动建。根据 Agent 记忆的查询模式我建了三个索引idx_messages_session按会话 ID 查询是最频繁的操作比如“加载当前会话的所有消息”。idx_messages_created按时间排序和范围查询比如“最近一小时的消息”。idx_messages_importance按重要性筛选比如“找出所有重要记忆”。索引不是越多越好。每个索引都会增加写入时的开销因为插入数据时数据库要同时更新索引。对于 Agent 记忆这种读多写少的场景三个索引是合理的。如果你发现写入性能下降明显可以检查一下是不是索引建多了。实操心得建索引之前先用 EXPLAIN QUERY PLAN 看一下查询计划。如果一条查询没有走索引SQLite 会显示 SCAN TABLE这时候就需要考虑加索引了。我一开始没看查询计划凭感觉建了五个索引后来发现其中两个根本用不上删掉之后写入速度提升了将近 30%。4. Node.js 接入 SQLite从安装到跑通第一条查询4.1 驱动选择better-sqlite3 vs node:sqliteNode.js 生态里操作 SQLite 有两个主流选择第三方包 better-sqlite3 和 Node.js 22 之后内置的 node:sqlite 模块。better-sqlite3 的优势是成熟稳定、API 同步、性能好。它的同步 API 对前端开发者特别友好不需要处理 Promise 和回调写起来像在操作一个本地对象。缺点是它是原生模块安装时需要编译在某些环境下可能会遇到编译问题。node:sqlite 是 Node.js 官方内置的模块不需要额外安装API 也是同步的。缺点是还处于实验阶段API 可能变化而且需要 Node.js 22 以上版本。我的建议是如果你用的是 Node.js 22先用 node:sqlite 跑通流程如果遇到问题或者需要更稳定的方案再换 better-sqlite3。两者 API 很相似迁移成本不高。# 如果用 better-sqlite3 npm install better-sqlite3 # 如果用 node:sqlite不需要安装直接 import4.2 初始化数据库连接先写一个数据库初始化的模块负责打开数据库文件、建表、建索引。// db.js import Database from better-sqlite3; // 如果用 node:sqlite: import { DatabaseSync } from node:sqlite; const DB_PATH ./agent-memory.db; let db; export function getDB() { if (!db) { db new Database(DB_PATH); db.pragma(journal_mode WAL); db.pragma(foreign_keys ON); initTables(); } return db; } function initTables() { db.exec( CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, title TEXT DEFAULT 未命名会话, user_id TEXT DEFAULT default, created_at TEXT DEFAULT (datetime(now)), last_active_at TEXT DEFAULT (datetime(now)), message_count INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN (user, assistant, system)), content TEXT NOT NULL, importance INTEGER DEFAULT 3 CHECK(importance BETWEEN 1 AND 5), tags TEXT DEFAULT , created_at TEXT DEFAULT (datetime(now)), updated_at TEXT DEFAULT (datetime(now)) ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); CREATE INDEX IF NOT EXISTS idx_messages_importance ON messages(importance); ); }这里有两个 pragma 设置值得说明journal_mode WAL开启 Write-Ahead Logging 模式。默认的 journal 模式在写入时会锁住整个数据库WAL 模式下读写可以并发对 Agent 这种“一边写对话一边查历史”的场景更友好。foreign_keys ONSQLite 默认不启用外键约束需要手动开启。虽然我们的表结构里没有显式的外键但开启这个选项是个好习惯后续如果要加关联表会用到。4.3 写入记忆把对话存进去写入记忆的核心函数是 addMessage它接收 session_id、role、content 等参数插入一条记录同时更新 sessions 表的 message_count 和 last_active_at。// memory.js import { getDB } from ./db.js; export function addMessage({ sessionId, role, content, importance 3, tags }) { const db getDB(); const insertMessage db.prepare( INSERT INTO messages (session_id, role, content, importance, tags) VALUES (?, ?, ?, ?, ?) ); const updateSession db.prepare( UPDATE sessions SET message_count message_count 1, last_active_at datetime(now) WHERE id ? ); const ensureSession db.prepare( INSERT OR IGNORE INTO sessions (id) VALUES (?) ); const transaction db.transaction(() { ensureSession.run(sessionId); const result insertMessage.run(sessionId, role, content, importance, tags); updateSession.run(sessionId); return result.lastInsertRowid; }); return transaction(); }这段代码有几个关键点事务包裹。插入消息和更新会话计数是两个操作必须保证要么都成功要么都失败。用 db.transaction 包裹之后SQLite 会自动处理回滚。我一开始没加事务结果有一次插入消息成功但更新计数失败导致 message_count 和实际消息数对不上排查了很久。INSERT OR IGNORE。确保会话存在如果不存在就创建一条默认记录。这样调用方不需要先手动创建会话直接写消息就行。prepared statement。db.prepare 返回一个预编译的语句对象重复执行时不需要重新解析 SQL性能更好。而且它自动处理参数转义避免 SQL 注入。4.4 检索记忆按需召回检索是记忆系统最核心的功能。我实现了几个不同维度的检索函数// 按会话加载最近 N 条消息 export function getRecentMessages(sessionId, limit 20) { const db getDB(); return db.prepare( SELECT * FROM messages WHERE session_id ? ORDER BY created_at DESC LIMIT ? ).all(sessionId, limit).reverse(); } // 按关键词搜索记忆 export function searchMessages(keyword, limit 10) { const db getDB(); return db.prepare( SELECT * FROM messages WHERE content LIKE ? ORDER BY importance DESC, created_at DESC LIMIT ? ).all(%${keyword}%, limit); } // 按重要性召回高价值记忆 export function getImportantMemories(minImportance 4, limit 10) { const db getDB(); return db.prepare( SELECT * FROM messages WHERE importance ? ORDER BY created_at DESC LIMIT ? ).all(minImportance, limit); } // 按时间范围查询 export function getMessagesByTimeRange(startTime, endTime) { const db getDB(); return db.prepare( SELECT * FROM messages WHERE created_at BETWEEN ? AND ? ORDER BY created_at ASC ).all(startTime, endTime); }这几个函数覆盖了 Agent 记忆检索的主要场景。getRecentMessages 用于加载当前对话上下文searchMessages 用于关键词召回getImportantMemories 用于提取高价值记忆getMessagesByTimeRange 用于时间线分析。注意LIKE 查询在数据量大时性能会下降因为它是全表扫描。如果记忆条数超过几万条建议改用 FTS5 全文索引。SQLite 内置了 FTS5 模块可以建一个虚拟表来做全文检索速度比 LIKE 快几个数量级。这个我在后续的 Day 里会专门讲。4.5 更新与删除让 Agent 学会遗忘记忆系统不仅要能记还要能忘。用户可能要求删除某条消息或者系统自动清理过期记忆。// 更新记忆的重要性 export function updateImportance(messageId, importance) { const db getDB(); return db.prepare( UPDATE messages SET importance ?, updated_at datetime(now) WHERE id ? ).run(importance, messageId); } // 删除单条记忆 export function deleteMessage(messageId) { const db getDB(); return db.prepare(DELETE FROM messages WHERE id ?).run(messageId); } // 清理过期记忆保留重要记忆 export function cleanupOldMessages(daysToKeep 30) { const db getDB(); return db.prepare( DELETE FROM messages WHERE importance 4 AND created_at datetime(now, ?) ).run(-${daysToKeep} days); }cleanupOldMessages 这个函数的设计思路值得说一下它只删除重要性低于 4 且超过指定天数的消息重要性高的记忆会被保留。这模拟了人类的记忆机制——不重要的事情会逐渐淡忘重要的事情会长期记住。5. 把记忆接入 Agent完整流程与实操记录5.1 对话循环中的记忆读写有了数据库层接下来要把它接入 Agent 的对话循环。核心逻辑是每次用户发消息先写入数据库然后从数据库检索相关记忆拼进 prompt调用模型再把模型的回复写入数据库。// agent.js import { addMessage, getRecentMessages, searchMessages } from ./memory.js; async function chat(sessionId, userInput) { // 1. 写入用户消息 addMessage({ sessionId, role: user, content: userInput, importance: 3 }); // 2. 检索最近对话作为上下文 const recentMessages getRecentMessages(sessionId, 10); // 3. 检索相关记忆关键词匹配 const keywords extractKeywords(userInput); const relatedMemories keywords.flatMap(kw searchMessages(kw, 3)); // 4. 去重并组装 prompt const memoryContext dedupe([...recentMessages, ...relatedMemories]) .map(m ${m.role}: ${m.content}) .join(\n); const prompt 你是一个有记忆的助手。以下是你和用户的对话历史及相关记忆 ${memoryContext} 用户最新消息${userInput} 请基于以上信息回复。 ; // 5. 调用模型这里用伪代码表示 const reply await callLLM(prompt); // 6. 写入助手回复 addMessage({ sessionId, role: assistant, content: reply, importance: 3 }); return reply; } function extractKeywords(text) { // 简单实现提取长度大于2的词 // 实际项目中可以用分词库或者让模型提取 return text.split(/\s/).filter(w w.length 2).slice(0, 3); } function dedupe(messages) { const seen new Set(); return messages.filter(m { if (seen.has(m.id)) return false; seen.add(m.id); return true; }); }这个流程跑通之后Agent 就有了基本的记忆能力它能记住当前会话的上下文也能通过关键词召回历史记忆。5.2 重要性评分的自动计算手动给每条消息打重要性评分不现实。我加了一个简单的自动评分逻辑根据消息长度、是否包含问号、是否包含特定关键词来打分。function autoScore(content) { let score 3; // 长消息通常包含更多信息 if (content.length 100) score 1; if (content.length 300) score 1; // 包含问号可能是重要问题 if (content.includes(?) || content.includes()) score 1; // 包含特定关键词 const importantWords [记住, 重要, 截止, 必须, 关键]; if (importantWords.some(w content.includes(w))) score 1; // 限制在 1-5 范围内 return Math.max(1, Math.min(5, score)); }这个评分逻辑很粗糙但比固定值 3 要好。实际项目中可以让模型来打分或者用更复杂的规则。关键是要有一个自动化的机制否则记忆系统就变成了手动标注工具用起来很累。5.3 会话恢复重启后继续对话持久化的价值在重启后体现得最明显。程序重启后通过 session_id 就能恢复之前的对话// 恢复会话 function resumeSession(sessionId) { const db getDB(); const session db.prepare(SELECT * FROM sessions WHERE id ?).get(sessionId); if (!session) { console.log(会话不存在创建新会话); return null; } const messages getRecentMessages(sessionId, 50); console.log(恢复会话${session.title}); console.log(消息数${session.message_count}); console.log(最后活跃${session.last_active_at}); return messages; }我实测下来从数据库恢复一个包含 500 条消息的会话耗时在 10 毫秒以内。这个性能对于交互式应用来说完全够用。6. 常见问题与排查技巧实录6.1 数据库文件锁问题现象程序运行时报错SQLITE_BUSY: database is locked。原因SQLite 默认的 journal 模式下写入时会锁住整个数据库。如果有多个进程同时访问同一个数据库文件就会冲突。解决开启 WAL 模式。WAL 模式下读写可以并发只有一个写入者但读取不会被阻塞。db.pragma(journal_mode WAL);如果还是遇到锁问题可以设置 busy_timeout让 SQLite 在遇到锁时等待一段时间而不是立即报错db.pragma(busy_timeout 5000); // 等待 5 秒6.2 时间戳时区问题现象查询“最近一小时的消息”时结果不对。原因SQLite 的 datetime(now) 返回的是 UTC 时间而你可能在用本地时间做比较。解决统一用 UTC 时间存储和查询展示时再转成本地时间。或者在查询时用 datetime(now, localtime) 获取本地时间。-- 存储 UTC查询时转换 SELECT * FROM messages WHERE created_at datetime(now, -1 hour);实操心得我建议所有时间戳都用 UTC 存储这是最不容易出错的做法。前端展示的时候用 JavaScript 的 toLocaleString() 转成本地时间就行。混用本地时间和 UTC 是 bug 的温床。6.3 中文全文检索的坑现象用 LIKE %关键词% 搜索中文时结果不准确。原因LIKE 是简单的字符串匹配不理解中文分词。比如搜索“数据库”时包含“数据”和“库”的消息也会被匹配到。解决短期可以用更精确的匹配条件长期建议上 FTS5 全文索引。FTS5 支持中文分词需要额外配置 tokenizer这个在后续文章里展开。6.4 常见问题速查表问题可能原因解决方法SQLITE_BUSY多进程写入冲突开启 WAL 模式设置 busy_timeout查询结果为空时间格式不匹配统一用 UTC 时间写入速度慢索引过多检查查询计划删除无用索引中文搜索不准LIKE 不支持分词改用 FTS5 全文索引数据库文件过大未清理旧数据定期执行 cleanupOldMessages重启后数据丢失数据库路径错误检查 DB_PATH 是否指向持久化目录6.5 性能优化的小技巧批量插入用事务。如果你要一次性插入多条消息用事务包裹比逐条插入快几十倍。我实测过插入 1000 条消息逐条插入耗时约 2 秒用事务包裹后降到 50 毫秒以内。const insertMany db.transaction((messages) { for (const msg of messages) { insertStmt.run(msg.sessionId, msg.role, msg.content); } }); insertMany(messages);查询只取需要的字段。SELECT * 会返回所有列如果只需要 content 和 role就明确指定减少数据传输量。定期 VACUUM。SQLite 删除数据后不会立即释放磁盘空间需要执行 VACUUM 命令来整理碎片。对于长期运行的 Agent建议每周执行一次。db.exec(VACUUM);这个操作会重建数据库文件期间会锁住数据库所以最好在低峰期执行。7. 从 SQLite 出发记忆系统的下一步演进SQLite 把 Agent 的记忆从内存搬到了硬盘解决了持久化和结构化查询的问题。但记忆系统还有很大的优化空间。语义检索是下一个要解决的问题。关键词匹配只能找到字面相同的记忆但用户可能用不同的词表达同一个意思。下一步可以引入 embedding 模型把消息转成向量存到 SQLite 的向量扩展里实现语义级别的记忆召回。记忆压缩也值得做。随着对话增多数据库会越来越大检索效率会下降。可以定期把多条相关消息合并成一条摘要减少数据量的同时保留核心信息。记忆关联是更高级的需求。比如用户提到“上次说的那个项目”Agent 需要能关联到之前关于项目的讨论。这需要在消息之间建立关联关系用图结构来组织记忆。不过这些都是后话。Day 16 的目标很明确用 SQLite 给 Agent 一个真正的记忆库让它能记住、能查询、能遗忘。这套基础打好了后面的优化才有立足点。我在实际使用中发现SQLite 的稳定性远超预期。连续跑了三天写入了几万条消息没有出现一次崩溃或数据损坏。对于一个单文件数据库来说这个表现相当可靠。如果你也在给 Agent 做记忆系统不妨先用 SQLite 把流程跑通等遇到真正的性能瓶颈再考虑换方案。大多数情况下SQLite 能撑到你想不到的量级。