跳转至

如何设计一个高效的对话记忆管理系统?

设计对话记忆管理系统,最容易犯的错误是把“记住一切”当成目标。真正高效的记忆,是让 Agent 在正确的时刻,用最小的代价,拿到最有价值的上下文。拆解下来,核心就三件事:存得聪明、忘得果断、取得精准。


💡 核心设计原则:分层 + 索引分离 + 主动遗忘

image.png

  • 热记忆:当前对话轮次,直接喂给 LLM,保证毫秒级响应。

  • 温记忆:近期对话的摘要/压缩版,通过 KV 高速读取,向量辅助检索。

  • 冷记忆:完整历史,走向量检索后从冷存储(DB / 对象存储)按需加载。

这三层不是互相替代,而是热度衰减下的接力。下面看具体怎么落地。


🧱 关键机制详解

① 写入:先过“价值过滤器”

并不是每句话都值得记。我会在写入前加一层轻量判断:

  • 事实性信息(用户说“我女儿叫小美”)→ 走语义记忆更新。

  • 决策/偏好(“我不喜欢太啰嗦的风格”)→ 打上高权重标签。

  • 纯寒暄(“好的”、“收到”)→ 直接丢弃。

# 伪代码:写入前的价值评估
def should_store(message):
    if len(message) < 4 and not any(kw in message for kw in ["记住", "设置", "偏好"]):
        return False
    if is_pure_acknowledgment(message):
        return False
    return True

② 压缩:窗口满了就生成摘要

短期窗口设一个 token 上限(比如 2000),满了就调用一次廉价的 LLM 把前 N 轮压缩成一段 200 字的摘要,存入温记忆层,然后清掉窗口里的旧消息。这样上下文永远不超限,且关键信息不丢失。

def compress_window(self):
    # 取出旧对话
    old_msgs = self.messages[:self.window_keep]
    prompt = f"将以下对话浓缩为一段200字以内的摘要,保留所有事实、决定和用户偏好:\n{old_msgs}"
    summary = self.llm.invoke(prompt)
    # 摘要存入温记忆
    self.warm_memory.store(summary, metadata={"type": "summary"})
    # 截断窗口
    self.messages = self.messages[self.window_keep:]

③ 检索:时间衰减 + 语义召回

当需要补充上下文时,不是一股脑把历史全喂进去。我会先从温记忆里按时间取最近的 3 条摘要,再并行去冷记忆做语义检索(取 top 3)。最后用一个简单的加权公式排序:

最终相关度 = 语义相似度 × 时间衰减因子

时间越久远,衰减越多;但高权重的“偏好型记忆”可以抗衰减。

④ 遗忘:硬策略 + 软策略

  • 硬策略:超过 90 天未被检索过的记忆,物理删除(从向量库和 KV 都删掉)。

  • 软策略:如果一个记忆被频繁修正(用户反复说“不,那是以前的,现在改成...”),自动降低其权重,并在向量库中标记为“过时”,检索时自动过滤。


💻 系统骨架代码

下面是一个最精简但五脏俱全的实现,整合了短期窗口、摘要压缩、向量检索和 KV 存储。

import chromadb
import redis
import json
import uuid
from datetime import datetime
from sentence_transformers import SentenceTransformer
from collections import deque

