Skip to content

Agent 记忆系统:为什么跑着跑着就失忆,记忆该怎么存怎么忘

本文是 AI 应用系统学习系列的 L2 核心篇。前置:35. Agent 系统:ReAct、工具与记忆42. Context Engineering。 学完可以配合面试题食用:05-agent-architecture-design29-multi-agent-collaboration

提出问题

先看一个做 Agent 的人都见过的场景。一个客服 Agent,第一轮用户说"我上个月买的那台咖啡机又漏水了"。第二轮用户发来一句"还是漏",Agent 回复:"请问您遇到什么问题呢?"

用户瞬间失去耐心。失败点很具体:上下文窗口放不下几百轮对话,早期消息被截断丢掉了。窗口是消耗品,第 50 轮的请求里根本看不到第 1 轮说了什么。

解法是把记忆搬到窗口外面:窗口里只放当下用得着的内容,历史放外部存储,要用的时候再查回来。这就是 Agent 记忆系统要解决的事——决定存什么、存成什么样、怎么找回来、什么时候删。

窗口与外部记忆的分工

上下文窗口和外部记忆不是二选一,是分工。窗口里放本次任务的工作集:系统提示、当前任务描述、最近几轮对话、工具返回的关键结果。外部记忆放任务之外还要长期保留的东西:用户是谁、历史结论、踩过的坑。

分工的判断标准就一条:这个信息下一次会话还用得上吗?"用户偏好代码评审严格一点"会话结束后依然有用,该进外部记忆;"git diff 的修改建议"出了会话就是废纸,留在窗口里随它去。

窗口还有第二个用途:模型改变行为只看当前这屏 token,外部记忆再丰富,不查回来就等于不存在。所以记忆系统的核心工作是"查回来"——查什么、什么时候查、查多少。

三层记忆

按存活时间和用途,记忆分三层。拿做项目类比:脑子里正在推演的方案是短期记忆,桌上的便利贴和任务看板是工作记忆,项目经验和技术积累是长期记忆。

  • 短期记忆:对话历史加 scratchpad。原样保留近期消息,存活范围是当前会话。满了会被截断或摘要,天然靠不住。
  • 工作记忆:当前任务的状态——目标、已完成的步骤、中间变量、待办。通常表现为一段不断被改写的任务状态 JSON,存活范围是一次任务而不是一轮对话。
  • 长期记忆:跨会话存活。向量库存经历类信息,知识图谱存实体关系,普通表存结构化事实。第 35 篇 Agent 系统:ReAct、工具与记忆 里 ReAct 循环的 Thought 轨迹就是典型的短期记忆材料。
mermaid
flowchart LR
    A[用户输入] --> B[记忆检索]
    B --> C[窗口组装<br/>系统提示 + 检索到的记忆 + 近期对话 + 任务状态]
    C --> D[LLM 推理]
    D --> E[产出回复 / 工具调用]
    E --> F[记忆写入决策]
    F -->|近期原文| G[短期: 对话历史]
    F -->|状态快照| H[工作: 任务状态]
    F -->|值得长期保留| I[长期: 向量库 / 知识图谱]
    G --> B
    H --> B
    I --> B

检索是关键路径:每轮请求前先用当前输入去长期记忆查相关条目,塞进窗口。所以记忆系统本质上是带写入策略的检索系统,第 47 篇讲的向量数据库是它的存储底座。

什么值得记

不是所有对话都值得存。全存有两个直接后果:检索时噪声淹没真正有用的条目,存储成本白烧。按"下次还有用"过滤,值得记的大致四类:

  • 用户偏好:语言、代码风格、沟通习惯。"用户希望回答里先给结论"——存一条,之后几百次会话受益。
  • 任务结论:做完了什么、得到什么结果。"7 月 3 日把支付模块重构为状态机,原因是原 if-else 分支 14 处"。
  • 错误教训:踩过的坑。某工具的参数有坑、某 API 限流阈值是 10 QPS——这类记忆价值密度最高。
  • 事实性知识:项目结构、部署环境、关键路径。

