LongMemory实战:为AI助手构建跨会话长期记忆层
半年前我起手做一个内部知识助手用户反馈里出现频率最高的一句话是它怎么又忘了用户昨天刚跟我对齐过的工作习惯、刚确定的项目代号、刚说过不想看哪种格式的报告第二天打开新会话一律不记得。这是所有大模型应用都会撞上的老问题LLM 本身是无状态的上下文窗口再大也只是当前会话里的临时白板。我一开始用最土的办法——把完整对话历史拼进 system prompt结果 Token 消耗肉眼可见地上涨回答质量却越来越飘。后来在社区里看到 CaviraOSS 开源的 LongMemory 项目名字直白到不需要解释给 AI 应用补一层长期记忆。下面不打算做源码逐行解析而是把从本地跑通、拆设计、改代码、接生产环境这一个完整周期里沉淀下来的设计思路和实战经验捋一遍适合正在做 Agent、AI 助手或智能客服的开发者参考。1. AI助手的失忆症到底出在哪1.1 会话隔离是架构问题不是调参能解决的从产品形态看对话式 AI 默认把每次会话当成一个独立世界。以兼容 OpenAI 的 Chat Completions 接口为例messages 数组就是一次会话的全部上下文聊完即焚。为了伪造记忆最常见的做法是把历史消息塞进 system message让模型在每次回答前重新读一遍聊天记录。这个方案在小流量、短会话场景下勉强能用一旦会话变长、用户变多问题会成倍放大Token 消耗随历史线性增长长会话单次请求可能吃进几万甚至十几万 Token模型注意力被大量噪声稀释真正重要的偏好和结论反而被淹没跨会话依然无能为力用户换设备、隔天回来历史基本等于清零。我见过不少团队在这个问题上反复横跳今天加长上下文窗口明天做消息摘要压缩后天又回到全量注入。这些本质上都是在会话这个维度里做文章方向就偏了。会话隔离是产品架构层面的设定靠调参改 prompt 是绕不过去的。1.2 记忆的本质把对话过程变成结构化事实LongMemory 的设计起点是把问题重新定义了一遍AI 要记得的不是原始聊天记录而是事实。用户说我平时都用 Python 写数据脚本报告只要 PDF不要 Excel这条消息里真正有用的其实是三个结构化事实——编程语言偏好、报告格式偏好、否定偏好。把这类事实从对话流里抽取出来单独存储下次任何会话只要检索到这条事实就能以自然语言形式注入 prompt让模型想起来。这个视角的转变很关键记忆不是聊天记录的存档而是经过抽取、压缩、清洗之后的结构化知识。这也是判断要不要引入 LongMemory 这类记忆层的分水岭——如果你的应用只需要能翻看历史聊天记录那是另一个需求如果你需要模型真正记得用户的长期偏好和既定事实才需要这样一层中间件。2. LongMemory 的核心设计数据模型与双通道检索2.1 记忆条目长什么样LongMemory 里最小的单位是一条 MemoryRecord我拆源码时第一件事就是把它涉及的字段画出来。核心结构用一张表就能讲清楚字段类型说明memory_idstring记忆唯一 ID写入时生成owner_idstring归属方用户 / Agent / 租户隔离的第一道闸source_session_idstring来源会话 ID可空用于溯源contenttext抽取后的结构化事实文本也就是注入 prompt 的内容embeddingvectorcontent 的向量表示用于语义召回entitiesjson实体列表如人名、项目名、文件名created_atdatetime创建时间last_access_atdatetime最近一次被命中召回的时间access_countinteger被召回次数衡量这条记忆的活跃度statusstringactive / archived / superseded记忆生命周期状态我对 access_count 和 last_access_at 这两个字段尤其偏爱。它们看着不起眼却是后面做记忆权重排序的核心依据。只有 created_at 的话你只能做时间衰减加上访问频率就能区分用户半年前说过但每周都在确认的偏好和前天随口一提的废话。2.2 为什么是向量关系型双通道而不是只靠向量库很多人一听到语义检索就默认上纯向量数据库LongMemory 的检索层用的却是双通道一条走向量语义相似度一条走结构化条件过滤最后汇合打分。原因很实在。先走结构化过滤能把 owner_id、statusactive、时间范围这些硬条件直接从候选集里切掉向量库只负责召回语义相关这能少很多脏数据。反过来如果只靠 SQL 硬匹配我想要轻一点的反应这种口语化表达根本匹配不到偏好简洁回复这条记忆必须靠向量兜底。实际运行中我推荐的配套是主存储用 PostgreSQL 加 pgvector一套库同时搞定关系型数据和向量检索避免维护两套存储的数据同步问题。数据量上到千万级再考虑拆独立向量库也不迟。对绝大多数个人项目和中小团队pgvector 的召回质量和查询性能完全够用。2.3 写入前的三步处理抽取、压缩、去重记忆不是用户每说一句话就存一条那样库会变成垃圾场。LongMemory 在写入前有一套 pipeline核心是三步。第一步是抽取用 LLM 把原始对话转成第三人称的结构化事实。比如用户说以后报告直接发我邮箱吧别用企业微信抽取结果可能是{action: 通知方式偏好, channel: email, 禁止: 企业微信, scope: 报告}。第二步是压缩合并同主题的新事实要和已有记忆合并。比如库里已有报告格式偏好 PDF新抽取结果又出现PDF 格式就不新建记忆条目而是更新原条目的 access_count 和最后确认时间。第三步是去重通过 embedding 相似度和实体重叠度双重判断相似度超过阈值的旧条目直接丢弃或降权。这一步很关键但也最容易误伤后面我会专门讲踩过的坑。3. 一次带记忆的 LLM 调用内部到底发生了什么3.1 写入链路先缓冲再异步落库LongMemory 的写入不是同步的用户发送消息时不会等记忆写入完成才返回回复。我落地时把消息先推到一个内存队列由后台 worker 消费调用抽取模型再验重、落库。核心流程的伪代码如下import asyncio from dataclasses import dataclass dataclass class MemoryRecord: owner_id: str content: str embedding: list[float] source_session_id: str | None None created_at: float 0.0 last_access_at: float 0.0 access_count: int 0 status: str active class MemoryWritePipeline: def __init__(self, llm, store, dedup_threshold0.92): self._queue asyncio.Queue() self._llm llm # 负责抽取和总结的模型 self._store store # pgvector 存储层 self._dedup_threshold dedup_threshold async def enqueue_message(self, owner_id, session_id, user_text): await self._queue.put((owner_id, session_id, user_text)) async def run_worker(self): while True: owner_id, session_id, user_text await self._queue.get() facts await self._llm.extract_facts(user_text) for fact in facts: await self._write_one(owner_id, session_id, fact) async def _write_one(self, owner_id, session_id, fact): # 1. 语义查重 vec await self._llm.embed(fact.content) dup await self._store.find_similar( owner_id, vec, top_k1, thresholdself._dedup_threshold ) if dup: await self._store.touch(dup.id) # 只更新时间和计数 return # 2. 新记忆落库 await self._store.insert(MemoryRecord( owner_idowner_id, contentfact.content, embeddingvec, source_session_idsession_id, ))这里有个细节抽取调用用的模型可以和对话模型不同单次延迟高一点也不用怕因为它在后台跑不影响对话接口的 P95。我生产环境用的是一条独立的抽取 prompt要求模型只输出 JSON 数组每条事实必须满足单主语、单事实约束这样后面合并和去重才做得干净。3.2 读取链路召回、打分、选择每次用户发消息LongMemory 会先把消息向量化然后执行一条混合查询-- 示意pgvector 混合过滤 SELECT memory_id, content, embedding :query_vec AS distance, created_at, last_access_at, access_count FROM memories WHERE owner_id :owner_id AND status active AND created_at now() - interval 180 days ORDER BY embedding :query_vec LIMIT 20;拿到候选集后在代码里做综合打分我用的加权公式大致是score 0.65 * (1 - distance) 0.20 * time_decay(last_access_at) 0.15 * min(access_count / 10, 1)time_decay 是越近权重越高的指数衰减函数。注意纯向量相似度只占 0.65因为语义相关不等于当前有用。用户可能长期聊项目 A最近几天切换到了项目 B项目 A 的记忆在语义上依然和对话高度相关但此刻不该占太多 prompt 空间——时间衰减就是干这个的。最终只取打分最高的前 3~5 条注入 prompt既保证有用信息在场又控制 Token 开销。3.3 记忆注入 prompt 的方式与位置我试过三种注入位置结论是单独放在 user 消息之前、作为一个 Memory Context 段落效果最好。system prompt 里塞动态内容容易把系统指令挤偏直接拼接在 user 消息里又会让模型分不清哪些是记忆、哪些是当前问题。正确做法是在 system prompt 末尾留一个占位段每次请求前动态填入你有一个关于用户的长期记忆以下内容来自历史会话请留意其中隐含的用户偏好与既定事实 - 用户偏好 PDF 格式的报告不需要 Excel 版本 - 项目代号极光对应数据迁移服务不可与其他项目混淆 - 用户通常在每周五下午需要当周数据汇总 请结合以上记忆和当前对话内容作答。还有一个容易被忽略的点记忆注入不应影响多轮对话的即时上下文。当前会话里用户刚说的内容优先级永远最高记忆只是补充背景不能覆盖用户当下明确表达的新指令。所以每次请求我都把记忆段放在最前面让模型在阅读新消息之前先获得背景同时在 prompt 里明确写一句如与当前对话冲突以当前对话为准。4. 生产环境实测检索质量与 Token 成本的平衡4.1 光调 embedding 模型解决不了相关性不等于有用我最初以为检索质量只取决于 embedding 选得好不好结果连换几个 embedding 模型它怎么记得乱七八糟的东西这类反馈并没有消失。原因是 embedding 负责解决语义相关但解决不了这条记忆现在是否重要。用户上周五说过这周先不处理权限模块这周一直在忙接口联调语义检索大概率仍会把权限那条记忆捞出来而它对当前任务已经没有价值。最后是靠两件事解决的一是上面说的综合打分公式特别是 time_decay二是给记忆加生命周期。我在 LongMemory 里加了一个 refresh 机制每次记忆被命中召回时如果内容里的时间描述与当前时间明显冲突worker 会把这条记忆标记为 archived不再参与后续召回。注意这个动作是异步的不能阻塞正常对话响应。4.2 记忆冲突是必然的关键是处理策略用户可能周一说报告用 PDF周三改口还是发 Excel 吧。新旧两条记忆都会存在检索时可能同时命中模型就会懵。LongMemory 的处理思路不是物理删除旧记忆而是状态流转写入新事实时对同主题、同实体的旧记忆打 superseded被取代标记查询只取 statusactive 的最新一条同时把旧记忆保留在归档区方便审计和回溯。判断同主题我用的是实体重叠加向量相似度双重条件只有两者都超过阈值才敢做取代。只靠向量相似度容易误伤——我不再需要每周报告和我每周需要报告的向量距离可能很近但含义完全相反纯语义判断几乎无法区分这类反转表达。4.3 实测效果与关键参数我在内部知识助手上接了一个月A/B 对比了有无 LongMemory 的表现指标无记忆层接入 LongMemory用户重复陈述偏好次数每周约 34 次约 9 次跨会话正确回忆率人工抽检约 31%约 82%单次请求平均 Token 数48203150P95 响应延迟2.8s3.1s有个反直觉的结果单次平均 Token 反而降了。原因是之前为了让模型记住我把整段历史压缩后塞进 prompt起步就是几千 Token接入记忆层后只注入 3~5 条结构化事实prompt 反而瘦身了。P95 涨的那 0.3 秒主要是向量检索和打分排序的耗时可以通过给 embedding 列建 HNSW 索引压回去建完基本回到 2.9s 以内。4.4 三个识别度很高的坑第一个坑是切换 embedding 模型后没有重建向量索引。旧向量和新向量不在同一空间检索结果会突然变差而且这种变差是渐变的很容易被误判成数据写少了。我现在每次换模型都会跑全量重建脚本并在配置里标注当前向量维度方便复查。第二个坑是并发写入导致记忆顺序错乱。多个消息同时进入写入队列抽取任务完成顺序和消息顺序可能不一致导致用户后表达的偏好被当成早期事实旧记忆错误地 superseded 新记忆。解决办法是给队列按 owner_id 分片同一个用户的写入任务保证串行消费。第三个坑是抽取模型过度总结。抽取 prompt 写得太开放时模型会把用户今天心情不错也抽成一条结构化事实存进去。后来我在 prompt 里加了硬性约束只抽取三类信息——明确偏好、明确事实、明确约定其余一律忽略。效果立竿见影新增记忆条目的有效利用率从 42% 提到了 78%。5. 从单用户助手到多 Agent 协作LongMemory 的扩展边界5.1 所有权隔离owner_id 是记忆安全的生命线在我看owner_id 是 LongMemory 里最重要的字段没有之一。它决定了记忆只能被谁读取。多租户或者多 Agent 场景里记忆隔离一旦做错轻则串话重则隐私事故。我落地时做了三层校验存储层在查询 SQL 里强制带 owner_id服务层在读取接口里从鉴权信息解析身份再注入查询条件禁止客户端直接传 owner_idprompt 注入层只取当前请求归属方的记忆。宁可多写一层校验也不在这种地方图省事。多 Agent 场景还有一个额外问题Agent 之间可能需要共享一部分公共记忆比如项目代号字典是所有 Agent 都要遵守的约定。LongMemory 的做法是允许一条记忆挂在 owner_group 下公共记忆属于 group私密记忆属于个人检索时两者都会召回但权限判断先行。5.2 记忆沉淀从原始碎片到周期性总结单条记忆只适合存原子事实面对长周期信息比如最近三个月用户偏好发生了哪些变化需要另跑一个沉淀任务。借鉴业界常见的反思思路我在 LongMemory 上做了一个每天凌晨执行的 batch job拉取该用户当天所有被命中的记忆按实体分组调用模型产出一份每日画像摘要存成一条高权重记忆参与第二天的召回。这个设计解决了一个实际问题长期使用的用户会产生大量零散记忆逐条注入 prompt 根本塞不下。沉淀任务能把几十条碎片压缩成几条画像级别的高价值记忆相当于把原始记忆升级成了人设记忆。开始跑这个任务的第二周用户明显反馈助手变得懂我了。当然代价是每天多一次模型调用成本很小但收益很明显。5.3 可以继续扩展的方向LongMemory 跑顺之后我列了几个想继续推进的方向。一是记忆与 RAG 的分工把记忆层定位成动态高频信息RAG 定位成静态低频知识两者混合召回各管一段。二是记忆的可解释性给每条记忆附上来源会话链接用户点击就能追溯它是在什么上下文里产生的信任度会高很多。三是多模态记忆目前只存文本事实后续打算把图片、语音指令里的偏好也抽取成结构化事实。我个人在实际使用中最深的体会是长期记忆这件事难点从来不在能不能存而在存了之后敢不敢信。LongMemory 的价值不在于存储容量而在于它把记忆做成了一个可以筛选、可以过期、可以被取代的动态系统。这才是 AI 应用从能用走向好用的那层关键差距。如果你也在做 Agent 或 AI 助手建议先把记忆模型画清楚再写代码谁拥有记忆、什么信息值得存、旧信息怎么退场。这三个问题想透了选型就是水到渠成的事。