news 2026/9/26 13:16:45

ai-memory实践:给大模型装外部记忆的完整方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ai-memory实践:给大模型装外部记忆的完整方案

ai-memory这名字最近在圈子里讨论度不低。光看标题就够直白:给AI装上记忆。大模型本身是典型的“三秒记忆”——每个会话都是独立的,上一轮聊完的东西,下一轮就跟你装不认识了。我这次实践的,就是给一个智能助手搭一套外部记忆模块,让它跨会话记住用户身份、偏好、历史结论,这恰好就是ai-memory这个项目要解决的核心问题。整个方案覆盖记忆存储、语义检索、上下文注入三块,做完之后我最大的感受是:记忆本身不复杂,难的是知道记什么、忘什么、什么时候把记忆拿出来用。这篇稿子就把整个落地方案、核心代码和踩坑过程完整拆一遍,适合正在做智能客服、个人助理、大模型应用的开发者参考。

1. 项目解读:给AI装记忆,本质是装一套“外部大脑”

1.1 为什么大模型天生没记忆

大模型本质是一个函数:给它一段文本,它返回下一段最可能的文本。它的知识全部固化在训练参数里,能回答“怎么写营销文案”,但记不住“你上一轮说过你正在创业”。这种状态叫无状态。真实产品里,用户第一轮说“我是做智能硬件的产品经理,团队十个人”,第二轮直接问“帮我写一份新品发布会邀请函”,模型根本不记得他是硬件公司,更不知道他有什么新品可发。这种割裂感在聊天机器人、AI助手、智能客服里非常致命。

有人第一反应是:把历史对话全塞进上下文不就行了?问题是上下文窗口有上限。GPT-4级别128K的窗口听起来很大,但长对话累计下来,几轮就顶到天花板。而且token就是成本,每次请求把几千字历史反复发送,开销成倍涨。更麻烦的是,信息量过大之后,模型注意力会被稀释,越旧的内容越容易被忽略,表现为“明明把文档塞进去了,模型还是答非所问”。所以通用方案不是塞原文,而是把记忆“提炼”出来,放到模型上下文之外,需要时再取回来。

这就是ai-memory这类项目的底层逻辑:外部记忆层。它把记忆从模型参数和上下文窗口中解耦,做成独立存储,用的时候按需召回。整套系统拆成三件事:写入、检索、注入。写入把对话浓缩成一条条结构化记忆;检索根据当前问题找到最相关的记忆;注入把记忆拼回输入上下文。三个环节串起来,AI才有“我认识你”的体验。

1.2 “存-取-用”三层结构是怎么设计的

我用一个最简单的比喻来拆解:记忆系统就像图书馆。写入相当于采购新书并把书编目;检索相当于用户查书目找书;注入相当于把选中的书放在桌面上供阅读。每层职责单一,才能分别调优。

写入层是整个系统的重头。不是所有对话都值得记,寒暄、临时问题、闲聊统统过滤掉。我通常在一轮对话结束后,用模型把多轮对话压缩成结构化条目,比如“用户是硬件产品经理”“用户偏好简洁设计风格”“用户当前在准备新品发布会”。这些条目才是能长期复用的记忆,原文对话反而是噪音。

存储层负责把记忆条目转为向量,存进向量数据库。向量就是一句话在语义空间里的坐标,相似意思的句子坐标接近。这也是记忆检索和传统SQL查库最大的区别——传统数据库要靠精确关键词匹配,但用户不会每次都用同样的词描述同一件事。今天说“我喜欢简洁的界面”,两周后问“有什么干净点的笔记软件”,“简洁”和“干净”在语义空间里是邻近的,向量检索就能匹配上。

检索层是保障体验的关键。每次用户发出新问题,先把问题转成向量,然后去库里找相似度最高的记忆。这步有几个参数直接影响效果:召回数量、相似度阈值、时间衰减。后面会详细讲。

