记忆,让 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 替换为 PostgresStore 或 RedisStore 即可获得真正的持久化。
自动摘要压缩: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 结尾,提取用户新信息
- 避免每轮都写(设置提取置信度或内容长度过滤)
本篇小结
几个核心观点:
- 四种记忆类型各司其职:感觉记忆是 LLM 调用本身;工作记忆是对话历史;情景记忆是压缩摘要;语义记忆是跨会话的 KV Store
- checkpointer 管会话内,store 管跨会话:thread_id 是会话维度,user_id 是用户维度,两个维度分开管理
- 摘要压缩是长对话的核心方案:RemoveMessage + 摘要注入,让 Agent 在无限长对话中保持 Token 可控
- 会话隔离是基础:不同 thread_id 的历史互不干扰,不同 user_id 的长期记忆互不干扰
- 从 InMemoryStore 到 PostgresStore 只需换一行:架构不变,后端可插拔
下一篇:知识库集成------RAG 作为 Agent 工具和 RAG Pipeline 的本质区别,多知识库路由,Agent 如何决定什么时候检索、检索什么、检索几次。
参考资料
欢迎来我的个人主页找到更多有用的知识和有趣的产品