跳到主内容

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 的行为会比没有记忆更混乱。

实操第三步:把召回接入对话主流程 ​

主流程的接线很简单:每轮用户输入到来时,先用输入作为查询召回记忆,作为系统上下文注入,再进入正常对话;会话结束时触发写入流水线。这里有一个容易忽略的细节——不是每轮都需要检索,带路由的变体是让轻量模型先判断「本轮是否涉及用户长期偏好或历史约定」,命中才检索,把平均延迟降下来。

注入格式也有讲究:召回的记忆要集中放在一个明确标注的区块里(如「已知用户背景」),而不是散落在对话中假装成历史消息。前者让模型清楚「这是背景资料」,可以放心引用也可以礼貌纠错;后者会让模型把记忆当成上一轮自己说过的话,一旦记忆有误就会沿着错误一路走到底。区块内每条记忆单独成行、带时间前缀,模型对「多久之前的信息」的感知会明显更准确。

避坑总结 ​

  1. 写入必须有门槛,宁缺毋滥。没有置信度过滤的记忆库会被闲聊污染,检索结果全是噪声;抽取提示词里明确「只记稳定信息、每条独立可理解、不确定就丢弃」。
  2. 冲突处理比去重更重要。用户改主意是常态,检测到冲突时以新覆旧、留变更痕迹;让矛盾记忆共存于库中,是最隐蔽的正确性事故来源。
  3. 检索必须按用户强隔离。记忆的 filter 条件要作为硬约束写在检索层而不是提示词里,杜绝提示词被绕过后跨用户泄露信息;同时支持按用户清空记忆,满足隐私删除要求。
  4. 时间衰减参数要可解释。衰减系数决定「多久前的记忆开始不可信」,按业务节奏设定而非拍脑袋,并让模型看到记忆的时间戳,自行权衡引用力度。
  5. 记忆质量要靠评测集守门。构造跨会话回归用例(会话 A 告知偏好,会话 B 验证是否遵守),每次调整抽取或检索逻辑后全量回归,避免「感觉更聪明了」式的自我感动。

写在最后 ​

记忆系统的价值不在「记了多少」,而在「该记的记住了、该忘的清掉了、取回来的恰好有用」。分层设计把这三个诉求拆给了写入门槛、冲突合并和时间衰减检索三个机制,每个机制都不复杂,难的是上线后持续的评测与调参。把记忆当数据治理问题来做,Agent 才能从金鱼记忆成长为真正的长期协作者。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。