class ConversationMemoryManager:
    def __init__(self, llm, window_size=2000):
        self.llm = llm                               # 用于生成摘要的LLM
        self.window = deque(maxlen=window_size)      # 热记忆:消息窗口
        self.encoder = SentenceTransformer('all-MiniLM-L6-v2')

        # 温/冷记忆存储:向量库(索引) + KV(正本)
        self.chroma = chromadb.Client()
        self.collection = self.chroma.create_collection("conversations")
        self.kv = redis.Redis(decode_responses=True)

    # ========== 公共方法 ==========
    def add_message(self, role, content):
        """写入一条消息,自动触发压缩与遗忘检查"""
        if not self._should_store(content):
            return

        self.window.append({"role": role, "content": content, "ts": datetime.now()})

        # 窗口满了?压缩后存入温记忆
        if len(self.window) >= self.window.maxlen * 0.8:
            self._compress_and_promote()

    def retrieve_context(self, current_query, top_k=3):
        """检索最有价值的记忆,返回拼接好的上下文字符串"""
        # 1. 从温记忆取最近的摘要
        recent_summaries = self._get_recent_summaries(limit=2)
        # 2. 从冷记忆做语义检索
        cold_mems = self._semantic_search(current_query, top_k)
        # 3. 合并、去重、按时间+权重排序
        combined = recent_summaries + cold_mems
        combined.sort(key=lambda x: x.get("weight", 0) * self._time_decay(x["ts"]), reverse=True)
        # 4. 格式化为上下文
        context_parts = [item["content"] for item in combined[:5]]
        return "\n".join(context_parts)

    # ========== 内部机制 ==========
    def _should_store(self, content):
        """价值过滤器:纯确认、短句忽略"""
        if len(content) < 4:
            return False
        if content.strip() in ["好的","收到","ok"]:
            return False
        return True

    def _compress_and_promote(self):
        """将窗口前半部分压缩为摘要,存入温记忆"""
        old_msgs = list(self.window)[:len(self.window)//2]
        text_block = "\n".join([f"{m['role']}: {m['content']}" for m in old_msgs])
        prompt = f"将以下对话浓缩为一段200字以内的摘要,保留事实、决定、偏好:\n{text_block}"
        summary = self.llm(prompt)   # 调用LLM
        mem_id = str(uuid.uuid4())
        mem_obj = {
            "content": summary,
            "type": "summary",
            "weight": 0.8,           # 摘要默认权重略高
            "ts": datetime.now().isoformat()
        }
        # KV存完整对象
        self.kv.set(mem_id, json.dumps(mem_obj))
        # 向量库只存索引
        emb = self.encoder.encode(summary).tolist()
        self.collection.add(embeddings=[emb], documents=[summary],
                            metadatas=[{"mem_id": mem_id, "weight": 0.8}], ids=[mem_id])
        # 清掉旧消息
        for _ in range(len(old_msgs)):
            self.window.popleft()

    def _semantic_search(self, query, k):
        """冷记忆语义检索,带时间衰减"""
        q_emb = self.encoder.encode(query).tolist()
        results = self.collection.query(query_embeddings=[q_emb], n_results=k)
        mem_ids = [meta["mem_id"] for meta in results["metadatas"][0] if meta]
        # 从KV批量拉取
        pipe = self.kv.pipeline()
        for mid in mem_ids:
            pipe.get(mid)
        objects = [json.loads(obj) for obj in pipe.execute() if obj]
        return objects

    def _get_recent_summaries(self, limit):
        """通过KV的时间索引快速获取最新摘要(实际可用Sorted Set实现)"""
        # 简化:扫描KV并过滤,生产环境用ZSET
        keys = self.kv.keys("*")
        items = []
        for k in keys[:20]:  # 粗略限制
            obj = json.loads(self.kv.get(k))
            if obj.get("type") == "summary":
                items.append(obj)
        items.sort(key=lambda x: x["ts"], reverse=True)
        return items[:limit]

    def _time_decay(self, ts_str):
        """时间衰减函数,记忆越旧权重越低"""
        ts = datetime.fromisoformat(ts_str)
        days = (datetime.now() - ts).days
        return max(0.1, 1.0 - days/90)   # 90天后衰减至0.1

🚀 效率优化的几个细节

  1. 异步写入:存储记忆时不阻塞当前对话,用后台任务把向量化和入库异步完成,用户感知不到延迟。

  2. 分层缓存:温记忆的最近几条摘要直接缓存在内存中,避免每次检索都访问 Redis。

  3. 元数据过滤:在向量查询时带上 where={"type": "summary"} 之类的条件,缩小搜索空间,比纯全局检索快一个数量级。

  4. 遗忘守护进程:每天凌晨跑一个定时任务,对超过 N 天未使用或权重低于阈值的记忆进行清理,保持库的“精瘦”。


🧭 一个容易被忽视的视角

大部分讨论都聚焦在“如何记住”,但真正让系统高效的,往往是“选择性遗忘”的设计。我把遗忘策略看成和检索同等重要的一线模块——没有它,再漂亮的存储架构最终也会被时间腐化成噪声沼泽。在面试时能主动聊到这一点,通常会拿到非常好的反馈。