构建智能Agent的记忆系统:三层记忆管理器设计

导读 :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 向量库

注意这个对应关系里最精妙的三个"拟人化"设计:

  1. 短期记忆"用完即弃"------会话结束就没了,所以存储选最便宜的进程内存,根本不落盘;
  2. 长期记忆"会淡化"------7 天 TTL,就像你记不清两周前午饭吃了什么,但昨天的事历历在目;且每次读写都续期,活跃用户的记忆一直"保鲜",沉默用户自然遗忘------这简直就是人类记忆的"用进废退";
  3. 语义记忆"永久但抽象" ------不存对话原文,存问题的向量表示,做的是"相似问题检测",本质是把经验泛化成知识

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_managermilvus_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.dumpsdatetime 会直接报错);二是比较排序是 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=1000popitem(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)

这是短期记忆唯一的对外输出格式,三个设计决策值得咀嚼:

  1. 默认只取最近 5 轮 ,而不是全部 20 轮。上下文注入是有 token 成本的,5 轮通常已覆盖当前话题的指代范围。参数化 last_n 让上层可以按场景调节------普通闲聊传 3,深度追问场景传 10。
  2. 每条裁剪到 200 字符 。控制注入上下文的 token 总量上界:5 轮 × 200 字 ≈ 最多 1000 字,按中文 1 字 ≈ 1.5 token 估算,上下文成本被钉死在 2000 token 以内,成本可预算、可审计
  3. 角色渲染为"用户/助手"前缀而不是 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_managerfakeredis 之类),不注入则首次使用时从全局模块拉单例。相比在 __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 层面的经验回放。

三个工程细节:

  1. query[:100]feedback[:200] 的截断------注释写得非常诚实:"只需记住问题大意""保留关键的改进建议"。反思记录的价值密度在前 100/200 字符里,截断是对 Redis 内存和网络的双重仁慈。
  2. history[-50:] 固定长度队列 ------防止"反思历史"这个 field 无限膨胀。50 条 ≈ 一周活跃使用的反思量,与 7 天 TTL 的量级自洽,单 field 大小被钉死在几十 KB 以内。任何存进 Redis 的列表,都必须回答"它最长能长到多长"这个问题,答不上来就是埋雷。
  3. 读-改-写模式的并发风险 ------load → append → save 三步不是原子的。同一用户两个并发请求同时反思,后写的会覆盖先写的,丢一条反思记录。低频写入场景下(一个用户一天反思几十次)这个概率可以接受;如果你要把它做成高频路径,升级方案是 WATCH/MULTI/EXEC 乐观锁或 Lua 脚本原子 append。知道哪里可以妥协、哪里必须较真,是工程成熟度的分水岭。

七、第三层:SemanticMemory ------ 语义记忆的设计与实现

文档字符串里定义了它的规格:

  1. 语义记忆: 问题向量 --- 存储: 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', ['高血压'])

它的价值不在"转发",而在三件事:

  1. 钉死调用面 。业务代码只 import 这一个类,永远不直接摸 ShortTermMemory_sessions 或 Redis 客户端。将来任何一层换实现(短期记忆换成 Rust 扩展、Redis 换成 TencentDB Agent Memory 这种专用引擎),业务代码一行不改。
  2. 同步异步边界清晰add_turn/get_context 是同步方法(纯内存操作,微秒级,包成 async 纯属增加负担);save_long_term/load_long_term/save_reflection 是 async(真 I/O,必须让出事件循环)。门面层是声明这个边界的最佳位置------调用方看一眼方法签名,就知道该 memory.add_turn(...) 还是 await memory.save_long_term(...),不需要理解内部哪层碰了网络。
  3. 它是记忆系统在会话生命周期里的"编排点" 。对照业界总结的带记忆 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 非空比例(多轮对话占比,直接决定缓存策略收益)。


十三、演进路线图:这套底座还能长出什么

  1. 记忆蒸馏与压缩 :借鉴 Memory-R1 的"答案前蒸馏",get_context 从"取最近 5 轮原文"升级为"LLM 压缩摘要 + 关键事实抽取",同样的 token 预算装下更多信息。
  2. RL 记忆管理 :把 save_reflection 的无条件 append 升级为策略网络驱动的 ADD/UPDATE/DELETE 决策------用记忆库的最终 QA 效果做奖励,小样本即可起训(Memory-R1 证明了 152 个样本就够)。
  3. 团队记忆 :新增 memory:team:{team_id} 空间,让项目背景、历史决策成为团队资产,个人记忆做权限隔离后按需引用------这是 Agent 记忆从"个人助手"进化为"团队基建"的必经之路。
  4. 时序知识图谱:当业务出现"事实随时间演变"的需求(偏好变化、状态迁移),在语义记忆之上引入 Graphiti 式的时序图谱,保持事实历史时间线。
  5. 用户画像 :长期记忆里的 preferences 字段扩展为多维画像(兴趣、表达习惯、知识水平),配合会话开始时的画像注入,实现真正的个性化。
  6. 短期记忆持久化兜底 :进程优雅停机时把 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

再加四条超越代码本身的收获:

  1. 分层不是仪式感,是数据生命周期与访问模式的自然结果------每层数据"活多久、怎么被读",决定了它该住在哪。
  2. 记忆系统必须是"可降级"的 ------except + warning 的静默降级,换的是主链路的绝对可用。
  3. 任何缓存 Key 的改动都要回答"存量命中率怎么办" ------get_context_digest 的空串不变量,是灰度安全的教科书答案。
  4. 一切无界数据结构都必须有界------会话数 1000、轮次 20、反思 50,三个数字撑起了整个系统的内存确定性。

最后留一道思考题:如果把 get_context_digestlast_n 从 5 改成 20,缓存命中率和上下文正确性各会怎么变?想明白这个权衡,你对"记忆与缓存的张力"就算真正入门了。


如果这篇文章帮你捅破了 Agent 记忆系统的窗户纸,请一键三连(点赞 + 收藏 + 关注)。 下一篇我会在这套底座上继续加码:用反思历史驱动 Agent 自我进化的完整闭环实现(含评估 Prompt 设计与自动反思调度),同样全程硬核代码。评论区留下你想深挖的方向------Redis 记忆的分布式改造、Milvus 语义去重的实战调参、还是团队记忆的权限模型,点赞最高的优先安排!

相关推荐
张忠琳2 小时前
【deepseek-harness】Cordis 开源项目深度介绍
ai·agent·deepseek·harness·cordis·dsh
wangruofeng2 小时前
2000+ 小时实战后,我的 Agentic Engineering 全套装备「精译」
aigc·agent·ai编程
tachibana23 小时前
如何设计多 Agent 的协作与动态切换机制?
网络·人工智能·ai·大模型·llm·agent
DeepAgent4 小时前
AI Agent 工程实践(39):第一次实现——先做一个最小 Agent
大数据·人工智能·agent
大模型真好玩4 小时前
DeepSeek Harness 入门很简单(一)——认识DeepSeek Harness并安装
人工智能·agent·deepseek
leeyi4 小时前
微前端:14 个子应用是怎么做到不散架的——DeepFlux 前端工程拆解(第102篇)
前端·aigc·agent
机械改造鹅5 小时前
从零开始拆解Pi系列——(8)compaction 机制
agent
宋哥转AI5 小时前
深入理解 AI Agent:从黑盒到全链路——生产级 Agent 的可观测性体系怎么建
人工智能·agent·ai编程
岁月宁静5 小时前
二、《从零手撸 Agent》 — 聊聊 LLM 的失忆真相
前端·python·agent