给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安全早报 + 实战攻防案例
浙公网安备 33010602011771号