Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 6 期:长期记忆——Store 跨会话记东西

上册练习 11(第 4 期的 checkpointer)解决的是“同一场对话记不记得上一句“。 练习 15 问了一个不一样的问题:客人换一场全新对话再来找你,模型还认不认识他? 而且练习 15 的重点根本不在“记住“本身——一个文件想写多少写多少,难的是 “写进去容易,什么时候该改、该删,谁来判断”。练习 15 用一份模型自己维护的 MEMORY.md 回答这个问题:想写新的用 write_file,想改一条用 edit_file, 没有专门的“记住“或“忘记“工具,记错一件事和改错一行代码是同一种操作。

LangGraph 里对应的机制叫 Store。这一期给客服 agent 接上它,同时先把两把钥匙的 分工说清楚:checkpointer 记的是“这场对话说了什么“,认 thread_idstore 记的是“这个用户身上值得跨会话保留的内容“,认 user_id。这两把钥匙各管各的, 互不相干——下面有一个真实踩到的坑,起因正是把它们搞混了。

敲进去

第 6 期的代码在 code/ep06/get_orderget_policycancel_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 就是那个 Storeruntime.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_idc1)之后,回答里就没有“王总“了:

[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。请问还需要其他帮助吗?

发生了什么

checkpointerthread_idstoreuser_id,两者是完全独立的两套隔离。 框架不会替你把它们对上。thread_id 撞车会让不同用户看到同一份对话历史, store 的隔离本身没有出错——chen 的笔记全程是空的,串号串的只有历史。 真实系统里 thread_id 不能是手敲的短字符串,得由应用层保证全局唯一, 常见做法是拿 user_id 拼会话序号,或者干脆用 UUID。

跨会话记忆的证据就是“没有历史却知道“。 实验二整场对话只有一条 HumanMessagecheckpointer 里没有任何“王总“的记录,回答里却出现了它—— 这个信息只能来自 store,来自 agent 节点每次调用前主动读的那一次 store.get

这里选择整条覆盖,是练习 15“价值在筛选“的直接体现。 remember_note 的 docstring 要求传完整笔记,逼着模型自己判断“王总“这条现在还成不成立,重新 组织出一份干净的笔记。如果改成“追加一行“,两条互相矛盾的称呼要求会一起 躺在笔记里,之后每次回答模型都得自己猜该信哪一条——这正是练习 15 说的 “写进去容易,越记越乱”。

常见问题

InMemoryStore 什么时候够用? 开发和测试。跟 InMemorySaver 一样,进程 一退出就没了,这一期真机跑用的是 SqliteStore,接口几乎一样,多一步 store.setup()。生产环境官方推荐 PostgresStore,还有 MongoDBStoreRedisStoreUpstashStore,接口跟这一期写的代码不用改,换的只是 build_graph 传进去的那个 store 对象。

为什么不干脆做成一个列表,每次 append 可以,加分练习里试试看, 但练习 15 的教训会立刻找上门:客人反复改口几次之后,列表里会堆一堆互相 矛盾的记录,模型下次读的时候未必挑得出哪条还作数。这一期用单条覆盖, 是故意逼着模型自己筛选,列表写法留给加分练习里自己试。

SqliteStore 为什么要显式 setup()InMemoryStore 不用? InMemoryStore 什么表都不用建,进程内存直接当字典用;SqliteStorePostgresSaver 一样,第一次用之前要建表,setup() 干的就是建表,重复调用 不会报错。

Store 除了 get/put 还能做什么? 这一期只用了最简单的键值存取。search 还能按 query 做语义检索,这需要配置 embedding,属于第 8 期检索的地盘, 这一期没碰。

加分练习

  1. remember_note 改成往列表里 append 而不是整条覆盖,重跑实验一和 实验四。多改几次口之后打印整个列表,看看有没有出现互相矛盾的记录 堆在一起——这是练习 15“越记越乱“的直接演示。
  2. main.py 里把 thread_id 自动拼上 user_id 前缀(比如 f"{user_id}:{thread_id}"),防止不同用户复用同一个 thread_id。改完 重放一次 chen/t1 那个坑,验证串号不再发生。
  3. store.search((user_id,)) 列出某个用户名下所有的记录,包成一个 --memory-all <user_id> 命令——如果以后把 remember_note 改成列表形式 (加分练习 1),这个命令立刻有用。
  4. 想一想(不用跑):create_agentSummarizationMiddleware 跟这一期解决的 问题一样吗?跨会话记忆解决的是“下次还认不认识你“,摘要中间件解决的是 “这场对话本身太长了”,两者面对的问题不一样。