做agent开发,绕不开“记忆”这两个字。模型本身没有记忆,每次调用都是独立的,但用户期望的是:上一轮说过的话,这一轮还作数;上周聊过的偏好,今天提问时仍被尊重。短期记忆、长期记忆在概念图上很好画,可真要落进代码里,会遇到一连串具体问题:上下文怎么组织、token不够了怎么办、长期记忆存什么格式、检索不准怎么兜底。这篇文章就是把这些事情拆开讲清楚,我会从原理说到可落地的接口设计和检索策略,也把我踩过的几个坑一并交代。
1. 先厘清一件事:agent的短期记忆与长期记忆分别解决什么问题
1.1 没有记忆的agent,连一个“多轮约束”都扛不住
举个例子。用户说“帮我找三篇适合睡前读的英文短文”,agent给出回答;用户又说“第一篇太长了,换一篇”。如果agent没把上一轮的三篇短文标题放进记忆,它连“第一篇”指的是什么都不确定,后续只能重新来一遍,体验直接崩塌。这类问题在多轮任务型对话里非常常见。
模型本身是无状态的,它不是记不住,而是每次请求都是一张白纸。想让agent看起来“记得”,就必须由开发者在请求里显式携带相关信息。你如果只做单轮问答,短期记忆确实可以不做;但只要是对话式agent,一点不做就是灾难。而长期记忆解决的是另一个问题:用户第二天回来了,换了新会话,agent还得知道对方是谁、偏好什么、上次聊到哪。
1.2 短期记忆和长期记忆,不是容量大小的区别
很多人会把“短期记忆”等于“上下文窗口”,把“长期记忆”等于“向量数据库”。这个理解不能说全错,但会带偏设计。上下文窗口只是短期记忆的物理容器,真正要管的是“往里放什么”;向量库也只是长期记忆的一种存储方式,长期记忆的核心在于“什么时候写、怎么检索、怎么更新”。看清这一点,后面很多设计取舍就顺了。
| 维度 | 短期记忆 | 长期记忆 |
|---|---|---|
| 存储位置 | 当前请求上下文、会话缓存 | 向量库、关系库、KV存储 |
| 生命周期 | 一次会话甚至一次请求 | 跨会话、跨天、跨月 |
| 容量边界 | token上限、上下文长度 | 理论上可以很大,但受检索成本约束 |
| 访问方式 | 直接拼接进prompt | 通过检索召回后再注入 |
| 核心目标 | 保证当前任务的连续性 | 沉淀事实、偏好和背景知识 |
| 典型失败 | 上下文超长、中间遗忘 | 召回不准、写入污染、画像漂移 |
1.3 从使用场景推导记忆分层
不同场景对记忆的需求是不同的。单轮问答,比如“翻译这句话”,完全不需要记忆;多轮任务,比如“帮我规划一次旅行”,需要短期记忆来维持目标和约束;跨会话个性化,比如用户每次回来都说“按老规矩来”,需要长期记忆;项目级背景,比如用户长期维护一个知识库,就需要长期记忆里沉淀大量结构化事实。
所以agent开发里的记忆,短期和长期是配合关系,不是一个替代另一个。很多团队一开始只做短期,把历史全塞给模型,等用户量大了token成本爆炸,才被迫补长期。与其这样,不如一开始就把记忆当作独立的系统模块来设计,后面会省非常多的事。
2. 短期记忆落地:上下文窗口、截断策略与摘要压缩
2.1 token上限就是短期记忆的物理边界
每家模型厂商都会公布上下文长度,但这不代表你真的可以把所有历史都塞进去。一方面,成本会随token线性增长;另一方面,实测里超长上下文会出现“中间忘记”的现象,模型对长文本中段信息的关注度和提取能力明显下降。我在一个项目里试过把所有历史全部拼接,结果对话轮次超过二十轮后,模型开始重复早先已经回答过的内容。
原因不是模型笨,而是信息太多,重点被稀释了。短期记忆设计的第一原则不是“装更多”,而是“挑重点”。要清醒认识到:上下文再长,也不可能无限;即便有百万级窗口,成本和延迟也不允许你这么用。
2.2 滑动窗口:保最近、丢最远,但要给关键信息开“后门”
最基础的做法是固定轮数或token上限,超出就丢掉最早的内容。这个方式实现简单,但存在明显问题:用户可能在早期说过一个重要约束,比如“所有方案控制在500字以内”,滑动窗口一滑,这条约束就没了。我在实际开发里会额外维护一个“must-keep槽位”,把用户明确的偏好、任务目标、未完成的待办单独存起来,无论窗口怎么滑动都不清除。
拼接prompt时,可以按这个顺序组织:
- 系统指令
- 当前任务目标与硬性约束(must-keep槽位)
- 短期记忆窗口中最新的N轮对话
- 当前用户输入
这么做的目的是让关键信息处在模型注意力更集中的位置,也更容易被后续模块读取。你可能会问:must-keep槽位会不会写太满?会。所以它只放“会影响后续所有决策”的信息,不是把每轮对话都扔进去。
2.3 摘要压缩:让早期内容“缩着活下来”
如果想保留更多历史信息,又不想被token拖垮,常见手段是摘要压缩:在对话超过一定长度时,让模型把最早的一部分历史总结成一段摘要,替代原始内容。这个做法本身没问题,但摘要会丢失细节,尤其是数字、名称、否定表述这类信息,模型在总结时很容易弄丢或记错。
所以我的做法是把历史分成两类。一类是“可压缩的闲聊和过程”,比如“用户先问了A方案,又问了B方案”,可以摘要化;另一类是“必须保留的硬事实”,比如用户明确表态“不用A方案,因为预算超了”、最终确认的方案编号、具体数值,这些不进摘要,进must-keep槽位。摘要适合用来保留“脉络”,不适合用来保留“事实”。压缩的时候也别只压一次,对话特别长时可以递归压缩,先压最早的部分,再压次早的部分,始终保持近期内容完整。
2.4 短期记忆的清理时机
短期记忆不是永久保留的。任务完成、会话结束、或者已经写入长期记忆之后,该清就清。不清理的后果不只是浪费token,更麻烦的是旧信息会对新任务造成上下文干扰。比如用户之前在处理“周报生成”的任务,隔了两天后问“帮我安排下周的会议”,残留的周报历史会让模型下意识往周报方向理解。
我的习惯是在会话空闲超时后直接把短会话状态归档,或者只保留最后一条“任务结论”作为下次的起点。短期记忆的管理,本质是控制“当前任务的有效范围”,范围越小,模型越不容易跑偏。
3. 长期记忆架构:存储建模、检索召回与写入更新
3.1 长期记忆不是日志,要分三层建模
直接保存原始对话记录是最省事的,但检索时很麻烦。我一般会把长期记忆拆成三层:原始数据层、事实层、画像层。
- 原始数据层:存对话原文或关键事件,用于审计、回溯、重新建模,不直接参与在线检索。
- 事实层:把“用户住在某个城市”“偏好简洁回复”“正在做一个移动端项目”这类结构化信息抽出来,存成带类型的fact条目。
- 画像层:由事实层聚合出的稳定结论,比如“用户偏好早期反馈”“喜欢列表式输出”。
为什么要分层?因为检索需求不同。用户问“你还记得我上次说的那个项目吗”,需要查事实层;系统想决定回复风格时,直接读画像层更快;出问题时,回到原始层追溯。三层各有用途,不要全部塞进一个向量库里了事。
3.2 存储选型:向量库、关系型表、KV,各有各的用途
| 存储方式 | 适合解决 | 不适合 |
|---|---|---|
| 向量库 | 语义相似检索、模糊回忆 | 精确查值、结构化条件查询 |
| 关系型表 | 按类型查偏好、过滤条件 | 高并发低延迟的极简读取 |
| KV存储 | 用户画像、快速读取 | 复杂检索、语义匹配 |
不要神化向量检索。它擅长“意思相近”的召回,但对专有名词、精确数字不敏感。比如用户提到一个特定术语,向量库里存的是同义描述,可能关联得上,但如果你搜的是“那个项目的截止日期是几号”,向量就没有优势了。所以实际系统里我常做混合检索:向量召回一批,关键词或规则召回一批,合并去重后统一排序。事实层适合用关系型表存,画像层适合用KV缓存,向量库主要用来做语义召回,各干各的活。
3.3 检索召回:什么时机拉取、拉多少、怎么排序
长期记忆的检索不是每轮都要做,触发时机很关键。我在设计时会考虑三个时机:
- 新会话开始时,先拉取该用户的核心画像,让agent从一开始就“知道”对话对象是谁。
- 用户输入里出现明显指代,比如“上次说的”“按老规矩”“那个方案”,主动检索关联记忆。
- 任务涉及历史事实判断时,由系统在prompt里注入召回结果。
召回数量建议控制在5到10条之间,给太多反而稀释注意力。排序时我会用一个综合分数:
score = relevance × 时间衰减系数 + importance权重
相关性来自向量相似度或关键词匹配;时间衰减系数让最近发生的事实权重更高;importance由写入时打标决定,用户明确表达的偏好就比一次闲聊内容重要。召回结果要带来源和时间戳,让agent知道这条记忆是什么时候留下的,而不是把一条陈旧信息当新鲜事用。
3.4 写入与更新:避免“越记越乱”
长期记忆写入的时机,我一般只认三个:
- 用户明确表达偏好,比如“以后都用中文给结论”;
- 一次关键任务得到明确结论,比如“最终决定用方案B”;
- 对话结束后的异步总结,把本次会话值得沉淀的内容抽取出来。
更新要注意两点。一是同一事实反复出现时以最新为准,不能堆积重复条目;二是用户主动纠错时,必须同步更新旧记忆,否则老记忆和新说法同时存在,模型就会自相矛盾。遗忘机制也要有,用户记忆不该只增不减。我会给每条记忆加时间戳和置信度,定期清理低置信度、超期未命中的条目。长期记忆的写入质量,直接决定agent后续所有回答的质量,这一步宁可慢,不能糙。
4. 把短期记忆和长期记忆接在一起:一个可直接照搬的接口设计
4.1 先抽象一个Memory接口
为了让上层业务不关心底层存储,我会先定义一个memory store接口。伪代码结构如下:
class MemoryStore: # 短期记忆 def read_short_term(self, session_id: str) -> list: ... def write_short_term(self, session_id: str, messages: list) -> None: ... # 长期记忆 def read_long_term(self, user_id: str, query: str, top_k: int = 5) -> list: ... def write_long_term(self, user_id: str, facts: list) -> None: ... # 更新与遗忘 def update_fact(self, user_id: str, fact_id: str, new_value: dict) -> None: ... def delete_fact(self, user_id: str, fact_id: str) -> None: ...接口不复杂,但它把“短期记忆用什么存”“长期记忆用什么库”这些实现细节都隔离在后面。后面想换存储、加缓存、加衰减,都不会影响上层业务逻辑。我建议哪怕是一个很小的demo也先用接口思维去写,后面扩展会省很多事。实现类可以各自接会话缓存、向量库、关系表,只要满足这个接口,上层完全不用改。
4.2 带记忆的一次请求,完整走一遍流程
一个最简单的带记忆处理流程,伪代码如下:
def handle_message(user_id, session_id, user_input): # 1. 从长期记忆里检索相关内容 long_term_facts = memory_store.read_long_term(user_id, user_input) # 2. 读取短期记忆,组装prompt short_term_history = memory_store.read_short_term(session_id) prompt = build_prompt( system=SYSTEM_PROMPT, long_term=long_term_facts, history=short_term_history, current_input=user_input, ) # 3. 调用模型 reply = llm.call(prompt) # 4. 短期记忆同步更新,保证下一轮还能用 memory_store.write_short_term(session_id, [user_input, reply]) # 5. 长期记忆异步更新,后台抽取值得记住的事实 enqueue_extract_facts(user_id, user_input + reply) return reply为什么短期更新要同步?因为用户下一句话很可能马上引用这一轮内容,如果不同步写,上下文就断了。为什么长期更新可以异步?因为抽取事实需要模型分析,延迟较高,而用户通常不会要求立刻记住,异步处理能让请求更快返回。另外,异步还能做去重和合并,避免每轮对话都往长期记忆里写一堆噪音。
4.3 从Demo到生产,还差这些工程细节
接口跑通只是第一步,生产级系统还需要补齐几件事:
- 并发控制:同一会话的多轮请求可能同时进来,写短期记忆时要保证顺序,否则上下文会乱。
- 隔离:不同用户、不同会话的记忆必须严格隔离,user_id和session_id一定不能漏。
- 记忆审计:长期记忆要有查看和删除入口,用户有权清除自己的记忆数据。
- 记忆注入的格式:召回结果要标注“参考资料”,不能伪装成既定事实,否则模型容易直接当成用户原话。
- 降级机制:长期记忆服务挂了,要能自动降级为“只带短期记忆”的会话模式,而不是整个agent不可用。
5. 我在agent项目里踩过的记忆相关的坑
5.1 “上下文越长越聪明”是我踩过最贵的坑
早期我以为多塞历史肯定更好,于是把全部对话记录一股脑拼进prompt。结果有几类状况频繁发生:一是token消耗飙升;二是模型开始复读早前的回答;三是用户提了个早期细节,模型反而抓不住。后来我做了切口:先把关键约束剥离出来单独放,把可压缩的历史做摘要,窗口只保留最近几轮。效果立刻改善。
如果你也遇到模型“越聊越笨”的情况,先别怀疑模型能力,回头检查一下是不是历史塞太满了。短期记忆的价值不在“全”,而在“关键信息稳定出现”。
5.2 检索召回不准时,别硬把记忆塞给模型
有一段时间,我们的agent几乎每次都会把长期记忆的top-5结果塞进prompt。结果其中掺杂了两三条与当前问题无关的记忆,模型却没有分辨能力,会把“用户曾经提到喜欢简洁回复”和当前“询问技术选型”的问题强行关联起来,答得又长又偏。
后来我做了两件事。召回结果在prompt里明确标注为“以下内容仅为参考,不一定是当前任务依据”,并加了一层置信度过滤,低于阈值的直接不注入。这样模型在信息不足时可以坦然说不知道,而不是拿着过期记忆硬编。你要接受一个事实:长期记忆检索不可能永远精准,与其硬塞,不如让模型学会“参考但不盲从”。
5.3 记忆污染:用户一句玩笑话,被当成长期画像
印象最深的一次,用户在对话里说“我要是有钱就先买三个”,我们的抽取模块把“买三个”提取成用户偏好,后续推荐全部按“数量三”来,用户莫名其妙。问题出在写入策略太激进,只要对话里出现疑似事实就写入。
现在我的写入规则是:用户明确用“我喜欢”“我不”“以后都”这类句式表达的内容,或者同一事实出现两次以上,才允许进入长期记忆。其它内容最多留在短期里做上下文,不进画像。另外,隔一段时间让模型对画像层做一次自检,也会很有用:把用户主要偏好列出来,看是否有明显互相矛盾或经不起推敲的条目,然后人工或自动清理。
5.4 长期记忆的时效衰减,细节决定体验
用户偏好是会变的。三个月前喜欢长文,最近一周都在看短回答,如果不做时间衰减,老记忆会一直排在最前,导致agent显得“反应迟钝”。我给每条记忆加了时间戳和衰减因子,排序分数近似处理为:
score = similarity * decay(age)
每次召回时,近期的记忆天然占优,但重要的历史偏好也不会彻底消失。这个方法实现成本很低,但对长期使用体验的提升非常明显。时间衰减系数的具体取值可以按业务调整,比如偏好在30天内比较稳,就设计成30天内衰减慢、之后衰减加快。
最后再分享一个我一直保留的小习惯:给每条长期记忆都加confidence字段。凡是置信度不足的记忆,宁可让agent多问用户一句,也不要拿它去生成回答。多问一句只是体验上稍微啰嗦一点,可一旦拿着错误记忆去回答,用户对agent的信任就回不来了。短期记忆管的是当下这轮对话,长期记忆管的是整个服务周期;两者都需要有“敢于忘记”的机制,这样的记忆系统才是健康的。