注入层是把记忆用回对话里的最后一步。我习惯把检索出来的记忆拼进system prompt的“用户档案”区块,同时限定向模型声明:“以下是关于用户的已知信息,与当前问题相关才使用。”不让记忆反客为主,也不让模型编造细节。

1.3 为什么最终选了“向量检索+外部存储”

做这个方案之前,我认真对比过两条路线:全量拼接和微调。全量拼接前面说了,token成本线性增长、窗口受限、注意力被稀释。微调呢?开销大且频率跟不上,记忆是实时变化的,用户昨天还在做智能硬件,今天可能转行做餐饮了,总不能每个用户改一次模型。微调适合给模型注入稳定领域知识,不适合做个性化记忆。

向量检索加外部存储是折中最优解:存储廉价,写入实时,检索按需。成本可控,体验也自然。这套路和RAG(检索增强生成)底层是同一套技术:把知识外置,按问题动态召回。ai-memory本质上就是RAG在个人语境下的应用,只不过回取的不是文档片段,而是用户画像和历史结论。

工具选型上,我建议中小项目一开始别上重武器。我用的是Chroma作为向量库,本地轻量、零运维、支持元数据过滤。等数据大到千万级以上,再考虑换Qdrant或Milvus。嵌入模型线上方案用OpenAI的text-embedding-3-small,本地开发用bge-small-zh-v1.5,中文场景下效果不错、速度也快。

2. 核心细节拆解:记忆的存取都有讲究

2.1 记忆条目的数据模型怎么设计

很多初做记忆系统的人直接把一段对话原文丢进向量库,这其实是个坑。原文太啰嗦、噪声大、检索时容易匹配到一堆无意义片段,还白白占存储。我的做法是归一化为结构化条目。每一条记忆包含下面这些字段:

{ "id": "mem_123456", "type": "preference", "content": "用户喜欢简洁配色,反感弹窗广告", "importance": 0.8, "created_at": "2025-01-10T14:22:31", "source_session": "session_8899", "access_count": 3, "last_access_at": "2025-01-18T09:05:12" }

字段不多,但每个都有用途。type区分记忆类型,我这边用了四种:fact表示客观身份信息,preference表示偏好和态度,task_summary表示任务进展,chat_log表示有价值的结论性对话。不同类型在检索时权重不同,比如用户问“帮我写个方案”,fact和preference的优先级就高。importance字段是置信度,模型抽取记忆时自我评估,高重要度记忆进入长期库,低重要度的放在短期缓存,定期清理。access_count和last_access_at是实现记忆翻新的基础,一个记忆被反复用到,它就该在排序时获得加分,这模拟了人的记忆强化机制。

注意一点:content本身也讲究。存储前要保证一句话信息完整,主谓宾齐全。比如“喜欢简洁设计”这种话,单独看还能理解,但“喜欢简洁”被抽出来就太模糊了。所以在提炼阶段,我会让模型把零散信息补全成完整表达,这也是后面检索成功率的一个隐含影响因素。

2.2 写入侧:会话结束后的“记忆提炼”

写入时机我选在会话结束后。在线抽取会拖慢响应速度,而且用户每说半句话就要触发一次记忆更新,大部分都不值得写。我的做法是:当会话暂停或被判定为结束时,把这一段的对话丢给一个提炼Prompt,输出结构化记忆。下面是实际在用的抽取模板:

extract_prompt = """你是一个记忆提炼器。根据以下对话,提取值得长期记住的内容。 只输出JSON数组,每条记录包含: - content: 一句话描述,主谓宾完整 - type: fact | preference | task_summary - importance: 0到1的小数,0.7以上才值得长期记忆 不要提取: 临时寒暄、一次性问题、任何证件号码、银行卡号、密码等敏感信息。 对话记录: {conversation} """

用这个Prompt调一次模型,返回JSON批量入库。这样做有几层考虑:一是过滤无效信息,二是把口语化表达转成结构化描述,三是天然加了隐私边界。实测下来,大部分闲聊都抽不出记忆,而能抽出的都是真实可复用的信息,写入质量直接决定了后续检索质量,这步值得做细。

