Agent系列(六):记忆管理——让 Agent 记住重要的事

记忆,让 Agent 从"工具"变成"助手"

一个没有记忆的 Agent,每次对话都是从零开始。你告诉它你叫李雷、你是 Python 工程师、你喜欢动手实践------下次对话,它完全不记得。

这不是 Bug,是架构缺失。

LLM 本身没有持久记忆,每次调用都是无状态的。要让 Agent 记住事情,需要在架构层显式地存储、管理和读取记忆。这就是记忆管理模块要解决的问题。

本篇从四个维度拆解 Agent 记忆:记忆类型的分类模型、上下文管理的三种策略、LangGraph 提供的两种记忆机制(checkpointer 和 store)、以及超长对话的自动压缩方案。


四种记忆类型:从认知科学到工程实现

借鉴认知科学对人类记忆的分类,Agent 的记忆也可以分为四层,每一层在 LangGraph 中都有对应的实现方式:

scss 复制代码
┌──────────────────────────────────────────────────────────┐
│                     记忆层级                              │
├──────────────────────┬───────────────────────────────────┤
│ 感觉记忆 (Sensory)   │ 当前 Turn 的 in-flight 消息       │
│                      │ 生命周期:单次 LLM 调用            │
├──────────────────────┼───────────────────────────────────┤
│ 工作记忆 (Working)   │ 对话历史 MessageHistory(有限 K 轮)│
│                      │ 实现:messages 列表注入 Prompt     │
├──────────────────────┼───────────────────────────────────┤
│ 情景记忆 (Episodic)  │ 向量化/摘要化的历史片段            │
│                      │ 实现:摘要压缩 + VectorStore 检索  │
├──────────────────────┼───────────────────────────────────┤
│ 语义记忆 (Semantic)  │ 长期存储的用户偏好、事实           │
│                      │ 实现:LangGraph store (KV Store)  │
└──────────────────────┴───────────────────────────────────┘

感觉记忆:当前 Turn 的消息

最短暂的记忆。一次 LLM 调用的输入和输出,用完即弃:

python 复制代码
q = "Python 中 len([1, 2, 3]) 等于多少"
answer = llm.invoke([HumanMessage(q)])
# answer.content → "len([1, 2, 3]) 的结果等于 3。"
# 这次 invoke 结束后,这个 answer 就消失了

不需要任何机制来"管理"感觉记忆------它是 LLM 调用本身。

工作记忆:有限的对话历史

把之前几轮对话消息拼接进 Prompt,是最直接的记忆实现。效果立竿见影:

python 复制代码
history = [
    HumanMessage("我叫李雷,是一名 Python 工程师"),
    AIMessage("你好,李雷!很高兴认识你。"),
    HumanMessage("我最近在学习 LangGraph"),
    AIMessage("LangGraph 很强大,特别适合构建有状态的 Agent。"),
]
test_q = "我之前告诉你我叫什么名字?"

实测对比:

arduino 复制代码
有历史 → "是的,你之前告诉我你的名字是李雷,并且你是一名 Python 工程师..."
无历史 → "抱歉,我无法回忆起您之前告诉我的名字,因为作为一个 AI,我没有
          持久的记忆功能来存储个人数据..."

差距非常直观。工作记忆的限制是 Token 成本随对话长度线性增长,需要结合截断或摘要来管理。

情景记忆:摘要化的历史片段

当对话历史很长时,把全部历史直接塞进 Prompt 代价太高。情景记忆的做法是先压缩,再存储

python 复制代码
long_history = history * 4  # 16 条消息
summary = llm.invoke([
    SystemMessage("将以下对话压缩为 60 字以内的摘要,保留关键信息"),
    HumanMessage(str([m.content for m in long_history])),
])
# → "李雷,Python工程师,积极学习LangGraph,赞其强大,适合构建有状态Agent。"

16 条消息压缩成 28 个字,下一轮用摘要代替原始历史,Token 消耗大幅下降。

语义记忆:跨会话的用户事实

最持久的记忆层。不随对话结束而消失,专门存储关于用户的长期事实(姓名、职业、偏好等):

python 复制代码
# 把用户信息存入 KV Store,下一次会话直接读取
user_profile = {
    "name": "李雷",
    "role": "Python 工程师",
    "interests": ["LangGraph", "Agent 开发"],
    "level": "中级",
}
# 基于这些信息,Agent 能给出个性化的回答
# "下一步学习方向" → 推荐 LangGraph 进阶用法而不是 Python 入门

