
AI 长期记忆是对话类应用绕不开的能力。用户昨天表达过的偏好、上个月提交过的需求、项目里沉淀过的结论如果下一次对话全部丢失系统就永远只能处理单轮问题。多数团队听到长期记忆的第一反应是引入向量数据库先把文本切成块再用 embedding 模型转成向量最后通过向量相似度召回。这条路很成熟但它不是唯一选择也不是所有业务阶段都该立刻上。对于中小型项目、内部工具、个人知识库而言不引入专用向量数据库同样可以实现可用的 AI 长期记忆。本文给出一个可以复制到本地跑通的开源轻量方案Python 3 FastAPI SQLite FTS5 jieba 分词。这套方案把记忆拆成结构化条目用全文检索和元数据过滤做召回整个过程只需要一个 SQLite 文件不需要额外部署中间件。文章会覆盖设计思路、建表 SQL、核心代码、运行验证、常见坑和生产化建议你可以照着步骤在本地验证再决定是否真的要上向量数据库。1. AI 长期记忆为什么难以及向量数据库不是唯一答案1.1 长期记忆在对话系统里的作用长期记忆解决的是这样一个问题大模型本身不保留用户状态。API 调用之间对话上下文可以放进携带的 messages 数组里但数组有长度限制也不可能保存所有历史。用户第一次对话时提到“我在写一个跨境电商的库存管理系统”第二次对话又提到“库存超卖问题需要优先处理”系统如果无法把这两条信息关联起来第二次回答就会失去业务背景。长期记忆在工程上通常拆成三个环节写入从历史对话中提取值得长期保存的事实、偏好、约束和进度。检索在用户发起新问题时从记忆库中找出最相关的内容。注入把检索结果以系统提示词或上下文片段的形式送给大模型。这三个环节缺一不可。很多团队只实现了“把完整聊天记录存起来”新请求到来时把所有记录一股脑塞进提示词这会在 token 成本和检索准确率上同时出现问题。长期记忆真正考验的是“怎么从大量历史中挑出最值得看的那几条”。1.2 向量数据库方案的优点和隐藏成本向量数据库方案的工作方式是把文本交给 embedding 模型生成几百到几千维的浮点向量然后存到 Milvus、Qdrant、pgvector、ChromaDB 这类系统中。查询时也用同一模型生成向量再做近似最近邻搜索召回语义相近的文本。它的优点非常明显能处理“换一种说法”的语义匹配。用户第一次说“我喜欢安静的办公室”第二次说“工作环境吵我会分心”向量空间里这两句话的距离可能比较近关键词检索则很难直接关联。但向量数据库也有隐藏成本引入一套新的存储系统意味着部署、运维、监控、备份都要跟上。embedding 过程依赖模型如果是商用 API会产生调用费用如果用本地模型要考虑推理资源和延迟。数据写入后删除、更新、过期管理比关系库复杂。维度一旦固定后续模型升级需要考虑重新生成向量或者双写兼容。对只有几千条记忆的中小项目来说向量检索的收益并不明显。因此在方案选型阶段不应该默认“长期记忆向量数据库”。完全可以用传统数据库加全文索引先解决最核心的“持久保存 相关召回”问题。1.3 无向量库时需要补足哪些能力放弃向量数据库之后照样需要保证记忆系统的召回结果可用。要补的能力包括分词中文文本必须切词否则全文索引无法工作。全文索引用倒排索引快速定位包含关键词的文本。结构化元数据用 user_id、category、importance、时间等字段过滤和排序。时间衰减太久没访问的记忆重要性应该下降。记忆清理既要防止数据库膨胀也要防止过时信息干扰模型。这些能力都不依赖向量数据库。SQLite 自带的 FTS5 支持全文索引和 BM25 排序PostgreSQL 的 tsvector 也能做甚至连 Redis 的全文模块也是一种选择。下面先以 SQLite 为主因为本地起一个文件就能验证完整流程。2. 选型思路用 SQLite FTS5 结构化记忆表撑起 AI 长期记忆2.1 不要把聊天记录当记忆先抽出记忆条目设计长期记忆系统时最容易犯的错误是把“历史消息”直接当“记忆”存下来。消息是一串原始文本里面包含问候、停顿、上下文无关的闲聊直接放进检索索引会大量召回无意义内容。更合理的设计是新增一张记忆条目表每行代表一条已经被抽象过的知识。例如用户说“我最近在做一个库存管理系统最头疼的是库存超卖。”这条原始消息可以拆成两条记忆用户在开发库存管理系统。库存超卖是用户当前最关心的问题。每条记忆还可以打上 category 和 importance。category 可以区分偏好、目标、项目背景、任务进度importance 用于后续排序时给高分记忆加权。这种抽象过程可以由程序完成也可以让大模型在对话结束时异步提炼。先用程序写入人工整理好的记忆条目跑通链路后续再接 LLM 摘要也不迟。2.2 全文检索与结构化筛选怎么配合FTS5 解决的是“哪些记忆文本包含用户当前问题里的关键词”。它基于倒排索引查询速度远快于LIKE %关键词%。但它不擅长精确筛选比如“只看某个用户的记忆”“只看某类记忆”。所以检索时要把两类条件组合起来FTS 条件memory_fts MATCH ?负责相关性。SQL 条件user_id ? AND category ?负责限权、范围过滤。排序时可以使用 FTS5 提供的bm25()函数。BM25 是经典的相关性打分算法综合了词频、逆文档频率和文本长度。在 SQLite 中bm25(memory_fts)返回值越小表示相关度越高。如果只依赖关键词召回效果会比较机械但至少具备可解释性日志里可以清楚看到用户问题被切成了哪些词命中了哪条记忆。这一点在调试阶段比向量黑盒更友好。2.3 为什么这个方案适合中小型项目SQLite 方案的优势集中体现在四个地方维度表现部署成本零额外服务数据库就是一个文件数据备份直接复制 .db 文件即可起步门槛一套代码即可跑通不需要理解向量索引原理调试体验检索失败能明确找到原因是分词还是索引从数据规模看当记忆条目在几万条以内时FTS5 的检索性能完全够用。FastAPI 服务本身是同步接口也适合中小并发场景。如果未来数据量增长可以把 SQLite 替换为 PostgreSQL 的全文检索代码改动量不会太大。这个方案也适合作为团队内部原型。先用它验证“长期记忆到底能不能提升产品体验”如果确实有效再投入资源做语义向量版。如果连原型阶段都没跑明白直接引入向量数据库只会让问题更难排查。2.4 方案边界要诚实地说清楚FTS5 元数据的方案不能完全替代向量检索。当用户换一种完全不同但语义相同的表达方式时关键词检索可能失败。比如记忆里写的是“用户喜欢极简桌面”用户新问题说的是“我希望桌面别放太多图标”这两句之间没有共同关键词全文检索就召不回。但这不代表方案不可用。很多场景下用户提问会直接包含记忆里的核心名词比如项目名、产品名、技术栈、人名。只要记忆条目在写入时做了合理的分词和关键词补充召回率就能达到实际可用水平。等到数据量和真实语义匹配需求都上来之后再在这个基础上叠加向量层也不迟。3. 从零实现记忆写入、检索和对话注入3.1 环境准备与目录结构本机环境建议使用 Python 3.9 以上版本。需要安装三个依赖pip install fastapi uvicorn jieba项目目录结构保持简单ai-memory/ ├── app.py ├── memory.py └── requirements.txtmemory.py负责数据库初始化、记忆写入、记忆检索。app.py提供 HTTP 接口用于模拟记忆写入和对话请求。先创建requirements.txtfastapi uvicorn jieba安装完毕后在项目根目录运行命令能正常 import 即说明环境就绪python -c import fastapi, uvicorn, jieba; print(deps ok)3.2 数据库表设计与 FTS5 索引在memory.py中先定义数据库初始化和分词函数。import sqlite3 from datetime import datetime import jieba DB_PATH ai_memory.db STOPWORDS set(的是了和就也都).strip() def segment(text: str): return [w for w in jieba.cut(text) if w.strip() and w not in STOPWORDS] def init_db(): conn sqlite3.connect(DB_PATH) conn.executescript( CREATE TABLE IF NOT EXISTS memory_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, content TEXT NOT NULL, category TEXT DEFAULT general, importance INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime(now)), last_accessed_at TEXT DEFAULT (datetime(now)) ); CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( segmented, memory_id UNINDEXED ); ) conn.commit() conn.close()这里有两张表memory_entries记忆的真实内容字段包含用户、分类、重要度、创建时间、最后访问时间。memory_ftsFTS5 虚拟表保存分词后的文本和回表用的memory_id。注意 FTS5 表里没有保存完整原始文本只保存分词结果和对应记忆 ID。这样检索时可以 join 回memory_entries拿到结构化字段。为什么不用LIKE直接查memory_entries因为LIKE %关键词%无法利用索引在数据量上来之后性能下降明显。FTS5 建立倒排索引查询时先定位包含关键词的文档 ID速度更快。同时 FTS5 还附带 BM25 排序函数方便做相关度排序。3.3 记忆写入模块写入记忆时必须同时写两张表。先插入结构化条目再对 content 分词把分词结果插入 FTS5 表。def add_memory(user_id: str, content: str, category: str general, importance: int 1): conn sqlite3.connect(DB_PATH) cur conn.cursor() cur.execute( INSERT INTO memory_entries (user_id, content, category, importance) VALUES (?, ?, ?, ?) , (user_id, content, category, importance), ) memory_id cur.lastrowid tokens segment(content) segmented .join(tokens) cur.execute( INSERT INTO memory_fts (segmented, memory_id) VALUES (?, ?), (segmented, memory_id), ) conn.commit() conn.close() return memory_id为什么要在写入时提前分词因为 SQLite FTS5 内置的 unicode61 分词器对英文和空白字符友好但中文如果不预先切词整句话会被当成一个 token检索时难以命中。jieba 把“我喜欢安静的工作环境”切分成多个词再用空格连接FTS5 才能对每个词建立索引。停用词层面这里只给了极小的示例集合。生产环境可以扩充停用词表或者保留所有词只调整排序权重。需要注意的是停用词过滤太激进可能丢失记忆中的关键人名和项目名建议先小步验证。3.4 记忆检索模块检索模块的作用是接收用户当前问题返回相关记忆列表。def search_memories(user_id: str, query: str, top_k: int 5): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row cur conn.cursor() tokens segment(query) if not tokens: tokens [query] match_query OR .join(tokens) cur.execute( SELECT m.id, m.content, m.category, m.importance, m.created_at, m.last_accessed_at, bm25(memory_fts) AS bm25_score FROM memory_fts JOIN memory_entries m ON m.id memory_fts.memory_id WHERE memory_fts MATCH ? AND m.user_id ? ORDER BY bm25_score ASC LIMIT ? , (match_query, user_id, top_k * 2), ) rows cur.fetchall() # 在 Python 中结合重要度和时间衰减做二次排序 scored [] for row in rows: bm25_score row[bm25_score] or 0 score -bm25_score row[importance] * 0.5 try: last_access datetime.fromisoformat(row[last_accessed_at]).timestamp() now datetime.utcnow().timestamp() days_since max(0, (now - last_access) / 86400.0) score - days_since * 0.01 except Exception: pass scored.append((score, row)) scored.sort(keylambda x: x[0], reverseTrue) result [dict(row) for _, row in scored[:top_k]] # 更新访问时间让最近命中的记忆在后续排序中更有优势 for row in result: cur.execute( UPDATE memory_entries SET last_accessed_at datetime(now) WHERE id ?, (row[id],), ) conn.commit() conn.close() return result这段代码的核心逻辑可以拆分为四步对用户查询做同样的 jieba 分词拼成词1 OR 词2 OR 词3。用 FTS5 MATCH 先召回候选记忆同时用user_id过滤用户。用bm25()得到基础相关度再叠加importance加权和访问时间衰减。重新按分数排序截取前top_k条并更新命中记忆的last_accessed_at。这里有三个细节值得说明。第一bm25()返回负值值越小代表相关度越高所以 SQL 里ORDER BY bm25_score ASC。Python 里加上负号转成正相关分数。第二为什么先查top_k * 2再二次排序因为 SQL 层只处理了 BM25 相关性重要度和时间因子在 SQL 里表达不直观先多取一些候选再在应用层精准排序。第三last_accessed_at的记录是为了实现“越被经常访问的记忆越容易被再次召回”的效果。3.5 对话接口如何注入记忆下面用 FastAPI 提供两个接口一个写入记忆一个模拟对话。from fastapi import FastAPI from pydantic import BaseModel import memory app FastAPI() class MemoryCreate(BaseModel): user_id: str content: str category: str general importance: int 1 class ChatRequest(BaseModel): user_id: str message: str app.on_event(startup) def startup(): memory.init_db() app.post(/memory) def create_memory(req: MemoryCreate): memory_id memory.add_memory( req.user_id, req.content, req.category, req.importance, ) return {memory_id: memory_id} app.get(/memory/search) def search(user_id: str, q: str, top_k: int 5): rows memory.search_memories(user_id, q, top_k) return {memories: rows} app.post(/chat) def chat(req: ChatRequest): rows memory.search_memories(req.user_id, req.message, top_k5) memory_lines \n.join([f- {m[content]} for m in rows]) system_prompt ( 你是智能助手。以下是从用户长期记忆中召回的条目 请结合记忆内容回答\n f{memory_lines}\n ) # 这里用字符串模拟大模型回复实际项目可替换为 # openai.ChatCompletion.create 或本地模型接口 reply f模拟 LLM 输出构造出的 system prompt 如下\n{system_prompt} return {reply: reply}这个chat接口展示了长期记忆系统的完整闭环用户发来新消息。系统调用search_memories用新消息分词结果召回相关记忆。把记忆列表拼进系统提示词。再调用真实 LLM 接口完成回复。实际项目中系统提示词还可以拼接用户画像、项目背景、禁止事项等静态内容。记忆内容只是其中一部分要注意控制 token 用量。4. 运行验证用一组测试确认记忆真的被召回4.1 启动服务并写入模拟记忆在项目根目录执行uvicorn app:app --reload --port 8000服务启动后先写入几条记忆。curl -X POST http://127.0.0.1:8000/memory \ -H Content-Type: application/json \ -d {user_id: u001, content: 用户正在开发库存管理系统最关心库存超卖问题, category: project, importance: 5}curl -X POST http://127.0.0.1:8000/memory \ -H Content-Type: application/json \ -d {user_id: u001, content: 用户偏好安静的工作环境, category: preference, importance: 3}curl -X POST http://127.0.0.1:8000/memory \ -H Content-Type: application/json \ -d {user_id: u002, content: 用户正在做电商数据分析平台, category: project, importance: 4}第二条和第三条分别属于不同用户用来验证user_id过滤是否有效。4.2 验证检索结果用 u001 用户搜索“库存超卖怎么解决”curl -G http://127.0.0.1:8000/memory/search \ --data-urlencode user_idu001 \ --data-urlencode q库存超卖怎么解决预期返回结果里会包含第一条记忆且不会包含 u002 的记忆。如果返回为空首先要检查分词结果和user_id是否一致。再用 u001 搜索“哪里比较安静”curl -G http://127.0.0.1:8000/memory/search \ --data-urlencode user_idu001 \ --data-urlencode q哪里比较安静预期能召回“用户偏好安静的工作环境”。这个测试验证了关键词召回能力。4.3 验证对话上下文注入调用/chat接口curl -X POST http://127.0.0.1:8000/chat \ -H Content-Type: application/json \ -d {user_id: u001, message: 库存管理应该先解决什么问题}返回结果中会看到系统提示词里拼入了与“库存”相关的记忆。真实项目中当前这一步就是模型能感知长期记忆的入口。如果返回结果中的 memory_lines 为空说明message与已写入记忆没有共同关键词。可以尝试调整记忆写入时的内容摘要或者在检索时放宽匹配要求。5. 常见问题排查与关键参数调优5.1 为什么检索结果为空可能原因检查方式处理建议查询分词后没有有效词打印segment(query)结果增加停用词外的保留词或直接使用原始文本FTS5 表里没有对应分词查询SELECT * FROM memory_fts确认写入时调用了add_memory而不是只插入memory_entriesuser_id不匹配查看memory_entries.user_id确保写入和检索使用相同用户标识MATCH 语法特殊字符问题查询中带括号、引号等对分词结果做转义或先去掉特殊字符记忆内容过短导致分词结果被停用词过滤停用词表包含业务关键词检查并裁剪停用词表排查顺序建议是先打印用户查询的分词结果再查 FTS5 表里的segmented字段最后检查 join 条件和user_id。5.2 中文分词与 FTS5 的坑FTS5 默认分词器并不适合中文。如果直接把“我喜欢的编程语言是Python”写入 FTS5默认的 unicode61 会把整段中文按字切分或整体作为一个 token检索时很难命中。解决方案就是写入前用 jieba 分词再用空格连接。但还有两个细节停用词过滤不要过度。建议只过滤最通用的“的、了、和、是”不要过滤项目名、技术栈、品牌名。业务术语需要自定义词典。比如“库存超卖”如果被 jieba 切错检索召回会不完整。可以通过jieba.add_word(库存超卖)固定词条。另一个常见错误是查询端分词和写入端分词使用不同逻辑导致索引词不匹配。生产环境应保证segment()是唯一的切词入口。5.3 记忆膨胀与相关性下降长期运行后memory_entries会持续增加。太多不相关记忆不仅拖慢检索还会让排序结果变得不稳定。至少要做三件事每条记忆设置importance低重要性记忆在排序中天然靠后。定期清理过期记忆比如超过 90 天未访问且 importance 很低的记录。对相似记忆做合并把多条碎片化记忆压缩成一条摘要。删除记忆时必须同时删除 FTS5 中的对应记录def delete_memory(memory_id: int): conn sqlite3.connect(DB_PATH) cur conn.cursor() cur.execute(DELETE FROM memory_fts WHERE memory_id ?, (memory_id,)) cur.execute(DELETE FROM memory_entries WHERE id ?, (memory_id,)) conn.commit() conn.close()如果不删除 FTS5 中的记录检索时 join 不到memory_entries会出现“有索引但看不到内容”的情况。5.4 学习环境与生产环境的差异上面的代码适合本地学习和原型验证。进入生产环境前还需要考虑以下几点关注点学习环境生产环境数据库SQLite 单文件PostgreSQL 或 SQLite 备份机制写入时机手动调用对话结束后由异步任务提取写库检索并发单进程演示连接池、读写分离、缓存热点记忆日志无记录查询词、召回数、命中记忆 ID安全无访问控制用户维度鉴权防止越权读取故障恢复直接删库重新初始化定时备份、数据库迁移脚本、回滚方案如果选择 PostgreSQL可以把memory_fts换成tsvector列和GIN索引检索语法改为to_tsquery和ts_rank。核心思路一致只是具体函数不同。5.5 关键参数速查表参数含义建议值调大影响调小影响top_k最终注入的记忆条数3-8上下文更丰富但 token 消耗增加召回不完整模型可能失去重要背景importance记忆重要度1-5权重高容易被召回容易被普通记忆覆盖时间衰减系数每过一天降低的分数0.01更重视近期记忆历史记忆不容易被遗忘match_query 连接符分词间用 OR/ANDOR召回多噪声大召回少精度高候选倍数SQL LIMIT 与 top_k 的倍数top_k * 2二次排序更准查询更重可能漏掉高质量候选这些参数没有绝对标准需要根据业务数据做离线样本调优。建议先记录一批真实问题和期望召回结果再对比不同参数下的命中率。6. 最佳实践、清单与下一步扩展6.1 记忆写入的最佳实践先把“写入什么”想清楚再动手写代码。比较好的做法是让大模型在对话结束后异步抽取记忆程序只负责存储和索引。抽取时必须规定输出格式例如 JSON{ memories: [ { content: 用户正在开发库存管理系统, category: project, importance: 5 }, { content: 库存超卖是用户当前最关心的问题, category: goal, importance: 4 } ] }程序处理这个 JSON 时可以过滤空字段、去重、限制单条长度。插入成功后返回memory_id方便后续删除或修改。写入时不要同步调用 LLM 抽取否则对话接口延迟会明显变高。推荐把写库操作放进消息队列或后台线程让用户请求尽快返回。6.2 发布前检查清单上线长期记忆功能前建议逐项确认[ ] 每条记忆是否带有user_id检索时是否强制按用户过滤。[ ] 记忆内容是否经过脱敏是否包含手机号、证件号等敏感信息。[ ] 写入和查询是否使用同一套分词逻辑。[ ] 是否有删除记忆的接口和定时清理任务。[ ] 系统提示词中的记忆内容是否有最大长度限制。[ ] 是否记录检索日志方便分析召回质量问题。[ ] 是否存在用户主动关闭长期记忆的开关。[ ] 数据库是否有备份和恢复机制。这份清单同样适用于半成品原型。越早把用户隔离和敏感信息处理掉后面返工成本越低。6.3 什么时候还是应该考虑向量检索虽然本文方案能跑通但遇到以下信号时就应该重新评估向量数据库记忆条数超过十万关键词重叠率低全文检索频繁召回空结果。用户问题经常使用同义词、指代、口语化表达关键词召回命中率明显下降。产品需要跨语言召回例如中文问题要召回英文记忆。推荐质量对业务影响很大愿意为更复杂的语义匹配付出运维和成本。届时可以把当前方案作为基础层保留结构化字段和排序逻辑只把“文本召回”这一步升级为向量检索。记忆表依旧存在向量只作为附加召回通道两种结果做融合排序这样会比一步到位替换成向量库更稳妥。6.4 下一步扩展建议如果暂时不需要向量数据库可以沿着下面几个方向继续完善把 SQLite 替换成 PostgreSQL使用tsvector或pg_trgm做全文检索。在现有 FTS5 基础上增加“同义词扩展”把用户常用同义词写入扩展表。引入记忆分层区分为短期记忆、长期记忆和永久规则不同层采用不同的过期策略。增加反馈机制用户对回答不满意时可以降低对应记忆的 importance。用定时任务统计记忆访问频率自动合并或删除低价值记忆。长期记忆真正决定体验的不是存储技术选型而是“能否在正确时机把正确背景送给模型”。向量数据库提供了强大的语义召回能力但中小规模项目先用 SQLite FTS5 和结构化元数据跑通链路往往能更快得到业务验证结果。等真实的召回缺陷出现了再针对缺陷决定是否引入向量检索这才是更务实的路径。