1. “上下文塞不下”才是起点:ai-memory 想解决的真实痛点
1.1 从一次让人抓狂的 AI 对话说起
先讲一件我自己遇到的事。半年前我在做一个内部咨询问答机器人,模型用的是当时很流行的长上下文大模型,窗口给得足够大方。结果实际用起来,用户每隔几轮就要重新解释一遍背景:“我之前不是说过我们部门用的是飞书吗?”“你忘了刚才那个项目叫猎户座了?”
模型当然不是故意的,它就是没记住。每一次接口调用,模型面对的都只是一段静态文本。这轮对话结束之后,除了日志文件里躺着记录,它脑子里什么都不剩。用户感受到的,就是“同一个问题每次都要从头说起”。这个体验真的很糟,尤其当对话拉长到十几轮以上,问题会越来越明显——上下文越来越长,费用越来越高,回复质量反而越来越差。
这类问题有一个统一的技术说法:模型缺少持久化记忆。我当时动手做的这个 ai-memory 项目,就是为了解决它:让模型在进行多轮对话时,能像一个正常人一样,记住关键信息、回顾历史结论、在需要的时候主动调出旧知识,而不是把所有内容都塞进上下文里硬扛。
1.2 长期记忆不是“更大的上下文窗口”
很多人第一反应是:既然记不住,那就把上下文窗口开大一点,把所有历史消息都扔进去,模型不就记住了吗?
这个思路短期有效,但长期来看是个无底洞。假设你的窗口从 128K 涨到 1M,听起来很夸张,可如果你的用户长期使用一个业务系统,每天产生几千条操作记录,1M 的 token 也就够容纳一两天的内容,之后照样溢出。更关键的是,上下文长度和模型性能并非线性关系——塞进去的内容越长,模型越容易在无关信息里迷失,注意力的分布被稀释,关键细节反而更容易被忽略。我见过不少案例,把完整历史丢进长上下文之后,模型居然把一个月前的一句话当成了当前指令,回复完全跑偏。
打个比方:上下文窗口是工作台,记忆系统是档案柜。工作台上一次性能摊开多少东西是有限的,你要是一直往上堆,最后连自己正在加工的那个零件都找不着。正确的做法是,干完一步就把半成品收进档案柜,下次要用的时候再精准取出来。ai-memory 做的就是档案柜这件事:写入、索引、检索、更新、归档,一条龙管理。
1.3 我把项目边界划在哪
聊清楚这个项目的边界很重要,不然你会什么都想做,最后什么都做不成。我给自己划了三层目标:
- 第一层(核心):多轮对话的历史消息能够被压缩成可检索的记忆条目,而不是逐字保存原始文本。
- 第二层(进阶):从对话中抽取结构化信息,比如用户偏好、项目代号、决策结果、关键日期,形成“事实型记忆”。
- 第三层(加分):记忆具有时效性,能根据时间衰减和重要程度决定优先级,让模型重点关注最近、最相关的信息。
我不做的一件事是:不把完整对话历史原封不动地存下来。原始日志有它的用途,但记忆系统要的是提炼之后的信息,不是大而全的流水账。逐字存档看着信息无损,实际检索体验和成本控制都会崩掉。
2. 记忆的三种形态:工作记忆、知识仓库与事实画像
2.1 三种记忆的分工
在做具体设计之前,我先把记忆分成了三类,因为它们的读写方式、存储介质和更新策略完全不同。一开始我也只做了一个大杂烩式的记忆表,后来发现所有查询都变得又慢又钝,才痛下决心拆开。
工作记忆(Working Memory):对应当前这一轮任务相关的上下文。比如用户正在跟 AI 一起写一份季度汇报,那么这轮对话里出现的几个关键数据、近期结论,就属于工作记忆。它的特点是生命周期短、更新频率高,通常存在会话级缓存里,不必落库。
知识仓库(Knowledge Base):偏长期、偏通用的事实性内容,比如公司的产品文档、技术规范、历史项目经验。它的特点是相对稳定,适合切片后向量化,通过语义检索召回。
事实画像(Profile):关于用户本身的稳定信息,比如称呼、所在部门、常用工具、偏好习惯。它的特点是结构化程度高,适合用键值对或关系型表存储,更新频率很低。
三类记忆对模型的配合方式也不一样。工作记忆直接拼进上下文,知识仓库靠向量检索召回后拼接,事实画像是先查出来,再按需注入。三者缺一不可,但混在一起管理就是灾难。
2.2 为什么不适合只用一张表搞定
有人可能会问,为什么不能把三种记忆统一成“文本片段+向量”,全都丢进向量数据库?我测试过这种方案,遇到一个很棘手的问题:向量检索对“事实型精确查询”的支持很弱。
举个例子,用户问“我们这个项目的截止日期是哪天?”如果记忆里有用户画像字段deadline=2025-08-30,直接查结构化字段,结果零误差、速度极快。但如果你把所有信息都转换成自然语言文本切片存进向量库,检索可能召回一段模糊相关的文本,模型需要再从文字里猜日期,一旦猜错就是致命的。
后来我定的方案是:文本型记忆走向量检索,事实型记忆走结构化查询,两套并行。向量库用轻量的,结构化部分直接上 SQLite,连服务都不用单独起。很多人看到“AI 记忆系统”就以为一定要上重型的向量数据库,其实 SQLite 加一个 embedding 列就能解决相当大比例的问题。
2.3 会话上下文与全局记忆的联动
那历史和当前会话是怎么衔接的呢?我采用了一个很实用的折中方案:最近 N 轮对话的原文始终保留在上下文里,超过 N 轮的内容一律压缩进知识仓库。这个 N 我一般设成 10,实测下来比全量塞入和完全压缩的效果都好。它兼顾了两头:保留近期细节的丰富度,又把早期内容的检索成本控制住。
当用户问到早期某件事时,系统会用问题向量去知识仓库里检索,把命中的记忆条目拼接进上下文。这样既不需要提前把所有历史都放进 prompt,也不会让模型“失忆”。
3. 搭建一套能跑通的记忆层:表结构、写入流程与检索链路
3.1 存储层:我还是选了轻量优先
前面说了,我用 SQLite 承载结构化部分,这个选择后来被证明非常明智。记忆系统在无状态服务面前本来就是个附加组件,如果为它单独维护一套 MySQL 集群或者专门的向量数据库,运维成本立刻翻倍。SQLite 单文件、零配置、支持 JSON 字段,对一个中等规模项目来说性能足够。
核心的两张表长这样(注意这是简化的核心结构):
-- 事实画像表 CREATE TABLE memory_facts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, entity TEXT NOT NULL, -- 主体:用户、项目、团队等 attribute TEXT NOT NULL, -- 属性:偏好、截止日期、决策等 value TEXT NOT NULL, -- 值:属性对应的内容 importance REAL DEFAULT 0.5, -- 重要性权重 0-1 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 文本记忆表 CREATE TABLE memory_episodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, content TEXT NOT NULL, -- 压缩后的记忆文本 embedding TEXT, -- 向量,JSON 数组 source TEXT, -- 来源事件ID created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_access_at DATETIME, -- 最近一次被召回的时间 access_count INTEGER DEFAULT 0 -- 召回次数,用于热度分析 );两张表的分工很干净:memory_facts处理“用户的界面偏好是深色模式”“项目 X 的截止日期是 6 月 30 日”这类精确事实;memory_episodes处理“他在某次讨论里提到对旧报表系统的不满,主要原因聚焦在导出速度”这类语义型记忆。
3.2 记忆写入:不是存原文,而是做压缩
写入是整个系统里最容易被低估的环节。很多初版设计就是“把每轮对话拿去 embedding 入库”,结果库里面全是互相重叠的废话,检索时还相互干扰。我后来改成了一条流水线:
第一步,过滤。不是所有对话都值得记住。寒暄、重复表述、过程中间态,这些都属于噪声。我给模型一条硬规则:只有在用户明确提出要求、提供了新的关键信息、或者做出了决策时,才需要触发记忆写入。
第二步,摘要压缩。对于值得记忆的长对话,调用一次模型进行摘要,把三五百字的原始内容压成五六十字的记忆描述。压缩时我会强调保留五要素:谁、什么时间、针对什么对象、做了什么决定、结果如何。这样检索回来之后,模型看到的是结构化知识,而不是一段没法定位的流水账。
第三步,分型入表。摘要内容如果依赖语义联想才能找到,就进 episodes 表并生成向量;如果从中能抽出明确的主谓宾事实,就进 facts 表。同一个事件可以同时进入两张表,它们各司其职。
3.3 检索链路:先粗筛,后精排
检索链路我设计成两段式:先做候选召回,再做重排。候选召回阶段,根据用户当前的问题向量,在 episodes 表里做向量相似度检索,取回 Top 20。同时,如果问题里包含实体名词(比如“猎户座项目”),我就用实体名去 facts 表里做精确查询,把这些事实也拉进来。
重排阶段不调模型,纯规则计算,这个我下一章会详细讲。最终只保留分数最高的 5 条记忆拼进 prompt,超过 5 条反而会稀释注意力。
3.4 不依赖外部服务的轻量实现
整套系统我跑通之后,外部依赖只有一个 embedding 服务。模型用的是开源 embedding 接口,向量距离计算直接写在代码里,用余弦相似度。没有消息队列、没有独立的向量数据库、没有 Redis 缓存,部署时一个 Docker 容器就全部搞定。
如果你是有一定用户量的线上服务,我建议把 SQLite 换成 PostgreSQL 加pgvector插件,写入并发和向量索引能力都能大幅提升,但业务代码无需改动太多。架构上保留清晰的接口边界,底层存储随时可以替换,这是这个项目我自认为最正确的前置设计。
4. 检索时如何挑选回忆:相关度、时间衰减与重要性评分
4.1 为什么“最相似”不等于“最该想起”
我用第一版检索的时候天真地以为,按向量相似度取 Top 5 就够了。跑了一周发现效果很尴尬:用户昨天刚聊过的话题能被准确召回,但一周前强调过三次的关键约束,反而经常被挤掉。
原因不复杂——向量相似度只衡量了“语义相关度”,而记忆的提取还应该考虑新鲜度和重要度。这就跟人脑的记忆机制一样,一件事对你重不重要、最近有没有提过,会直接决定你能不能第一时间想起来。
4.2 打分公式:三个维度的组合
我后来把排序规则改成加权打分:
score = 0.6 * similarity + 0.3 * recency_factor + 0.1 * importance三个分量的设计逻辑如下:
similarity(语义相似度):问题向量与记忆向量的余弦相似度。这是最基础的维度,它保证召回的内容方向不跑偏。
recency_factor(时间衰减):我用的不是简单的倒数,而是带半衰期的指数衰减:
import math def recency_factor(created_at, half_life_days=7): age_days = (now - created_at).total_seconds() / 86400 return 0.5 ** (age_days / half_life_days)也就是说,一条记忆在 7 天前被写入时,它的新鲜度得分只有 1 天前的一半。这符合直觉:最近的对话内容通常与当前问题的关联更大。
importance(重要性):写入记忆时,由模型给一个 0 到 1 之间的评价。我给了模型几条参考标准:包含明确日期、包含决策结果、涉及钱或资源、用户使用了“很重要”“绝对”“必须”这类强度词,有这些特征的重要性就往上调;如果只是随口一提,就压到 0.3 以下。
4.3 绕开的一个误区:access_count 双刃剑
我还曾经尝试把访问次数也作为一个正向权重,理由是“经常被想起的记忆大概率更重要”。事实证明这条假设只对了一半。在真实业务里,一条记忆被频繁访问,很可能只是因为用户最近频繁碰到同一个问题,这本质上还是和时间衰减重合了。更麻烦的是,访问次数会自我强化:一条记忆被召回的次数越多,它的权重就越高,于是下次更容易被召回,形成马太效应。最后用户会感觉,模型翻来覆去只会记得那两三件事,其他记忆都被压制了。
所以我后来做了一个折中:access_count仍然记录,但它只作为人工分析的参考指标,不参与排序计算。我在后台的统计页面能看到哪些记忆被高频访问,用来判断记忆写入质量,但线上排序不用它。
4.4 重排以后的效果对比
同样一批测试对话,重排前后差别是肉眼可见的。重排之前,模拟用户问“我们之前定过这个功能的优先级吗?”返回的相关记忆里,最新一条浮于表面的讨论排在前面,而三天前那次明确拍板“P0 是搜索、P1 是推荐”的决策记录落在第 11 位,根本就不会进上下文。重排之后,决策记录被拉进 Top 3,模型能准确回答“定了,搜索是 P0”。
这个提升没有任何模型层面的改动,纯粹是检索策略的价值。在大模型应用里,很多时候你不需要换更强的模型,你只需要把信息找得更准。
5. 让模型自己管理记忆:主动抽取、自动合并与遗忘机制
5.1 不要假设用户会主动教模型记什么
第一版设计里,我让前端加了一个按钮:“记住这个信息”。实测结果很真实——根本没人点。用户不会像用数据库一样去管理自己的记忆,他们默认地认为 AI 就应该自己知道。所以记忆写入必须自动化,不能让用户承担“维护记忆”的认知负担。
这导向了一个核心设计:用模型自己来当记忆管家。每轮对话结束之后,系统会跑一次轻量的记忆提取任务,模型根据新对话内容做三件事,返回一段结构化的 JSON 命令。大概长这样:
{ "write_facts": [ {"entity": "用户", "attribute": "preferred_report_period", "value": "每周五"}, {"entity": "猎户座项目", "attribute": "priority", "value": "P0"} ], "write_episodes": [ "用户决定把搜索功能列为猎户座项目的P0优先级,预期两周内完成技术验证。" ], "merge_facts": [ {"target_entity": "用户", "target_attribute": "work_style", "source": "上一轮记忆", "reason": "语义相同,以最新表达为准"} ], "forget": [ {"id": "memory_episodes_42", "reason": "内容已被更完整的记忆覆盖"} ] }系统拿到 JSON 后逐条执行。这比让模型直接改写数据库要安全得多——模型只负责产出结构化意图,真正的 SQL 操作还是由代码执行,避免模型生成非法语句把表搞坏。
5.2 抽取时的提示词设计
记忆抽取的提示词,我迭代过好几版,踩过不少坑。最初我给的指令非常宽泛:“提取对话中的关键信息”。结果模型把闲聊也当成关键信息,库里面存了一堆“用户提到今天下雨,路上堵车”。后来我把指令改细了,加了明确的抽取目标清单:
- 用户的明确偏好和习惯
- 项目相关的决策、时间节点、优先级
- 用户表达出的目标或计划
- 重要的背景约束(比如“不能用付费 API”“必须兼容老系统”)
同时,我把“不值得记”的类型也写进提示词:情绪化表达、过程性试错、临时性的随口提及、已经在上下文中反复出现的内容。模型有了正反两面的边界之后,抽取质量提升非常明显。
5.3 合并逻辑:别让记忆库变成复读机
跑了两星期之后我发现一个新问题:关于“用户是产品经理”这条事实,在 facts 表里竟然躺着五条记录,分别来自五次不同对话,内容基本一致,措辞略有差异。这就是没有合并机制的后果,记忆库变成了复读机。
解决思路是这样的:每次写入新的 fact 之前,先按entity + attribute查一遍已有记录。如果已存在,就执行更新而不是插入:
UPDATE memory_facts SET value = ?, importance = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND entity = ? AND attribute = ?;这样每条事实画像只保留一条最新记录,永远不会有重复。这里的代价是,如果事实在某一轮被错误抽取,就只能靠下一条正确记录覆盖,好在覆盖的频率足够快,实际问题不大。
5.4 遗忘:记忆系统里最反直觉但最必要的一环
很多人做记忆系统,默认目标是“记得越多越好”。但真实世界不是这样的。我是一个很看重极简的人,写这个项目时也贯彻了极简原则:记忆必须主动丢弃,否则时间一长,库里塞满了过时的、矛盾的、低价值的内容,检索质量会整体雪崩。
我的遗忘策略分三级:
- 硬覆盖:同一属性的事实有更新值时,旧值直接覆盖。这是最高频也最安全的遗忘。
- 降权:长时间没有访问且重要性低的记忆,重要性权重自动下调,直到排在检索结果底部,相当于“弱遗忘”。
- 硬删除:对于被模型标记为矛盾或已废弃的记忆,直接删除。比如用户说“以后别再推荐 Python 了,团队已经全面转 Go”,与之相关的 Python 偏好记录就会被删除。
我个人的体会是,等级三很少发生,但每次发生都特别有价值。没有遗忘机制的 AI 记忆系统,就像一个人记着一本十年的流水账,什么都记得,但对当前决策几乎没有帮助。
6. 拉了二十多天的实测,我把踩过的坑摊开来讲
6.1 实测配置与测试方法
项目在内部跑了二十多天,参与的测试用户有 11 人,都是公司内部的研发和产品。测试任务覆盖了三类场景:日常问答咨询、项目联合方案讨论、多轮信息收集。我没上复杂的评测集,就用最土但最有效的办法看两类指标:
- 记忆准确率:在一轮对话结束后,由测试者询问模型是否记得某个具体信息,人工判定回答正确与否。
- 检索相关性:每轮召回的记忆列表旁边,让用户标记“是否有用”。我没依赖机器自动评估,因为这种主观性很强的东西,机器评不准。
6.2 几个典型的翻车案例
翻车案例一:记忆串线。有两个测试者都在聊“迁移到新系统”的话题,但一个是在说迁移 CRM,一个是在说迁移数据仓库。由于抽取时没绑定实体上下文,模型把 CRM 的迁移结论串到了数据仓库那边。修复措施是在 facts 表里给每条记录增加project_name字段作为隔离维度,检索时优先按当前对话涉及的实体名精确过滤。这个改动上线后,串线情况基本绝迹。
翻车案例二:时效性失控。用户在第一周说“项目下周启动”,模型记进了 facts。第二周用户已经说“项目推迟了”,但因为更新逻辑没有触发,老的启动时间仍然躺在库里,回答时给了错误信息。症结在于我的抽取提示词没有要求“检测变更”。给模型加了变更检测指令之后,类似派生事件才被正确覆盖。顺带说一句,记忆系统的时效性检测,比初始抽取难做得多。初始抽取是增量写入,变更检测要求模型主动发现“这不是新信息,而是对旧信息的否定”,这真的需要反复调整提示词。
翻车案例三:向量检索的相似度阈值没校准。最开始我把召回阈值设成 0.7,结果很多热门但无关的内容被召回来。后来我把阈值降到 0.3,负面效果更明显——检索结果开始出现大量语义发散的记忆。最终我放弃了固定阈值,改成“取 Top 20 候选再重排取 Top 5”,效果最稳定。事后反思,固定阈值在向量检索里不是一个好思路,因为不同 query 的分布差异巨大,硬性划定边界没有意义。
6.3 原有的检索链路翻车处理总结
我把这次踩坑的整体流程图写在这里供参考,虽然不长,但每一步我都验证过:
- 问题进来,先做实体识别,确定关联的
project_name、user_id等隔离字段。 - 用隔离字段精确过滤 facts 表,取出完全匹配的结构化事实。
- 用问题向量在 episodes 表召回 Top 20。
- 对 20 条候选做混合评分(相似度+时间衰减+重要性)。
- 取 Top 5,连同结构化事实一起注入 prompt。
- 若模型在没有足够记忆信息时,显式返回“记忆不足”标记,而不是胡编。
第 6 点是我后期补上的。没有这个标记,模型会拿几条不相关的记忆硬凑答案,看起来态度很好,实际错得离谱。有了“记忆不足”信号之后,产品层可以引导用户补充信息,体验反而更诚实。
6.4 成本与性能的实测数据
最后说点实际的。整个 ai-memory 系统每轮对话的额外开销大概是:
- 记忆抽取调用一次摘要模型,约 300 到 600 token(视对话长度)。
- embedding 计算约 0.2 到 0.4 秒延迟。
- 向量检索 + 重排约 20 到 40 毫秒。
- 总延迟增加约 300 到 800 毫秒。
这个增量对于多数对话型应用来说是可以接受的。但要注意,摘要模型调用不是每轮都必须做。如果对话中发现没有新增关键信息,我会用规则跳过抽取步骤,直接不调用模型。具体做法是在对话完成时检查是否有实体或决策关键词命中,没有命中就跳过。这个简单规则帮我把平均抽取调用率降到了 60% 左右,成本和延迟双双下降。
我在实际运营中的体会是:AI 记忆系统不是越复杂越好。如果产品阶段还不明确,先上一个轻量的两层结构(facts + episodes),配合半衰期衰减的检索重排,就能解决绝大多数“记不住”的问题。那些一上来就设计复杂知识图谱、事件溯源、异步重放管线的方案,维护成本高到通常撑不到上线就跑不动了。把这个基础版本跑熟,再逐步加心智记忆、群组记忆、跨会话推理这些进阶能力,才是一条走得稳的路。
如果你也正在被 AI 的“金鱼记忆”折磨,我建议你先拿我这个最小可用设计去改造自己的场景,不需要一上来就推倒重来。把记忆库建起来、把检索排序调好、把遗忘机制接上,你离一个真正“懂事”的 AI 助手就不远了。