给AI Agent装上"长期记忆":5种方案我都试了一遍,最后只有1种能用

给AI Agent装上"长期记忆":5种方案我都试了一遍,最后只有1种能用

上周四晚上11点,我盯着屏幕上的对话记录发呆。

用户第三次问我同一个问题——"我的数据库连接串在哪?"前两次我都答对了,但每次都是新会话,Agent完全不记得之前说过什么。用户烦了,我也烦了。

说白了,这就是一个没记忆的Agent。它每次醒来都是全新的,像个失忆的金鱼。

用户第1次问:数据库连接串在 .env 文件里,KEY 是 DB_URL。
用户第2次问:(新会话)数据库连接串在 .env 文件里,KEY 是 DB_URL。
用户第3次问:(又一个新会话)数据库连接串在 .env 文件里,KEY 是 DB_URL。

这不是Bug,这是架构缺陷。


我试过的5种记忆方案

方案1:直接塞System Prompt

最暴力的方式——把历史对话全塞进system prompt里。

def build_prompt(history: list[dict], current_msg: str) -> str:
    """把最近N轮对话塞进system prompt"""
    history_text = "\n".join(
        f"[{h['role']}]: {h['content']}" for h in history[-20:]
    )
    return f"""你是我的助手。以下是历史对话记录:

{history_text}

当前用户消息:{current_msg}"""

踩坑:第3天就出问题了。20轮对话大概8000个token,加上当前消息和系统指令,直接撞到4096 token限制。模型开始截断前面的内容,用户发现我"忘记"了最早说的几件事。

更惨的是,第5天历史记录膨胀到15000 token,API直接报错。我加了个截断逻辑:

MAX_HISTORY_TOKENS = 3000

def truncate_history(history: list[dict], max_tokens: int) -> list[dict]:
    """从最新的开始保留,直到token数超限"""
    result = []
    current_tokens = 0
    for h in reversed(history):
        msg_tokens = len(h['content']) // 3  # 粗略估算
        if current_tokens + msg_tokens > max_tokens:
            break
        result.insert(0, h)
        current_tokens += msg_tokens
    return result

能用,但记忆范围被压缩到了最近5-8轮。时间一长,该忘的还是忘了。

结论:适合对话轮次少(<10轮)、对成本不敏感的场景。超过10轮就开始出问题。


方案2:向量数据库存语义

这是网上教程最多推荐的方案。把每轮对话做Embedding存起来,下次用的时候按语义检索。

from sentence_transformers import SentenceTransformer
import chromadb

class VectorMemory:
    def __init__(self):
        self.model = SentenceTransformer('all-MiniLM-L6-v2')
        self.client = chromadb.Client()
        self.collection = self.client.create_collection("memory")

    def remember(self, text: str, metadata: dict = None):
        embedding = self.model.encode(text).tolist()
        self.collection.add(
            documents=[text],
            embeddings=[embedding],
            ids=[f"mem_{hash(text)}"],
            metadatas=[metadata or {}]
        )

    def recall(self, query: str, top_k: int = 3) -> list[str]:
        query_emb = self.model.encode(query).tolist()
        results = self.collection.query(
            query_embeddings=[query_emb],
            n_results=top_k
        )
        return results['documents'][0]

踩坑:语义检索对"数据库连接串在哪"这种精确事实查询效果很差。用户问"我的DB URL在哪",向量检索返回的是"用户提到过数据库"、"之前讨论过连接问题"——全是相关但不是答案的内容。

查询:数据库连接串在哪?
返回:
1. "之前讨论过数据库优化的问题"  ← 相关但没用
2. "用户提到.env文件里有配置"     ← 接近了
3. "连接池配置建议最大10个连接"    ← 跑题了

根本原因是Embedding擅长捕捉"语义相似",但记忆系统需要的是"精确事实召回"。你问"钥匙在哪",它给你返回所有关于"门"的对话。

修正:加了个关键词索引层,事实类信息单独存:

import re