另一对维度是 Episodic 和 Semantic。Episodic 是经历:哪天处理了什么工单、当时怎么解决的。Semantic 是从经历里提炼出的事实:这个用户的发票系统是金蝶的。经历适合回顾和追溯,事实适合直接复用。好的记忆系统会定期把多条 Episodic 提炼合并成一条 Semantic,检索效率和回答质量都更好。

怎么找回来

存进去只是前半件事,找得回来才算数。基础做法是相关性召回:把当前输入 embed 成向量,去记忆库做相似度检索,取 top-k 塞进窗口。很多实现到这一步就停了,然后遇到一个经典 bug:

用户上周说"我不用 Python,用 Go"。今天问"帮我写个脚本"。纯相似度检索,"写脚本"和"上周聊过 Python"的向量相似度不低,Python 相关的记忆照样被召回,Agent 开开心心写出 Python。相关性不等于正确性。

所以召回通常要叠三个修正。时间衰减:给每条记忆一个随时间下降的分数,30 天前的偏好和今天的偏好冲突时倾向信新的。权重混排:相似度分数、时间分数、重要性分数加权合并,经验配置是相关性 0.6、时近性 0.3、重要性 0.1,按业务调。显式覆盖:写入时识别到"我不用 Python 了"这类修正语句,直接把旧条目标记为被替换,不留给检索去猜。

记忆压缩与 token 预算

窗口永远不够用,所以窗口内的历史要分档管理:近详远略。最近 5-10 轮留原文——对话的语气、代词指代都在细节里,摘要会丢。更早的滚动摘要——把 20 轮压成 200 字,保留结论和关键决定,丢掉过程。

token 预算拿 32k 窗口举例:系统提示 2k,检索回来的长期记忆 2-4k,任务状态 1k,近期对话原文 8-10k,滚动摘要 1-2k,剩下全部留给本轮输入和输出。输出至少预留 4k,回复被截断的 Agent 比失忆的更常见。

压缩的触发时机有两种:被动压缩是历史超出预算才动手,实现简单,代价是压缩那一刻请求延迟会抖一下;主动压缩是后台定期把老对话滚进摘要,请求时窗口永远不超限,代价是要维护后台任务。请求延迟敏感就选主动压缩。

遗忘机制

不删东西的记忆库会变成垃圾场。三个月后检索"用户偏好",返回的第一条是用户半年前的临时想法,第二条是重复存了五遍的同一条目。遗忘机制至少三件事:

  • TTL:临时性信息直接设过期。"用户今天要赶飞机"三天后就是噪声,到期删除或降权。
  • 重要性打分:写入时打分,来源可靠、复用频率高、用户显式强调的条目分数高。低分记忆过期淘汰,高分记忆不过期。
  • 去重与冲突合并:新记忆写入前先查相似条目。相似度超过阈值,两种处理:内容矛盾就新覆盖旧(保留时间戳),内容雷同就合并计数("提过 3 次"本身是重要性的证据)。

去重对向量库尤其重要:同一件事换个说法存十遍,检索时十条全召回,直接挤掉别的记忆的名额。

动手实操:80 行写一个最小记忆层

不依赖任何框架,纯 Python 把上面几件事串起来:写入去重、远期滚动摘要、相关性召回、冲突更新。生产系统比这复杂,主干就这么多。

python
import json, time
from openai import OpenAI

client = OpenAI()  # 需设置 OPENAI_API_KEY