2.3 检索侧:召回数量、相似度阈值、时间衰减怎么调

检索逻辑看似简单:问题转向量,查向量库,返回前K条。但三个参数调不好,效果差距很大。先把参数含义讲清楚。

Top-K我常用3到5。太少容易漏,太多会稀释当前对话的注意力。有一次我调到10,系统把“用户喜欢猫”这条老记忆也拉出来了,导致模型在推荐笔记软件时非要扯一句“考虑到你养猫”,这就喧宾夺主了。3到5条是安全区间。

相似度阈值需要结合你用的向量库距离算法理解。Chroma默认返回的是L2距离,值越小表示越相似。不同距离算法、不同嵌入模型,相似度分布差异很大,所以别直接抄网上的阈值,要拿你自己的数据实测。我的经验是先用0.5左右的宽松阈值跑,看召回结果里的噪音比例,再逐步收紧到刚好过滤掉无关记忆的位置。阈值太紧会导致“存了但检索不出来”,这是最常见的问题之一。

时间衰减是让近期记忆权重更高的手段。我在排序时对每个记忆分数乘以衰减系数:

decay = 0.9 ** days_ago final_score = similarity * decay

意思是30天前的一条记忆,相似度即使很高,分数也会被压到约0.04,基本沉底。同时我给检索加了一层“记忆翻新”:被召回的记忆access_count加1,access_count高的记忆在排序时加一个小权重。这套组合下来,系统对近期的偏好变化很敏感,又不会彻底忘掉长期稳定的事实。

2.4 注入侧:给上下文留出“记忆专区”

检索到的记忆怎么放进对话,同样有讲究。直接拼进用户消息里会很突兀,模型容易把记忆当成新对话内容,导致格式混乱。我统一放在system prompt里,单独做一块:

_SYSTEM_PROMPT_TEMPLATE = """你是我的AI助手。请根据下面的已知信息回答用户问题。 已知信息如果与当前问题相关,请合理使用;如果不相关,请忽略,不要主动提及。 【用户记忆档案】 {memory_blocks} 【当前对话规则】 1. 回答要简洁、直接 2. 基于事实,不确定的内容不要编造 """

为什么要加“不相关就忽略”这句?不加的话,模型经常把记忆里最显眼的一条当成主题,比如用户问天气,它非要把“用户喜欢咖啡”也带上,毫无意义。加了这句话之后,模型能判断相关性,检索结果里混入的一些轻微噪音也不影响最终输出。

记忆冲突也要处理。旧记忆说“用户喜欢A”,新记忆说“用户现在用B”,两个都在库里,排序时可能同时被召回,模型就蒙了。我的解决办法是写入检测:新入库的preference和fact类记忆,如果与已有记忆内容冲突,直接执行覆盖或淘汰旧条目。冲突检测条件不能太严格,我的判断标准是有重叠实体且主题同一,比如都提到“笔记软件”且结论不同,才算冲突。

3. 实操:从零搭一个最小可用的ai-memory模块

3.1 技术选型和环境准备

先列环境。Python版本我用的是3.10+,向量库Chroma,嵌入模型默认OpenAI接口,本地可以切bge-small-zh-v1.5。安装命令:

pip install chromadb openai

如果用本地嵌入,加一句:

pip install sentence-transformers

Chroma选它的理由是够轻,一个Python进程就能跑,数据落盘在本地目录,不需要单独部署服务。项目初期数据量不大时完全够用,等要上生产了再迁移到Qdrant也容易,因为抽象层在Memory类里,换存储后端不用改业务代码。

3.2 核心代码:Memory类

我习惯把记忆操作封装成一个类,对外暴露三个方法:从对话里提炼并写入、单条写入、召回。业务方完全不关心底层是Chroma还是别的,只调用add和recall。

