摘要 :上一篇的 checkpointer 是单 thread 内的短期记忆,切换会话就会丢失记忆。本篇介绍长期记忆:使用
Store(InMemoryStore)跨 thread 持久化用户偏好 / 画像,用 namespace+key 层级组织、context传 user_id 做隔离、IndexConfig接入 OpenAI embedding 实现语义召回(内置 mini-RAG),并区分语义 / 情景 / 程序三类记忆。配合 DeepSeek 实测,做一个跨会话召回偏好的 Agent。
前言
上一篇,我们介绍了 Agent 的短期记忆:checkpointer 让 Agent 跨 invoke 记住对话,但只在单 thread 内有效、切换会话就会丢失记忆。
传送门:【LangChain 1.x】10、短期记忆|checkpointer 持久化与上下文治理三件套
在实际开发场景中,有很多需要跨会话的记忆,比如:用户上次说过喜欢简洁回复,这次新开会话也应该记得;用户的画像、偏好、历史订单,不应该每次都重头来。这就需要用到长期记忆。
本篇,介绍 Agent 的长期记忆:
- 使用 Store (
InMemoryStore)跨 thread 持久化数据 - 用 namespace + key 层级组织、按 user_id 隔离
- 工具经 runtime.store 读写、用 context 传 user_id
- 向量搜索 (
IndexConfig+ embed)语义召回,内置 mini-RAG - 实战:跨会话偏好记忆 Agent,区分三类记忆
一、长期记忆与 Store:跨会话的持久化
短期 vs 长期:checkpointer 和 Store 的分工
上一篇中的 checkpointer 是「单 thread 内」的短期记忆:让 Agent 跨 invoke 记住对话,但状态绑定 thread_id,换一个会话(thread_id)就会重新开始。
长期记忆要解决的是「跨会话」:用户上次说过喜欢简洁回复,这次新开会话也应该记得。这就需要使用 Store,与 checkpointer 两者分工如下:
| checkpointer | Store | |
|---|---|---|
| 作用域 | 单 thread(会话内) | 跨 thread(会话间) |
| 存什么 | Agent 状态(messages 等) | 业务数据(偏好 / 画像 / 知识) |
| 隔离维度 | thread_id | namespace(自定义) |
Store 基础 API
Store 可以独立于 agent 使用,先测试一下 Store 的基础使用 put/get:
python
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
# 写:namespace=("users",) + key + 值字典
store.put(("users",), "user_123", {"name": "小明", "language": "中文"})
# 读:按 namespace + key 精确查找,返回 Item,.value 取字典
item = store.get(("users",), "user_123")
print(item.value) # {'name': '小明', 'language': '中文'}
print(item.key) # user_123
# namespace 是元组、可多层:("users", user_id) 天然按用户隔离
store.put(("users", "alice"), "profile", {"city": "北京", "hobby": "篮球"})
store.put(("users", "bob"), "profile", {"city": "上海", "hobby": "足球"})
print(store.get(("users", "alice"), "profile").value) # {'city': '北京', 'hobby': '篮球'}
print(store.get(("users", "bob"), "profile").value) # {'city': '上海', 'hobby': '足球'}
put(namespace, key, dict)写入:namespace 是元组、key 是字符串、值是字典。get(namespace, key)读取:返回 Item,.value取值字典。- namespace 是元组,可以做层级:
("users", user_id)这种写法天然按用户隔离。
同一个 key(profile)放在不同 namespace 下,互不干扰:
css
alice: {'city': '北京', 'hobby': '篮球'}
bob: {'city': '上海', 'hobby': '足球'}
InMemoryStore 会存在内存里(适合开发/测试快速验证场景),生产环境要用 PostgresStore 等数据库落盘。
二、Store 跨 thread:短期各自独立、长期共享
光讲 store API 还看不出「跨 thread」,因为 store 是全局对象,put/get 和 thread_id 无关。要真正体现「store 跨 thread vs checkpointer 单 thread」,必须使用 agent:让同一个 agent 配置 store + checkpointer,使用两个不同 thread_id 调用,查看返回结果:
python
store = InMemoryStore()
store.put(("users",), "alice", {"name": "Alice", "city": "北京"})
@tool
def get_user(user_id: str, runtime: ToolRuntime) -> str:
"""查用户信息(从 store 读)。"""
item = runtime.store.get(("users",), user_id)
return str(item.value) if item else f"未找到 {user_id}"
agent = create_agent(
model=deepseek_llm,
tools=[get_user],
store=store,
checkpointer=InMemorySaver(),
)
config1 = {"configurable": {"thread_id": "t1"}}
config2 = {"configurable": {"thread_id": "t2"}}
# 两个 thread 各自自我介绍
agent.invoke({"messages": [{"role": "user", "content": "我叫小明,请记住。"}]}, config=config1)
agent.invoke({"messages": [{"role": "user", "content": "我叫小红,请记住。"}]}, config=config2)
# 同样问名字 / 同样查 alice
agent.invoke({"messages": [{"role": "user", "content": "我叫什么名字?"}]}, config=config1)
agent.invoke({"messages": [{"role": "user", "content": "我叫什么名字?"}]}, config=config2)
agent.invoke({"messages": [{"role": "user", "content": "查一下 alice 的信息。"}]}, config=config1)
agent.invoke({"messages": [{"role": "user", "content": "查一下 alice 的信息。"}]}, config=config2)
两个 thread 各自做一次自我介绍(thread-1 说「小明」、thread-2 说「小红」),然后同样的问题对比:
arduino
--- 同样问「我叫什么名字」---
thread-1 回答: 你叫小明,我刚刚已经记住啦!
thread-2 回答: 你叫小红呀!
--- 同样查 alice ---
thread-1 查到: Alice / 北京
thread-2 查到: Alice / 北京
- 问名字:thread-1 答「小明」、thread-2 答「小红」,说明短期记忆(checkpointer)按 thread 隔离。
- 查 alice:两个 thread 都查到了,说明长期记忆(store)跨 thread 共享。
这就是 Store 相比 checkpointer 的核心价值:短期记忆各自独立、长期记忆全局共享。
三、工具读写 Store:context 动态隔离
在前面的介绍中,工具读取 store 时 user_id 是硬编码的(get_user("alice"))。
但在实际开发中 user_id 应该是运行时传入的,这就是 context 的作用:在 invoke 调用模型时,可以通过 context 传 user_id,在工具中通过 runtime.context.user_id 获取参数值。
python
from dataclasses import dataclass
from typing_extensions import TypedDict
@dataclass
class UserContext:
user_id: str # 运行时传入的当前用户 ID
class UserInfo(TypedDict):
name: str
city: str
@tool
def save_user_info(user_info: UserInfo, runtime: ToolRuntime[UserContext]) -> str:
"""保存当前用户的信息到长期记忆。"""
runtime.store.put(("users",), runtime.context.user_id, dict(user_info))
return f"已保存用户 {runtime.context.user_id} 的信息。"
@tool
def get_user_info(runtime: ToolRuntime[UserContext]) -> str:
"""从长期记忆读取当前用户的信息。"""
item = runtime.store.get(("users",), runtime.context.user_id)
return str(item.value) if item else f"暂无用户 {runtime.context.user_id} 的信息。"
agent = create_agent(
model=deepseek_llm,
tools=[save_user_info, get_user_info],
store=store,
context_schema=UserContext,
)
# alice、bob 各自保存自己的信息
agent.invoke(
{"messages": [{"role": "user", "content": "我叫小明,住在北京。请记住。"}]},
context=UserContext(user_id="alice"),
)
agent.invoke(
{"messages": [{"role": "user", "content": "我叫小红,住在上海。请记住。"}]},
context=UserContext(user_id="bob"),
)
# 各自查自己的信息
agent.invoke(
{"messages": [{"role": "user", "content": "查一下我的个人信息。"}]},
context=UserContext(user_id="alice"),
)
agent.invoke(
{"messages": [{"role": "user", "content": "查一下我的个人信息。"}]},
context=UserContext(user_id="bob"),
)
要点:
context_schema=UserContext声明上下文结构(前面第8篇介绍过)。- invoke 时
context=UserContext(user_id=...)作为关键字参数传入。 - 工具通过
runtime.context.user_id读取当前用户,runtime.store.put/get读写长期记忆。 ToolRuntime[UserContext]泛型写法:让 IDE 知道 context 的类型(官方推荐)。
alice 和 bob 各自保存各自查询,数据按 user_id 天然隔离:
css
--- alice 查 ---
回答: 姓名:小明 / 所在城市:北京
--- bob 查 ---
回答: 姓名:小红 / 所在城市:上海
store 里 alice: {'name': '小明', 'city': '北京'}
store 里 bob: {'name': '小红', 'city': '上海'}
同一个 agent、同一套工具,靠 context 传不同 user_id 就能服务多个用户,数据互不串台。
四、向量搜索:内置 mini-RAG
store 的 get 方法是「精确查找」,必须知道完整的 key 才可以获取到信息。
但在实际开发中,更多场景需要做「模糊查找」,比如:用户问「运动爱好」,要召回「我喜欢打篮球」那条记忆,key 里没有「运动」但语义相关。这时可以使用 store 的向量搜索 store.search(query=...)。
原理:store 接入 IndexConfig(embed 函数 + 维度),put 时会自动给每条记忆做 embedding;search 时会对 query 做 embedding,并按相似度排序召回。这是 store 内置的 mini-RAG,不用单独搭向量库。
python
from langgraph.store.base import IndexConfig
def embed(texts):
"""文本列表 → 向量列表(用 OpenAI embedding)"""
return openai_embeddings.embed_documents(texts)
# store 接入 IndexConfig:embed 函数 + 维度
store = InMemoryStore(index=IndexConfig(embed=embed, dims=1536))
namespace = ("users", "alice", "notes")
# 存几条笔记(put 时自动算 embedding)
for key, text in {
"sport": "我喜欢打篮球,每周都去球场。",
"movie": "我爱看电影,尤其是科幻片。",
"pet": "我养的猫叫橘子,今年三岁。",
"tech": "最近在学 LangChain 做 Agent。",
}.items():
store.put(namespace, key, {"text": text})
# 语义查找(按相似度排序,score 越大越相关)
for it in store.search(namespace, query="有什么运动爱好?"):
print(f"[{it.score:.3f}] {it.value['text']}")
三个要点:
IndexConfig(embed=, dims=)来自langgraph.store.base。- embed 函数:文本列表→向量列表。这里使用 OpenAI 的
text-embedding-3-small(1536 维)。 store.search(namespace, query=...)按相似度排序返回,每个 item 带.score(越大越相关)。
存几条不同主题的笔记,用语义查找召回:
erlang
query: "有什么运动爱好?"
[0.461] 我喜欢打篮球,每周都去球场。
[0.332] 我爱看电影,尤其是科幻片。
...
query: "养了什么宠物?"
[0.423] 我养的猫叫橘子,今年三岁。
...
每个 query 最相关的都排在第一(score 最高)。对比 get 和 search:
get要知道完整 key,精确但死板。search靠语义相似度,适合「只记得大概、不知道 key」的模糊查找。
五、实战:跨会话偏好记忆 Agent
把本篇中前面介绍的能力综合起来,做一个「记住用户偏好、跨会话召回」的 Agent:checkpointer(短期)+ store 带 IndexConfig(长期)+ save_preference / recall_preferences 工具 + context。
python
@tool
def save_preference(preference: str, runtime: ToolRuntime[UserContext]) -> str:
"""把用户的一条偏好存入长期记忆。"""
ns = ("users", runtime.context.user_id, "preferences")
runtime.store.put(ns, preference[:20], {"text": preference})
return f"已记住偏好:{preference}"
@tool
def recall_preferences(query: str, runtime: ToolRuntime[UserContext]) -> str:
"""按语义召回当前用户的相关偏好。"""
ns = ("users", runtime.context.user_id, "preferences")
items = runtime.store.search(ns, query=query)
...
agent = create_agent(
model=deepseek_llm,
tools=[save_preference, recall_preferences],
store=InMemoryStore(index=IndexConfig(embed=embed, dims=1536)),
checkpointer=InMemorySaver(),
context_schema=UserContext,
system_prompt=SYSTEM_PROMPT,
)
# 会话1:用户说偏好 → save_preference 存入 store
agent.invoke(
{"messages": [{"role": "user", "content": "我喜欢简洁直接的回复,讨厌啰嗦。请记住。"}]},
config={"configurable": {"thread_id": "session-1"}},
context=UserContext(user_id="alice"),
)
# 会话2(新会话):召回偏好、按偏好回复
agent.invoke(
{"messages": [{"role": "user", "content": "介绍一下 Python。"}]},
config={"configurable": {"thread_id": "session-2"}},
context=UserContext(user_id="alice"),
)
演示:会话1 用户说偏好 → save_preference 存入 store;会话2(新 thread_id)→ recall_preferences 召回偏好、按偏好回复。
ini
--- 会话 1:用户表达偏好 ---
[工具] save_preference 被调用:'喜欢简洁直接的回复,讨厌啰嗦。'
store 里的偏好: ['喜欢简洁直接的回复,讨厌啰嗦。']
--- 会话 2(新会话):介绍 Python ---
[工具] recall_preferences 被调用:query='Python 编程 语言'
[工具] 召回 1 条:['喜欢简洁直接的回复,讨厌啰嗦。']
回答: (按「简洁」偏好回复 Python 介绍)
会话2 是全新会话(不同 thread_id),但通过 store 召回了会话1 存的偏好,这就是长期记忆的价值。工具日志 [工具] ... 被调用 能清楚看到 save / recall 都触发了。
三类记忆
长期记忆在概念上分三类(Memory 概念指南):
| 类型 | 含义 | 本篇对应 |
|---|---|---|
| 语义记忆(semantic) | 事实 / 知识:用户是谁、喜欢什么 | store 存的偏好 |
| 情景记忆(episodic) | 过往事件:之前的对话经历 | checkpointer 的会话历史 |
| 程序记忆(procedural) | 技能 / 规则:怎么做事 | system_prompt、工具用法 |
一个完整的记忆系统往往三类都有:语义记忆存用户画像、情景记忆存交互历史、程序记忆存行为规则。
六、总结
本篇,把 Agent 的长期记忆梳理了一遍:
- 长期记忆 = Store:跨 thread 持久化,和 checkpointer(单 thread)分工。
- namespace + key:元组 namespace 层级组织,按 user_id 隔离。
- 工具读写 :
runtime.store配合 context(传 user_id),同一套工具服务多用户。 - 向量搜索 :
IndexConfig+ embed,store.search语义召回,内置 mini-RAG。 - 三类记忆:语义(偏好)、情景(对话)、程序(规则)。
一句话概括:短期记忆靠 checkpointer(单 thread)、长期记忆靠 Store(跨 thread);用 namespace 组织、context 隔离、向量搜索召回,再分清三类记忆的用途。
下一篇,继续介绍人机协同(Human-in-the-loop):让 Agent 在高风险动作前暂停、等人确认。