
当我把一个装配了优秀基座模型的运营助手接进真实的客户服务工单时被问到最频繁的不是“你怎么还不懂这道题”而是“你上次答应过我的事呢”。这是一个典型的记忆问题——模型能力很强但它在对话窗口之外什么都不剩。有同行认为“把更多历史拼进上下文”就是记忆我试过结果只是把下次请求的延迟和账单一起拉高。真正改变项目走向的是一套面向智能体应用的持久记忆层我把它叫作ai-memory将对话中产生的关键信息提炼成画像、事实、偏好与约束规则用向量索引存储起来在每次对话前按需检索注入。这篇文章完整记录我从零搭建这套系统的过程以及生产环境里踩过的坑和走通后的取舍。适合谁看被上下文窗口瓶颈困扰的智能体开发者、想把RAG升级为“会记住你”的记忆组件的同学、以及打算自己搭一套离线记忆服务的技术负责人。1. 为什么智能体越做越像“金鱼”以及记忆系统到底在记什么我最初接手的是一个内部知识库问答助手初期效果不错——回答准确、引用规范。但客户换了一种用法把它当成长期项目助理今天讨论需求明天补充数据口径一周后要求它“按上次说好的逻辑出报表”。这个时候问题集中爆发了助手完全不记得之前确认过什么每次都重新问一遍甚至给出和上周结论相反的方案。用户容忍度迅速下降而研发团队还在不断加长上下文窗口。1.1 “把全部历史塞进提示词”为什么是一条死路很多团队的第一反应是把聊天记录全部转成文本拼到 system prompt 里。这个方案在数据量小的时候勉强能用但稍微放大就会崩。我算过一笔账一次跨两周的复杂项目协作聊天记录累计超过 200k token。就算模型支持 128k 或 200k 的窗口实际生产环境出于成本和延迟考虑通常会限定在 40k 到 60k 以内。全量塞入意味着每次请求都要重新编码一遍完整历史首字延迟从 1 秒变成 5 秒以上token 费用成倍上涨一个每天 5000 次请求的助手仅上下文费用就接近全部算力成本的一半关键是长文本里真正有用的信息密度极低模型注意力会被大量寒暄和噪音稀释反而丢失关键结论。此外还有工程层面的隐患上下文达到上限后要么截断早期信息要么触发滑动窗口策略。后者听起来合理但实际是——如果业务逻辑依赖一条“上周确认的口径”它被滑出去之后模型完全不知道不会主动去查询也不会承认自己忘了。这就是“金鱼现象”的根源模型没有记忆只有一段正在滑动播放的录像带。1.2 记忆系统应该记什么不记什么所以 ai-memory 的第一版设计我先做了记什么和不记什么的边界定义。不是所有对话都值得沉淀也不该把摘要做得又长又全。经过多轮验证我把记忆内容划分为四类类型示例存储形式用户画像用户是市场部同事负责投放预算属性键值可覆盖更新事实结论上周确认使用“按点击量分摊成本”口径带时间戳的命题文本偏好与约束报告里不要出现英文缩写图表用柱状图约束规则带优先级事件轨迹已跟进过的工单编号、曾给出的建议结构化索引可追溯这四类信息有一个共同特征它们不依赖对话上下文也能独立被理解且对后续决策有稳定影响。相反随口寒暄、临时数据明细、情绪表达都不进入长期记忆。一开始我也舍不得丢后来发现这些噪音会直接污染检索质量让向量检索召回一些看似相似但毫无价值的片段。所以清理是必要的记忆系统不是黑匣子录音机它更像人的长期记忆——只留下对行为有持续影响的部分。1.3 对“遗忘”也要做设计明确了记什么之后我突然意识到一个反直觉的点遗忘机制和记忆机制同等重要。真实场景里用户的偏好会变项目口径会调整曾经正确的结论在新背景下可能不再成立。如果记忆系统只写不改就会变成一本过时的档案册AI 引用的都是错误前提。因此 ai-memory 从架构层面设计了“可修改、可失效、可淘汰”的机制。每条记忆都不作为不可变事实直接落库而是保留一个生命周期状态当新的对话信息与旧记忆冲突时不是简单覆盖旧记录而是触发一次“冲突裁决”。这个细节给后期生产稳定性帮了大忙后面第 5 部分会完整展开。2. 最小可用的记忆回路从 SQLite 和嵌入模型搭出第一个版本不要一开始就上重型向量数据库。第一版我刻意控制依赖数量用两个库就打通了完整回路sqlite-vec做向量索引OpenAI 的text-embedding-3-small做文本向量化。运行了大概三周足够验证流程本身是否合理。2.1 核心数据结构设计我用四张表组织记忆系统另加一张关联表维护记忆与原始对话片段的溯源关系CREATE TABLE memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type TEXT NOT NULL, -- profile / fact / preference / event content TEXT NOT NULL, status TEXT DEFAULT active, -- active / pending / deprecated priority REAL DEFAULT 1.0, source_turn_id TEXT, created_at INTEGER, last_access_at INTEGER, access_count INTEGER DEFAULT 0, last_verified_at INTEGER ); CREATE TABLE memory_embeddings ( memory_id TEXT PRIMARY KEY, embedding BLOB NOT NULL, model_name TEXT NOT NULL, updated_at INTEGER ); CREATE TABLE turns ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER );关键设计在于source_turn_id——每条记忆都能追溯到具体产生它的对话轮次。这给后续做“信息漂移检测”留了后路没有这层溯源后面遇到多轮摘要导致的信息失真时基本无从排查。last_verified_at字段当时还被组内同事认为是多余的结果后来成了清理陈旧记忆的关键开关。2.2 记忆回路的四个步骤整个回路分四步裁剪、向量化、存储、检索。这里给核心代码骨架这是第一版就跑通的最小实现class MemoryService: def __init__(self, embed_modeltext-embedding-3-small): self.db sqlite3.connect(memory.db) self.db.enable_load_extension(True) self.db.load_extension(vec0) self.embed_model embed_model def remember(self, user_id: str, turn_batch: list[dict]): 写回把一批对话轮次沉淀为记忆条目 candidates self._extract_memories(turn_batch) for item in candidates: embedding self._embed(item[content]) mem_id uuid.uuid4().hex self.db.execute( INSERT INTO memories VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (mem_id, user_id, item[type], item[content], active, 1.0, item[source_turn_id], int(time.time()), 0, 0, int(time.time())) ) self.db.execute( INSERT INTO memory_embeddings VALUES (?, ?, ?, ?), (mem_id, embedding, self.embed_model, int(time.time())) ) def recall(self, user_id: str, query: str, top_k: int 10): 检索向量化查询从索引里取回候选集 q_emb self._embed(query) rows self.db.execute( SELECT m.id, m.content, m.type, m.priority, vec_distance_L2(e.embedding, ?) AS distance FROM memories m JOIN memory_embeddings e ON m.id e.memory_id WHERE m.user_id ? AND m.status active ORDER BY distance ASC LIMIT ? , (q_emb, user_id, top_k) ).fetchall() return [{id: r[0], content: r[1], type: r[2], score: 1.0 / (1.0 r[4])} for r in rows]_extract_memories是核心它本质上是一个“记忆提炼器”输入一批对话轮次输出结构化记忆候选。第一版我用模型来实现提示词很简单核心约束是——只抽取可独立理解、可被检索、对后续对话有帮助的信息。不要抽取感叹句、不要抽取临时性数据、避免主观评价。2.3 为什么第一版就用 SQLite 而不是 Milvus当时团队有人建议一步到位上 Milvus我坚持先用sqlite-vec因为验证阶段最大的风险不是检索性能而是记忆流程本身是否成立。几十万条以下的数据规模SQLite 加向量扩展完全够用而引入重型分布式数据库意味着运维成本、部署复杂度、网络调优负担同时上来反而干扰对核心逻辑的判断。而且用 SQLite 跑通有利于排查所有表都是普通数据库表可以直接配合 SQL 做数据审计一条记忆被检索多少次、有没有被写回、状态对不对全部透明可见。这在调试阶段完全对冲掉了它的性能上限。实践证明第一版的瓶颈从来不在查询速度而在“写回的内容质量差”和“检索排序不合理”这是用任何重型数据库都无法解决的。3. 真正决定记忆质量的是写回策略而不是向量库很多参考资料一上来就讲向量相似度、HNSW 参数、ANN 召回率但我的切身体会是记忆系统的上限八成由写回策略决定。所谓写回就是把对话过程中散落的信息凝练成可检索的记忆条目的过程。写得太碎存储迅速膨胀且检索噪音大增写得过粗单条记忆过于泛化召回时根本不解决具体问题。3.1 时序钩子与主题钩子第一版我使用“每 10 轮对话写回一次”的固定策略效果很差。高频场景下大量中间状态被写入低频场景下关键结论可能要拖到很晚才落库。后来我改成了双钩子触发时间钩子距离上次写回超过 30 分钟强制进行一次写回主题钩子检测到对话主题发生明显切换时立即写回例如用户从“讨论部署方案”跳到“写周报”中间的关键结论必须先固化下来避免后续上下文被新主题覆盖。主题钩子的实现并不复杂不需要专业的主题分类模型只需要对连续几轮对话的向量做一次平均再计算新出现内容与当前主题质心的余弦距离超过阈值就视为主题切换。这个方法实测下来既便宜又稳定。3.2 摘要长度和记忆条目粒度的平衡写回时另一个常见误区是追求全面。一次写回恨不得把 20 轮对话所有信息浓缩成一整段几百字的摘要结果产生了一条“既像什么又不像什么”的记忆。检索时即使命中了它它也无法直接回答具体问题。我最终采用的策略是把一次写回的对话窗口拆成若干个“语义块”每块独立判断是否值得沉淀每条记忆控制在 30 到 60 个字之间表达一个完整且单一的事实如果同一事实出现在多个语义块中合并为一条并更新最后确认时间。举个例子。某次对话用户先后提到“我预算上限是 10 万”“优先投放搜索引擎渠道”“不要太依赖信息流广告”系统会产出三条独立记忆而不是一条 300 字的会议纪要。这样后续用户问“渠道偏好”和问“预算上限”时检索到的都是高度相关的单点记忆回答精确度明显提升。3.3 原始片段溯源防止多轮写回造成的“信息熵增”多轮写回有一个隐藏杀手信息漂移。每一轮摘要都可能在压缩过程中丢掉细节、改变措辞经过三轮写回后最终保存的内容可能和最初的事实大相径庭。如果没有原始溯源这个问题永远发现不了。我在表结构里加上source_turn_id之后就开始对每条记忆维护一条溯源链从最初的对话轮次到第一次提炼出的记忆文本再到后续每次更新的中间版本。一旦发现某条记忆在多次更新后语义偏移明显就用原始片断重新做一次提炼回滚错误。这个机制在长期运行中非常值钱尤其是处理偏好类记忆因为用户偏好本身就容易变并不是所有变化都是漂移需要一个可对比的基线。4. 从相似度到可用检索排序必须叠加时间衰减与冲突裁决向量检索只解决“找出语义相近的候选”不解决“哪些记忆此刻真正有用”。我踩过最典型的坑用户一周前说过“预算 10 万”昨天改口“预算提高到 20 万”但向量检索时旧事实和新事实语义都很接近旧记忆因为历史访问次数更多、排序更靠前被注入到了上下文中助手因此给出了完全过时的信息。4.1 排序公式不只是语义分数为此我基于语义相似度、时间衰减、访问频率和置信度四项指标重新设计了排序分数final_score semantic_score * 0.55 recency_bonus * 0.25 frequency_bonus * 0.10 confidence_bonus * 0.10其中recency_bonus基于last_access_at和last_verified_at计算。具体规则是距离当前时间 24 小时以内保持满分超过 24 小时每多 7 天衰减 20%超过 90 天继续衰减到下限 0.1。frequency_bonus则避免冷门记忆永不出现——访问次数高的记忆说明是常用事实可以给予排序加成。这套公式直接替代了原先“只按向量距离排序”的逻辑。它不追求极致的相关性而是追求“在特定时刻最有用的记忆”。比如用户只在一个月前提过一次的“差旅报销上限”平时基本不需要但如果今天对话刚好涉及出差安排向量相似度已经把这条召回上来了这时候时间衰减会让它顺利排进前三。4.2 对待冲突记忆不直接覆盖而是裁决记忆冲突是生产环境最棘手的问题。用户昨天说“我不太用微博”今天说“微博还是得发”。如果系统简单覆盖旧条目失去的是“原本偏好”的历史信息如果保留两条检索时又会同时返回互相矛盾的内容。我的方案是这个写回阶段检测到与现有记忆语义冲突时不直接覆盖而是进入一个“冲突裁决”流程。先由提取模型做一次判断规则如下如果新信息明确表示取代旧信息如“不用微博改成用小红书”保留新条目旧条目标记为 deprecated如果新旧是互补关系如“日常不刷微博但工作需要运营微博”合并为一条更精确的记忆如果新信息只是一次性特例如“这周不推送微博内容”不改变长期记忆只更新短期会话状态。裁决结果都会保留在一条memory_changes审计日志里方便复盘系统为什么做出这样的调整。这个机制运行第一周就避免了至少三次明显的错误回复。4.3 召回后的二次过滤即使排序公式已经生效我仍然建议对 topK 召回结果做一次 LLM 二次过滤。这一步的本质是“让模型判断这些记忆对当前问题是否真的有用”因为向量相似度判不了“有用性”。比如检索“项目进度”时召回了一段关于“项目命名规则”的讨论语义相似但不构成有效上下文。做法很简单把当前问题、候选记忆列表、以及一段系统指令一起送入模型让它剔除与当前任务无关的记忆保留最相关的 3 到 5 条。代价是一次额外的模型调用增加约 50 到 120 毫秒延迟但对最终回答质量的提升非常明显。如果对延迟敏感可以只在候选数大于 8 时启用该步骤。5. 生产环境里最值得复盘的三类记忆问题5.1 新鲜记忆反复“复活”现象某条记忆明明被新信息取代了比如用户把预算从 10 万改成 20 万但之后几天的对话里系统仍然经常检索到 10 万这条旧记忆。我一度以为是冲突裁决逻辑的问题查了发现裁决确实已经把旧条目标记为 deprecated。真正的根因在检索链路召回时带了status active条件做过滤但内部排序时“访问频次加权”对旧记忆做了补偿。旧记忆历史上访问次数多frequency_bonus偏高即使被标记为 deprecated前一轮如果没把过滤条件执行干净它还是能混进候选集。修复方案是在 SQL 层把状态过滤前置并且在deprecated状态下即使向量完全匹配也直接排除。这个坑提醒我——记忆系统的数据质量不仅取决于写入逻辑也取决于查询链路里每一个过滤条件是否足够严格。5.2 陈旧记忆占用上下文空间另一个高频问题是大量长期未被访问的记忆在检索时因为时间衰减系数已经很低但仍可能进入 topK占用了宝贵的上下文空间。与其让它们挤占候选取不如让它们进入“冷宫”。我的做法是定期扫描access_count和last_access_at超过 90 天从未被验证过的记忆会暂时从检索范围移除只保留在归档表里。这里关键一点不是删除而是离线存放。谁知道某个季度性业务会突然需要一条半年前的事实呢5.3 用户画像的“标签膨胀”用户画像型记忆如果按属性键值存储时间一长会出现严重膨胀。一个活跃用户经过三个月使用可能积累了上百个标签其中一半已经不再适用。如果不做清理检索时这些旧标签会被当作画像的一部分注入 prompt导致助手认为自己面对的客户和真实情况完全不匹配。我的解法是画像记忆单独建表每个属性有confidence与confirmed_at字段一套规则自动设置属性的有效期限——身份类属性最长两年偏好类属性最长三个月临时场景类属性最长一周。接近过期时系统会发起一次主动询问或等待用户提及新信息完成续期。这比无限期保存所有画像属性可靠得多。6. 评测与选型如何判断记忆系统真的“记住”了用户记忆系统的好坏不能靠感觉评估。没有量化指标你根本不知道调的是代码还是玄学。我推进了三套评测方案由易到难都可以直接复用。6.1 离线事实注入测试最低成本的基线评测这是我最先采用的方案人工构造 100 条“事实”每条都具备可查询性例如“用户所在城市是杭州”“用户偏好周报用柱状图”。把这些事实写入系统再用 50 条改写后的查询去召回看 topK 中能否命中对应记忆。评价指标用 Hit5 和 Hit10目标分别是 0.75 和 0.9。这套方案的优点是快周期不到一天适合任何模型和向量库选型的初步判断。缺点是它测不出真实对话的复杂上下文干扰。所以我把这关当作“准入线”过不了线的系统直接淘汰。6.2 多轮对抗测试向记忆系统“发起矛盾”第二步我设计了一组对抗测试。做法是在一轮对话中先后输入互相矛盾的信息例如先输入“预算上限 10 万”隔几条再输入“预算上限 20 万”然后查询系统给出的记忆版本。正确结果应该是系统最终保留新信息并能合理说明旧信息的废弃原因。这个测试暴露了很多问题。最初版本直接覆盖时模型在后续对话中引用旧预算的次数仍然偏多。加入冲突裁决后测试通过率从 62% 提升到 94%。剩下 6% 的失败主要发生在信息表达不够明确的场景比如用户没有说“改成”只是暗示性地提到新数字提取器没有触发冲突裁决。这个测试我强烈建议任何做记忆系统的团队都跑一跑你会发现“记忆一致性”远比想象中难做到。6.3 生产日志回归测试以真实数据持续监控维护期最关键是建立回归集。每周从生产日志中抽取 200 条真实对话人工标注其中应该被记住的关键信息然后在新版本代码上跑一遍召回对比版本间指标变化。这里的指标不只包括召回率还包括“错误记忆注入率”——即检索结果中与当前对话无关或被判定为过时的记忆占比。这个比例高于 15% 时系统体验就会出现明显退化。实话说这套回归机制在初期被团队忽视直到一次升级为了优化 20% 的召回率结果引入了一批低质量记忆错误注入率从 9% 飙升到 23%线上回复风格变得非常怪异。没有回归测试这类问题很难被快速定位。6.4 向量库与嵌入模型的替换路线参考如果你看完前面内容决定自己动手选型时可以参考我实测后的总结阶段存储方案嵌入模型适用规模原型验证SQLite sqlite-vectext-embedding-3-small十万级以下生产初版Qdrant 单机BGE-m3 / bge-large-zh百万级以下规模化部署Milvus 或 Qdrant 集群Cohere embed v3 / 自训练模型千万级以上之所以最后建议考虑专用集群一个重要原因是当记忆条目接近百万时索引构建、增量更新和向量检索的资源竞争会开始影响业务主链路的稳定性拆分部署能有效隔离风险。嵌入模型方面BGE-m3 在中文场景效果出色而且能本地部署数据不出内网对很多团队是刚需。7. 我在实际运行中的一些体会做完这套系统再回头看最大的感悟是——记忆系统不是一个检索模块它是一个需要持续维护的数据产品。写回策略决定数据质量冲突裁决决定数据一致性淘汰机制决定数据新鲜度这三者比向量库选型重要得多。很多文章把 RAG 和记忆混为一谈但从实践看记忆系统比通用 RAG 复杂在“写”和“改”这两个环节RAG 的文档基本是静态的而记忆每时每刻都在产生、冲突、更新、过期。你在设计时就要把这些动态都考虑进去。如果你准备做类似的项目我建议第一周只做一件事把写回流程和冲突裁决跑通甚至不用接真正的向量库用枚举扫描就能验证逻辑。等到写回质量稳定了再考虑大规模索引和检索优化。顺序反了后面大概率要推翻重来。我自己就是从 SQLite 起步一步步走到集群部署每一步都集中在验证核心逻辑上而不是提前优化性能。这条路走下来系统复杂度可控问题排查也快。如果你的智能体也面临“金鱼化”的问题ai-memory 这套思路值得一试。