
1. 先聊清楚AI记忆系统到底要解决什么问题给大模型应用加“记忆”是我最近一年做个人项目时踩得最深的一个坑。最开始我做了一个本地知识库问答工具模型本身回答质量挺好但只要你换个窗口、隔天再打开它就像失忆了一样——用户上个月明明在对话里说过“我习惯早睡晚上九点后不要提醒我做事”到了第二天它照样在晚上十点弹出提醒。这种体验非常割裂。后来我抽空整理了一个小项目代号叫 TiMem定位是“给任意大模型应用加一套可插拔的本地记忆组件”。它不是某个厂商的一体化服务而是一套自己能跑起来、能看懂、能改的代码方案。核心思路就一句话把对话历史、用户偏好、任务状态这些信息变成结构化的“记忆单元”存下来然后在合适的时机按照语义相似度找回最相关的那部分再注入到当前上下文中。这篇文章记录的就是 TiMem 从零到一的全过程。适合两类人看一是已经在做大模型应用、被“上下文窗口不够”或“多轮对话失忆”折磨过的开发者二是想入门 RAG 和记忆机制、但不想一上来就上重型框架的初学者。我会把架构思路、核心代码、参数计算、踩坑过程全部展开你照着做基本能在本地跑出一套可用的 AI 记忆系统。2. 整体设计拆解记忆系统由哪几块构成2.1 记忆不是“把聊天记录存下来”这么简单很多人一开始会把记忆做成“存日志”每个用户的所有对话拼起来需要时一把梭全部塞给模型。这在对话量小的时候勉强能用数据一多就废了——token 预算爆掉、无关信息干扰判断、重复内容稀释注意力。真正的记忆系统至少要做三件事写入从原始对话中提取值得记住的信息过滤掉废话。存储把信息转成语义可检索的向量连同元数据一起落库。召回根据当前问题找出最相关的若干条记忆按格式组织后注入上下文。打个比方人的记忆不是录像机逐帧存储而是在关键节点上抽象出“当时发生了什么、对我有什么影响”。TiMem 做的就是这件事把对话压缩成记忆单元再按需提取。2.2 分层记忆策略短期、长期、工作记忆我最初的设计只有一张表所有记忆混合存储结果很快发现两个问题首先是存储膨胀每条对话都存几千轮后就变得非常臃肿其次是召回混乱几天前的琐碎话题和当前任务混在一起模型分不清主次。后来参考认知科学的思路把记忆分成三层短期记忆、长期记忆和工作记忆。短期记忆只保留当前会话内的信息会话结束且不再活跃后自动丢弃或降级长期记忆是经过筛选、压缩后沉淀下来的核心信息比如用户偏好、项目约束、长期目标工作记忆是每次请求时从长期记忆中临时召回的集合只存在于本次上下文中。层与层之间不是物理隔离而是通过“重要性”和“新鲜度”两个指标动态流动。短期记忆在写入时打上时间戳定期把“被反复提及”的内容提升到长期记忆层长期记忆中的内容如果长期未被使用也会逐步降低权重。2.3 为什么选择本地优先的存储方案我记得第一版考虑过各种外部存储服务但实际操作下来本地文件加嵌入式数据库反而是最稳的起点。原因很直接向量检索的数据量在个人项目阶段就那么几十万条单机完全扛得住其次记忆是隐私敏感数据全放本地更容易控制再者调试时可以随时打开数据库直接看数据排查问题效率高很多。等数据量真正上来再迁移到更高性能的方案不迟架构上留好接口就行。TiMem 的存储层选型是这样的元数据用一张关系表存包括用户 ID、会话 ID、记忆内容、时间戳、访问次数、重要性分数向量部分用一个本地方可持久化的向量索引来存写入时同时写两边查询时先走向量检索拿到候选 ID 集合再回表做过滤和排序。3. 记忆单元模型怎么设计才不丢信息3.1 核心字段与它们的用途记忆单元是整套系统的最小数据载体。我反复改了三版最终确定了一组比较稳妥的字段字段类型作用memory_idstring全局唯一 IDuser_idstring所属用户session_idstring来源会话contenttext记忆正文只存提炼后的信息embeddingfloat[]语义向量用于检索created_atdatetime首次写入时间last_accessdatetime最后被召回时间access_countint被召回次数importancefloat重要性评分 0~1sourcestring写成 memory / preference / task 等标签里面最关键的是 importance 和 last_access它们决定记忆的“生命力”。我维护记忆时每轮召回都会更新这两个字段并触发一次级联的权重重算。3.2 重要性分数怎么算时间衰减加访问频率重要性不能拍脑袋定我采用了一个简单但有效的加权公式base_score 0.6 * semantic_importance 0.4 * frequency_factor time_decay exp(-0.003 * hours_since_last_access) final_score base_score * time_decaysemantic_importance 是写入时由模型或规则打的分比如包含“喜欢”“讨厌”“记住”“每次”这类关键词的记忆基础分就高一些frequency_factor 跟 access_count 挂钩被召回的次数越多权重越大time_decay 控制遗忘速度超过两周未活跃的记忆分数会降到初始值的一半以下。公式里的 0.003 和 0.6/0.4 是我在项目里按经验调出来的起点参数。社区里的做法也大多围绕这几项打转关键是理解它的含义越是频繁使用、语义价值高的记忆越该留越久没碰的越该淡出。3.3 标签体系记忆不只是“文字”还要能分类早期我只存纯文本结果检索时经常把偏好类和任务类混在一起。后来加了 source 标签把它当成记忆的“分类文件夹”。同一段对话产生的记忆按内容类型瓜分进不同标签下用户明确表达的偏好放进 preference项目待办放进 task背景信息放进 memory。召回时可以根据当前场景绑定标签条件比如做日程类问答时只从 preference 和 task 中提取减少噪音。4. Embedding 选型与本地向量化细节4.1 选 embedding 模型维度、语言效果、速度怎么权衡TiMem 的检索质量很大程度取决于向量模型。我试过几类不同的方案总结下来的选择标准有三个维度、中文效果、推理速度。维度影响存储和计算量256 维的向量和 1024 维的向量在百万量级下检索速度相差好几倍但维度过低又会损失语义区分度。中文场景下通用开源小模型的效果参差不齐有的对口语化表达很敏感有的对短句不太友好。我最终选了一个在本地 CPU 上跑得动、输出 512 维的轻量级模型原因是在项目中做了对比测试它在“用户偏好”这类短句上的检索准确率能到八成以上再大的模型提升有限但耗时翻倍。一条通用的经验先不要迷信大模型拿你自己的记忆文本跑一批检索测试把候选模型逐一对比 Top-5 命中率再决定用哪个。这比看任何榜单都实在。4.2 向量归一化检索质量的第一道保障很多人在这步翻车。写入和查询时必须用同一套预处理流程且向量要做 L2 归一化。归一化的好处是让余弦相似度退化为点积运算速度快同时避免向量长度影响排序。我项目里实际遇到过未归一化时的反常排序一条语义关系一般的长文本因为向量模长偏大分数超过了真正语义相近的短句。归一化之后这种问题就消失了。另外提前把句子做轻量的清洗比如统一大小写、去掉多余的空白符号、去除高频无意义词能让向量更集中表达语义。这步不要做过分比如过度分词或改写反而会破坏语义完整性。4.3 写入时的向量生成流程记忆写入的完整流程是这样的def write_memory(user_id, session_id, raw_text, source): # 1. 清洗 cleaned preprocess_text(raw_text) # 2. 生成向量 emb embed_model.encode(cleaned) # 3. 归一化 emb normalize(emb) # 4. 存入向量索引 vector_index.add(user_id, emb) # 5. 存入元数据表 sqlite_db.execute( INSERT INTO memories ... VALUES (?, ?, ?, ?, ?, ...), (gen_memory_id(), user_id, session_id, cleaned, source, ...) ) # 6. 触发增量合并检查 maybe_run_compaction(user_id)这里面最容易忽略的是第 6 步。每次写入都触发一次全量合并的话性能会崩掉但完全不合并记忆碎片会越来越多。我采取的方案是当某个用户的记忆条数超过 500 条时才启动一次后台合并任务合并时把相似度高于 0.92 的记忆合并成一条并汇总它们的来源标签。5. 核心检索流程怎么把记忆“想”起来5.1 查询侧 Pipeline检索记忆时我遵循一条固定流水线解析当前请求 → 生成查询向量 → 向量召回候选集 → 根据时间、标签、重要性做过滤重排 → 按 token 预算裁剪 → 组织成上下文文本。def recall_memory(user_id, query, top_k5): # 1. 查询向量 q_vec normalize(embed_model.encode(preprocess(query))) # 2. 向量召回从索引里取 20 条候选而不是直接取 5 条 candidates vector_index.search(q_vec, top_k20, user_iduser_id) # 3. 回元数据表补全信息 rows [sqlite_db.fetch_memory(mid) for mid in candidates.ids] # 4. 重排 rows.sort(keylambda r: r[importance] * decay_weight(r[last_access]), reverseTrue) # 5. 按预算裁剪 return rows[:top_k]这里有个值得说的细节召回阶段先取 20 条候选排序后再压缩到 5 条。因为向量检索只看语义相似度但候选里可能混杂了重要性很低的历史琐事直接取 top 5 有可能错过“语义排名第 10、但重要性极高”的记忆。扩大候选池再做混合排序命中率会明显提上来。5.2 动态 Top-K不是每轮都固定取 5 条我项目里一开始固定 top_k5后来发现不同场景差异很大闲聊时取 3 条就够复杂任务规划时要 10 条甚至更多。后来改成根据当前请求的 token 预算动态决定先用一个简单启发式方法估算请求复杂度比如按问题和历史摘要的长度判断然后按预算除每条记忆的平均长度算出可以放几条。核心原则是控制记忆注入后总输入长度仍在上限的 70% 以内给生成留出空间。5.3 为什么相似度阈值不能一刀切相似度阈值是个双刃剑。设太高比如 0.85检索会非常保守很多语义上相关、但措辞不同的记忆被丢掉设太低比如 0.6噪音会大量混进来。我采用了两段式过滤第一段设置一个较低的硬门槛0.65过滤掉明显无关的向量第二段在重排时再做一次精筛按记忆标签和场景匹配度打分而不是只看向量分数。举个例子用户当前说“帮我安排明早的会议”向量召回里可能有一条记忆“用户一般下午参加项目同步会”语义相似度只有 0.58看着无关但它属于 task 标签且最近被频繁引用重排时就能把它的排序拉上来最终被选中。这就是纯向量排序做不到的地方。6. 手把手实操搭出第一版 TiMem6.1 环境准备与依赖我拿 Python 3.10 作为基础版本依赖项尽量精简pip install numpy sqlite3-vec fastapi pydantic pip install sentence-transformerssqlite3-vec 是 SQLite 的向量检索扩展个人项目阶段完全够用sentence-transformers 负责加载 embedding 模型。如果你不想依赖太重也可以直接用 numpy 手动算余弦相似度数据量小的时候性能也说得过去。6.2 建表语句元数据表结构我最终定成这样CREATE TABLE IF NOT EXISTS memories ( memory_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, session_id TEXT, content TEXT NOT NULL, source TEXT DEFAULT memory, importance REAL DEFAULT 0.5, access_count INTEGER DEFAULT 0, last_access TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_memories_user ON memories(user_id); CREATE INDEX idx_memories_last_access ON memories(last_access DESC);索引设计上有两个要点user_id 必须有索引因为所有查询的第一步都是按用户隔离last_access 也值得建索引因为定期清理“僵尸记忆”时要按时间排序扫表。向量部分由 sqlite3-vec 单独建一张虚拟表通过 memory_id 关联主表。6.3 写入与合并的完整代码写入函数的核心逻辑我在前面列过这里补一段合并逻辑。合并不是简单删除旧记录而是要生成一条“综述式记忆”。我用一个简单的文本拼接模板来实现def maybe_run_compaction(user_id): count get_memory_count(user_id) if count 500: return # 取出相似度高于阈值的分组 groups find_similar_groups(user_id, threshold0.88) for group in groups: # 按重要性排序保留主记忆 main sorted(group, keylambda m: m[importance], reverseTrue)[0] other_texts [m[content] for m in group if m[memory_id] ! main[memory_id]] new_content .join([main[content], *other_texts[:3]]) if len(new_content) 300: new_content new_content[:300] … # 更新主记忆删除其余 update_memory(main[memory_id], contentnew_content, importancemin(1.0, main[importance] 0.05)) delete_memories([m[memory_id] for m in group if m[memory_id] ! main[memory_id]])合并时有个细节拼接顺序按重要性从高到低排列保证模型在召回时首先读到核心内容。因为模型对长文本开头的注意力强于后面把关键信息放在“综述记忆”开头能明显提升下游任务的表现。6.4 召回后如何注入模型上下文召回只是第一步把记忆组织成模型理解的格式同样重要。我用的模板是这样的[sys] 你是一名拥有长期记忆的助手。以下是关于用户的历史记忆 记忆 - [2025-03-20] 用户偏好晚睡习惯早上十点后开始工作。 - [2025-03-18] 任务正在做跨平台笔记同步工具关注离线优先。 /记忆 请基于当前对话和上述记忆回答用户。 [user] 我今天想继续推进笔记工具的后端设计。记忆条目按时间顺序排列标签和正文用冒号分开。这样模型在生成时既能感知时间线又能通过标签快速识别记忆类型。我还设置了 token 预算每条记忆最多占 80 个 token超出部分截断一轮最多注入 800 个 token 的记忆。实测下来这个比例对大部分应用都很稳既不会让记忆淹没当前用户指令又能提供足够的背景信息。7. 参数调优几个影响最终效果的核心旋钮7.1 相似度阈值、Top-K 与时间衰减这三个参数直接决定记忆“想起来什么”和“忘记什么”。我整理了一张起始推荐表你可以在小批数据上先跑再按自己的场景微调参数推荐起始值调高时的影响调低时的影响相似度硬门槛0.65更保守噪音少但可能漏掉相关记忆召回变多上下文噪音增大候选池大小20重排更有余地但耗时变长快速但容易错过高质量记忆最终 Top-K5背景更丰富但 token 压力大上下文干净但信息可能不够时间衰减系数0.003忘得快长期记忆易被淘汰忘得慢旧记忆长期占据权重这些参数彼此联动不要单独调。比如时间衰减系数调大了Top-K 也应该适当加大否则近期记忆过多时远期重要信息很难浮出来。7.2 如何判断调参有没有效果我设计了一个简单的离线评估脚本准备 50 条用户问题每条对应一个真实记忆跑一遍召回流程统计 Top-3 命中率。比如命中率达到 80% 以上就说明参数组合基本靠谱低于 60% 就要考虑是不是 embedding 模型的问题而不是继续拧参数。这套评估方法很土但效果直观比看单个样例的“感觉”靠谱得多。7.3 记忆预算token 与条数的双重控制实际运营 TiMem 一段时间后我发现单靠条数限制不够长记忆会挤爆 token 预算。所以我加了一道“按 token 截断”的防线每条记忆先按 token 数排序只取最长的若干条直到贴近预算剩下的记忆即使语义相关也不注入。这属于有损策略但能保证响应速度——你在交互场景下一次请求时间超过 5 秒用户就会明显不耐烦所以留足生成时间比多塞两条记忆更重要。8. 常见问题与排查实录8.1 召回不准确怎么办这是被问得最多的问题。排查顺序我建议这样走第一先确认查询向量和写入向量用的是同一套模型和同一套预处理。项目中出现过模型文件被意外替换然后所有召回分数全面走样的情况。第二检查记忆是否太碎片化。比如“用户喜欢喝茶”被拆成了好几条相近记录互相挤占候选名额。这种情况下合并功能会帮你解决一部分但最根本的还是在写入时就把信息提炼完整。第三确认标签是否准确。如果记忆被归错了 source过滤阶段就把它误杀了。排查时我最常用的手段是打开调试模式打印每条候选记忆的相似度分数、重要性和标签。用真实案例定位是向量问题还是规则问题比盲目换模型高效得多。8.2 写入慢、存储膨胀怎么办写入慢的根源通常是 embedding 模型推理耗时太长。两个方向解决一是用小一点的模型比如把参数量减半速度能快上不少代价是部分场景的语义精度略降二是批量写入攒够 10 条再一次性推理CPU 利用率更高吞吐能涨将近一倍。存储膨胀靠两层处理单条记忆上限控制比如一条记忆正文超过 500 字就截断或拆分“僵尸记忆”定期清理超过 30 天未访问且重要性低于 0.3 的记忆直接删除。这个清理脚本我用系统调度器每天凌晨跑一次长期下来数据库体积非常稳定。8.3 旧记忆覆盖新记忆上下文越聊越乱有个典型问题用户在系统里留下了一条旧偏好“习惯早上处理复杂工作”后来实际已经改成“深夜工作效率更高”。但因为旧记忆访问次数多、重要性被刷得很高排序总是压过新记忆导致模型一直按旧偏好行事。解决思路有两个层面。首先是查询时加重对最近更新的考量也就是对 last_access 比较近的记忆给予额外权重加成。其次是合并或更新记忆时保留“覆盖关系”当新记忆和旧记忆相似度较高时不是新增一条而是替换旧内容的正文并把 created_at 刷成当前时间。这就是前面说“去重写”的意义——否则同一件事会有多个版本互相打架。8.4 冷启动新用户没有任何记忆效果很差冷启动阶段其实不该强行追求“记忆”。我的做法是先准备一套行业通用的“初始记忆模板”比如给笔记类工具的用户预置“倾向于本地化存储、重视数据隐私”之类的基础背景。这套模板充当默认角色设定。随着对话推进真实记忆逐渐覆盖默认模板用户就会感觉“这个助手慢慢懂我了”。千万别在冷启动时强行编造记忆一旦方向错了后面纠偏成本很高。9. 一个额外的小技巧用调试模式观察记忆的内部排序最后分享一个我实际项目里的调试技巧。TiMem 启动时如果加 --debug 参数会把每次召回的候选记忆、分数、过滤原因全部打印到终端。我开发阶段基本都开着它看到某条记忆莫名其妙被过滤掉就能立刻回到代码里查规则。建议大家搭完后也用类似方式跑一段时间不要只看最终回答效果。记忆系统是一个数据处理链路链路里每一环的偏差都会在最终效果里放大把中间过程看清楚比在最终结果上反复调参更能解决问题。