
1. 为什么你的 Agent 总是“转头就忘”做过 Agent 项目的人大概率都遇到过这种场景用户上一句刚说了“我住在杭州平时喜欢喝美式”下一句问“帮我推荐个附近的咖啡店”Agent 却像失忆一样反问“请问您在哪个城市”。这不是模型不够聪明而是记忆架构没搭好。大模型本身是无状态的每一次调用对它来说都是“第一次见面”所谓“记住”本质上是我们外部给它喂了历史信息。喂多少、喂哪些、喂多久、什么时候丢这一整套设计就是 Agent 记忆架构。我前后在客服机器人、个人助理、代码助手这几类项目里都踩过记忆相关的坑最深的体会是记忆不是把聊天记录一股脑塞进上下文就完事那样既烧 token 又容易让模型抓不住重点。真正商用的 Agent 记忆通常要拆成四层来设计——会话记忆、短期记忆、长期记忆、遗忘机制。这四层各司其职配合起来才能让 Agent 既“记得住当下”又“想得起过去”还不会“被垃圾信息淹没”。这篇文章我会把这四层从原理到代码完整拆一遍给出可直接复现的实现思路。适合正在做 Agent 开发、被上下文长度和记忆混乱折磨过的同学也适合刚入门想搞清楚“记忆到底怎么存”的朋友。全程用大白话加代码尽量让你看完就能动手改自己的项目。2. 记忆架构的整体设计与分层思路2.1 四层记忆到底各管什么先把概念理清楚不然后面代码会乱。我习惯把 Agent 记忆按“时间尺度”和“存储位置”两个维度切成四块会话记忆Session Memory当前这一轮对话的完整上下文生命周期就是这次会话。用户关掉窗口、超时、主动结束它就没了。它保证的是“对话连贯性”。短期记忆Short-term Memory跨几轮甚至跨会话但有时效性的信息比如“用户今天在查机票”。它通常放在内存或 Redis 里带 TTL。长期记忆Long-term Memory用户偏好、身份信息、历史结论这类需要长期保留的落到数据库或向量库靠检索召回。遗忘机制Forgetting不是单独一层而是贯穿前三层的清理策略——过期删除、重要性衰减、容量淘汰。为什么非要分这么细因为它们的读写频率、存储成本、召回方式完全不同。会话记忆每轮都全量读必须快长期记忆偶尔查一次可以慢但必须准。混在一起做要么慢要么贵要么乱。2.2 为什么不能只靠“把历史全塞进 Prompt”很多人第一版 Agent 就是这么干的维护一个 messages 数组每轮把整个数组拼进 prompt。小规模能用一旦上量就崩。我实测过一个客服场景单会话平均 40 轮每轮平均 120 token到后面光历史就 4800 token加上系统提示和工具定义一次调用轻松破 8000。按商用 API 的价格算成本直接翻几倍而且模型在长上下文里对中间部分的注意力会衰减早期关键信息反而被忽略。所以分层设计的核心目的就两个控制进入 prompt 的信息量以及让该被记住的信息以更高优先级出现。会话记忆负责最近几轮短期记忆负责“最近发生的事”长期记忆负责“这个人的底色”遗忘机制负责把没用的清出去。2.3 一个可落地的数据流我常用的数据流是这样的用户输入进来先写进会话记忆原始消息同时抽取关键信息写入短期记忆每轮组装 prompt 时按“系统提示 → 长期记忆召回 → 短期记忆摘要 → 会话最近 N 轮”的顺序拼接会话结束时把值得长期保留的内容沉淀到长期记忆其余交给遗忘机制处理。这个顺序有讲究长期记忆放前面是因为它相对稳定能作为“背景设定”短期记忆摘要放中间作为“近期状态”最近几轮对话放最后因为模型对末尾内容注意力最强正好用来承接当前话题。下面几章我会逐层展开。3. 会话记忆让对话不“断片”的基础层3.1 会话记忆的本质是一个滑动窗口会话记忆说白了就是“最近几轮对话”。但“最近几轮”怎么定义直接决定体验。我见过两种极端做法一种是只保留最后一轮Agent 完全没有上下文另一种是全保留token 爆炸。合理的做法是滑动窗口 摘要压缩。滑动窗口的意思是只保留最近 K 轮原文比如 K6。超出的部分不是直接丢而是压缩成一句摘要塞进短期记忆。这样既保证了近期对话的完整细节又不会让历史无限膨胀。K 的取值要看场景闲聊类可以小一点4~6任务类比如订票、写代码可以大一点8~10因为任务往往需要更多上下文。3.2 会话 ID 与状态管理会话记忆必须绑定一个会话 ID否则多用户并发时会串台。我一般用user_id session_id作为 keysession_id 在用户开启新会话时生成。这里有个容易忽略的点会话的“开启”和“连接”是两回事。连接是网络层的会话是业务层的。用户可能断线重连但会话应该延续也可能一直连着但主动点了“新对话”那会话就该重置。把这两个概念分开能避免很多诡异 bug。存储上单机可以用内存字典多实例部署必须用 Redis 之类的共享存储否则用户请求打到不同实例上就会“记忆分裂”。这是我早期踩过的大坑本地测试一切正常一上负载均衡就各种失忆。3.3 会话记忆的代码实现下面是一个精简但可用的会话记忆实现用 Python 写存储抽象成接口方便替换from collections import deque from typing import List, Dict class SessionMemory: def __init__(self, max_turns: int 6): # 每个 turn 是一条 user 或 assistant 消息 self.max_turns max_turns self.buffer deque(maxlenmax_turns * 2) def add(self, role: str, content: str): self.buffer.append({role: role, content: content}) def get_recent(self) - List[Dict]: return list(self.buffer) def overflow(self) - List[Dict]: # 返回被挤出窗口的消息交给摘要器处理 return list(self.buffer)[: -self.max_turns * 2] if len(self.buffer) self.max_turns * 2 else []deque设了maxlen超出自动从左边弹出天然就是滑动窗口。overflow方法用来拿到被挤出的内容交给下一层的摘要逻辑。实际项目里我会把 buffer 换成 Redis 的 list用LPUSHLTRIM实现同样的效果这样多实例共享。注意滑动窗口的“轮”要按对话轮次算不是按消息条数。一轮 用户一句 助手一句别把工具调用的中间消息也算进去否则窗口会被工具消息占满。3.4 会话记忆的实操心得我踩过的一个坑是把工具调用的返回结果也塞进会话记忆结果一次搜索返回几千字直接把窗口撑爆。后来改成工具结果只保留摘要原文放短期记忆按需召回。另一个心得是会话记忆里最好保留原始措辞不要过早改写因为用户可能引用自己之前的话改写后对不上。还有一点会话记忆的读取要“按需”不是每轮都全量拼。比如用户只是说了句“谢谢”完全没必要把六轮历史都带上。我一般会根据当前输入的意图判断如果是延续性话题就带全窗口如果是全新话题就只带最近一两轮。4. 短期记忆跨轮次的“工作台”4.1 短期记忆和会话记忆的边界很多人分不清短期记忆和会话记忆我用一个类比会话记忆是“你正在说的这段话”短期记忆是“你今天脑子里装的事”。会话记忆随会话结束而消失短期记忆可以跨会话但有时间限制比如几小时到几天。典型场景用户上午问“帮我看看明天杭州的天气”下午又问“那我要不要带伞”。这两次可能是不同会话但短期记忆里存着“用户关注杭州明天天气”Agent 就能接上。如果只靠会话记忆下午这次就是全新对话Agent 根本不知道“那”指什么。4.2 短期记忆存什么、怎么存短期记忆我一般存三类东西实体信息用户提到的地点、时间、人名、任务状态正在进行的任务、已完成步骤、临时结论Agent 自己算出来或查到的结果。存储结构用 key-value 加 TTL 最省事Redis 的SETEX一行搞定。TTL 的设置要看信息类型任务状态可以短一点30 分钟实体信息可以长一点24 小时。我习惯给每条短期记忆打一个“重要性”分数重要性高的 TTL 长低的短。重要性怎么算简单点可以用规则比如包含时间、金额、人名的加分复杂点可以用一个小模型打分但大多数场景规则就够了。4.3 短期记忆的读写代码import json import time import redis class ShortTermMemory: def __init__(self, r: redis.Redis, prefix: str stm:): self.r r self.prefix prefix def _key(self, user_id: str) - str: return f{self.prefix}{user_id} def write(self, user_id: str, slot: str, value, ttl: int 3600, importance: float 0.5): # importance 越高ttl 越长 real_ttl int(ttl * (0.5 importance)) data {value: value, ts: time.time(), importance: importance} self.r.hset(self._key(user_id), slot, json.dumps(data)) self.r.expire(self._key(user_id), real_ttl) def read(self, user_id: str, slot: str): raw self.r.hget(self._key(user_id), slot) return json.loads(raw)[value] if raw else None def all_slots(self, user_id: str): return {k.decode(): json.loads(v) for k, v in self.r.hgetall(self._key(user_id)).items()}这里用 hash 存一个用户的所有短期记忆槽位整体设过期时间。importance影响 TTL 是个实用技巧重要信息活得更久。读取时all_slots可以一次性拿到所有短期记忆组装 prompt 时按重要性排序取前几个。4.4 短期记忆的压缩与摘要短期记忆不能无限堆我一般设一个上限比如每个用户最多 20 个槽位超了就按重要性和时间淘汰。另外短期记忆在进入 prompt 前最好做一次摘要把多个槽位合并成一段自然语言比直接塞 JSON 更省 token 也更好理解。摘要的 prompt 我常用这个模板“以下是用户近期的关键信息请用一段话概括保留时间、地点、任务状态{slots}”。实测下来20 个槽位摘要后通常能压到 100 token 以内效果比原始 JSON 好很多。提示短期记忆的写入时机很关键。不要等会话结束才写那样中途断线就丢了。我一般在每轮对话后异步写用消息队列解耦避免阻塞主流程。5. 长期记忆让 Agent 真正“认识”用户5.1 长期记忆的两种形态长期记忆我分成两类结构化记忆和非结构化记忆。结构化的是用户画像比如“用户叫张三常住杭州偏好美式咖啡是后端工程师”存数据库字段清晰查询快。非结构化的是历史对话片段、文档、结论存向量库靠语义检索召回。为什么要分两种因为它们的召回方式不同。结构化信息适合精确匹配和过滤比如“查所有偏好美式的用户”非结构化信息适合模糊语义检索比如“用户之前提过的那个项目叫什么来着”。两者配合才能既准又全。5.2 向量检索的核心参数非结构化长期记忆的核心是向量检索。这里有几个参数必须调好否则召回质量很差chunk 大小切分粒度。太小语义不完整太大检索不准。我一般按 200~500 token 切带一点重叠。top_k召回条数。太少漏信息太多噪声大。常用 3~5。相似度阈值低于阈值的不召回避免答非所问。经验值 0.7 左右具体看 embedding 模型。重排序召回后用一个 cross-encoder 重排能显著提升精度但会增加延迟按需开启。这些参数没有万能值必须拿真实数据调。我的做法是先固定 top_k5然后调阈值观察召回结果的相关性再反过来调 chunk 大小。5.3 长期记忆的写入策略长期记忆不能什么都写否则很快变成垃圾场。我的写入策略是“事件驱动 重要性过滤”会话结束时触发一次沉淀用一个小模型或规则判断哪些内容值得长期保留。值得保留的通常是用户明确表达的偏好、身份信息、重要结论、反复出现的话题。def should_persist(text: str, importance: float) - bool: # 规则 分数双重判断 keywords [我喜欢, 我习惯, 我是, 记住, 以后都] if any(k in text for k in keywords): return True return importance 0.75这个函数很粗糙但实用。实际项目里我会再加一层去重用向量相似度判断新记忆和已有记忆是否重复避免同一偏好存十遍。5.4 长期记忆的召回与注入召回时机有两个每轮都召回和按需召回。每轮都召回简单但费钱按需召回省钱但可能漏。我折中一下先用一个轻量分类器判断当前输入是否需要长期记忆比如问“我是谁”“我之前说过什么”就需要需要才召回。召回后的注入也有讲究。不要把召回原文直接塞进 prompt最好整理成“背景信息”的形式比如“关于该用户的历史信息1. 常住杭州2. 偏好美式咖啡”。这样模型更容易理解这是背景而非当前对话。注意长期记忆召回的内容要标注来源和时间比如“2024-05 记录”避免模型把过期信息当现状。用户搬家了但旧记忆还写着老地址这种错误很常见。6. 遗忘机制会“忘”才是好记忆6.1 为什么必须主动遗忘人的记忆靠遗忘来保持高效Agent 也一样。不遗忘的后果有三个存储成本无限增长、检索噪声越来越大、过期信息误导模型。我见过一个项目长期记忆库跑了一年没清理用户问“我上次说的那个方案”召回出来五个不同时期的方案模型直接懵了。遗忘不是删除那么简单要分清楚“该忘什么”。我的原则是时效性强的信息到期就删重要性低的信息优先淘汰矛盾的信息保留最新的。6.2 三种遗忘策略基于时间的遗忘TTL短期记忆天然带 TTL到期自动清。长期记忆里的临时信息也设 TTL比如“用户本周在出差”这种一周后自动失效。基于重要性的遗忘LRU/LFU 变体给每条记忆打重要性分容量满时淘汰最低分的。重要性可以随访问次数动态调整被召回过的加分。基于矛盾的遗忘新记忆和旧记忆冲突时保留新的、标记旧的为失效。比如用户说“我搬到上海了”旧的“常住杭州”就该失效。6.3 遗忘机制的实现class ForgettingPolicy: def __init__(self, max_items: int 1000, decay: float 0.95): self.max_items max_items self.decay decay def score(self, item: dict, now: float) - float: age_hours (now - item[ts]) / 3600 # 时间衰减 重要性 访问加成 return item[importance] * (self.decay ** age_hours) 0.1 * item.get(hits, 0) def evict(self, items: list, now: float) - list: if len(items) self.max_items: return items items.sort(keylambda x: self.score(x, now), reverseTrue) return items[: self.max_items]这个score函数把时间衰减、重要性和访问次数揉在一起decay0.95表示每小时衰减 5%。实测下来这个简单公式比纯 LRU 更符合直觉因为重要的老记忆不会被轻易淘汰。6.4 遗忘的实操心得遗忘最怕“误删”。我踩过的坑是 TTL 设太短用户第二天回来发现 Agent 完全不认识自己了。后来改成核心画像姓名、长期偏好永不过期只对临时信息设 TTL。另外删除前最好留个“墓碑”记录方便排查问题比如记录“某条记忆在何时因何被删”。还有一个技巧是“软删除”不真删只标记失效检索时过滤掉。这样万一误删还能恢复代价是存储多一点。对大多数项目来说这点存储成本完全值得。7. 全链路串联与常见问题排查7.1 一次完整请求的记忆流转把四层串起来看一次请求用户输入 → 写入会话记忆 → 抽取实体写短期记忆 → 判断是否召回长期记忆 → 组装 prompt长期 短期摘要 会话窗口→ 调用模型 → 返回结果写会话记忆 → 会话结束触发长期沉淀和遗忘清理。这个流程里每一步都可能出问题下面整理成速查表。现象可能原因排查方向Agent 完全失忆会话 ID 丢失或存储不通检查 session_id 生成和 Redis 连接记得旧的不记新的短期记忆 TTL 太长或未更新检查写入时机和 TTL 配置召回答非所问向量阈值太低或 chunk 太大调高阈值、缩小 chunktoken 超限会话窗口太大或长期记忆召回太多缩小 K、限制 top_k多实例记忆不一致用了本地内存存储换成共享存储过期信息误导遗忘机制没生效检查 TTL 和矛盾检测7.2 并发场景下的记忆安全Agent 扛并发时记忆的读写要加锁或用原子操作。比如两个请求同时更新同一个用户的短期记忆可能互相覆盖。我的做法是用 Redis 的HSET原子性保证单字段更新安全跨字段的更新用 Lua 脚本或分布式锁。会话记忆的追加用LPUSH天然原子不用担心。另一个并发坑是“会话串台”用户 A 的请求因为异步处理结果写到了用户 B 的会话里。这通常是上下文传递时丢了 user_id排查时重点看异步任务的参数传递。7.3 记忆架构的演进建议刚开始做不用一上来就四层全上可以按需演进第一版只做会话记忆能跑通对话第二版加短期记忆解决跨会话第三版加长期记忆和向量检索解决个性化最后加遗忘机制解决长期运行。每加一层都要有明确的痛点驱动别为了架构而架构。我个人的体会是记忆架构的复杂度要和业务价值匹配。一个内部工具类 Agent会话记忆加简单短期记忆就够了一个面向 C 端的个人助理长期记忆和遗忘机制才是核心竞争力。想清楚你的 Agent 到底要“记住什么”比堆技术更重要。最后分享一个小技巧给记忆系统加一个“调试面板”能实时看到当前用户的所有记忆内容。排查问题时一眼就能看出是没存进去、存错了还是没召回。这个面板我每个项目都会做省下的排查时间远超开发成本。