class HybridMemory:
    def __init__(self):
        self.facts: dict[str, str] = {}  # 精确事实
        self.vector_store = VectorMemory()  # 语义记忆

    def remember(self, text: str):
        # 检测是否包含事实性信息
        if self._is_fact(text):
            key = self._extract_key(text)
            self.facts[key] = text
        self.vector_store.remember(text)

    def recall(self, query: str) -> list[str]:
        # 先查精确事实
        exact = self._exact_match(query)
        if exact:
            return [exact]
        # 再查语义
        return self.vector_store.recall(query)

好了一点,但事实提取的规则又得手写。这个方案的维护成本远超预期。


方案3:结构化JSON存储

不搞花里胡哨的,直接用JSON存关键信息。

import json
from pathlib import Path

class StructuredMemory:
    def __init__(self, path: str = "memory.json"):
        self.path = Path(path)
        self.data = self._load()

    def _load(self) -> dict:
        if self.path.exists():
            return json.loads(self.path.read_text())
        return {
            "user_facts": {},      # 用户事实
            "project_info": {},    # 项目信息
            "preferences": {},     # 偏好设置
            "conversation_log": [] # 对话摘要
        }

    def save(self):
        self.path.write_text(
            json.dumps(self.data, ensure_ascii=False, indent=2)
        )

    def update_user_fact(self, key: str, value: str):
        self.data["user_facts"][key] = value
        self.save()

    def get_context_prompt(self) -> str:
        """生成记忆上下文,塞进system prompt"""
        facts = "\n".join(
            f"- {k}: {v}" for k, v in self.data["user_facts"].items()
        )
        prefs = "\n".join(
            f"- {k}: {v}" for k, v in self.data["preferences"].items()
        )
        return f"""已知用户信息:
{facts}

用户偏好:
{prefs}"""

踩坑:最大的问题是"谁来写入"。让Agent自己判断哪些信息该存、用什么key存,结果经常存错:

{
  "user_facts": {
    "数据库": ".env文件里的DB_URL",
    "连接信息": "数据库连接串在.env",
    "数据库连接": "DB_URL在.env文件中"
  }
}

同一个事实存了三遍,key还不一样。我试过让LLM自己提取,结果它把"今天天气不错"也存成了用户事实。

最终方案:用Prompt约束提取规则,配合正则校验。


方案4:对话摘要链

不存原始对话,每轮结束后让LLM生成摘要,下一轮把摘要当上下文。

class SummaryMemory:
    def __init__(self):
        self.summary = ""
        self.current_buffer = []

    def add_message(self, role: str, content: str):
        self.current_buffer.append({"role": role, "content": content})

    def should_summarize(self) -> bool:
        return len(self.current_buffer) >= 6

    def generate_summary(self, llm_call) -> str:
        """让LLM生成对话摘要"""
        prompt = f"""之前的摘要:
{self.summary}

最近的对话:
{json.dumps(self.current_buffer, ensure_ascii=False)}

请用3句话总结关键信息、用户意图和待办事项。"""
        
        new_summary = llm_call(prompt)
        
        # 合并:旧摘要 + 新摘要
        if self.summary:
            self.summary = f"{self.summary}\n\n最新:{new_summary}"
        else:
            self.summary = new_summary
        
        self.current_buffer = []
        return self.summary

踩坑:摘要会"漂移"。每轮压缩都会丢失细节,到第10轮的时候,最早的事实已经被压缩成一句话甚至消失了。更可怕的是,LLM有时候会在摘要里"编造"——它记得用户说过"配置有问题",但具体什么配置记不清了,就自己猜一个。

实测数据:原始对话1000字 → 第1轮摘要200字 → 第5轮摘要180字 → 第10轮摘要160字。字数没怎么降,但信息密度越来越低,废话越来越多。


方案5:混合方案(最终采用)

试了前4种,最后我用的是混合方案。核心思路:

记忆分三层,每层的存储和检索策略不同:

┌─────────────────────────────────────────┐
│  L1: 工作记忆(当前对话)                 │
│  存储:内存列表                           │
│  检索:直接访问                           │
│  容量:最近10轮                           │
├─────────────────────────────────────────┤
│  L2: 事实记忆(长期事实)                  │
│  存储:JSON文件 + 结构化字段              │
│  检索:Key精确匹配                        │
│  容量:无限                               │
├─────────────────────────────────────────┤
│  L3: 语义记忆(模糊回忆)                  │
│  存储:向量数据库                         │
│  检索:语义相似度                         │
│  容量:按相关度取Top-K                    │
└─────────────────────────────────────────┘

代码实现:

class HybridMemorySystem:
    def __init__(self, user_id: str):
        self.user_id = user_id
        self.working = []  # L1
        self.facts = StructuredMemory(f"memory/{user_id}.json")  # L2
        self.semantic = VectorMemory()  # L3

    def on_message(self, role: str, content: str):
        """每条消息经过三个层的处理"""
        # L1: 直接追加
        self.working.append({"role": role, "content": content})
        if len(self.working) > 20:
            self.working = self.working[-20:]

        # L2: 尝试提取事实(异步)
        if role == "user":
            self._maybe_extract_fact(content)

        # L3: 存入向量库
        self.semantic.remember(content, {"role": role, "ts": time.time()})

    def build_context(self, current_msg: str) -> str:
        """构建完整的记忆上下文"""
        parts = []

        # L2 优先:精确事实
        fact_context = self.facts.get_context_prompt()
        if fact_context.strip():
            parts.append(fact_context)

        # L3 补充:语义相关
        related = self.semantic.recall(current_msg, top_k=3)
        if related:
            parts.append("相关历史片段:\n" + "\n".join(f"- {r}" for r in related))

        # L1 最后:当前对话
        recent = self.working[-6:]
        if recent:
            parts.append("当前对话:\n" + "\n".join(
                f"[{m['role']}]: {m['content']}" for m in recent
            ))

        return "\n\n---\n\n".join(parts)

实际效果对比

我在同一个测试集上跑了5种方案,测试用例:用户在第1轮说了"数据库连接串在.env的DB_URL",第15轮再问同样的问题。

方案准确率响应延迟Token消耗维护成本 System Prompt塞满60%低高(>8000)低 纯向量检索30%中中中 纯JSON结构化85%低低(<500)高 对话摘要链40%中中低 混合方案90%中中中

混合方案的90%准确率不是说它完美,而是它在"精确事实召回"和"模糊语义回忆"之间取了个平衡。剩下10%的失败案例主要是:用户换了种说法问同一个事实(比如"DB配置在哪" vs "数据库连接串在哪"),L2的key匹配失败,L3的语义检索又没找到。


最后3个坑

坑1:时间戳必须存

没存时间戳的记忆是灾难。用户说"我昨天改了密码",Agent不知道"昨天"是哪天,可能会用过期信息回答。

# ❌ 错误
self.remember("用户改了密码")

# ✅ 正确
self.remember(f"[{datetime.now().isoformat()}] 用户改了密码")

坑2:记忆要支持覆盖

用户说"数据库从MySQL换成PostgreSQL了",旧记忆必须被覆盖,不能两个都存着。我的做法是用固定的key做索引,新值直接覆盖旧值:

def update_fact(self, category: str, key: str, value: str):
    if category not in self.data:
        self.data[category] = {}
    old = self.data[category].get(key)
    self.data[category][key] = value
    if old and old != value:
        logger.info(f"Fact updated: {key} = {old} -> {value}")
    self.save()

坑3:记忆有上限,必须有淘汰策略

存了3个月的记忆,JSON文件已经有2MB了。事实条目400多条,向量库里10万条。检索变慢,上下文也塞不下。

淘汰策略:按最后访问时间排序,3个月没被检索过的条目自动降级(从L2降级到L3,只保留语义向量,不塞进精确匹配池)。


这套系统跑了2个月,用户反馈"终于不用重复说同一件事了"。没那么完美,但至少不是金鱼了。



关注「安全值班室」公众号

每天一篇AI安全早报 + 实战攻防案例

关注安全值班室

posted on 2026-05-27 09:01  明.Sir  阅读(35)  评论(0)    收藏  举报

导航