上下文管理三策略:截断 / 摘要 / 检索

当对话历史越来越长,上下文窗口撑不住怎么办?三种策略各有取舍:

策略一:截断(Truncation)

最简单粗暴------保留最近 N 条消息,其余全部丢弃:

python 复制代码
# 保留最近 4 条消息
truncated = history[-4:]
resp = llm.invoke(truncated + [HumanMessage(test_q)])

实测用 8 个主题(16 条消息)的历史,截断到最近 4 条后问"Python 列表是什么":

arduino 复制代码
截断后最早可见:'解释一下 Python 装饰器'(第 5 个主题,"列表"在第 1 个)
回答:Python 中的列表是一种内置的数据结构... (靠 LLM 自身知识回答)
⚠ 丢失了我们"学过"列表这个事实,LLM 只是在用通用知识回答

适用场景:对历史连贯性要求不高的场景,或历史本身就不重要的纯问答类 Agent。

策略二:摘要(Summarization)

用 LLM 把长历史压缩成一段摘要,后续对话以摘要代替原始历史:

python 复制代码
summary_resp = llm.invoke([
    SystemMessage("将对话历史压缩为一段摘要(不超过 80 字),保留所有已介绍的主题名称"),
    HumanMessage("\n".join([f"{m.type}: {m.content}" for m in history])),
])
# → "Python列表可变有序,元组不可变省内存,字典键值对映射,集合唯一元素,
#    函数封装逻辑,类面向对象,装饰器函数包装,生成器惰性计算。"
# 16 条消息 → 66 字摘要

同样问"Python 列表是什么",摘要方案的回答能体现"这是我们讨论过的话题",而不只是通用知识。

三种策略对比

策略 Token 消耗 信息保留 实现复杂度 适用场景
截断 最低 只有近期 极低 纯问答、历史不重要
摘要 全局脉络 教学、咨询、长期对话
检索 最低 精准相关 高(需向量库) 知识库、多领域 Agent

策略三:检索(Retrieval)

只拉取与当前问题语义相关的历史片段------是效果最好也最复杂的方案:

python 复制代码
# 简化演示:按关键词过滤(生产中用向量相似度)
relevant = [m for m in history if "列表" in m.content or "list" in m.content.lower()]
# 16 条 → 3 条相关历史
resp = llm.invoke(relevant + [HumanMessage(test_q)])

适用场景:知识库型 Agent、用户有大量历史记录的个人助手。


LangGraph checkpointer:会话内状态持久化

LangGraph 的 MemorySaver(checkpointer)用 thread_id 区分不同会话,在同一会话内自动积累对话历史:

python 复制代码
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent

checkpointer = MemorySaver()
agent = create_react_agent(model=llm, tools=[get_weather], checkpointer=checkpointer)

# 同一 thread_id = 同一个会话,状态持久化
config_a = {"configurable": {"thread_id": "weather_001"}}

跨轮引用实测

会话内连续三轮问天气:

csharp 复制代码
[Turn 1] 用户: 北京今天天气怎么样?
         Agent: 北京今天的天气是晴,温度为 25°C,东北风 3 级,空气良好。
         (当前状态中消息数: 4)

[Turn 2] 用户: 那上海呢?          ← "那" 字没有明确指代,需要上文理解
         Agent: 上海今天的天气是多云,22°C,东南风 2 级,轻度雾霾。
         (当前状态中消息数: 8)

[Turn 3] 用户: 这两个城市哪个更适合今天出行?   ← 需要前两轮的查询结果
         Agent: 考虑到上海的雾霾情况,建议您在北京出行。
         (当前状态中消息数: 10)

Turn 2 和 Turn 3 的回答都依赖了前面的历史,checkpointer 自动完成了跨轮上下文管理。

会话隔离

不同 thread_id 之间完全独立:

css 复制代码
[新会话 - thread_id: weather_002]
用户: 我刚才问的是哪个城市?
Agent: 您刚才问的是"哪个城市",但是没有提供具体的城市名称。
      如果您需要查询某个城市的天气,请告诉我具体的城市名称。
→ 新 thread_id 没有任何历史,完全不知道刚才问过什么

会话继续

同一 thread_id 隔一段时间再回来,历史依然存在:

makefile 复制代码
[会话 A 继续 - 同一 thread_id]
用户: 刚才查的两个城市,再查一下深圳对比一下
Agent: 深圳今天的天气是阵雨,27°C,西南风 2 级,同时还有雷暴预警。
       (进行了北京/上海/深圳三城比较)