class MemoryStore:
    def __init__(self, keep_recent=6):
        self.keep_recent = keep_recent   # 近期保留原文的轮数
        self.history = []                # 短期:原文对话
        self.summary = ""                # 远期:滚动摘要
        self.facts = {}                  # 长期:key -> {"v": 内容, "t": 时间, "n": 次数}

    # --- 写入:去重 + 冲突更新 ---
    def remember(self, key, value):
        old = self.facts.get(key)
        if old and old["v"] == value:            # 完全重复:只更新次数和时间
            old["n"] += 1
            old["t"] = time.time()
        else:                                     # 新事实或冲突:新覆盖旧
            self.facts[key] = {"v": value, "t": time.time(),
                               "n": old["n"] + 1 if old else 1,
                               "old": old["v"] if old else None}

    # --- 压缩:近期留全文,远期滚进摘要 ---
    def compress(self):
        if len(self.history) <= self.keep_recent:
            return
        overflow, self.history = self.history[:-self.keep_recent], self.history[-self.keep_recent:]
        text = "\n".join(f"{m['role']}: {m['content']}" for m in overflow)
        resp = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content":
                       f"把下面的对话压缩成 150 字以内的摘要,保留结论和决定:\n{text}"}])
        self.summary = (self.summary + "\n" if self.summary else "") + resp.choices[0].message.content

    # --- 检索:关键词重合数做相关性打分,够用但记得换 embedding ---
    def recall(self, query, k=3):
        scored = []
        for key, rec in self.facts.items():
            overlap = len(set(key) & set(query))   # 字符级重合,粗打分
            scored.append((overlap, rec["t"], key, rec))
        scored.sort(reverse=True)
        return [rec["v"] for _, _, key, rec in scored[:k] if _ > 0]

    # --- 组装:按 token 预算把三层记忆拼进窗口 ---
    def build_context(self, query):
        recalled = self.recall(query)
        msgs = [{"role": "system", "content":
                 "已知用户信息与历史结论:\n" + "\n".join(recalled)}]
        if self.summary:
            msgs.append({"role": "system", "content": "更早对话摘要:\n" + self.summary})
        return msgs + self.history + [{"role": "user", "content": query}]

# --- 使用 ---
store = MemoryStore()
store.remember("偏好语言", "用户主用 Go,不用 Python")
store.remember("发票系统", "公司用金蝶")
store.history = [{"role": "user", "content": "帮我写个脚本统计日志错误数"},
                 {"role": "assistant", "content": "已生成脚本并运行"}]
store.compress()
for m in store.build_context("再帮我写个定时任务"):
    print(m["role"], ":", m["content"][:60])

关键行说明:remember()n 字段是复用计数,检索时"提过多次"的事实可以加权重;compress() 只压缩溢出部分,近期原文不动;recall() 用字符重合做演示,换成 embedding 相似度就是生产形态;build_context() 是每轮请求的组装入口,检索发生在这一步而不是写死的系统提示里。

跑一下使用代码,system 消息里会带上"用户主用 Go,不用 Python",后续请求就不会再输出 Python。试两分钟就能看到区别。

生产形态:独立记忆服务

字典和向量索引维护到一定规模就会想拆出去。mem0、Letta 这类产品把记忆层做成了独立服务,应用侧只面对两个接口:

  • add(messages, user_id):传入对话,服务端自己判断哪些值得记、写入哪层。
  • search(query, user_id):传入当前问题,返回组装好的记忆条目列表。

表结构大同小异,核心是两张表:memories(id、user_id、content、embedding、metadata、score、updated_at)和 history(记录每条记忆的增删改,出问题能回溯)。选型判断:记忆是产品核心能力就自建,控制召回逻辑和打分策略;是通用辅助功能就用服务,省掉运维。不管用哪家,分层和写入策略的思路与第 35 篇的接口设计相通。

常见误区与小结

常见误区:

  • 什么都存。全量对话直接进库,检索噪声指数上涨,两周后记忆质量反而不如不用。
  • 只管写不管忘。没有 TTL 和去重,三个月后 top-k 全是重复和过时条目。
  • 只做相似度检索。缺时间衰减和显式覆盖,用户改了口,Agent 还拿旧偏好说话。
  • 把记忆当另一个 RAG。知识库回答"世界是什么样",记忆回答"这个用户/任务是什么样",写入策略和生命周期完全不同,混在一套管线里两边都做不好。
  • 摘要压掉关键细节。把"用户说不用 Python"压没了,后面每次都犯错。

小结:记忆系统让 Agent 的能力跨过单次会话存活。本文的三层结构、写入过滤、带时间衰减的召回、滚动压缩、遗忘机制,就是第 42 篇 Context Engineering 在"跨会话"维度的落地。下一篇讲 Agent 和 Workflow 的选型边界——什么时候该上循环,什么时候写死流程反而更好。

参考

  • mem0 文档:https://docs.mem0.ai
  • Park et al., "Generative Agents: Interactive Simulacra of Human Behavior", UIST 2023(记忆流 + 反思机制的开山论文)
  • Letta(MemGPT)文档:https://docs.letta.com

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1