在时光旅行:回溯状态、断点重跑与分支分叉中,我们彻底挖掘了 Checkpointer 的能力:它可以将单个 thread_id 内的每一次对话状态保存为不可变的版本树,让我们能够断点续传甚至篡改分叉。
然而,无论 Checkpointer 记录得多么精细,它的生命周期都被死死锁在了当前这个 thread_id 内部。
如果用户今天开启了一个新线程,说:“我们账户以后追投都只用 aggressive 档”,明天开启了另一个全新的线程说:“帮「双11-美妆拉新」追投两天,就按我的偏好来”。因为 thread_id 不同,新线程的 Checkpointer 从零开始,Agent 会像失忆一样傻傻反问:“请问这次追投用哪个档位?”
要想跨越 thread_id 的次元壁,让 Agent 沉淀出跨会话的长期认知(例如用户画像、全局知识库、全局配置),我们必须引入 LangGraph 持久化体系的另一块核心拼图——Store(存储)。
Table of contents
Open Table of contents
一、双库架构:Checkpointer 与 Store 的职责分工
在构建复杂的 Agent 系统时,记忆系统往往会被切分为两层结构:工作记忆(Working Memory) 与 长期记忆(Long-Term Memory)。在 LangGraph 中,它们分别由 Checkpointer 与 Store 来承载。
必须严格区分它们的定位与数据组织形式:
-
Checkpointer(工作记忆 / 状态栈):
- 作用域:局限于单一的
thread_id,本质是一次任务、一张追投单或一次连贯对话的现场。 - 数据结构:追加写入的不可变版本树(
StateSnapshot)。 - 核心职责:管理图节点状态,支持打断点(Human-in-the-loop)、断点续传、时光旅行和并发回滚。
- 作用域:局限于单一的
-
Store(长期记忆 / 事实库):
- 作用域:全局的,或者与特定
user_id/org_id绑定,能在无数个thread_id间共享。 - 数据结构:Key-Value 文档,按
Namespace(命名空间元组)分层组织,就地覆写更新(Mutable)。 - 核心职责:管理跨会话的用户偏好、静态规则、企业知识文档或向量化数据。
- 作用域:全局的,或者与特定
在一次对话中,Agent 的大脑不仅要看自己在这个 thread_id 中走到哪一步了(Checkpointer),还要时刻能查阅并更新关于该用户的长效事实(Store)。
二、引入 Store 记忆库
为了让 Agent 在追投过程中自动记住用户的档位偏好,我们需要把 BaseStore 实例挂载到图中。
与 Checkpointer 类似,LangGraph 为 Store 提供了多种实现:
- 内存调试:
InMemoryStore - 本地落盘:与 Sqlite 配合(目前部分依赖扩展包)
- 生产环境:
AsyncPostgresStore
在调用 create_agent(或 compile())时,将实例传入 store 参数即可:
from langgraph.store.memory import InMemoryStore
from langgraph.checkpoint.sqlite import SqliteSaver
# ... 之前的 SQLite 连接建立
store = InMemoryStore()
graph = create_agent(
model=chat_model(),
tools=TOOLS,
system_prompt=SYSTEM,
# 通过 Middleware 拦截器注入记忆(下文详解)
middleware=[PreferenceMiddleware()],
state_schema=State,
checkpointer=SqliteSaver(conn),
store=store, # 挂载长期记忆库
)
三、操作 Store 的三个维度
Agent 如果要利用 Store,就必须掌握如何写和如何读。LangGraph 提供了非常优雅的原生接口,其中最核心的概念就是 Namespace。
1. 命名空间(Namespace)设计
Store 并非扁平的字典,它强制通过层级化的命名空间(元组类型)来隔离数据,防范数据污染。例如,针对用户的偏好,我们可以设计这样的结构:
("users", "u_001", "preferences")
在这个 Namespace 之下,我们可以存放不同的键,比如 tier 档位偏好、blocked_placements 屏蔽版位等。
2. 在 Tool 中写入长期记忆:InjectedStore
我们希望 Agent 能够主动在对话中提取并记忆用户的偏好。最好的方式是提供一个工具(Tool),并在这个工具中把 Store 实例以依赖注入(Dependency Injection)的方式拿出来使用。
使用 InjectedStore 类型标注,LangGraph 会在调用工具时自动将全局配置的 Store 实例塞进去:
from langgraph.prebuilt import InjectedStore
from langgraph.store.base import BaseStore
from typing import Annotated
from langchain.tools import tool
USER_ID = "u_001"
@tool
def save_user_preference(
preference: str,
# 框架检测到 InjectedStore,会自动注入当前挂载的 store 实例
store: Annotated[BaseStore, InjectedStore()]
) -> str:
"""保存用户的偏好以供未来对话使用。当用户提到自己的习惯、偏好时调用。"""
# 存入 Store:命名空间、键名、字典值
store.put(
("users", USER_ID, "preferences"),
"tier",
{"value": preference}
)
return "Preference saved."
通过这一层封装,Agent 只需要像调用普通工具一样思考:“我应该调用 save_user_preference 把这个喜好记下来”。剩下的脏活累活全由底层图机制搞定。
3. 在 Middleware 中动态读取并注入提示词
记忆存下去了,怎么让 Agent 在下一次完全不同的对话(另一个 thread_id)里回忆起来呢?
LangGraph/LangChain 的现代架构中,通过 AgentMiddleware(中间件拦截器)是极其优雅的解法。我们在每次大模型调用前,在它的 SystemMessage 开头动态拼上属于当前用户的记忆文本。
from langchain.agents.middleware import AgentMiddleware, ModelRequest
from langchain_core.messages import SystemMessage
class PreferenceMiddleware(AgentMiddleware):
def wrap_model_call(self, request: ModelRequest, handler):
# 1. 从 Request 运行时中拿到 store 实例
if request.runtime.store:
# 2. 搜索属于该用户的长效记忆
prefs = request.runtime.store.search(("users", USER_ID, "preferences"))
if prefs:
pref_text = "\n\n[用户偏好]\n"
for p in prefs:
pref_text += f"- {p.key}: {p.value.get('value')}\n"
# 3. 追加到原来的 System Prompt 末尾,构成新的 SystemMessage
existing = request.system_message.content if request.system_message else ""
new_msg = SystemMessage(content=existing + pref_text)
# 4. 覆盖请求中的 system_message,原样不可变(Immutable)传递给 handler
request = request.override(system_message=new_msg)
# 放行请求给后续链路(大模型调用)
return handler(request)
为什么这种方案好?
如果强迫 Agent 每次都调用 read_preferences 工具去读取,会凭空消耗一轮 Agent -> Tool -> Agent 的大模型往返,大幅增加延迟和成本。在 before_model 层面前置拦截读取,等于把环境知识在物理层无缝塞入了模型大脑。
四、跨会话实测:让历史指引未来
写个主函数来跑两个完全独立的 Thread,验证 Store 是否真正跨越了次元壁。
第一幕:在旧线程中记忆偏好
# 线程 1 开启
config1 = {"configurable": {"thread_id": "thread-1-save-pref"}}
goal1 = "我们账户以后追投都只用 aggressive 档。"
first = graph.invoke(
{"messages": [HumanMessage(content=goal1)], "seen_adgroup_ids": [], "orders": {}},
config1
)
底层执行轨迹:
- 拦截器
PreferenceMiddleware读取 Store,空无一物。 - 模型看到用户的指令,决定调用
save_user_preference(preference="aggressive")。 InjectedStore捕获到操作,在("users", "u_001", "preferences")命名空间下落盘{"tier": {"value": "aggressive"}}。
第二幕:在新线程中自动应用
隔天,用户开了一个完全独立的新会话:
# 线程 2 开启,全新的 thread_id
config2 = {"configurable": {"thread_id": "thread-2-use-pref"}}
goal2 = "计划「双11-美妆拉新」11 月 1 日起追投两天,帮我看看有哪些广告组,按我的偏好挑档位。"
second = graph.invoke(
{"messages": [HumanMessage(content=goal2)], "seen_adgroup_ids": [], "orders": {}},
config2
)
底层执行轨迹:
- 拦截器发威!
PreferenceMiddleware去 Store 捞取,命中昨天存下的aggressive。 - 拦截器默默篡改大模型的
SystemMessage,塞入[用户偏好] - tier: aggressive。 - 模型读取到上下文,先调用
search_campaign("双11-美妆拉新")拿到CP-1024,再调用search_adgroups("CP-1024", "2026-11-01")。 - 搜索工具回灌计划下的三个广告组。
- 模型精准挑出唯一支持
aggressive档(已过学习期)的AG-002,调用boost_adgroup下追投单!
没有任何多余的提问或确认,Agent 的智力获得了跨越时间维度的升华。
接下来
至此,我们的**存储三板斧(State 业务状态通道、Checkpointer 线程版本树、Store 跨线程记忆库)**已全部集齐。借助它们,哪怕节点挂机、代码重跑或跨月聊天,Agent 系统都拥有了健壮且连贯的状态韧性。
然而在这个流程中,依然有一个薄弱点:当我们的 get_campaign_report 等工具内部网络请求阻塞或缓慢时,整个图依然会被同步卡死;当我们的系统规模变大,我们希望能以流式(Streaming)将节点的思考过程推送到前端。
在下一篇流式输出与事件溯源中,我们将从底层的 invoke 切换到现代框架标配的 stream,剖析 LangGraph 事件订阅的设计。