→ 记住了前面查过北京和上海

MemorySaver 是内存实现,进程重启后数据丢失。生产中用 SqliteSaver(本地文件)或 PostgresSaver(数据库)代替。


LangGraph InMemoryStore:跨会话长期记忆

checkpointer 解决的是单次会话内的记忆。跨会话的长期记忆需要 store

复制代码
checkpointer  →  绑定 thread_id,会话生命周期内有效
store         →  绑定 user_id,跨会话永久存在

核心 API

python 复制代码
from langgraph.store.memory import InMemoryStore

store = InMemoryStore()

# 写入:(namespace, key, value)
store.put(("user_facts", user_id), key, {"fact": "李雷,后端工程师"})

# 读取:搜索某个 namespace 下的所有条目
facts = store.search(("user_facts", user_id))
for item in facts:
    print(item.value["fact"])

# 精确读取
item = store.get(("user_facts", user_id), specific_key)

跨会话记忆实测

在会话 A 中,Agent 自动从对话中提取用户信息并存入 store:

css 复制代码
[会话 A] 用户说了三句话 → 自动提取并存储:
  • 李雷,后端工程师
  • Python, Go, LangGraph, Agent 开发
  • 动手实践,不喜欢纯看文档

在完全不同的会话 B(新 thread_id,但同一 user_id)中问"你认识我吗?":

makefile 复制代码
[会话 B - 全新 thread_id]
用户: 你好,你认识我吗?
Agent: 你好!根据你提供的信息,我认识你。你是李雷,一位后端工程师,
       擅长使用 Python、Go、LangGraph 和 Agent 进行开发。
       你更喜欢动手实践,而不是仅仅阅读文档。有什么可以帮助你的吗?
→ 虽然是全新 thread_id,但 store 中的用户信息跨会话持久

不同 user_id 的数据完全隔离,互不干扰。

checkpointer vs store 对比

python 复制代码
# 短期记忆:checkpointer --- 绑定 thread_id,会话内有效
app = graph.compile(checkpointer=MemorySaver())
result = app.invoke(input, config={"configurable": {"thread_id": "abc"}})

# 长期记忆:store --- 绑定 user_id,跨会话有效
store = InMemoryStore()
app = graph.compile(store=store, checkpointer=MemorySaver())
# 在 node 中操作 store
store.put(("user_facts", user_id), key, {"fact": "..."})
stored = store.search(("user_facts", user_id))

生产中 InMemoryStore 替换为 PostgresStoreRedisStore 即可获得真正的持久化。


自动摘要压缩:RemoveMessage + 摘要轮替

当消息数超过阈值时,触发自动压缩------这是让 Agent 在无限长对话中保持清醒的核心机制。

图结构

css 复制代码
[chat 节点]
    │
    ├─ 消息数 ≤ 阈值 → END
    └─ 消息数 > 阈值 → [compress 节点] → END

compress 节点:RemoveMessage 删除旧消息

python 复制代码
def compress_node(state: SummaryState) -> dict:
    messages = state["messages"]
    to_compress = messages[:-2]   # 保留最新 2 条,其余全部压缩
    keep = messages[-2:]

    # 旧消息 → 新摘要
    new_summary = llm.invoke([
        SystemMessage("将以下内容压缩为 120 字以内的摘要"),
        HumanMessage(existing_summary + old_messages_text),
    ]).content

    # RemoveMessage:告诉 add_messages reducer 删除这些消息
    remove_ops = [RemoveMessage(id=m.id) for m in to_compress]
    return {"messages": remove_ops, "summary": new_summary}

RemoveMessage 是 LangGraph 专用的消息删除操作符,add_messages reducer 看到它会从状态中删除对应 id 的消息。

实测效果

11 轮对话,阈值设为 8 条消息:

ini 复制代码
[Turn 1-4]  消息数:  2/4/6/8  | 摘要: ○ 无

  [压缩触发] 10 条 → 压缩 8 条,保留 2 条
  [新摘要]   Python列表常用方法包括查找、排序、添加删除等。
             `dict.get()` 避免 `KeyError`,返回默认值。
             `*args` 接收任意位置参数,`**kwargs` 接收任意关键字参数...

[Turn 5]    消息数:  2  | 摘要: ✓ 已压缩  ← 压缩后从 2 条重新开始

  [压缩再次触发] 10 条 → 压缩 8 条,保留 2 条

