导读 :2026 年的 Agent 赛道,"会不会干活"已经不是分水岭,"记不记得住"才是。本文以一套已在生产环境打磨过的三层记忆管理器
memory/manager.py为蓝本,从架构哲学到每一行代码的设计深意,做一次彻底的深度剖析。读完这篇,你将掌握:LRU 淘汰的工程实现、Redis Hash 的记忆持久化、Milvus 语义去重、缓存 Key 的上下文隔离技巧、反思学习(Reflection)的落地写法,以及与 Mem0、Zep/Graphiti、Memory-R1、TencentDB Agent Memory 等业界方案的正面对比。全文超两万字,建议先收藏再细读。
一、为什么 Agent 必须有"记忆"?------ 从一个真实的翻车现场说起
先讲一个几乎所有 Agent 开发者都踩过的坑。
你辛辛苦苦搭了一个医疗问答 Agent,演示的时候一切完美。用户问:"阿莫西林应该怎么吃?" Agent 答得头头是道。用户接着问:"它的副作用是什么?"------Agent 一脸茫然地反问:"请问'它'是指什么?"
问题的根源不是模型能力,而是你的 Agent 根本不记得上一轮说过什么。每一次请求都是一次"失忆重启":上下文不加载、个人偏好不沉淀、历史决策不继承。当 Agent 从个人玩具走向生产系统、从单人助手走向团队协作时,记忆就从一个"加分项"变成了"生死线"。
过去一年,这个判断已经被业界反复验证:
- 腾讯云数据库团队开源了面向 AI Agent 的分层记忆引擎 TencentDB Agent Memory,明确提出要给长期记忆建立层级、给短期记忆引入符号压缩,并推动 Agent 记忆从"个人记忆"进化到"团队资产";
- Memory-R1 用强化学习教会 LLM 自己决定"记什么、删什么、怎么用记忆",在 LOCOMO 基准上超越 Mem0、A-Mem、LangMem 等一众方案;
- Zep/Graphiti 走知识图谱路线,用"时序感知的三层知识图谱"管理 Agent 记忆,维持事实与关系的历史时间线;
- 阿里通义的 AgentScope 1.0 把"智能上下文管理"(短期记忆优化 + 跨会话长期记忆)列为框架关键特性,专门解决智能体的"失忆"和"归零重启"问题。
一句话总结 2026 年的共识:没有记忆的 Agent 只是一个带工具的问答机器人,有记忆的 Agent 才是真正"越用越懂你"的数字员工。
但"做记忆"这件事,网上绝大多数教程只停留在概念层:画一张"短期/长期/向量"的三层架构图,然后就没有然后了。真正的工程难点全在细节里------
- 会话多了内存怎么不爆?
- 多轮对话里同一句话在不同上下文答案不同,缓存怎么不"串话"?
- Redis 记忆怎么设置过期又怎么续期?
- Agent 怎么从自己的错误中"反思学习"?
- 异步框架下同步的 Redis 客户端怎么优雅调用?
这篇文章就把这些细节一个一个掰开揉碎。我们直接上一套完整的、可投产的代码。
二、先想清楚:记忆为什么要分三层?
2.1 向人类大脑偷师
认知科学把人类记忆粗略分为三层,这恰好是一个完美的架构模板:
| 人类记忆 | 特征 | Agent 对应层 | 技术载体 |
|---|---|---|---|
| 工作记忆 | 容量极小(7±2),只服务于"当前正在想的事",用完即弃 | 短期记忆 | 进程内 dict + 滑动窗口 |
| 情景记忆 | 亲身经历的事件序列,有明确时间戳,会随时间淡化 | 长期记忆 | Redis Hash + 7 天 TTL |
| 语义记忆 | 抽象出来的知识("巴黎是法国首都"),不依赖具体经历,近乎永久 | 语义记忆 | Milvus 向量库 |
注意这个对应关系里最精妙的三个"拟人化"设计:
- 短期记忆"用完即弃"------会话结束就没了,所以存储选最便宜的进程内存,根本不落盘;
- 长期记忆"会淡化"------7 天 TTL,就像你记不清两周前午饭吃了什么,但昨天的事历历在目;且每次读写都续期,活跃用户的记忆一直"保鲜",沉默用户自然遗忘------这简直就是人类记忆的"用进废退";
- 语义记忆"永久但抽象" ------不存对话原文,存问题的向量表示,做的是"相似问题检测",本质是把经验泛化成知识。
2.2 为什么不是一层,也不是两层?
只用一层(比如把所有东西塞进向量库)会怎样? 每一轮对话都要过一次 embedding + 向量检索,多轮上下文的注入延迟从微秒级(内存 dict 读取)劣化到几十毫秒级,成本和延迟双杀。而且对话上下文是"精确的、有序的",向量检索是"模糊的、无序的",用模糊检索去还原精确上下文,方向就错了。
只用两层(短期 + 向量)会怎样? 用户的偏好、高频话题、Agent 的反思记录,这些是结构化、强 schema、需要整存整取的数据,硬塞进向量库既难更新(向量库不擅长 in-place 更新)又难过期管理。Redis Hash 恰好补上这一块:按 field 精确读写、原生 TTL、读写都在亚毫秒级。
所以三层不是"为了架构图好看",而是每层的数据特征、访问模式、生命周期完全不同,硬凑成一层必然顾此失彼。这是本套设计的第一条哲学:
按数据的生命周期和访问模式分层,让每一层用最合适的技术载体。
三、整体架构鸟瞰
先看全文的骨架(建议配合代码食用):
┌─────────────────────────────┐
│ MemoryManager │ ← 统一门面(Facade)
│ 对外暴露全部记忆能力 │
└──────────┬──────────────────┘
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ ShortTermMemory │ │ LongTermMemory │ │ SemanticMemory │
│ 短期记忆 │ │ 长期记忆 │ │ 语义记忆 │
│────────────────────│ │────────────────────│ │────────────────────│
│ 存储: 进程内 │ │ 存储: Redis Hash │ │ 存储: Milvus │
│ OrderedDict │ │ memory:user:{id} │ │ (复用 collection) │
│ TTL: 会话生命周期 │ │ TTL: 7 天(滑动续期) │ │ TTL: 永久 │
│ 策略: LRU + 滑动窗口│ │ 结构: topics/偏好/ │ │ 用途: 相似问题检测, │
│ 用途: 多轮对话上下文 │ │ 反思历史 │ │ 避免重复计算 │
└────────────────────┘ └────────────────────┘ └────────────────────┘
模块头部文档里写明了三条设计要点,这三条是整个文件的灵魂,我逐条解读:
设计要点一:复用现有 redis_manager 和 milvus_manager,不创建新连接。
这一条看似平淡,实则是生产代码和老手代码的分水岭。见过太多"教程级"实现,每个类 __init__ 里自己 redis.Redis(...) 连一遍------在微服务环境里,一个进程里挂着十几个 Redis 连接、几十个 Milvus 连接,连接池形同虚设,压测一来连接数直接打爆。正确姿势是全局单例连接管理器,各模块注入或懒加载引用 ,本文代码两种姿势都演示了(构造函数注入 + _ensure_redis 懒加载兜底)。
设计要点二:异步友好,所有 I/O 操作通过 asyncio.to_thread。
记忆系统天然是 I/O 密集的(Redis 读写、Milvus 检索),而现代 Agent 服务端几乎清一色 FastAPI + asyncio。直接在协程里调同步的 redis-py 客户端,会把整个事件循环卡住------所有并发请求排队等这一个阻塞调用。asyncio.to_thread 是标准库里最优雅的解法,后文有一整节深度剖析。
设计要点三:短期记忆使用 LRU 淘汰策略,限制内存占用。
进程内缓存最怕的就是无界增长(unbounded growth)。一个线上 Agent 服务跑三个月,如果会话字典只进不出,内存曲线会是一条不回头的斜线,最终 OOM 被杀。LRU 是最经典也最够用的答案,而 Python 标准库的 OrderedDict 让你零依赖实现它。
在进入逐行精讲之前,先记住这三个数字,它们是整个系统的"容量宪法":
python
MAX_SESSIONS = 1000 # 短期记忆最大会话数
MAX_MESSAGES_PER_SESSION = 20 # 每会话最大消息数(滑动窗口)
LONG_TERM_TTL = 7 * 24 * 3600 # 长期记忆 TTL:7 天(单位:秒)
四、数据模型与基础设施:ConversationTurn 的四个字段
python
@dataclass
class ConversationTurn:
role: str
content: str
timestamp: float = 0.0
metadata: Dict[str, Any] = field(default_factory=dict)
一段对话的最小单元就这四个字段,但每个字段都有讲究:
role :取值只有 'user' / 'assistant' 两种(从 get_context_str 里的三元表达式可以反推)。不引入更复杂的 role 体系(如 system / tool / function),是因为短期记忆的消费者是上下文拼接器,它只需要知道"谁说的",越简单越不容易出错。
content :原始文本。注意这里不做截断 ------截断发生在渲染阶段(get_context_str 里 [:200])。这是一个重要的分层决策:存储层保真,展示层裁剪。因为你永远不知道未来的消费者需不需要完整文本,存储时截断是不可逆的信息损失,而展示时截断随时可以调整。
timestamp :默认 0.0,但 add() 里实际会写入 time.time()。为什么不用 datetime 对象?两个原因:一是 float 时间戳序列化零成本(json.dumps 对 datetime 会直接报错);二是比较排序是 O(1) 的数值比较。存时间戳、用时格式化,是数据结构设计的基本素养。
metadata: Dict[str, Any] = field(default_factory=dict) :这里藏着一个 Python 高频面试题------为什么不能直接写 metadata: Dict = {}?因为可变默认参数在 Python 里是所有实例共享的 ,A 会话往 metadata 里塞了 token 数,B 会话会"看到"它,这是经典的生产事故源。field(default_factory=dict) 保证每个实例拿到独立的空字典。一行代码避开一个坑。
用 @dataclass 而不是手写 __init__ / __repr__ / __eq__,还有个隐性收益:dataclass 自动生成的 __repr__ 让你调试时能直接打印整个轮次内容,日志排查体验完全不同。
五、第一层精讲:ShortTermMemory ------ 用 30 行代码写一个工业级 LRU
5.1 为什么选 OrderedDict?
Python 3.7 之后普通 dict 也保序了,为什么还要用 OrderedDict?因为它有两个普通 dict 没有的"LRU 原语":
move_to_end(key)------ 把某个 key 移到"最近使用"端;popitem(last=False)------ 从"最久未用"端(头部)弹出元素;普通 dict 的popitem()不接受参数,只能从尾部弹出。
也就是说,OrderedDict 天生就是一个双向链表 + 哈希表的复合结构,正是教科书上 LRU 的标准实现(LeetCode 146 题的工业版答案),而且全部逻辑在 C 层实现,性能远超手写。零第三方依赖实现生产级 LRU,这是我对这段代码最欣赏的地方。
5.2 add():一次写入里的三次防御
python
def add(self, session_id: str, role: str, content: str, metadata: Dict = None):
if session_id not in self._sessions:
if len(self._sessions) >= self._max_sessions:
# 淘汰最久未访问的会话
self._sessions.popitem(last=False)
self._sessions[session_id] = []
else:
self._sessions.move_to_end(session_id)
turns = self._sessions[session_id]
turns.append(ConversationTurn(
role=role,
content=content,
timestamp=time.time(),
metadata=metadata or {}
))
if len(turns) > MAX_MESSAGES_PER_SESSION:
self._sessions[session_id] = turns[-MAX_MESSAGES_PER_SESSION:]
第一重防御:会话级 LRU 淘汰。 新会话进来时,如果会话数已达 MAX_SESSIONS=1000,popitem(last=False) 会把整个系统里最久没被碰过的那个会话连根拔掉。注意淘汰的是"最久未访问"而不是"最早创建"------一个三天前创建但一分钟前还在对话的长会话,比一小时前创建后就没动静的会话更"值得留下",这正是 LRU 相比 FIFO 的精髓。
第二重防御:会话内滑动窗口。 turns[-MAX_MESSAGES_PER_SESSION:] 只保留最近 20 轮。这里有个容易忽略的细节:为什么是"窗口"而不是"摘要压缩"?20 条消息通常足以覆盖一次多轮追问的完整指代链(用户问药 → 追问副作用 → 追问禁忌 → 追问替代药),超过 20 轮的远古内容对"它/这个/刚才说的"这类指代消解的贡献趋近于零,直接裁剪是最简单可靠的方案。先做对,再做巧------等真有业务需要更长的上下文,再考虑"窗口 + 滚动摘要"的混合方案。
第三重防御:metadata or {}。 上一节讲过的空值兜底,调用方传 None 也不会在后续 metadata[...] 访问时炸出 AttributeError。
还有一处结构上的巧思值得单独说:新会话插入时用 self._sessions[session_id] = [],老会话命中时用 move_to_end。这两条路径共同保证了 OrderedDict 的"尾端"永远是最近活跃的会话,"头端"永远是淘汰候选------LRU 的一致性就靠这个不变量维持。
5.3 get_context_str():记忆的"出口协议"
python
def get_context_str(self, session_id: str, last_n: int = 5) -> str:
turns = self.get(session_id, last_n)
if not turns:
return ""
lines = []
for turn in turns:
prefix = '用户' if turn.role == 'user' else '助手'
lines.append(f"{prefix}: {turn.content[:200]}")
return "\n".join(lines)
这是短期记忆唯一的对外输出格式,三个设计决策值得咀嚼:
- 默认只取最近 5 轮 ,而不是全部 20 轮。上下文注入是有 token 成本的,5 轮通常已覆盖当前话题的指代范围。参数化
last_n让上层可以按场景调节------普通闲聊传 3,深度追问场景传 10。 - 每条裁剪到 200 字符 。控制注入上下文的 token 总量上界:5 轮 × 200 字 ≈ 最多 1000 字,按中文 1 字 ≈ 1.5 token 估算,上下文成本被钉死在 2000 token 以内,成本可预算、可审计。
- 角色渲染为"用户/助手"前缀而不是 JSON。下游消费者是 LLM 的 system prompt 或上下文模板,自然语言格式对模型最友好,同时避免了 JSON 转义地狱。
5.4 一个必须补上的方法:get()
细心的读者读到这里应该已经发现了------get_context_str 和上层 MemoryManager.get_turns 都调用了 self.get(session_id, last_n),但代码清单里 ShortTermMemory 并没有定义这个方法,直接运行会抛 AttributeError。这是从设计文档到代码落地时最容易"消失"的一个方法。给出补全实现:
python
def get(self, session_id: str, last_n: int = 5) -> List[ConversationTurn]:
"""读取会话最近 last_n 轮(不改变 LRU 顺序,纯读操作)"""
turns = self._sessions.get(session_id, [])
return turns[-last_n:] if last_n and last_n > 0 else list(turns)
这里有个值得停下来讨论的设计抉择:读操作要不要 move_to_end 刷新 LRU? 支持者说"刚被读过的会话是热的";反对者说"读取太频繁(每次拼上下文都要读),会把 LRU 顺序搅得只反映读频率而非真正的活跃度"。本实现的语义是写刷新、读不刷新------我认为对会话场景这是更稳的选择,因为一个会话只要还在产生新消息,写路径自然会持续刷新它;纯读不写的会话,本来就该被淘汰。
5.5 session_count 与 @property
python
@property
def session_count(self) -> int:
return len(self._sessions)
注释里说得直白:把方法变只读属性,封装内部细节。补充一层工程价值:session_count 是这个模块最重要的监控指标 ------它长期贴近 1000 说明容量该扩了(调大 MAX_SESSIONS 或上分布式),长期只有个位数说明流量远低于预期配置。把它做成 @property,上层健康检查接口可以直接 memory.short_term.session_count 当指标暴露给 Prometheus,无括号、无副作用、语义就是一个"状态读数"。
5.6 这一层没有做的事(同样重要)
- 没有持久化 :进程重启,1000 个会话全丢。这是故意的------短期记忆的生命周期本来就是"会话级",丢了顶多多问一句,不影响正确性。把廉价数据当廉价数据处理,是控制复杂度的关键。
- 没有线程锁 :单进程 asyncio 场景下,同步方法都在事件循环线程里串行执行,天然无竞态。但如果这个模块被多线程 Web 框架(如 Django + gunicorn 多线程)使用,
OrderedDict的复合操作就需要加threading.Lock了------这是一个部署前提,后文生产章节还会展开。
六、第二层精讲:LongTermMemory ------ Redis Hash 的教科书式用法
6.1 为什么是 Redis Hash,而不是 String 或 JSON 文档?
先看 Key 设计:
memory:user:{user_id} → Hash {
"last_topics": json, # 最近讨论的话题
"preferences": json, # 用户偏好
"reflection_history": json, # 反思历史(Agent 学习)
}
三种候选方案的对比能说明为什么 Hash 是正解:
| 方案 | 优点 | 致命伤 |
|---|---|---|
每个 field 一个 String Key(memory:user:1:topics) |
实现简单 | Key 爆炸;TTL 只能按 field 分别设置,做不到"整个用户记忆 7 天过期"的原子语义 |
| 整个用户一个 String Key 存大 JSON | Key 少 | 任何 field 更新都要"读全文→改→写全文",读放大写放大双高,并发下互相覆盖 |
| 一个用户一个 Hash(本方案) | field 级独立读写(hset/hget 单 field 操作);expire 作用于整个 Key,TTL 语义干净 |
单 Hash 不适合存超大量 field(本文最多 3 个 field,毫无压力) |
再注意 field 的 value 统一是 json.dumps 的产物------Redis 只管字节,schema 由应用层定义,这是用 Redis 做结构化存储的标准姿势。
6.2 save():一次保存,两个动作,一个语义
python
async def save(self, user_id: str, key: str, value: Any):
self._ensure_redis()
redis_key = f"memory:user:{user_id}"
try:
serialized = json.dumps(value, ensure_ascii=False)
await asyncio.to_thread(
self._redis.client.hset, redis_key, key, serialized)
await asyncio.to_thread(
self._redis.client.expire, redis_key, LONG_TERM_TTL
)
except Exception as e:
logger.warning(f"[LongTermMemory] 保存失败: {e}")
逐行拆解:
json.dumps(value, ensure_ascii=False) ------ ensure_ascii=False 让中文原样存储而不是转成 \u9ad8\u8840\u538b 这种六倍膨胀的转义序列。存得省,网络传输也省,Redis 内存账单直接打三折。这是中文场景下最容易被忽略、收益又最实在的一个参数。
hset 之后紧跟 expire ------ 这两步组合实现了前文说的"滑动过期":每次写入都把整个用户 Hash 的 TTL 重置为 7 天。效果是:活跃用户的记忆永远保鲜,沉默用户一周后自然蒸发。人类的遗忘曲线,两行代码复刻。
异常降级为 logger.warning,绝不向上抛 ------ 这个选择我必须单独强调:记忆系统是"锦上添花"组件,不是"生死攸关"组件。Redis 挂了,Agent 顶多少点个性化、忘点老话题,主流程必须照常回答。如果把异常往上抛,记忆层的故障会级联成整个问答服务的不可用------可用性设计里这叫"故障域隔离"(当然,代价是记忆写入失败是静默的,需要靠日志告警兜底,生产上应配合告警规则监控这类 warning)。
6.3 _ensure_redis() 懒加载,以及一个必须修的命名 Bug
python
def __init__(self, redis_manager=None):
self.redis = redis_manager # ← 注意:这里存的是 self.redis
def _ensure_redis(self):
if self._redis is None: # ← 这里读的却是 self._redis
from redis_db import redis_manager
self._redis = redis_manager
这是一处真实的命名不一致 Bug :构造函数写入的是 self.redis,而 _ensure_redis 和后续所有方法读取的是 self._redis。首次调用 save() 时,self._redis 属性根本不存在,直接抛 AttributeError,然后被 except 静默吞掉,表现为"长期记忆永远保存失败但服务不报错"------这是最难排查的那类 Bug:不崩溃、只失效。
修复只需统一命名:
python
def __init__(self, redis_manager=None):
self._redis = redis_manager # 与 _ensure_redis 保持一致
顺便说说 _ensure_redis 这个模式本身,它解决的是**"依赖注入 + 兜底单例"的双保险问题**:有测试环境注入 mock 的 redis_manager(fakeredis 之类),不注入则首次使用时从全局模块拉单例。相比在 __init__ 里强制 import,它让这个类可测试、可独立复用,同时保留零配置开箱即用的体验。这个手法在任何"重基础设施依赖"的模块里都适用。
6.4 save_reflection():Agent 的"错题本",全文件最有灵魂的方法
python
async def save_reflection(self, user_id: str, query: str, score: float, feedback: str):
"""保存反思结果(用于 Agent 学习)"""
history = await self.load(user_id, "reflection_history") or []
history.append({
"query": query[:100],
"score": score,
"feedback": feedback[:200],
"timestamp": time.time(),
})
history = history[-50:]
await self.save(user_id, "reflection_history", history)
短短 8 行,是整个记忆系统里唯一体现"Agent 自我进化 "的部分。它的业务含义是:每次回答后,由评估流程(可以是用户打分、自动评测模型、或规则检查)产生一个 score 和改进建议 feedback,连同触发问题一起存进用户的"反思历史"。下次会话开始时,把这 50 条错题注入上下文,Agent 就能"上次这类问题我答得不好,原因是 X,这次要注意 Y"------这就是最朴素、也最有效的 Agent 反思学习(Reflection)落地,不需要训练模型,不需要微调,纯 prompt 层面的经验回放。
三个工程细节:
query[:100]与feedback[:200]的截断------注释写得非常诚实:"只需记住问题大意""保留关键的改进建议"。反思记录的价值密度在前 100/200 字符里,截断是对 Redis 内存和网络的双重仁慈。history[-50:]固定长度队列 ------防止"反思历史"这个 field 无限膨胀。50 条 ≈ 一周活跃使用的反思量,与 7 天 TTL 的量级自洽,单 field 大小被钉死在几十 KB 以内。任何存进 Redis 的列表,都必须回答"它最长能长到多长"这个问题,答不上来就是埋雷。- 读-改-写模式的并发风险 ------
load → append → save三步不是原子的。同一用户两个并发请求同时反思,后写的会覆盖先写的,丢一条反思记录。低频写入场景下(一个用户一天反思几十次)这个概率可以接受;如果你要把它做成高频路径,升级方案是WATCH/MULTI/EXEC乐观锁或 Lua 脚本原子 append。知道哪里可以妥协、哪里必须较真,是工程成熟度的分水岭。
七、第三层:SemanticMemory ------ 语义记忆的设计与实现
文档字符串里定义了它的规格:
- 语义记忆: 问题向量 --- 存储: Milvus(复⽤现有 collection)--- TTL: 永久 --- ⽤途: 相似问题检测, 避免重复计算
它解决的是一个纯性能问题:用户问"高血压吃什么药"和"高血压该用啥药物治疗",语义上是同一个问题,答案完全可以复用。如果每次都走完整的"检索→推理→生成"链路,重复计算的成本惊人。语义记忆的做法是:问题 → embedding → 向量检索 → 相似度超过阈值 → 直接复用历史答案/计算结果。
给出与现有代码风格一致的参考实现骨架:
python
class SemanticMemory:
"""语义记忆 --- Milvus 向量存储,复用现有 milvus_manager 连接"""
SIM_THRESHOLD = 0.92 # 余弦相似度阈值:超过即认定"同一个问题"
def __init__(self, milvus_manager=None):
self._milvus = milvus_manager
def _ensure_milvus(self):
if self._milvus is None:
from milvus_db import milvus_manager
self._milvus = milvus_manager
async def find_similar(self, query: str, embedding_fn) -> Optional[Dict]:
"""查找语义相似的历史问题,命中则返回缓存结果"""
self._ensure_milvus()
try:
vector = await asyncio.to_thread(embedding_fn, query)
results = await asyncio.to_thread(
self._milvus.search,
collection_name="qa_cache",
data=[vector],
limit=1,
output_fields=["answer", "query", "created_at"],
)
if results and results[0]:
hit = results[0][0]
if hit.score >= self.SIM_THRESHOLD:
return {"query": hit.entity.get("query"),
"answer": hit.entity.get("answer"),
"similarity": hit.score}
except Exception as e:
logger.warning(f"[SemanticMemory] 检索失败: {e}")
return None
async def remember(self, query: str, answer: str, embedding_fn):
"""将新问题-答案对写入语义记忆(永久)"""
self._ensure_milvus()
try:
vector = await asyncio.to_thread(embedding_fn, query)
await asyncio.to_thread(
self._milvus.insert,
collection_name="qa_cache",
data=[{"query": query, "answer": answer, "vector": vector}],
)
except Exception as e:
logger.warning(f"[SemanticMemory] 写入失败: {e}")
两个关键参数的设计逻辑:
SIM_THRESHOLD = 0.92 是整层的"灵魂旋钮"。调低 → 缓存命中率高、省钱,但会把"相似而不相同"的问题误判成同一个(比如"阿莫西林副作用"命中"头孢副作用"的缓存,医疗场景这是事故);调高 → 零误判但缓存形同虚设。严肃领域宁高勿低 ,同时给命中结果附上 similarity 分数,让调用方有权做二次裁决。
"复用现有 collection" 与长期记忆复用 redis_manager 是同一个哲学:向量库连接和 collection 初始化都不便宜,全局一份,按业务前缀(如 qa_cache)隔离数据。
还要说清这层与前两层的本质区别:短期记忆记住"你说过什么"(原文级、时序敏感),长期记忆记住"你是什么样的人"(结构化、会过时),语义记忆记住"这类问题该怎么答"(抽象化、跨用户、永久)。三层组合起来,才是一个完整的"经验体系"。
八、统一门面:MemoryManager ------ 为什么需要这层"皮"?
python
class MemoryManager:
def __init__(self, redis_manager=None):
self.short_term = ShortTermMemory()
self.long_term = LongTermMemory(redis_manager)
def add_turn(self, session_id, role, content, metadata=None):
self.short_term.add(session_id, role, content, metadata)
def get_context(self, session_id, last_n=5) -> str:
return self.short_term.get_context_str(session_id, last_n)
async def save_long_term(self, user_id, key, value):
await self.long_term.save(user_id, key, value)
async def load_long_term(self, user_id, key) -> Optional[Any]:
return await self.long_term.load(user_id, key)
async def save_reflection(self, user_id, query, score, feedback):
await self.long_term.save_reflection(user_id, query, score, feedback)
def clear_session(self, session_id):
self.short_term.clear(session_id)
这是标准的门面模式,用法示例直接抄文档字符串:
python
memory = MemoryManager()
memory.add_turn(session_id, 'user', '高血压怎么治疗?')
context = memory.get_context(session_id)
await memory.save_long_term(user_id, 'topics', ['高血压'])
它的价值不在"转发",而在三件事:
- 钉死调用面 。业务代码只 import 这一个类,永远不直接摸
ShortTermMemory的_sessions或 Redis 客户端。将来任何一层换实现(短期记忆换成 Rust 扩展、Redis 换成 TencentDB Agent Memory 这种专用引擎),业务代码一行不改。 - 同步异步边界清晰 。
add_turn/get_context是同步方法(纯内存操作,微秒级,包成 async 纯属增加负担);save_long_term/load_long_term/save_reflection是 async(真 I/O,必须让出事件循环)。门面层是声明这个边界的最佳位置------调用方看一眼方法签名,就知道该memory.add_turn(...)还是await memory.save_long_term(...),不需要理解内部哪层碰了网络。 - 它是记忆系统在会话生命周期里的"编排点" 。对照业界总结的带记忆 Agent 四阶段闭环------会话开始(识别用户、检索长期记忆、注入上下文)→ 对话进行中(短期记忆累积)→ 会话结束(提取有价值信息、归档长期记忆)→ 下次会话(带着记忆回来)------
MemoryManager的每个方法都能挂到具体阶段上:
| 会话阶段 | 调用的方法 |
|---|---|
| 会话开始 | load_long_term(user_id, 'preferences') + load_long_term(user_id, 'reflection_history') 注入上下文 |
| 对话进行中 | 每轮 add_turn();每次生成回答前 get_context() |
| 会话结束 | save_long_term(user_id, 'last_topics', [...])、save_reflection(...)、clear_session() |
| 下次会话 | 回到"会话开始",但 Agent 已经"记住"了你 |
九、全文件最精妙的 20 行:get_context_digest 与缓存"串话"问题
如果整篇只能记一个设计,记这一节。
9.1 问题:缓存和上下文,天生打架
Agent 服务为了扛量,普遍会在"问题 → 回答"外面包一层 Redis/本地缓存:Key 是问题的哈希,Value 是答案。但多轮对话一上场,灾难就来了:
用户 A 先问"阿莫西林怎么吃"(上下文:阿莫西林),接着问"它的副作用是什么 "→ 缓存里存下
MD5("它的副作用是什么") → 阿莫西林的副作用列表。用户 B 问"布洛芬怎么吃"(上下文:布洛芬),接着问"它的副作用是什么 " → 缓存命中!B 拿到了阿莫西林的副作用。
同一句话,不同上下文,答案应该不同------但缓存 Key 只看问题文本,完全无视上下文。这就是跨会话"串话",轻则答非所问,重则医疗场景直接酿成事故。而如果反过来把上下文全量拼进缓存 Key,Key 会膨胀到几 KB,且几乎永不重复命中------缓存废了。
9.2 解法:把上下文压成 12 位十六进制指纹
python
def get_context_digest(self, session_id: str, last_n: int = 5) -> str:
if not session_id:
return ''
context = self.short_term.get_context_str(session_id, last_n)
if not context:
return ''
return hashlib.md5(context.encode('utf-8')).hexdigest()[:12]
调用方把摘要拼进缓存 Key:cache_key = f"{query}|ctx:{memory.get_context_digest(session_id)}"。于是:
- 用户 A 的"它的副作用"带着阿莫西林上下文的指纹 ,用户 B 的带着布洛芬上下文的指纹 → Key 不同 → 串话被结构性消灭;
- 同一个用户在同一上下文里重问同一句 → 指纹相同 → 缓存照常命中;
- 指纹只有 12 个字符,Key 几乎不膨胀。
9.3 更深的一层:那两行 return '' 是"上线安全阀"
注释里那个"关键不变量"值得逐字品读:
无历史(首轮 / 无 session_id)时返回空串,缓存 Key 与升级前完全⼀致,因此⽆状态 / 压测路径的缓存命中⾏为不受任何影响。
翻译成运维语言:给一个已在跑的线上服务加"上下文感知缓存",最怕的就是改动那天命中率断崖下跌、回源流量暴涨打爆后端。 这个设计让首轮请求(无历史)的缓存 Key 和改造前一模一样------存量缓存全部继续命中,灰度上线零回源尖峰,压测链路行为完全不变。改动的影响被精确限定在"确实有上下文的请求"上。 这是"渐进式重构"思想在缓存设计上的教科书示范:新逻辑只接管新场景,老场景一个字节都不动。
9.4 顺手聊聊 12 位够不够
12 个 hex 字符 = 48 bit。按生日悖论,约 2²⁴ ≈ 1600 万个不同上下文指纹才有一半概率出现一次碰撞。对单个服务的会话量级,碰撞概率低到可以忽略;但如果你是超大规模平台、或对"串话"零容忍(医疗、法律),把 [:12] 改成 [:16](64 bit)成本为零、安全边际提升 65536 倍------知道自己的哈希什么时候会不够用,比会用哈希更重要。
十、异步架构深剖:asyncio.to_thread 的正确打开方式
全文件的 I/O 都长这样:
python
await asyncio.to_thread(self._redis.client.hset, redis_key, key, serialized)
三个高频疑问一次讲透:
Q1:为什么必须在 to_thread 里调,直接调不行吗?
redis-py 的同步客户端底层是阻塞 socket I/O。协程里直接调,事件循环这个"单线程调度大脑"会被这一步卡住------期间整个进程的所有请求、所有协程、心跳任务全部冻结 。一次 Redis 抖动 200ms,你的 FastAPI 服务就集体"假死" 200ms。to_thread 把阻塞调用扔进线程池,事件循环立刻继续调度别的协程。
Q2:Python 不是有 GIL 吗,线程有什么用?
GIL 阻止的是CPU 密集 的并行,但I/O 等待期间 GIL 是释放的------socket 等待响应的几十毫秒里,其他线程(包括事件循环所在的主线程)照常运行。所以"I/O 阻塞 + to_thread"是 GIL 时代完全合规的并发姿势,这正是所有同步 Redis 客户端接入 asyncio 世界的标准桥。
Q3:asyncio.to_thread(3.9+)比手写 loop.run_in_executor 好在哪?
三点:一是少写三行模板代码;二是自动传播 contextvars------日志追踪 ID、租户上下文等上下文变量能跟着进入线程,线程里打的日志不会丢失链路信息,这对生产排障是刚需;三是语义明确:它的存在本身就在告诉你"这是给阻塞函数准备的"。
一个隐藏的加分项:这套设计意味着你可以继续用同步的 redis-py 客户端(连接池、Lua、哨兵、集群支持全都成熟),而不必把整个技术栈绑死在某个异步客户端上。技术选型上留了退路。
十一、把这套方案放进业界坐标系:横向对比才算看懂它
11.1 vs. Mem0 / Memory-R1("让模型自己管记忆"路线)
Memory-R1 的思路是用强化学习训练两个组件:记忆管理器 在 RAG 之后自主决定对记忆库执行 ADD / UPDATE / DELETE / NOOP(CRUD 式操作),答案 Agent 回答前先做"记忆蒸馏"过滤检索内容。它仅用 152 个 QA 对训练(以下游 QA 正确性做奖励),就在 LLaMA-3.1-8B 和 Qwen-2.5-7B 上取得 SOTA,超过 Mem0、A-Mem、LangMem。
对比结论:Memory-R1 解决的是"记什么、忘什么、用哪条 "的策略问题,需要训模型;本文方案是"记忆基础设施 "------规则驱动、零训练、今天就能上线。两者不冲突,甚至可以叠加:Memory-R1 式的智能决策层,完全可以跑在本文这套三层存储底座上(把 save_reflection 的"无条件追加"升级为"RL 决定 ADD/UPDATE/DELETE")。
11.2 vs. Zep / Graphiti(知识图谱 + 时间线路线)
Graphiti 是动态、时序感知的知识图谱引擎,三层子图设计(Episode 子图 → 语义实体子图 → 社区子图),每层基于上层生成保证溯源;检索时从社区(全局主题)向下定位到语义实体(具体事实),并且维持事实与关系的历史时间线------"用户去年对花生过敏,上个月已脱敏"这类时序演变,图谱能表达。
对比结论:图谱路线的表达力上限更高(关系推理、时序演化),代价是构图/更新的复杂度和成本。本文方案的长期记忆是"扁平 KV + 反思列表",胜在极简、可调试、运维成本接近零。选型建议:个人助手/垂直问答用本文方案绰绰有余;涉及复杂实体关系(风控、客户 360、企业知识沉淀)再上图谱。
11.3 vs. TencentDB Agent Memory(数据库厂商的分层记忆引擎)
TencentDB Agent Memory 的关键词是"长期记忆层级化 + 短期记忆符号压缩 ",并且正推动从个人记忆走向团队记忆 ------Agent 继承项目上下文、团队经验与任务资产,让记忆从"用户私有"变成"团队共享资产"。这指出了本文方案的一个明确短板:本文所有记忆都以 user_id 为 Key,天然是"个人记忆";团队场景需要引入 org_id/team_id 维度的记忆空间(memory:team:{team_id}),以及读写权限模型。这是最值得抄的作业方向之一。
11.4 vs. AgentScope 1.0(框架内置路线)
阿里 AgentScope 1.0 把"智能上下文管理"(短期记忆优化 + 跨会话长期记忆)做成了框架能力,同时提供实时介入控制、工具沙箱、可视化监控。选择它的理由是省事和生态;选择本文这种自研路线的理由是完全掌控 ------当你需要 get_context_digest 这种为自家缓存策略深度定制的细节时,框架的通用抽象往往给不了你。这也呼应了业界对 Agent 开发三种姿势的分层讨论:写配置(Harness 层)、接基础设施(Runtime 层)、写状态机(Framework 层)------本文显然站在 Framework 层,用代码换取对记忆行为的完全控制权。
11.5 定位总结
本文方案 = Mem0 的三层分治思想 + 工业级细节(LRU/滑动窗口/TTL/缓存隔离)+ 反思学习(Memory-R1 的极简版),一个不依赖训练、不依赖图谱、今天就能部署的记忆底座。
十二、生产落地清单:上線前必须回答的六个问题
1. 内存到底吃多少?
上限估算:1000 会话 × 20 轮 × 平均 1KB(含 Python 对象开销)≈ 20MB 封顶 (add() 不截断 content,单条超长消息会推高这个值;若不可信输入,可在 add 里加 content[:2000] 兜底)。20MB 对现代容器是零压力,这也是 MAX_SESSIONS=1000 这个数字的底气。
2. 多进程/多副本部署怎么办?
ShortTermMemory 在进程内,意味着:uvicorn 多 worker、k8s 多副本场景下,同一会话的两个请求落到不同进程,短期记忆各自为政。三个解法按序选择:① 网关层会话粘性(最省事);② 单 worker + 事件循环扛并发(配合 to_thread,吞吐通常够用);③ 把短期记忆也外置到 Redis(牺牲微秒级读延迟换一致)。
3. session_count 贴近 1000 说明什么?
活跃会话数逼近上限,LRU 开始"误杀"还活着的会话(表现为用户突然失忆)。扩容路径:调大 MAX_SESSIONS → 外置短期记忆。
4. 反思历史并发丢数据怎么办?
低频场景接受它(丢一条反思不致命);高频场景把 load-append-save 换成 Redis WATCH 事务或 Lua 脚本。
5. 长期记忆"整 Key 7 天过期"的副作用?
用户沉默 7 天,偏好、话题、反思全部清零------"白纸一样重新认识你"。若这是产品不可接受的(VIP 用户记忆永不过期),改法很轻:按用户分层设置 TTL,或干脆不设 expire、改用定期归档任务清理。
6. 监控埋哪三个点?
① short_term.session_count(容量水位);② LongTermMemory 的 warning 日志(Redis 健康度------它是静默降级,全靠日志告警兜底);③ get_context_digest 非空比例(多轮对话占比,直接决定缓存策略收益)。
十三、演进路线图:这套底座还能长出什么
- 记忆蒸馏与压缩 :借鉴 Memory-R1 的"答案前蒸馏",
get_context从"取最近 5 轮原文"升级为"LLM 压缩摘要 + 关键事实抽取",同样的 token 预算装下更多信息。 - RL 记忆管理 :把
save_reflection的无条件 append 升级为策略网络驱动的 ADD/UPDATE/DELETE 决策------用记忆库的最终 QA 效果做奖励,小样本即可起训(Memory-R1 证明了 152 个样本就够)。 - 团队记忆 :新增
memory:team:{team_id}空间,让项目背景、历史决策成为团队资产,个人记忆做权限隔离后按需引用------这是 Agent 记忆从"个人助手"进化为"团队基建"的必经之路。 - 时序知识图谱:当业务出现"事实随时间演变"的需求(偏好变化、状态迁移),在语义记忆之上引入 Graphiti 式的时序图谱,保持事实历史时间线。
- 用户画像 :长期记忆里的
preferences字段扩展为多维画像(兴趣、表达习惯、知识水平),配合会话开始时的画像注入,实现真正的个性化。 - 短期记忆持久化兜底 :进程优雅停机时把
OrderedDict快照落盘,重启恢复------把"会话级丢失"的代价进一步压缩到"部署瞬间"。
十四、总结:一张表带走全文
| 维度 | 短期记忆 | 长期记忆 | 语义记忆 |
|---|---|---|---|
| 类 | ShortTermMemory |
LongTermMemory |
SemanticMemory |
| 存储 | 进程内 OrderedDict |
Redis Hash memory:user:{id} |
Milvus(复用 collection) |
| TTL | 会话生命周期 | 7 天滑动续期 | 永久 |
| 淘汰/限制 | LRU 1000 会话 + 滑动窗口 20 轮 | 反思历史固定 50 条 | 相似度阈值 0.92 |
| 典型用途 | 多轮对话上下文 | 用户偏好、话题、反思学习 | 相似问题检测、避免重复计算 |
| 关键手法 | popitem(last=False) + move_to_end |
hset+expire + asyncio.to_thread |
向量检索 + 阈值复用 |
| 最值得背的一行 | turns[-MAX_MESSAGES_PER_SESSION:] |
history = history[-50:] |
hit.score >= self.SIM_THRESHOLD |
再加四条超越代码本身的收获:
- 分层不是仪式感,是数据生命周期与访问模式的自然结果------每层数据"活多久、怎么被读",决定了它该住在哪。
- 记忆系统必须是"可降级"的 ------
except + warning的静默降级,换的是主链路的绝对可用。 - 任何缓存 Key 的改动都要回答"存量命中率怎么办" ------
get_context_digest的空串不变量,是灰度安全的教科书答案。 - 一切无界数据结构都必须有界------会话数 1000、轮次 20、反思 50,三个数字撑起了整个系统的内存确定性。
最后留一道思考题:如果把 get_context_digest 的 last_n 从 5 改成 20,缓存命中率和上下文正确性各会怎么变?想明白这个权衡,你对"记忆与缓存的张力"就算真正入门了。
如果这篇文章帮你捅破了 Agent 记忆系统的窗户纸,请一键三连(点赞 + 收藏 + 关注)。 下一篇我会在这套底座上继续加码:用反思历史驱动 Agent 自我进化的完整闭环实现(含评估 Prompt 设计与自动反思调度),同样全程硬核代码。评论区留下你想深挖的方向------Redis 记忆的分布式改造、Milvus 语义去重的实战调参、还是团队记忆的权限模型,点赞最高的优先安排!