外观
Agent 记忆系统设计踩坑实录:短期上下文、长期记忆与向量检索的组合拳
一个典型的翻车现场:数字助手第一天陪用户确定了「部署环境用 Ubuntu、代码风格遵循团队规范 A」,第二天新会话里,它推荐了 CentOS 的安装命令,还用了一整套被用户明确否掉的风格。用户愤怒的点不是答错,而是「我不是昨天刚说过吗」——Agent 的记忆天花板,直接决定了它能不能被当作长期协作者而不是一次性问答机。
记忆系统设计要回答三个问题:记什么、怎么存、怎么取。实践中最常见的错误是把这三个问题混成一个「把历史对话全塞进上下文」——短期能用,会话一长上下文窗口先爆炸,且关键信息被淹没在流水账里。这篇文章给出分层记忆的完整设计与落地代码。
方案对比:三种记忆策略的边界
| 策略 | 做法 | 主要问题 |
|---|---|---|
| 全量历史塞上下文 | 每轮把完整对话历史带全 | 成本线性上涨,长会话必超窗口,关键信息被稀释 |
| 滑动窗口 + 摘要压缩 | 保留近 N 轮,更早内容压缩成摘要 | 只解决「会话内」,跨会话依旧失忆 |
| 分层记忆 | 短期上下文 + 长期记忆库(向量检索) | 需要设计写入门槛与检索策略,复杂度更高 |
前两种是「会话内记忆」的优化,真正解决跨会话问题的是第三种:把值得长期保留的信息沉淀到外部记忆库,用向量检索按需召回。分层的关键在于各层职责清晰:短期层是当前对话的原始上下文,追求完整;长期层是跨会话的事实沉淀,追求精炼——一条记忆应该是一句可以独立理解的话(「用户的生产环境是 Ubuntu 22.04」),而不是一段聊天记录的切片。
实操第一步:设计记忆的数据模型与检索策略
每条记忆带四个关键元数据:所属用户、类型、置信度、时间戳。检索时相似度是主排序,但必须叠加时间衰减——半年前的旧偏好不该和昨天的权重一样:
python
import time
from dataclasses import dataclass
@dataclass
class Memory:
uid: str # 所属用户,检索时强制隔离
content: str # 一句可独立理解的事实,如"用户的生产环境是 Ubuntu 22.04"
kind: str # 类型:preference(偏好)/ fact(事实)/ event(事件)
confidence: float # 写入时的置信度,低置信不入库
last_hit_at: float # 最近一次被召回的时间,用于时间衰减
similarity: float = 0.0 # 检索时回填的相似度得分
def recall(uid: str, query: str, top_k: int = 5) -> list[Memory]:
"""带时间衰减的记忆检索:相似度为主,越久未命中的记忆权重越低"""
candidates = vector_store.search(
query=embed(query),
filter={"uid": uid}, # 按用户强隔离,禁止跨用户召回
top_k=top_k * 3, # 先宽召回,衰减排序后再截断
)
now = time.time()
scored = []
for m in candidates:
days_idle = (now - m.last_hit_at) / 86400
decay = 0.95 ** days_idle # 每闲置一天衰减 5%
score = m.similarity * decay * m.confidence
scored.append((score, m))
scored.sort(key=lambda x: -x[0])
return [m for _, m in scored[:top_k]]召回后的记忆拼进上下文时要标注来源与时间(「以下是关于该用户的已知信息,采集于 X 月 X 日」),让模型对久远信息保持适度怀疑,而不是当成当前事实直接引用。
实操第二步:记忆写入流水线——抽取、去重、合并
记忆系统最大的坑不在读而在写。把每轮对话都存下来,几周后记忆库就会变成垃圾场:重复条目互相干扰、过时信息排在前面。正确做法是会话结束后跑一条「抽取 → 比对 → 合并」的流水线:
python
async def write_memory(uid: str, dialogue: str) -> None:
"""会话结束后的记忆写入:LLM 抽取候选事实,与存量比对后增量合并"""
candidates = await llm_extract_facts(dialogue)
# 抽取提示词的约束示例:
# 只提取稳定信息(偏好/环境/长期约定),忽略一次性内容;
# 每条一句话独立可理解;输出置信度,低于阈值丢弃。
for fact in candidates:
if fact.confidence < 0.7:
continue
similar = vector_store.search(
query=embed(fact.content), filter={"uid": uid}, top_k=1)
if similar and similar[0].similarity > 0.92:
# 高相似:交给 LLM 判断是重复、细化还是冲突
await merge_memory(old=similar[0], new=fact) # 冲突以新覆旧并留痕
else:
vector_store.upsert(fact) # 新信息直接入库合并是整套设计的灵魂。同一条记忆会出现三种演进:重复(原样丢弃)、细化(「用 Ubuntu」升级为「用 Ubuntu 22.04」)、冲突(环境从 CentOS 迁到了 Ubuntu)。前两种合并即可,冲突必须以新覆旧并保留变更痕迹,否则新旧两条互相矛盾的记忆同时被召回,Agent 的行为会比没有记忆更混乱。
实操第三步:把召回接入对话主流程
主流程的接线很简单:每轮用户输入到来时,先用输入作为查询召回记忆,作为系统上下文注入,再进入正常对话;会话结束时触发写入流水线。这里有一个容易忽略的细节——不是每轮都需要检索,带路由的变体是让轻量模型先判断「本轮是否涉及用户长期偏好或历史约定」,命中才检索,把平均延迟降下来。
注入格式也有讲究:召回的记忆要集中放在一个明确标注的区块里(如「已知用户背景」),而不是散落在对话中假装成历史消息。前者让模型清楚「这是背景资料」,可以放心引用也可以礼貌纠错;后者会让模型把记忆当成上一轮自己说过的话,一旦记忆有误就会沿着错误一路走到底。区块内每条记忆单独成行、带时间前缀,模型对「多久之前的信息」的感知会明显更准确。
避坑总结
- 写入必须有门槛,宁缺毋滥。没有置信度过滤的记忆库会被闲聊污染,检索结果全是噪声;抽取提示词里明确「只记稳定信息、每条独立可理解、不确定就丢弃」。
- 冲突处理比去重更重要。用户改主意是常态,检测到冲突时以新覆旧、留变更痕迹;让矛盾记忆共存于库中,是最隐蔽的正确性事故来源。
- 检索必须按用户强隔离。记忆的 filter 条件要作为硬约束写在检索层而不是提示词里,杜绝提示词被绕过后跨用户泄露信息;同时支持按用户清空记忆,满足隐私删除要求。
- 时间衰减参数要可解释。衰减系数决定「多久前的记忆开始不可信」,按业务节奏设定而非拍脑袋,并让模型看到记忆的时间戳,自行权衡引用力度。
- 记忆质量要靠评测集守门。构造跨会话回归用例(会话 A 告知偏好,会话 B 验证是否遵守),每次调整抽取或检索逻辑后全量回归,避免「感觉更聪明了」式的自我感动。
写在最后
记忆系统的价值不在「记了多少」,而在「该记的记住了、该忘的清掉了、取回来的恰好有用」。分层设计把这三个诉求拆给了写入门槛、冲突合并和时间衰减检索三个机制,每个机制都不复杂,难的是上线后持续的评测与调参。把记忆当数据治理问题来做,Agent 才能从金鱼记忆成长为真正的长期协作者。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。