[Turn 11]  最终汇总:
           "根据我们之前的讨论,以下是您掌握的 Python 知识点汇总:
            1. Python 列表推导式...
            2. 集合推导式...  ← 通过摘要链条传承,第 1 轮的内容还在
            3. Lambda 函数..."

关键结果:11 轮对话始终只有 2-8 条 active 消息,但通过摘要链,所有历史知识都传承了下来。

摘要状态设计

python 复制代码
class SummaryState(TypedDict):
    messages: Annotated[list, add_messages]  # add_messages 处理 RemoveMessage
    summary: Optional[str]                   # 累积的历史摘要,注入 system prompt

def chat_node(state: SummaryState) -> dict:
    summary = state.get("summary") or ""
    system_parts = ["你是助手。"]
    if summary:
        system_parts.append(f"\n\n【历史摘要】{summary}")  # 摘要注入 system prompt
    resp = llm.invoke([SystemMessage("".join(system_parts))] + state["messages"])
    return {"messages": [resp]}

记忆管理设计清单

实现一套完整的 Agent 记忆系统需要考虑的点:

短期记忆(checkpointer)

  • 选择合适的 checkpointer 后端(开发用 MemorySaver,生产用 SqliteSaver/PostgresSaver)
  • 为每个用户/会话分配唯一的 thread_id
  • 设置历史截断阈值,防止 Token 无限增长

长期记忆(store)

  • 按 namespace 组织用户数据:(类型, user_id)("user_facts", uid)
  • 提取记忆时要有置信度过滤,避免存入无意义的噪声
  • 生产环境替换为 PostgresStore / RedisStore

上下文压缩

  • 确定压缩阈值(建议 8-20 条消息,视场景而定)
  • 摘要 Prompt 中明确要保留哪些信息(主题名称、关键决策、用户偏好)
  • 测试摘要链:第 N 轮压缩的摘要是否包含了第 1 轮的关键信息
  • RemoveMessage 而不是替换整个 messages 列表(后者在有 checkpointer 时会有问题)

记忆的读写时机

  • 读记忆:在 chat_node 开头,注入 system prompt
  • 写记忆:在 chat_node 结尾,提取用户新信息
  • 避免每轮都写(设置提取置信度或内容长度过滤)

本篇小结

几个核心观点:

  1. 四种记忆类型各司其职:感觉记忆是 LLM 调用本身;工作记忆是对话历史;情景记忆是压缩摘要;语义记忆是跨会话的 KV Store
  2. checkpointer 管会话内,store 管跨会话:thread_id 是会话维度,user_id 是用户维度,两个维度分开管理
  3. 摘要压缩是长对话的核心方案:RemoveMessage + 摘要注入,让 Agent 在无限长对话中保持 Token 可控
  4. 会话隔离是基础:不同 thread_id 的历史互不干扰,不同 user_id 的长期记忆互不干扰
  5. 从 InMemoryStore 到 PostgresStore 只需换一行:架构不变,后端可插拔

下一篇:知识库集成------RAG 作为 Agent 工具和 RAG Pipeline 的本质区别,多知识库路由,Agent 如何决定什么时候检索、检索什么、检索几次。


参考资料


欢迎来我的个人主页找到更多有用的知识和有趣的产品

相关推荐
深兰科技1 小时前
韩国KAIST AI半导体高管项目代表团到访深兰科技,聚焦AI算力与智能产业合作机会
人工智能·机器人·symfony·ai算力·深兰科技·韩国科学技术院·kaist
快乐on9仔1 小时前
NLP学习(一)transformers之pipeline体验
人工智能·深度学习
花椒技术1 小时前
企业内部 Agent 落地复盘:Gateway、Skill 和二次确认如何串起受控业务执行
后端·agent·ai编程
冬奇Lab1 小时前
一天一个开源项目(第113篇):notebooklm-py - 把 Google NotebookLM 变成可编程 API,还能接入 Claude Code
人工智能·google·开源
字节跳动开源2 小时前
Viking AI 搜索 CLI 正式发布:会说话,就能做搜索推荐
数据库·人工智能·开源
阿杰技术2 小时前
AI 编程助手落地实战:从提效到重构的全场景指南
人工智能·重构
Agent手记2 小时前
制造业生产流程自动化,Agent需要具备哪些能力?深度拆解2026工业级智能体落地范式与核心架构
大数据·人工智能·ai·架构·自动化
七牛云行业应用2 小时前
OpenHuman、OpenClaw、Hermes Agent 傻傻分不清楚?一篇说清三者定位
ai·agent·hermes agent