第 6 期:长期记忆——Store 跨会话记东西
上册练习 11(第 4 期的 checkpointer)解决的是“同一场对话记不记得上一句“。
练习 15 问了一个不一样的问题:客人换一场全新对话再来找你,模型还认不认识他?
而且练习 15 的重点根本不在“记住“本身——一个文件想写多少写多少,难的是
“写进去容易,什么时候该改、该删,谁来判断”。练习 15 用一份模型自己维护的
MEMORY.md 回答这个问题:想写新的用 write_file,想改一条用 edit_file,
没有专门的“记住“或“忘记“工具,记错一件事和改错一行代码是同一种操作。
LangGraph 里对应的机制叫 Store。这一期给客服 agent 接上它,同时先把两把钥匙的
分工说清楚:checkpointer 记的是“这场对话说了什么“,认 thread_id;store
记的是“这个用户身上值得跨会话保留的内容“,认 user_id。这两把钥匙各管各的,
互不相干——下面有一个真实踩到的坑,起因正是把它们搞混了。
敲进去
第 6 期的代码在 code/ep06/。get_order、get_policy、cancel_order 原样从
第 5 期搬过来,新增的是 remember_note:
from langgraph.prebuilt import ToolRuntime
@tool
def remember_note(note: str, runtime: ToolRuntime) -> str:
"""覆盖跨会话笔记。传笔记的完整内容——这次传的文本会整个替换掉旧笔记,
需要保留的部分要自己带上,只传新增的一小段会把之前该留的内容丢掉。
传空字符串等于清空笔记。"""
user_id = runtime.config["configurable"]["user_id"]
runtime.store.put((user_id, "memory"), "note", {"text": note})
return "笔记已更新" if note else "笔记已清空"
参数名叫 runtime、类型标注 ToolRuntime,LangGraph 会自动把它塞进来,不用
Annotated 包一层。runtime.store 就是那个 Store,runtime.config 是这次调用
的配置——工具函数能拿到跟节点一样的上下文。
store.put(namespace, key, value) 的 namespace 是个 tuple,这里用
(user_id, "memory");key 是 "note";value 是个 dict。get 反过来,
拿不到的时候返回 None。
agent 节点多了两个注入参数,用来在回答之前把笔记塞进系统提示词:
from langchain_core.runnables import RunnableConfig
from langgraph.runtime import Runtime
def agent(state: AgentState, config: RunnableConfig, runtime: Runtime) -> dict:
user_id = config["configurable"]["user_id"]
item = runtime.store.get((user_id, "memory"), "note")
note = item.value["text"] if item else None
reply = llm.invoke([SystemMessage(system_prompt(note)), *state["messages"]])
return {"messages": [reply]}
Runtime 本身不带 config(官方注释原话),所以要单独声明一个
config: RunnableConfig 参数,两个一起拿。build_graph 也多了一个参数:
def build_graph(checkpointer, store):
...
return builder.compile(checkpointer=checkpointer, store=store)
main.py 现在每次调用都要两把钥匙:
config = {"configurable": {"thread_id": thread_id, "user_id": user_id}}
还多了一个 --memory <user_id> 用来看某个用户当前记了什么。开发用
langgraph.store.memory.InMemoryStore,这一期跟 checkpointer 一样换成能跨进程用
的版本——langgraph.store.sqlite.SqliteStore,接口跟 SqliteSaver 几乎一样,
多一步 store.setup():
with SqliteSaver.from_conn_string(str(CHECKPOINT_DB)) as saver, \
SqliteStore.from_conn_string(str(MEMORY_DB)) as store:
store.setup()
graph = build_graph(saver, store)
跑起来
cd code
uv run python -m ep06.main wang t1 "以后称呼我王总,不要叫王小姐"
uv run python -m ep06.main --memory wang
uv run python -m ep06.main wang t2 "帮我查一下 KL-778 的改期政策"
# 这两条我是真的手滑,但正好留下一个反面教材,见下面"发生了什么"
uv run python -m ep06.main chen t1 "帮我查一下 KL-901 的退款政策"
uv run python -m ep06.main --history t1
uv run python -m ep06.main chen c1 "帮我查一下 KL-901 的退款政策"
uv run python -m ep06.main --memory chen
uv run python -m ep06.main wang t3 "其实不用那么客气了,还是叫我王小姐就行,之前说的王总作废"
uv run python -m ep06.main --memory wang
uv run python -m ep06.main wang t4 "帮我查一下 KL-778 的出行日期"
你应该看到什么
实验一:第一次记笔记
[agent] 要调 remember_note({'note': '客人希望被称呼为「王总」,不要叫「王小姐」。'})
[tools] remember_note 返回:笔记已更新
[agent] 回答:好的王总,已记住,以后都称呼您「王总」。请问有什么可以帮您?
user wang 的笔记:客人希望被称呼为「王总」,不要叫「王小姐」。
实验二:全新 thread,同一个人,没有历史也认得
[agent] 要调 get_order({'order_id': 'KL-778'})
[agent] 要调 get_policy({'product_id': 'SKU-1001', 'topic': 'reschedule'})
[agent] 回答:王总,您好!您的东京迪士尼一日票(KL-778)出行日为 9 月 7 日,
距今天还有 4 天,目前仍在可免费改期一次的范围内(出行前 3 天内才不支持)。
如需改期请告诉我。
t2 是一场全新对话,checkpointer 里没有 t1 那段“以后叫我王总“的记录,
模型却照样这么称呼——称呼来自 store,不来自对话历史。
意外的坑:thread_id 撞车,串号了
我给 chen 测隔离的时候手滑复用了 wang 用过的 "t1":
[agent] 回答:王总,订单 KL-901(东京迪士尼一日票,出行日 2026-09-05)的退款
政策是:出行日前 7 天可全额退款,7 天内不支持退款。
今天是 9 月 3 日,距出行日不足 7 天,因此该订单目前无法退款。
chen 从没要求被叫“王总“。--history t1 拿出来一看:
thread t1:10 条消息,下一步待执行节点:(无,对话已结束)
Human 以后称呼我王总,不要叫王小姐
AI tool_calls=['remember_note']
Tool 笔记已更新
AI 好的王总,已记住,以后都称呼您「王总」。请问有什么可以帮您?
Human 帮我查一下 KL-901 的退款政策
...
wang 那场对话的四条消息原封不动待在 t1 里,chen 的新问题直接续在了后面
——checkpointer 只认 thread_id,不知道也不关心换了个人。--memory chen
另外查过,确实是空的——串号串的是对话历史,笔记本身没有受影响。
换一个 chen 专属的 thread_id(c1)之后,回答里就没有“王总“了:
[agent] 回答:KL-901(东京迪士尼一日票,出行日 9/5)的退款政策:出行日前 7
天可全额退款;7 天内不支持退款。今天是 9/3,距出行仅 2 天,已不支持退款。
实验四:改口,笔记整条被换掉
[agent] 要调 remember_note({'note': '客人希望被称呼为「王小姐」,不要叫「王总」。'})
[tools] remember_note 返回:笔记已更新
[agent] 回答:好的王小姐,已记下,以后就这么称呼您。有什么可以帮您的?
user wang 的笔记:客人希望被称呼为「王小姐」,不要叫「王总」。
笔记变成了新的一整条,没有把“王总““王小姐“两个偏好堆在一起。模型自己判断了
“王总“这条已经作废,写新笔记的时候没有带上它。t4 里再验证一次,wang 的
回答变成“王小姐”,旧偏好确实不在了:
[agent] 回答:王小姐,您的订单 KL-778(东京迪士尼一日票,2 张)出行日期是
2026-09-07。请问还需要其他帮助吗?
发生了什么
checkpointer 认 thread_id,store 认 user_id,两者是完全独立的两套隔离。
框架不会替你把它们对上。thread_id 撞车会让不同用户看到同一份对话历史,
store 的隔离本身没有出错——chen 的笔记全程是空的,串号串的只有历史。
真实系统里 thread_id 不能是手敲的短字符串,得由应用层保证全局唯一,
常见做法是拿 user_id 拼会话序号,或者干脆用 UUID。
跨会话记忆的证据就是“没有历史却知道“。 实验二整场对话只有一条
HumanMessage,checkpointer 里没有任何“王总“的记录,回答里却出现了它——
这个信息只能来自 store,来自 agent 节点每次调用前主动读的那一次
store.get。
这里选择整条覆盖,是练习 15“价值在筛选“的直接体现。 remember_note 的
docstring 要求传完整笔记,逼着模型自己判断“王总“这条现在还成不成立,重新
组织出一份干净的笔记。如果改成“追加一行“,两条互相矛盾的称呼要求会一起
躺在笔记里,之后每次回答模型都得自己猜该信哪一条——这正是练习 15 说的
“写进去容易,越记越乱”。
常见问题
InMemoryStore 什么时候够用? 开发和测试。跟 InMemorySaver 一样,进程
一退出就没了,这一期真机跑用的是 SqliteStore,接口几乎一样,多一步
store.setup()。生产环境官方推荐 PostgresStore,还有 MongoDBStore、
RedisStore、UpstashStore,接口跟这一期写的代码不用改,换的只是
build_graph 传进去的那个 store 对象。
为什么不干脆做成一个列表,每次 append? 可以,加分练习里试试看,
但练习 15 的教训会立刻找上门:客人反复改口几次之后,列表里会堆一堆互相
矛盾的记录,模型下次读的时候未必挑得出哪条还作数。这一期用单条覆盖,
是故意逼着模型自己筛选,列表写法留给加分练习里自己试。
SqliteStore 为什么要显式 setup(),InMemoryStore 不用?
InMemoryStore 什么表都不用建,进程内存直接当字典用;SqliteStore 跟
PostgresSaver 一样,第一次用之前要建表,setup() 干的就是建表,重复调用
不会报错。
Store 除了 get/put 还能做什么? 这一期只用了最简单的键值存取。search
还能按 query 做语义检索,这需要配置 embedding,属于第 8 期检索的地盘,
这一期没碰。
加分练习
- 把
remember_note改成往列表里append而不是整条覆盖,重跑实验一和 实验四。多改几次口之后打印整个列表,看看有没有出现互相矛盾的记录 堆在一起——这是练习 15“越记越乱“的直接演示。 - 在
main.py里把thread_id自动拼上user_id前缀(比如f"{user_id}:{thread_id}"),防止不同用户复用同一个thread_id。改完 重放一次chen/t1那个坑,验证串号不再发生。 - 用
store.search((user_id,))列出某个用户名下所有的记录,包成一个--memory-all <user_id>命令——如果以后把remember_note改成列表形式 (加分练习 1),这个命令立刻有用。 - 想一想(不用跑):
create_agent的SummarizationMiddleware跟这一期解决的 问题一样吗?跨会话记忆解决的是“下次还认不认识你“,摘要中间件解决的是 “这场对话本身太长了”,两者面对的问题不一样。