LongMemory实战:为AI助手构建跨会话长期记忆层
2026/9/6 10:08:43 网站建设 项目流程

半年前我起手做一个内部知识助手,用户反馈里出现频率最高的一句话是:"它怎么又忘了?"用户昨天刚跟我对齐过的工作习惯、刚确定的项目代号、刚说过不想看哪种格式的报告,第二天打开新会话,一律不记得。这是所有大模型应用都会撞上的老问题: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、status='active'、时间范围这些硬条件直接从候选集里切掉,向量库只负责召回"语义相关",这能少很多脏数据。反过来,如果只靠 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_threshold=0.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_k=1, threshold=self._dedup_threshold ) if dup: await self._store.touch(dup.id) # 只更新时间和计数 return # 2. 新记忆落库 await self._store.insert(MemoryRecord( owner_id=owner_id, content=fact.content, embedding=vec, source_session_id=session_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(被取代)标记,查询只取 status='active' 的最新一条,同时把旧记忆保留在归档区,方便审计和回溯。

判断"同主题"我用的是实体重叠加向量相似度双重条件,只有两者都超过阈值才敢做取代。只靠向量相似度容易误伤——"我不再需要每周报告"和"我每周需要报告"的向量距离可能很近,但含义完全相反,纯语义判断几乎无法区分这类反转表达。

4.3 实测效果与关键参数

我在内部知识助手上接了一个月,A/B 对比了有无 LongMemory 的表现:

指标无记忆层接入 LongMemory
用户重复陈述偏好次数(每周)约 34 次约 9 次
跨会话正确回忆率(人工抽检)约 31%约 82%
单次请求平均 Token 数48203150
P95 响应延迟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_id;prompt 注入层只取当前请求归属方的记忆。宁可多写一层校验,也不在这种地方图省事。

多 Agent 场景还有一个额外问题:Agent 之间可能需要共享一部分公共记忆,比如"项目代号字典"是所有 Agent 都要遵守的约定。LongMemory 的做法是允许一条记忆挂在 owner_group 下,公共记忆属于 group,私密记忆属于个人,检索时两者都会召回,但权限判断先行。

5.2 记忆沉淀:从原始碎片到周期性总结

单条记忆只适合存原子事实,面对长周期信息(比如"最近三个月用户偏好发生了哪些变化")需要另跑一个沉淀任务。借鉴业界常见的反思思路,我在 LongMemory 上做了一个每天凌晨执行的 batch job:拉取该用户当天所有被命中的记忆,按实体分组,调用模型产出一份"每日画像摘要",存成一条高权重记忆,参与第二天的召回。

这个设计解决了一个实际问题:长期使用的用户会产生大量零散记忆,逐条注入 prompt 根本塞不下。沉淀任务能把几十条碎片压缩成几条画像级别的高价值记忆,相当于把"原始记忆"升级成了"人设记忆"。开始跑这个任务的第二周,用户明显反馈"助手变得懂我了"。当然代价是每天多一次模型调用,成本很小,但收益很明显。

5.3 可以继续扩展的方向

LongMemory 跑顺之后,我列了几个想继续推进的方向。一是记忆与 RAG 的分工:把记忆层定位成"动态高频信息",RAG 定位成"静态低频知识",两者混合召回,各管一段。二是记忆的可解释性:给每条记忆附上来源会话链接,用户点击就能追溯它是在什么上下文里产生的,信任度会高很多。三是多模态记忆:目前只存文本事实,后续打算把图片、语音指令里的偏好也抽取成结构化事实。

我个人在实际使用中最深的体会是:长期记忆这件事,难点从来不在"能不能存",而在"存了之后敢不敢信"。LongMemory 的价值不在于存储容量,而在于它把记忆做成了一个可以筛选、可以过期、可以被取代的动态系统。这才是 AI 应用从"能用"走向"好用"的那层关键差距。如果你也在做 Agent 或 AI 助手,建议先把记忆模型画清楚再写代码:谁拥有记忆、什么信息值得存、旧信息怎么退场。这三个问题想透了,选型就是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询