import json import chromadb import chromadb.utils.embedding_functions as embedding_functions from openai import OpenAI client = OpenAI() class MemorySystem: def __init__(self, collection_name="ai_memory"): self.chroma_client = chromadb.PersistentClient(path="./memory_store") self.ef = embedding_functions.OpenAIEmbeddingFunction( api_key="your-api-key", model_name="text-embedding-3-small" ) self.collection = self.chroma_client.get_or_create_collection( name=collection_name, embedding_function=self.ef ) def _generate_id(self): return f"mem_{uuid.uuid4().hex}" def add_memory(self, content, mem_type, importance=0.8, source_session=""): mem_id = self._generate_id() self.collection.add( ids=[mem_id], documents=[content], metadatas=[{ "type": mem_type, "importance": importance, "created_at": int(time.time()), "source_session": source_session, "access_count": 0 }] ) return mem_id def extract_and_add_memories(self, conversation_text, source_session=""): extract_prompt = """你是一个记忆提炼器。根据以下对话,提取值得长期记住的内容。 只输出JSON数组,每条记录包含: - content: 一句话描述,主谓宾完整 - type: fact | preference | task_summary - importance: 0到1的小数,0.7以上才值得长期记忆 不要提取: 临时寒暄、一次性问题、任何证件号码、银行卡号、密码等敏感信息。 对话记录: {conversation} """ resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": extract_prompt.format(conversation=conversation_text)}], temperature=0 ) raw = resp.choices[0].message.content try: records = json.loads(raw) except json.JSONDecodeError: records = [] added_ids = [] for record in records: if record.get("importance", 0) < 0.7: continue mid = self.add_memory( content=record["content"], mem_type=record["type"], importance=record["importance"], source_session=source_session ) added_ids.append(mid) return added_ids def recall(self, query, top_k=5, min_score=0.3): results = self.collection.query( query_texts=[query], n_results=top_k ) memories = [] for idx in range(len(results["documents"][0])): doc = results["documents"][0][idx] meta = results["metadatas"][0][idx] dist = results["distances"][0][idx] if dist > min_score: continue # 时间衰减,设定记忆有效期约30天 import math days_ago = (time.time() - meta["created_at"]) / 86400 decay = 0.92 ** days_ago score = (1 / (1 + dist)) * decay memories.append({ "content": doc, "type": meta["type"], "score": round(score, 4), "created_at": meta["created_at"] }) memories.sort(key=lambda x: -x["score"]) return memories[:3]

注意两个细节。一个是Chroma的Collection创建问题,如果重复创建同名Collection会抛异常,所以用get_or_create_collection更安全。另一个是阈值传进去的min_score代表距离上限,实际我结合L2距离直接判断过滤。排序时我没有直接用原始距离,而是转成一个1/(1+dist)的相似度分,再做时间衰减,这样分数更直观,也方便调试。

3.3 主流程接入:把记忆拼进system prompt

对话主流程接入很直接。每次用户发新问题时调recall,把召回记忆拼成文本,再注入system prompt,然后调模型。

def chat_with_memory(user_prompt, memory_system, session_id=""): memories = memory_system.recall(user_prompt, top_k=5) memory_blocks = "\n".join( [f"- {m['content']} (相关度:{m['score']})" for m in memories] ) system_prompt = f"""你是我的AI助手。请根据下面的已知信息回答用户问题。 已知信息如果与当前问题相关,请合理使用;如果不相关,请忽略,不要主动提及。 【用户记忆档案】 {memory_blocks} 【当前对话规则】 1. 回答要简洁、直接 2. 基于事实,不确定的内容不要编造 """ resp = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], temperature=0.7 ) return resp.choices[0].message.content

一句话总结这个流程:用户说话 → 查记忆 → 把记忆写进system prompt → 让模型回答问题。记忆是加强信息,不是直接回答。这保证即使召回不完美,模型也不会被坏记忆带偏。

3.4 本地效果实测:从“不认识你”到“记得你”

我用一个模拟场景实测效果。第一轮对话结束后调用提炼写入,第二轮新开一个会话问推荐,看模型是否能利用记忆。

