Augmenting Long-Term Memory(增强长期记忆)是当前大模型应用落地时绕不开的问题。很多团队把大模型接入业务后,很快会发现模型并不记得用户上次提到过的细节,每次对话都像第一次见面。背后的原因并不复杂:大模型的上下文窗口再大,也只是一个有限的工作内存;当会话变长、信息积累到一定程度后,旧的记忆会被挤出窗口,模型只能依赖用户重新描述。于是,如何给模型叠加一层长期记忆,就成了从“能用”到“好用”的关键工程问题。
这篇文章会围绕长期记忆的写入、存储、检索、遗忘四条主线,先解释为什么需要这套机制,再搭建一个最小可运行的记忆增强系统,最后讨论实际项目中更关心的参数配置、生产化改造和问题排查。无论你是做大模型应用开发,还是正在设计智能助手、客服系统或个性化 Agent,这套思路都可以复用。
1. 长期记忆为什么是大模型应用的一个真实短板
1.1 从“上下文窗口”到“永久记忆”之间的距离
可以把上下文窗口理解成一张临时草稿纸。模型在一轮对话中能读到的内容都写在这张纸上,纸的大小由窗口长度决定,用完就翻页。长期记忆则相当于一本可以随时查阅的笔记本。模型本身没有主动把信息抄进笔记本的能力,它只能在应用系统的协助下,通过外部存储和检索把相关片段再次放到草稿纸上。
所以,增强长期记忆并不是改变大模型内部参数,而是在模型外部构建一层可持久化的记忆系统,并在生成前把检索到的记忆注入上下文。这里最容易误解的地方是:模型最终记住什么,取决于应用把什么放进 prompt,而不是模型自己记住了什么。只要应用层不提供记忆检索,模型就永远只能依赖窗口里的临时信息。
1.2 长期记忆需要解决的四个核心问题
设计长期记忆系统时,可以先拆成四个问题,逐个解决。
第一是写入问题。用户说过的每一句话并不都值得保存,系统需要判断哪些信息是稳定偏好、哪些是临时状态、哪些只是当前任务的中间表达。写入过于激进会造成记忆污染,写入过于保守又会导致记忆不足。
第二是更新问题。用户的信息会变化,例如居住城市、工作状态、兴趣偏好。旧记忆如果一直存在,就会在检索时给出过期信息。系统需要支持替换、合并或标记旧记忆。
第三是检索问题。用户不会每次都用相同的关键词表达需求,系统需要基于语义相似度去召回相关记忆,而不是像传统数据库一样只做精确匹配。
第四是遗忘问题。长期记忆不等于永久保存。过期的验证码、一次性的临时任务、用户主动要求删除的内容,都要有合理的清理机制。
注意:长期记忆不是越大越好。真正有价值的记忆是被准确写入、能在正确时机被召回、并且不会干扰当前决策的那部分信息。
1.3 为什么现在才被当成工程问题
大模型自身没有跨会话持久化能力,这是由架构决定的。过去很多智能客服都使用“人工提取用户画像 + 规则注入”的方式,虽然能工作,但维护成本很高。向量数据库和文本嵌入模型的成熟,让“把自然语言记忆存下来,再用自然语言查询”这件事变成了通用基础设施。
因此,长期记忆已经不只是算法问题,而是工程问题:向量库怎么选、索引怎么维护、记忆怎么去重、用户隔离怎么做、过期数据怎么清理、召回质量怎么监控。这些问题会直接影响用户体验,也决定了一个记忆系统能不能真正上线。
2. 长期记忆系统的核心架构
2.1 记忆不是数据库,而是“写入-存储-检索-增强”管线
一个长期记忆系统通常不是单点组件,而是一条数据处理管线。原始的用户对话和历史行为会先经过记忆抽取,变成结构化的记忆条目;然后经过向量化,存入向量数据库;当用户发起新请求时,系统用用户问题去检索相关记忆,再把记忆放进 prompt 中交给大模型生成。
用户输入/事件 -> 记忆抽取 -> 文本规范化 -> 向量化 -> 写入向量库 用户提问 -> 语义检索 -> 合并记忆 -> 构造 prompt -> 大模型生成这条链路中,每一步都可能成为问题点。很多项目上线后出现“记忆不生效”,往往不是向量库的问题,而是写入端根本没有生成高质量记忆条目,或者检索端没有正确过滤用户 ID。
2.2 记忆类型的划分
从长期记忆的内容和使用方式来看,可以分成四类。不同类型的记忆,保存方式和召回时机不一样。
| 记忆类型 | 内容举例 | 存储方式 | 典型使用方式 |
|---|---|---|---|
| 短期记忆 | 当前会话前几轮对话 | 对话上下文窗口 | 每轮全量放入 prompt |
| 情景记忆 | 上周用户咨询过退款流程 | 向量库 + 摘要文本 | 按语义相似度召回 |
| 语义记忆 | 用户偏好、身份信息、稳定事实 | 向量库或键值存储 | 用户提问时自动注入 |
| 过程记忆 | 某类任务的执行步骤或业务规则 | 结构化规则或少量示例 | 触发对应流程时使用 |
这四类不是完全独立。实战中,短期记忆会随着对话轮次增加逐渐被压缩成情景记忆;语义记忆可以从多次情景记忆中归纳出来;过程记忆则更接近系统配置,通常不会随用户输入频繁变化。
2.3 关键模块:抽取、存储、检索、遗忘
抽取模块负责把原始对话转成记忆条目。它可以是规则模板,也可以由大模型生成摘要。推荐的做法是:先定义记忆条目的字段,再用大模型按照固定结构输出 JSON,避免自由文本堆积。
存储模块需要同时保存向量、原始文本和元数据。向量用于相似度检索,原始文本用于拼进 prompt,元数据用于用户隔离、时间过滤、记忆类型过滤。
检索模块负责把用户问题转换成向量,然后从向量库中召回最相关的记忆。检索结果必须结合用户 ID 做过滤,否则会出现跨用户串记忆的严重问题。
遗忘模块负责过期数据的清理。它包含两种形式:一种是主动删除,用户明确要求删除某条记忆时必须真正删掉;另一种是被动过期,对有时间属性的记忆设置有效期,超过有效期后不再召回。
3. 从零搭建最小长期记忆系统
下面用一个最小示例跑通“写入-检索-增强”这条链路。出于演示目的,技术选型使用 Python、SentenceTransformer 和 Chroma。实际项目可以根据团队技术栈替换,但整体思路是一致的。
3.1 环境准备和依赖安装
建议使用 Python 3.10 以上版本,并单独创建虚拟环境。示例依赖如下:
pip install chromadb sentence-transformers numpy安装完成后,可以快速验证依赖是否可用:
python -c "import chromadb, sentence_transformers; print('ok')"如果本机没有 GPU,SentenceTransformer 会自动使用 CPU 运行,示例规模下足够。
这里要注意:生产环境不要在本机直接加载一次 embedding 模型就跑服务,应当把 embedding 计算封装成独立服务或使用团队已有的模型服务,方便统一版本和监控。
3.2 记忆条目数据结构设计
在设计存储结构前,先定义一条记忆在应用层长什么样。下面的MemoryItem用 dataclass 表示一条记忆的核心字段。
import time from dataclasses import dataclass, field from typing import Dict, Optional @dataclass class MemoryItem: memory_id: str user_id: str content: str memory_type: str = "semantic" metadata: Dict[str, str] = field(default_factory=dict) created_at: float = field(default_factory=time.time) last_accessed_at: float = field(default_factory=time.time) access_count: int = 0 expired_at: Optional[float] = None关键字段的含义是:
memory_id:全局唯一标识,用于更新和删除。user_id:用户标识,所有检索都必须带上这个条件。content:真正要注入 prompt 的自然语言文本。memory_type:标记是语义记忆还是情景记忆,便于后续过滤。metadata:扩展字段,可以存放来源场景、业务标签等。expired_at:过期时间戳,用于遗忘策略。
这个结构不是固定标准,但建议所有记忆条目至少拥有 ID、用户 ID、内容和时间戳。否则后续做去重、删除和过期清理都会很困难。
3.3 文本向量化封装
长期记忆检索依赖向量化,这里使用一个本地 embedding 模型生成向量。
from sentence_transformers import SentenceTransformer EMBEDDING_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME) def embed_texts(texts): return embedding_model.encode(texts, normalize_embeddings=True).tolist()normalize_embeddings=True会对向量做归一化处理,这样使用余弦距离时计算更稳定。向量维度由模型决定,示例中的模型输出 384 维向量。如果更换模型,必须重新创建向量集合,否则会出现维度不匹配的报错。
3.4 初始化向量库并写入记忆
使用 Chroma 的本地持久化模式,把数据保存在./memory_db目录下。
import chromadb chroma_client = chromadb.PersistentClient(path="./memory_db") collection = chroma_client.get_or_create_collection( name="user_memory", metadata={"hnsw:space": "cosine"} ) def add_memory(item: MemoryItem): vector = embed_texts([item.content])[0] collection.add( ids=[item.memory_id], embeddings=[vector], documents=[item.content], metadatas=[{ "user_id": item.user_id, "memory_type": item.memory_type, "created_at": item.created_at, **item.metadata, }] )写入后,memory_id就是后续检索、更新和删除的入口。这里有个容易出错的地方:Chroma 要求 metadata 中的所有 value 必须是可序列化的基础类型。如果传入自定义对象或嵌套结构,会报错。因此需要将复杂对象提前压平再存入 metadata。
3.5 检索记忆并增强 prompt
检索时,先把用户问题转成向量,再调用collection.query。这里必须用where={"user_id": user_id}做用户隔离。
def retrieve_memories(query: str, user_id: str, top_k: int = 3): query_vector = embed_texts([query])[0] result = collection.query( query_embeddings=[query_vector], n_results=top_k, where={"user_id": user_id}, include=["documents", "metadatas", "distances"] ) return result def build_prompt(query: str, memories, chat_history: str = ""): docs = memories["documents"][0] if memories["documents"] else [] memory_text = "\n".join(f"- {doc}" for doc in docs) system_prompt = ( "你是用户助手。请优先使用长期记忆回答;" "如果记忆不足,请直接说明,不要编造。\n\n" f"长期记忆:\n{memory_text if memory_text else '暂无有效记忆'}\n" ) return system_prompt, querytop_k控制召回数量,示例里取 3。如果设置过大,会把不相关的记忆也塞进 prompt;如果设置过小,可能丢失关键信息。后面会单独讨论这个参数的取舍。
3.6 更新和删除记忆
用户信息变化时,不能只新增一条新记忆,应该更新原记忆,避免同一事实存在冲突版本。
def update_memory(memory_id: str, new_content: str): collection.update( ids=[memory_id], documents=[new_content], embeddings=[embed_texts([new_content])[0]] ) def delete_memory(memory_id: str): collection.delete(ids=[memory_id])这里必须注意:update只会修改传入的字段。如果只传 documents 和 embeddings,metadata 不会改变。如果新记忆变更了用户 ID 或记忆类型,需要显式传入新的 metadata。
4. 关键参数和配置说明
4.1 embedding 模型和向量维度
embedding 模型是整个长期记忆检索质量的地基。维度越高,表达能力不一定越强,但存储和检索成本会明显上升。以常见模型举例:
| 模型示例 | 输出维度 | 适用场景 |
|---|---|---|
| all-MiniLM-L6-v2 | 384 | 资源有限、中英文混合、快速验证 |
| bge-small-zh | 512 左右 | 中文场景为主 |
| text-embedding-3-small | 1536 | 调用外部 embedding 服务时 |
| text-embedding-3-large | 3072 | 对检索精度要求高、预算充足 |
如果使用外部 embedding 接口,还要额外考虑网络延迟、限流和成本。实际项目中,建议先拿业务语料离线评测:随机抽一批用户问题,分别用不同模型召回,对比相关结果的比例。
4.2 top_k 和距离阈值
top_k不是越大越好。它的作用是控制进入 prompt 的记忆条数。可以把top_k理解为“每轮最多携带多少条记忆”。如果用户问题很明确,3 到 5 条通常足够;如果问题很宽泛,比如“帮我总结一下最近的情况”,则需要更多记忆。
距离阈值用于过滤低质量召回。Chroma 默认使用 L2 距离或余弦距离,具体取决于集合配置。检索结果中会返回distances,通过观察正常命中的距离分布,可以确定一个合理阈值。
| 参数 | 调小 | 调大 |
|---|---|---|
| top_k | 结果更精准,但可能漏掉关键记忆 | 覆盖更全,但冗余信息多、token 成本上升 |
| 距离阈值 | 召回变少,更适合高精度场景 | 召回变多,更容易混入不相关内容 |
建议初始阶段先关闭阈值,只靠top_k控制数量,观察一段时间的召回日志后再决定是否加阈值。
4.3 用户隔离和元数据过滤
多用户环境下,用户隔离必须放在检索条件里。示例中where={"user_id": user_id}是硬性过滤条件。如果忘记写,就会出现一个用户能看到另一个用户记忆的严重问题。
元数据过滤还可以扩展为组合条件。例如只召回某个时间范围内的记忆:
where={ "user_id": user_id, "memory_type": "semantic", }不同向量数据库的过滤语法不完全相同,但设计思路一致:尽量把高频过滤条件放进 metadata,用数据库索引加速,而不是在召回后再用应用代码去过滤。
4.4 持久化和存储容量
Chroma 本地持久化适合学习和小规模验证。生产环境需要评估集中式向量数据库或云服务,原因有三个:支持并发读写、便于多实例共享、提供更完善的备份和权限体系。
| 存储方式 | 优点 | 限制 |
|---|---|---|
| 本地内存 | 启动快、实现简单 | 服务重启丢失、不可多实例 |
| 本地文件持久化 | 简单、可重启恢复 | 单机容量有限、运维困难 |
| 集中式向量数据库 | 可扩展、可监控、支持高并发 | 运维成本高、需要适配 |
| 关系型数据库 + 向量插件 | 复用现有数据库体系 | 向量检索能力可能不如专业引擎 |
取舍时不要只盯着检索速度,还要考虑数据备份、权限控制、删除合规和容量规划。记忆数据一旦丢失,比模型回复慢一点更麻烦。
5. 运行验证与结果分析
5.1 最小验证脚本
写完核心函数后,用一个最小脚本验证完整链路。下面是先写入三条记忆,再检索“用户平时怎么上班?”的示例。
add_memory(MemoryItem( memory_id="mem-001", user_id="u-1001", content="用户住在杭州,工作日通勤使用地铁,最近在准备 Java 面试。", memory_type="semantic", )) add_memory(MemoryItem( memory_id="mem-002", user_id="u-1001", content="用户偏好简洁直接的回答,不喜欢太长段落。", memory_type="semantic", )) add_memory(MemoryItem( memory_id="mem-003", user_id="u-1002", content="用户喜欢晚上跑步,每次跑 5 公里。", memory_type="semantic", )) result = retrieve_memories("用户平时怎么上班?", user_id="u-1001", top_k=2) print(result["ids"]) print(result["documents"]) print(result["distances"])因为只创建了一个u-1001用户,u-1002的记忆不应该被召回。如果返回结果中出现了“喜欢晚上跑步”这条内容,就说明用户隔离没有生效。
5.2 预期输出示例
正常运行时,输出可能如下:
[['mem-001', 'mem-002']] [['用户住在杭州,工作日通勤使用地铁,最近在准备 Java 面试。', '用户偏好简洁直接的回答,不喜欢太长段落。']] [[0.21, 0.58]]distances越小表示相似度越高。第一条记忆与查询高度相关,第二条记忆虽然包含了“用户偏好”,但和“怎么上班”的语义距离较大。这里就能看出top_k带来的问题:它会强行返回足够数量的结果,即使后几条并不相关。
验证时不要只看是否有结果,还要看返回结果的顺序和距离。如果第一条结果明显不相关,需要检查 query 的表述、embedding 模型、或者是写入时的内容质量。
5.3 常见失败模式
第一个常见失败是“检索为空”。可能原因是集合为空、用户 ID 不匹配、query 向量维度与索引不一致。先打印collection.count()和传入的user_id。
第二个常见失败是“检索到了记忆,但模型没有用”。这通常是 prompt 结构问题。记忆被放在很靠后的位置,模型可能忽略;或者记忆文本太长,被截断。建议把记忆放在 system prompt 中,并明确指示“优先使用长期记忆”。
第三个常见失败是“注入太多记忆导致回答啰嗦”。这个问题不是模型变笨了,而是输入噪音太多。要降低top_k,同时提高写入端的内容质量。
注意:不要只验证程序能启动,还要验证输入、输出、异常分支和日志是否符合预期。长期记忆系统的核心测试点不是“能跑”,而是“该召回时召得回,不该召回时不出现”。
6. 生产环境必须考虑的事情
6.1 学习环境和生产环境的差异
学习环境下,单用户、小数据量、本地持久化已经足够。生产环境面对的是多用户、大量写入、频繁检索、数据安全和监控治理,两者差异明显。
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 数据规模 | 几条到几百条 | 百万条以上 |
| 用户隔离 | 代码中过滤 user_id | 需要账号体系、权限模型、租户隔离 |
| embedding 模型 | 本机直接加载 | 独立服务、统一版本、缓存 |
| 存储 | 本地文件 | 高可用、备份、分片、监控 |
| 删除合规 | 手动删除 | 提供用户可见的删除接口,并同步清理向量和副本 |
| 可观测性 | print 日志 | 记录召回率、命中率、延迟、错误率 |
| 更新策略 | 直接覆盖 | 需要处理版本、冲突、并发 |
这不是说本地方案不好,而是说上线前要对照这个表补齐短板。
6.2 记忆数据的安全和隐私
长期记忆保存的是用户个人化信息,必须把它看作敏感数据。至少要满足三点:第一,用户能查看自己的记忆内容;第二,用户能删除单条记忆或全部记忆;第三,系统不得把用户 A 的记忆用于生成用户 B 的回复。
向量本身也可以反推出部分语义,不能因为“看不到明文”就放松加密要求。索引文件、数据库备份、日志中的 memory_id 和用户 ID,都需要纳入权限管理。
6.3 监控和回滚
在生产环境,长期记忆系统应该有几类核心指标:写入成功率、检索平均延迟、返回空记忆的比例、记忆注入后的用户满意度或任务完成率。写入量异常增长往往说明抽取逻辑过宽松;召回率突然下降,可能是因为向量库索引损坏或 embedding 模型版本变了。
如果 embedding 模型升级导致向量空间变化,老数据不能直接和新模型混用。上线前需要规划好“双跑、迁移、回滚”的过程:先保留旧索引,用新模型重建一批测试数据,对比召回效果后再切换。
7. 常见问题排查路径
7.1 检索不到记忆
先描述现象:调用retrieve_memories返回结果为空,但明明写入过记忆。
排查顺序:
- 检查集合是否为空,执行
collection.count()。 - 检查写入和检索是否使用相同的
user_id。 - 检查写入和检索是否使用相同的 embedding 模型。
- 检查 query 是否能被正常向量化,打印向量长度。
- 检查是否设置了
where条件,过滤字段值和写入时不一致。
python -c "import chromadb; c=chromadb.PersistentClient(path='./memory_db'); col=c.get_collection('user_memory'); print(col.count())"最常见的原因是 metadata 字段值不匹配,例如写入时user_id是字符串"1001",检索时却传了整数1001。
7.2 检索结果不相关
现象:返回了结果,但内容与用户问题无关。
可能原因有三类。第一,embedding 模型不适合业务语言,尤其中文场景需要验证;第二,query 太短或太模糊,建议先补全成完整问句再检索;第三,写入的记忆条目本身粒度太大,或者一段内容里混入了多个主题,导致检索时无法精确命中子主题。
处理方式不是盲目调top_k,而是先做离线测试。整理几十条真实用户问题,人工判断每条记忆应该命中哪些结果,然后对比检索输出。
7.3 重复记忆不断累积
现象:用户每次提到“我住在杭州”,系统都写入一条新记忆,最终集合里有很多相似重复项。
解决思路是在写入前做一次去重检查。检索用户当前输入时,如果找到相似度极高且用户 ID 相同的现有记忆,就执行更新而不是新增。重复数据会造成检索结果冗余,同时推高存储成本。
7.4 维度不匹配和序列化问题
维度不匹配通常发生在更换 embedding 模型之后。例如旧集合是 384 维,新模型输出 1536 维向量,调用写入时会报错。这种情况下必须重建集合,不能原地修改索引。
序列化问题主要集中在 metadata 中。凡是遇到“ValueError: metadata value must be a primitive”之类的错误,先检查是否往 metadata 塞了 list、dict 或对象。建议在add_memory函数里做一层 metadata 清洗,统一转成字符串或数值。
7.5 用户记忆串号
这是最严重的问题。如果用户在检索结果中看到别人的信息,先立即停止服务并检查所有检索函数是否都带了用户隔离条件。还要检查索引中是否写入过没有user_id的旧数据,因为where过滤对缺失字段的处理可能不符合预期。
8. 最佳实践与扩展方向
8.1 可复用的长期记忆检查清单
在把记忆系统发布到生产前,可以对照下面这份清单逐项检查:
- 是否所有写入和检索都传入了
user_id。 - 是否使用同一个 embedding 模型,模型版本是否有记录。
- 是否对记忆条目做了过期时间管理。
- 是否支持按用户删单条记忆。
- 是否支持清空某用户全部记忆。
- 是否对高相似度重复内容做了去重。
- 是否限制单次注入 prompt 的记忆条数和最大 token。
- 是否记录检索召回日志,至少包含 query、top_k、召回数、距离值。
- 是否在向量库迁移时预留旧索引回滚方案。
- 是否对敏感记忆内容做了加密或脱敏处理。
这份清单不一定覆盖所有场景,但可以帮团队在初期避免很多低级问题。
8.2 扩展方向:记忆压缩与要点合并
随着使用时变长,原始记忆条目会越来越多。常见做法是定期对某个用户的记忆做“压缩”:用大模型把多条相似记忆合并成一条更完整的语义记忆,同时保留原始 ID 用于追溯。
压缩策略不能只执行一次,它需要定义触发条件,例如记忆条数超过 N 条、或者距离上次聚合超过 7 天。压缩后要更新索引,并把旧的单条记忆标记为过期或删除,避免召回时新旧内容冲突。
8.3 扩展方向:写入策略应当智能而不过度
一种常见的错误是“把用户所有对话都存下来”。这会导致检索噪音大、存储成本高、合规风险大。更好的做法是明确什么需要写:
- 稳定的用户偏好,写。
- 一次性的临时指令,不写。
- 用户主动表达的身份信息,写。
- 客服工单中的问题描述,经过结构化摘要后写。
- 对话中的无意义词、情绪词,不写。
写入端可以使用大模型 + JSON Schema 做结构化抽取,也可以先用规则粗筛,再用模型精炼。
8.4 从记忆增强到智能 Agent 状态
长期记忆不只是给 prompt 增加几段文本,它其实是智能 Agent 的核心状态。Agent 要完成跨会话任务,必须记住目标、偏好、历史决策和当前进度。下一步可以在此基础上加入任务状态、工具调用记录和业务实体记忆,让“记忆”从辅助增强升级为 Agent 的长期工作区。
对初学者来说,先把这个最小系统跑通,再逐步加入用户隔离、过期清理、去重和监控,会比一开始就追求复杂架构更稳妥。长期记忆系统的价值,不在于用了多强的模型或多高级的数据库,而在于能不能在正确的时间,把正确的内容放回上下文里。