
1. 从聊完就忘说起Agent 记忆到底卡在哪做 Agent 开发的人几乎都经历过同一个尴尬时刻用户上一轮刚说过我叫老张做跨境电商的下一轮问帮我写个选品方案Agent 张口就是您好请问您从事什么行业。这不是模型笨是它压根没记住。更离谱的是有些 Agent 在同一通对话里都能自相矛盾——前面说预算 5 万后面按 50 万的方案给你排。我前后搭过七八个商用 Agent从客服场景到个人助理场景都趟过。踩下来最大的感受是记忆不是把历史消息全塞进 prompt这么简单。你全塞进去token 爆炸、成本失控、模型还会被无关信息干扰你只塞最近几条用户三天前交代的偏好就丢了。真正能上商用的记忆架构必须把记忆分层——会话记忆、短期记忆、长期记忆再配一套遗忘机制做减法。这四块拼起来才是一个完整的记忆闭环。这篇内容我打算把这条链路从头到尾拆一遍包括每层的存储选型、数据结构、读写时机、淘汰策略以及我在实际项目里踩过的坑。适合已经能跑通一个基础 Agent、但发现它怎么老是忘事的开发者也适合正在设计 Agent 架构、想提前把记忆这层想清楚的人。代码我会用 Python 写思路是通用的换成任何框架都能套。先说一个反直觉的结论Agent 健忘90% 不是模型能力问题是架构设计问题。你把记忆分层做对了哪怕用中等规模的模型体验也能吊打一堆堆大模型的产品。下面我们一层层来。2. 会话记忆一次对话内的上下文怎么管2.1 会话记忆的本质是滑动窗口 摘要会话记忆Session Memory解决的是单次会话内的连贯性问题。用户问它多少钱你得知道它指的是上一轮聊的那款产品。这层的核心矛盾是对话越长历史越多但模型的上下文窗口是有限的而且 token 是要花钱的。最朴素的做法是滑动窗口——只保留最近 N 轮对话。但纯滑动窗口有个致命问题用户在第 3 轮说的关键信息比如我对花生过敏到第 20 轮就被滑出去了。所以工业界的标准做法是滑动窗口 滚动摘要保留最近 N 轮原文更早的内容压缩成一段摘要。我一般这样设计最近 6 到 8 轮保留完整原文保证细节不丢超过的部分交给一个轻量模型做增量摘要。摘要不是每次重写而是旧摘要 新滑出的对话合并生成新摘要这样成本可控。class SessionMemory: def __init__(self, max_recent_turns8, summarizerNone): self.recent_turns [] # 最近N轮完整对话 self.rolling_summary # 更早内容的滚动摘要 self.max_recent_turns max_recent_turns self.summarizer summarizer # 一个轻量LLM调用 def add_turn(self, role, content): self.recent_turns.append({role: role, content: content}) # 超出窗口把最老的一轮挤出去并入摘要 if len(self.recent_turns) self.max_recent_turns * 2: overflow self.recent_turns[:2] # 挤出一轮(userassistant) self.recent_turns self.recent_turns[2:] self._merge_into_summary(overflow) def _merge_into_summary(self, overflow_turns): text \n.join(f{t[role]}: {t[content]} for t in overflow_turns) prompt f已有摘要{self.rolling_summary}\n新增对话{text}\n请合并成一段不超过150字的摘要保留关键事实、偏好、结论。 self.rolling_summary self.summarizer(prompt) def build_context(self): ctx [] if self.rolling_summary: ctx.append({role: system, content: f早前对话摘要{self.rolling_summary}}) ctx.extend(self.recent_turns) return ctx2.2 摘要里该保留什么、该丢什么这里有个很多人忽略的细节摘要不是越短越好而是该留的留、该丢的丢。我见过有人把摘要压成一句话用户咨询了产品问题那等于没摘要。摘要要保留的是三类信息事实性信息用户身份、订单号、具体数值、偏好性信息喜欢简洁回答、讨厌推销、结论性信息已经确定的方案、已达成的共识。反过来寒暄、重复确认、模型自己的废话这些都可以丢。我在 prompt 里会明确告诉摘要模型丢弃寒暄和重复内容只保留事实、偏好、结论。实测下来这样压出来的摘要信息密度高很多。注意摘要这一步一定要用便宜的小模型别用主力大模型。摘要是个信息压缩任务不需要多强的推理能力用大模型纯属浪费钱。我一般用同系列里最小的那个。2.3 会话记忆的存储与生命周期会话记忆是易失性的会话结束就该释放或者归档到长期记忆。存储上我推荐直接用内存 Redis键设计成session:{session_id}:memory设一个合理的 TTL比如 2 小时。别小看 TTL我踩过一次坑没设过期时间测试环境跑了一周Redis 里堆了几十万个僵尸会话内存直接告警。会话记忆的读写时机也很关键。读发生在每次调用模型前把build_context()的结果拼进 messages。写发生在模型返回后把这一轮的 user 和 assistant 消息都存进去。注意是都存只存 user 不存 assistant下一轮模型就不知道自己刚才说了啥会出现前后矛盾。3. 短期记忆跨会话但会过期的工作台3.1 短期记忆和会话记忆的区别很多人搞混会话记忆是这一次对话内短期记忆是最近一段时间内、跨多次会话。举个例子用户今天上午问了一次下午又来了这两次是不同会话但属于同一个短期记忆周期。短期记忆要记住的是这个用户最近在忙什么——比如他最近在对比三款产品、他上周提过要搬家。为什么需要这一层因为用户不会把所有事都塞在一次对话里。如果只有会话记忆用户每次新开对话Agent 都像第一次见面如果直接上长期记忆又会把上周临时问的事当成永久偏好造成干扰。短期记忆就是那个有保质期的工作台。3.2 用向量库 时间衰减实现短期记忆短期记忆我一般用向量数据库比如 FAISS、Milvus、pgvector 都行来存每条记忆带上时间戳和重要度。检索时不是单纯按相似度排而是相似度 × 时间衰减综合打分。import math, time def time_decay_score(similarity, created_at, half_life_hours72): 相似度结合时间衰减半衰期默认72小时 age_hours (time.time() - created_at) / 3600 decay math.pow(0.5, age_hours / half_life_hours) return similarity * decay def retrieve_short_term(query_vec, store, top_k5): candidates store.search(query_vec, top_k20) # 先粗召回 scored [ (item, time_decay_score(item.similarity, item.created_at)) for item in candidates ] scored.sort(keylambda x: x[1], reverseTrue) return [item for item, _ in scored[:top_k]]半衰期设多少看场景。客服场景我设 24 到 48 小时因为用户的问题通常当天就解决了个人助理场景我设 72 小时到一周因为最近在忙的事周期更长。这个参数没有标准答案得根据你的业务节奏调。3.3 短期记忆的写入别什么都往里塞短期记忆最容易犯的错是写入太随意。如果每轮对话都往向量库塞一条很快库就爆了检索质量也下降。我的做法是只在有信息增量时才写用户透露了新的偏好、新的任务、新的约束条件才写一条纯寒暄、纯确认不写。判断有没有信息增量可以让模型做也可以规则做。我一般用一个轻量判断让模型输出一个 JSON包含should_remember布尔、memory_typefact/preference/task、content记忆内容。这样写进去的记忆是结构化的检索时还能按类型过滤。EXTRACT_PROMPT 从下面这轮对话中提取值得长期记住的信息。 只提取事实、偏好、任务忽略寒暄。 输出JSON{should_remember: bool, memory_type: fact|preference|task, content: ...} 对话{dialogue}提示短期记忆的向量化建议用和检索时同一个 embedding 模型别写入用一个、检索用另一个否则相似度算出来是错的。这个坑我踩过排查了半天才发现是模型不一致。4. 长期记忆让 Agent 真正认识这个用户4.1 长期记忆存的是人不是话如果说短期记忆是工作台长期记忆就是用户档案。它存的是稳定的、跨时间的事实和偏好用户叫什么、什么职业、说话喜欢直接还是委婉、有没有固定的输出格式要求、对哪些话题敏感。这些信息一旦写入除非用户主动更正否则长期有效。长期记忆和短期记忆最大的区别是写入门槛。短期记忆可以宽松点长期记忆必须严格——因为一旦写错它会污染之后所有的对话。我见过一个案例用户随口说了句我最近在减肥系统把它当成长期偏好写进去了结果之后每次推荐餐厅都推轻食用户烦得不行。所以长期记忆的写入我坚持要么用户明确表达要么多次重复出现。4.2 长期记忆的存储结构结构化 向量双写长期记忆我推荐双写一份结构化的存数据库比如 PostgreSQL一份向量的存向量库。结构化那份用于精确查询和展示比如我的偏好设置页面向量那份用于语义检索。结构化表可以这样设计字段类型说明idbigint主键user_idvarchar用户标识mem_typevarcharfact/preference/constraintcontenttext记忆内容confidencefloat置信度 0-1sourcevarchar来源会话IDcreated_attimestamp创建时间updated_attimestamp更新时间hit_countint被检索命中次数confidence这个字段很关键。用户明确说的置信度给 0.9模型推断的给 0.5 到 0.7。检索时低于阈值的直接过滤掉避免猜出来的偏好干扰用户。4.3 长期记忆的检索先粗筛再精排长期记忆检索不能只靠向量相似度。因为长期记忆条目可能很多纯向量检索容易召回一堆语义相近但实际无关的。我的做法是两阶段先用结构化条件粗筛比如按 user_id、mem_type、confidence 阈值再对候选做向量精排。def retrieve_long_term(user_id, query_vec, db, vec_store, top_k3): # 阶段一结构化粗筛 rows db.query( SELECT id, content FROM memories WHERE user_id%s AND confidence0.6 ORDER BY hit_count DESC LIMIT 50, user_id) ids [r[id] for r in rows] # 阶段二向量精排 hits vec_store.search(query_vec, filter_idsids, top_ktop_k) # 更新命中计数用于后续排序 for h in hits: db.execute(UPDATE memories SET hit_counthit_count1 WHERE id%s, h.id) return hitshit_count的用途是经常被命中的记忆说明它确实重要下次排序时给它加权。这是个很轻量的记忆强化机制效果比想象中好。4.4 长期记忆的更新与冲突处理长期记忆会变。用户去年说我喜欢简洁回答今年说你多解释点这两条冲突了怎么办我的处理原则是新信息覆盖旧信息但保留历史。具体做法是给每条记忆加一个is_active标记新记忆写入时把同类型的旧记忆标记为 inactive而不是物理删除。这样既保证了当前行为正确又保留了可追溯性。冲突检测可以做得更细如果新记忆和旧记忆是同一维度比如都是回答风格就覆盖如果是不同维度一个是风格、一个是职业就并存。判断维度这件事我一般让模型在写入时顺便输出一个dimension字段比如response_style、profession、dietary同 dimension 才做覆盖。5. 遗忘机制会忘才是好的记忆系统5.1 为什么必须主动遗忘很多人做记忆只想着怎么记住忽略了怎么忘掉。但一个不会遗忘的系统迟早会被垃圾信息淹没。想象一下用户三年前随口提过一句我在学吉他系统一直记着每次对话都往这个话题上引用户只会觉得诡异。遗忘的价值有三个降噪去掉过时、无关的信息、控成本记忆越多检索和拼 prompt 越贵、保准确避免旧信息干扰当前判断。所以遗忘不是 bug是 feature。5.2 三种遗忘策略时间、频率、容量我一般组合用三种策略时间遗忘给每条记忆设一个保质期。事实类职业、姓名保质期长甚至永久偏好类保质期中等几个月任务类保质期短几天到几周。到期后不是直接删而是降权检索时排后面。频率遗忘长期没被命中的记忆说明它不重要逐步降权。我设的规则是30 天没命中confidence 打 8 折90 天没命中打 5 折低于 0.3 就归档。容量遗忘每个用户的长期记忆条目设上限比如 200 条超了就淘汰分数最低的。分数 confidence × 时间衰减 × 命中频率。def compute_memory_score(mem, now): age_days (now - mem.updated_at).days time_factor math.pow(0.5, age_days / 180) # 半年半衰 freq_factor math.log(mem.hit_count 1) / 10 # 命中越多分越高 return mem.confidence * time_factor * (1 freq_factor) def forget_pass(user_id, db, max_items200): mems db.query(SELECT * FROM memories WHERE user_id%s AND is_active1, user_id) if len(mems) max_items: return scored sorted(mems, keylambda m: compute_memory_score(m, time.time())) to_archive scored[:len(mems) - max_items] for m in to_archive: db.execute(UPDATE memories SET is_active0 WHERE id%s, m.id)5.3 遗忘要软不要硬我强烈建议软删除也就是标记归档而不是物理删除。原因有二一是万一误删了重要记忆还能恢复二是归档的记忆可以作为冷数据备查比如做用户画像分析时用得上。物理删除只在合规要求比如用户要求删除数据时才做。注意遗忘策略的参数半衰期、阈值、上限一定要做成可配置的别写死在代码里。不同业务、不同用户群体最优参数差别很大上线后肯定要调。6. 四层记忆怎么串起来一次完整调用的链路6.1 读取链路从外到内逐层检索一次完整的 Agent 调用记忆读取的顺序是这样的加载长期记忆按 user_id 拉取该用户的稳定档案职业、偏好、约束这部分通常直接全量拼进 system prompt因为量不大且都重要。检索短期记忆用当前 query 的向量去短期记忆库检索取 top-k 条拼进上下文。组装会话记忆把滚动摘要 最近 N 轮原文拼进去。合并去重四层信息可能有重叠做一次去重避免同一件事说两遍。拼进 prompt 的顺序也有讲究。我一般把长期记忆放最前作为背景短期记忆次之作为近期上下文会话记忆放最后紧贴当前对话。这样模型读起来是背景→近期→当前的自然递进。6.2 写入链路分层判断各归各位模型返回后写入链路要判断这轮对话产生了什么记忆会话记忆无条件写入这是基础。短期记忆判断有无信息增量有则写。长期记忆判断是否是稳定事实/偏好且置信度够高才写。遗忘每次写入后触发一次轻量的遗忘检查可以异步做别阻塞主流程。def after_turn(user_id, session_id, user_msg, assistant_msg, deps): # 1. 会话记忆 deps.session_mem.add_turn(user, user_msg) deps.session_mem.add_turn(assistant, assistant_msg) # 2. 提取记忆 extracted deps.extractor.extract(user_msg, assistant_msg) if extracted[should_remember]: if extracted[memory_type] task: deps.short_term.write(user_id, extracted[content]) else: deps.long_term.write(user_id, extracted, confidence0.8) # 3. 异步遗忘 deps.scheduler.enqueue(forget_pass, user_id)6.3 一个容易忽略的点记忆的作用域记忆是有作用域的。同一个用户在不同 Agent比如客服 Agent 和私人助理 Agent之间记忆该不该共享我的经验是长期记忆可以共享短期和会话记忆必须隔离。因为长期记忆是关于这个人的跨场景通用而短期记忆是这个人最近在这个场景下的事混在一起会串味。实现上就是给记忆加一个scope字段检索时带上 scope 过滤。这个设计在单 Agent 场景下看不出价值但一旦你开始做多 Agent 协作它就是救命的。7. 实战踩坑那些文档不会告诉你的细节7.1 摘要漂移越摘要越离谱滚动摘要用久了会出现漂移——摘要经过多次合并逐渐偏离原始事实。我遇到过一次用户明明说的是预算 5 万经过五六轮摘要合并后变成了预算 50 万直接导致方案全错。解决办法是定期用原始对话重建摘要而不是无限合并。我的做法是每合并 5 次就用最近一段完整对话重新生成一次摘要把漂移拉回来。另外摘要里涉及数字、专有名词的我要求模型原样保留不许改写。7.2 向量检索的假相关向量检索经常召回语义相近但实际无关的记忆。比如用户问推荐个餐厅检索召回了用户上次说喜欢某部电影因为喜欢这个词的语义相近。这种假相关会干扰模型。我的缓解办法是加一层重排召回 top-20 后用一个轻量模型对每条记忆和当前 query 做相关性打分只保留真正相关的 top-3。这一步成本不高但效果提升明显。7.3 记忆写入的重复爆炸同一个信息被反复写入是短期记忆库膨胀的主因。用户说我喜欢简洁回答可能在不同对话里说了三次就写了三条。解决办法是写入前做相似度去重新记忆先和已有记忆比一下相似度超过 0.9 就不写或者更新已有那条的时间戳。7.4 别让记忆拖慢响应记忆检索是 IO 操作如果串行做会明显拖慢首字响应。我的优化是并行化长期记忆、短期记忆、会话记忆的读取同时发起用asyncio.gather合并结果。写入侧则尽量异步别让用户等。import asyncio async def load_all_memory(user_id, query_vec, deps): long_term, short_term await asyncio.gather( deps.long_term.retrieve(user_id, query_vec), deps.short_term.retrieve(user_id, query_vec), ) session deps.session_mem.build_context() return merge_and_dedup(long_term, short_term, session)7.5 测试记忆系统要专门造长对话普通的功能测试测不出记忆问题因为记忆的 bug 都在长周期、多会话里才暴露。我一般专门写一组记忆测试用例模拟一个用户跨 10 次会话、每次 20 轮对话检查关键信息有没有丢、有没有串、有没有漂移。这组用例跑下来能揪出大部分记忆架构的隐患。8. 关于记忆架构我个人的几条经验做了这么多 Agent我越来越觉得记忆架构的难点不在存而在取和舍。存谁都会存但什么时候取哪一层、什么时候该忘掉才是区分好坏的地方。第一条经验先跑通最小闭环再优化分层。别一上来就设计四层记忆先用会话记忆 一个简单的长期记忆跑起来看看实际哪里不够用再针对性加层。我见过太多人把架构设计得无比复杂结果连基础对话都跑不顺。第二条记忆的每个参数都要能观测。命中率、召回准确率、摘要漂移程度、遗忘触发次数这些指标要打点监控。没有观测你调参就是瞎猜。第三条永远给用户一个记忆管理入口。让用户能看到 Agent 记住了什么、能手动删除和更正。这不仅是体验问题也是信任问题。用户发现 Agent 记错了却改不了会直接弃用。最后分享一个小技巧长期记忆写入时我会额外存一个evidence字段记录这条记忆是从哪句话提取的。当用户质疑你怎么知道我喜欢简洁回答时系统能回溯到原话既方便排查也让用户觉得可信。这个字段平时用不上但一旦出问题它就是救命稻草。