# 第一轮会话 conversation1 = """ 用户:我最近在做一个智能硬件产品,目标用户是喜欢极简风格的年轻人。 用户:这个产品最重要的是无感交互,我特别讨厌那种到处弹窗的提示方式。 """ memory_system = MemorySystem() memory_system.extract_and_add_memories(conversation1, source_session="session_001") # 第二天,用户开了新会话,完全不提背景 reply = chat_with_memory("帮我推荐几款适合记录灵感的笔记软件", memory_system) print(reply)

实测中,系统召回了两条核心记忆:“用户正在做智能硬件产品”“用户偏好极简风格,反感弹窗提示”。最终回答是:

考虑到你偏好极简风格并且不喜欢弹窗提示,我建议优先看这几款笔记软件:Flomo(界面干净、无广告弹窗)、Notion(可自定义但需要一些配置)、Apple备忘录(原生简洁)。其中Flomo比较适合快速记录灵感。

没有记忆时,同一个问题的回答大概率是通用罗列Notion、Obsidian、印象笔记,不会考虑“讨厌弹窗”这个约束。这个差异就是记忆系统的价值所在:不是回答得更准确,而是回答得更“像懂你的人”。

4. 常见问题与排查技巧实录

4.1 明明存了记忆,为什么检索不出来

这个问题我踩过不只一次。表现是库里明明有相关记忆,但recall返回空或者全是无关内容。排查顺序如下:先降相似度阈值试试——很多时候是距离阈值设得太紧,把本应命中的结果挡在门外。再看记忆条目的content是不是太短或太口语化,比如“喜欢简洁”这种断片式文本,向量含义模糊,很难和后续问题匹配上。

解决速度最快的办法是提高写入时的提炼质量。我在提取Prompt里特意加了“主谓宾完整”的约束,效果立竿见影。比如“用户喜欢简洁配色”比“简洁”强太多。如果感觉还不够,可以用多路召回:同一问题分别查原始对话摘要和结构化记忆,合并去重后再排序,命中率明显提升。

4.2 召回了无关记忆,把回答带偏了

这跟检索阈值太松或者Top-K过大有关。有一次我为了“不漏”把Top-K调到10,结果模型把“用户喜欢咖啡”都用来回答“如何优化登录流程”,输出极不专业。这事的根因不是单纯的数量问题,而是模型无法判断相关性。

解决方案有两条线。一条是调参:收窄Top-K到3到5,提高相似度下限。另一条是给模型更多约束:在system prompt里强调“不相关就忽略”。把这两条同时做掉,基本能把噪音影响压到最低。

4.3 记忆越积越多,膨胀到没法用

记忆库无限增长是必然的,不清理迟早出问题。我用的组合策略是:一是定时淘汰,importance低于0.5且access_count为0的条目,超过30天直接删除;二是会话级总结,历史会话的原始对话摘要保留在长期库,但限制条数;三是同类合并,多次出现的相同主题记忆自动合并,只保留最新版本。

4.4 新旧记忆打架

用户偏好变了,系统还留着旧偏好,这是最常见的冲突场景。比如用户之前说“我喜欢Notion”,后来又说“Notion太复杂了,换Flomo”。两条记忆同时存在,检索时都可能命中,模型就混乱。写入侧做冲突检测是最有效的手段:在新偏好写入前,检查同主题旧记忆并标记为过期。我的简化方案是删旧写新——检测content里是否有相同实体且type相同的记忆,有就先delete再add。

4.5 安全与遗忘能力是硬要求

最后说一个容易被忽视但必须重视的点:记忆系统要有遗忘能力。用户在对话里可能无意暴露手机号、地址、公司内部信息,系统不能机械全记住。除了在提炼Prompt里强调不抽取敏感信息,还要提供删除接口,让用户能主动清除某类记忆。这也是“可以被遗忘”的合规底线。做AI记忆,记得住是技术,忘得掉是责任。

