# 使用LangChain+LangGraph开发AI应用（7） - 记忆系统


![](https://static.xiongneng.me/langchain-langgraph-cover-20261017183003.png)

## 学习进度

模型本身是无状态的，每一次调用都是初次见面，你五分钟前说过喜欢美式咖啡，五分钟后它已经不记得。这一篇给应用补上记忆，体验上才像个能长期相处的助手。

短期记忆靠 checkpointer，把对话状态按线程存住，一个 thread_id 就是一次会话。长期记忆靠 store，把用户偏好写成键值文档，跨会话跨线程都能取回来。中间还穿插消息历史的两条管理手段，手动裁剪和自动摘要。示例在 `source/hello_memory.py`，九个 demo。完整案例是一个带长期记忆的个人助理 `source/assistant.py`，会话换了它还认得你。

## 前言用途

没有记忆的应用，每个功能都在跟失忆搏斗。用户在对话里说过「我对花生过敏」，三轮之后推荐菜谱照放花生，这种体验说不上哪坏了，但一定留不住人。要补记忆，先想清楚记什么、记多久。

对话历史是最直接的一类。当前会话里说过的话、调过的工具、拿到的结果，会话结束就没必要再留着，但会话进行中必须随时取用，这是短期记忆。用户的偏好、身份、历史决定是另一类，今天告诉它的事实，下周打开应用它还得认账，而且要跟别的用户分开，这是长期记忆。两类记忆的读写模式完全不同，LangGraph 因此给了两套组件，checkpointer 管短期，store 管长期，各自都能从内存版一路换到数据库版。

什么时候需要记忆，判断比 RAG 还简单。应用只要跨过一次交互，就躲不开。多轮对话需要短期记忆，个性化服务需要长期记忆，两者经常一起上，本篇讲的就是怎么把它们装到同一个 agent 上。

## 原理概念

LangGraph 把记忆拆成两套互补的持久化。官方文档的对比表这么分，checkpointer 持久化的是图状态的快照，范围限定在单个线程内，服务对话连续、中断恢复这些场景。store 持久化的是应用自己定义的键值数据，范围跨线程，服务用户偏好、事实档案这类需要长期保留的信息。多数应用两个都用，checkpointer 追踪当前对话，store 存放跨对话的档案。

![](https://static.xiongneng.me/memory-arch-20261017182916.png)

短期记忆的载体是线程。thread_id 类似邮件里的一封会话串，把一个用户的多次往返组织在一起。agent 编译时挂上 checkpointer，每次调用带上同一个 thread_id，状态就自动续上。不同 thread_id 之间完全隔离，你跟 A 用户聊的内容，B 用户的线程里一个字都看不见。状态在每步开始时读入、结束时写回，所以不只是消息，任何进了状态图的字段都享受同样的持久化。

长期记忆的载体是 store。数据以 JSON 文档的形式存放，靠 namespace 和 key 两级定位。namespace 像文件夹，通常装用户 ID 或业务标签，key 像文件名。`("users", "u1")` 这个空间里放一个 key 叫 profile 的文档，取的时候照样报出两个坐标就行。检索接口 search 还支持按 namespace 前缀扫和按内容过滤，文档数量上去之后，跨空间做统计这两招都接得住。

消息历史是短期记忆里最活跃也最容易失控的部分。对话越长，token 越贵，模型在长上下文里的表现还越差。LangChain 给了两个治理手段，手动用 RemoveMessage 删旧消息，或者上 SummarizationMiddleware 让模型自己把老历史压成一段摘要。两个手段都在中间件层工作，不碰业务代码。

## 核心用法

九个用法，示例在 `source/hello_memory.py`。记忆组件全部来自 langgraph，InMemorySaver 与 InMemoryStore 都是进程内实现，零依赖零部署，跑完即清空，各自的边界也清楚。

### 1. 短期记忆

checkpointer 加 thread_id，同一个线程里的对话自动续上。

```python
from langchain.agents import create_agent
from langchain.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    model=model,
    checkpointer=InMemorySaver(),
)
cfg = {"configurable": {"thread_id": "chat-1"}}
agent.invoke({"messages": [HumanMessage("我叫小明")]}, cfg)
result = agent.invoke({"messages": [HumanMessage("我叫什么？")]}, cfg)
```

```text
== 短期记忆 ==
  线程内消息数: 4
  模型回答: 你是小明，喜欢短句
```

第二次调用没带任何身份信息，模型答出「你是小明」，靠的是 checkpointer 把第一轮的两条消息带进了第二轮的上下文。四条消息就是这个线程的全部状态，下次再带这个 thread_id 来，四条一起进上下文。

### 2. 线程隔离与状态查看

换 thread_id 就是换会话，get_state 能看线程状态的快照。

```python
cfg1 = {"configurable": {"thread_id": "chat-1"}}
agent.invoke({"messages": [HumanMessage("我叫小明")]}, cfg1)

cfg2 = {"configurable": {"thread_id": "chat-2"}}
result = agent.invoke({"messages": [HumanMessage("我叫什么？")]}, cfg2)

state = agent.get_state(cfg1)
```

```text
== 线程隔离 ==
  chat-2 的回答: 我不知道你叫什么
  chat-1 快照字段: ['messages']
  chat-1 消息数: 2
```

chat-2 里模型直说不知道，chat-1 记的小明一个字都没漏过去。get_state 返回快照对象，values 字典装着状态字段，默认的 AgentState 里就是 messages 一项。排查「模型为什么忘了」这类问题，先 get_state 看快照里到底有没有那句话，一眼定位。

### 3. 手动裁剪历史

历史太长就删，RemoveMessage 配合 REMOVE_ALL_MESSAGES 可以一键清空再保留尾部。

```python
from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES

state = agent.get_state(cfg).values
keep = [RemoveMessage(id=REMOVE_ALL_MESSAGES)] + state["messages"][-2:]
agent.update_state(cfg, {"messages": keep})
```

```text
== 手动裁剪 ==
  裁剪前 4 条，裁剪后 2 条
  保留的首条: 我在学 Python
```

RemoveMessage 单条用就是按 id 删，配 REMOVE_ALL_MESSAGES 就是全清，后面的列表是被保留的消息。update_state 直接改线程状态，立刻生效。注意保留哪几条要自己拿主意，最稳的策略是保留最近几轮，更早的内容交给下一节的摘要去压缩。

### 4. 自动摘要

SummarizationMiddleware 在消息数越线时请模型写摘要，替换掉老历史。

```python
from langchain.agents.middleware import SummarizationMiddleware

agent = create_agent(
    model=main_model,
    middleware=[SummarizationMiddleware(
        model=summarizer_model, trigger=("messages", 4), keep=("messages", 2),
    )],
    checkpointer=InMemorySaver(),
)
```

```text
== 自动摘要 ==
  触发摘要后消息数: 4
  [HumanMessage] Here is a summary of the conve
  [AIMessage] 好的
  [HumanMessage] 我叫什么？
  [AIMessage] 你是小明，来自杭州
```

trigger 按消息条数设阈值，keep 决定摘要后保留最近几条。第三轮调用前历史到了五条，越线，中间件把前几条发给 summarizer 模型压成一段话。实测有个形态上的细节，写回历史的那条摘要消息是 HumanMessage，不是直觉里的 AIMessage，代码里按类型找摘要会扑空。trigger 也支持按 token 计数，生产上比条数更贴近真实成本。

### 5. Store 键值读写

长期记忆是 namespace 加 key 组织的 JSON 文档，search 支持前缀扫描与内容过滤。

```python
from langgraph.store.memory import InMemoryStore

store = InMemoryStore()
store.put(("users", "u1"), "profile", {"name": "小明", "city": "杭州"})
store.put(("users", "u1"), "note", {"text": "喜欢美式咖啡"})
store.put(("users", "u2"), "profile", {"name": "小红", "city": "上海"})

item = store.get(("users", "u1"), "profile")
items = store.search(("users",), filter={"city": "上海"})
```

```text
== Store 读写 ==
  get: {'name': '小明', 'city': '杭州'}（namespace=('users', 'u1')）
  精确空间检索: ['profile', 'note']
  前缀检索: ['profile', 'note', 'profile']
  内容过滤: ['小红']
  命名空间列表: [('users', 'u1'), ('users', 'u2')]
```

get 按两级坐标精确取，search 的第一个参数是 namespace 前缀，只给 `("users",)` 就把 u1、u2 的文档全翻出来。filter 按文档内容等值过滤，配合 list_namespaces 列出所有子空间，数据按用户分库之后想跨用户做统计，这套接口够用。

### 6. 工具里读写长期记忆

记忆要由 agent 自己动手存取才真的用得起来，工具拿到 ToolRuntime，runtime.store 就是 create_agent 传进去的那个 store。

```python
from langchain.tools import ToolRuntime, tool

@tool
def remember(fact: str, runtime: ToolRuntime) -> str:
    """记住一条关于用户的事实"""
    import hashlib
    key = hashlib.md5(fact.encode()).hexdigest()[:8]
    runtime.store.put(("memories",), key, {"text": fact})
    return "已记住"

@tool
def recall(runtime: ToolRuntime) -> str:
    """回忆用户告诉过你的事"""
    items = runtime.store.search(("memories",))
    return "；".join(i.value["text"] for i in items) or "还没有记忆"

agent = create_agent(
    model=model, tools=[remember, recall],
    store=store, checkpointer=InMemorySaver(),
)
```

```text
== 工具读写长期记忆 ==
  工具返回: 用户喜欢短句回复
  模型回答: 你喜欢短句回复
  Store 里实际存了: [{'text': '用户喜欢短句回复'}]
```

工具函数声明一个 runtime 参数，框架注入，不用你从配置里翻。key 用事实文本的哈希，同一条事实重复记住只会覆盖同一条记录，不会越堆越多。系统提示里写清楚「用户说偏好就调 remember，被问到就先调 recall」，模型就能自己维护自己的记忆。

### 7. 跨会话记忆

换一个全新的 thread_id，短期记忆清零，store 里的长期记忆还在。

```python
store.put(("memories",), "abc123", {"text": "用户喜欢短句回复"})
# 全新线程调用同一个 agent
fresh_cfg = {"configurable": {"thread_id": "brand-new"}}
result = agent.invoke({"messages": [HumanMessage("我喜欢什么样的回复")]}, fresh_cfg)
```

```text
== 跨会话记忆 ==
  全新线程消息数: 4
  工具返回: 用户喜欢短句回复
  模型回答: 根据长期记忆，你喜欢短句回复
```

brand-new 这个线程之前从没出现过，消息历史从零开始，但 recall 一调，老事实回来了。这就是 checkpointer 和 store 的分工，短期跟着线程走，长期跟着应用走。真要用起来，把 store 换成数据库后端（PostgresStore、Redis 都有官方包），进程重启、机器迁移都不丢。

### 8. TTL 坑

InMemoryStore 的 put 签名里有个 ttl 参数，实际一用就报错。

```python
store.put(("t",), "k", {"v": 1}, ttl=5)
```

```text
== TTL 坑 ==
  put 签名里有 ttl 参数: True
  supports_ttl: False
  put(ttl=5) 报错: TTL is not supported by InMemoryStore.
  Use a store implementation that supports TTL or set ttl=None.
```

方法签名是基类 BaseStore 定的统一接口，内存版没实现 TTL，调用直接 NotImplementedError。想给记忆设过期时间，只能换支持 TTL 的后端。报错信息本身把话说得很明白，看到就懂，不算难排查，但设计接口的时候最好先查一下 supports_ttl。

### 9. 持久化边界

内存版的两个组件都没有落盘能力，重启即失忆。

```python
from langgraph.checkpoint.memory import InMemorySaver

print(hasattr(InMemoryStore(), "dump"))
print(hasattr(InMemorySaver(), "dump"))
```

```text
== 持久化边界 ==
  InMemoryStore 有 dump: False
  InMemorySaver 有 dump: False
```

两个都是 False。对比向量库的 InMemoryVectorStore 好歹有 dump 和 load，这里的两个记忆组件连这个都没有，官方的设计意图很明确，内存版只管让你把流程跑通，落盘是数据库后端的职责，SqliteSaver 对短期记忆，PostgresStore 对长期记忆，接口不变，换个对象就完事。本篇的完整案例也因此只做单进程演示，多轮记忆跨进程要用，记得换后端。

## 完整案例

九个 demo 摊开讲完了。收个尾，把它们装进一个带长期记忆的个人助理，`assistant.py`。它有两个记忆工具，remember_fact 和 recall_facts，存取都走 runtime.store，namespace 按 `("memories", "default-user")` 分组，key 是事实文本的哈希。会话线程由 thread_id 圈住，跟长期记忆互不干扰。

```bash
uv run python assistant.py --demo
```

```text
[会话 session-a] 用户: 记住：我喜欢美式咖啡，我在学 LangChain
    工具调用: remember_fact({'fact': '用户喜欢美式咖啡'})
    工具返回: 已记住：用户喜欢美式咖啡
    工具调用: remember_fact({'fact': '用户在学 LangChain'})
    工具返回: 已记住：用户在学 LangChain
    助理: 都记下了，美式咖啡和在学 LangChain。

[会话 session-b] 用户: 我爱喝什么？最近在忙什么？
    工具调用: recall_facts({})
    工具返回: 用户喜欢美式咖啡；用户在学 LangChain
    助理: 你喜欢美式咖啡，最近在学 LangChain。

长期记忆库:
  [7e484591] 用户喜欢美式咖啡
  [34cedd68] 用户在学 LangChain
```

demo 里跑了两段会话。session-a 存了两条偏好，session-b 换了一个全新的 thread_id，消息历史一条都没有，全靠 recall_facts 从 store 里把事实捞回来再组织回答。最后直接翻库，两条记忆各占一个 key，重复记同一条事实不会膨胀。

--mems 命令翻看记忆库，--thread 换会话线程，--model 接真实模型（`提供商:模型名` 格式）。接上真实模型后它就是个能用的对话入口，用户随口说的事它记下来，过几天再问它还认账。

```bash
AGENT_MODEL=openai:deepseek-v4.1-flash uv run python assistant.py "我喜欢喝美式咖啡，记住"
AGENT_MODEL=openai:deepseek-v4.1-flash uv run python assistant.py "我爱喝什么" --thread next-day
```

```text
你: 我爱喝什么
助理: 你喜欢美式咖啡。
```

第二个命令换了新线程 next-day，对话历史是空的，记忆还在。测试在 `tests/test_hello_memory.py` 十四条加 `tests/test_assistant.py` 七条，线程续接、隔离、裁剪、摘要形态、store 前缀检索、跨线程读取、重复记忆去重都钉住了。加上前六篇的，全仓一百一十五条，全绿。

## 源码

[https://github.com/yidao620c/xneng-langchain-langgraph](https://github.com/yidao620c/xneng-langchain-langgraph)

## 参考

- [LangChain 官方文档 - Short-term memory](https://docs.langchain.com/oss/python/langchain/short-term-memory)
- [LangChain 官方文档 - Long-term memory](https://docs.langchain.com/oss/python/langchain/long-term-memory)
- [LangGraph 官方文档 - Persistence](https://docs.langchain.com/oss/langgraph/persistence)


---

> 作者: XiongNeng  
> URL: https://xiongneng.me/posts/ai/langchain/langchain-langgraph-07-memory/  