问题现象常见原因排查方向解决方案
存了但取不出阈值过严、内容残缺检查距离阈值和记忆文本放宽阈值、提升提炼完整性
取出但用不上Top-K过大、阈值过松检查召回结果噪音占比收紧参数、增加相关性约束
回答被带偏模型盲目使用记忆查看注入模板措辞加“不相关就忽略”声明
记忆爆炸缺少淘汰机制统计库内无效条目清理策略+合并压缩
新旧矛盾写入未做冲突检测检查同主题记忆删旧写新

我个人实际做下来最大的体会是:ai-memory这类项目,存储和检索反而是简单的,真正的门槛在“记忆策略”。什么时候记、记什么、怎么防止模型乱用记忆,这些决策没有标准答案,完全取决于你的产品形态。对话式助手可以激进一点,多存偏好;知识库问答就要保守,只存稳定事实。当初我做第一批测试时也觉得把对话全扔进向量库就行,结果被各种噪音折磨得够呛。后来老老实实把提炼、衰减、冲突处理都加上,效果才算稳定。建议你动手做的时候,先跑通流程,再逐项调策略,不要一上来就堆功能。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 13:16:13

OrangePi 5 Plus 软实时系统实战:2路EtherCAT与6路CAN扩展

1. 为什么要在 OrangePi 5 Plus 上折腾 EtherCAT 和 CAN拿到 OrangePi 5 Plus 这块板子的时候&#xff0c;我第一反应不是拿它当桌面小主机&#xff0c;而是盯着它那几路原生 CAN 控制器和 PCIe 接口琢磨——这配置放在工业现场&#xff0c;简直就是个天生的边缘控制器胚子。RK…

作者头像 李华
网站建设 2026/9/26 13:16:04

光伏功率时间序列K-means聚类实战:从特征工程到业务落地

说到光伏时间序列聚类&#xff0c;很多人第一反应是“不就是把曲线归归类”&#xff0c;但真正上手做一次基于K-means的光伏功率数据聚类&#xff0c;你会发现坑比想象中多得多。数据切分、特征构造、K值选择、评估指标&#xff0c;每一步都藏着细节&#xff0c;走错一步聚类结…

作者头像 李华
网站建设 2026/9/26 13:16:04

Spring Boot 3 + Vue 3 交友平台全栈项目设计与落地实践

一个很典型的全栈项目&#xff1a;后端用 Spring Boot 3&#xff0c;前端用 Vue 3&#xff0c;做成一个交友平台系统。这类项目在各类毕业设计、个人练手作品里出现频率相当高&#xff0c;但大多数写出来都停留在“能跑通”的层面&#xff0c;离“能拿得出手”还有不小距离。我…

作者头像 李华
网站建设 2026/9/26 13:15:37

论文降重却栽在AI率上?从文本相似度到机器痕迹的写作自救指南

“老师让把初稿拿去降重&#xff0c;我降完了&#xff0c;重复率倒是下来了&#xff0c;AI检测却标了百分之六十几&#xff0c;现在两头来回改&#xff0c;越改越乱。”这是上周一个学弟发给我的消息。类似的情况这两年我见得太多了——硕士论文送审前、期刊投稿后返修时&#…

作者头像 李华
网站建设 2026/9/26 13:14:15

Python第一次作业全攻略:从环境安装到运行调试

帮学弟看第一次Python作业的代码&#xff0c;结果他发来的截图不是代码报错&#xff0c;而是那句经典的"python 不是内部或外部命令&#xff0c;也不是可运行的程序或批处理文件"。这种画面我见过太多次了——很多人第一次接触Python&#xff0c;压根不是倒在语法上&…

作者头像 李华
网站建设 2026/9/26 13:13:01

SpringBoot电子发票管理系统实战:PDF解析、查重与防重复报销

简介&#xff1a;这是一套基于Java Spring Boot的电子发票管理系统完整项目源码&#xff0c;面向学习企业级Java开发的学生、初级开发者及需要课程设计或毕业设计参考的技术人员。项目围绕电子发票的开具、录入、存储备份、查询审核、报表统计与税务合规检查等业务展开&#xf…

作者头像 李华