用户第二轮提问时,Agent 必须还记得第一轮说了什么——仅靠单次 invoke 传入的 state 不够。需要 checkpointer 在每次 superstep 后落盘,并用 thread_id 区分会话。
段末注释:checkpoint(检查点)= 某一 superstep 的 State 快照 + 元数据;thread_id = 会话主键第一维。
社区方案:开发用官方 InMemorySaver(BaseCheckpointSaver),不要自写 dict 当记忆。风险:进程内三本账,重启/多 worker 不共享;MemorySaver 只是同名别名。

1. 定位
| 维度 | 内容 |
|---|---|
| 角色 | 跨 invoke 的短时记忆:Saver 存、图调 |
| 输入 → 输出 | configurable.thread_id → 加载/写入该会话最新快照 |
| 核心 API | InMemorySaver、compile(checkpointer=...)、get_tuple / put |
| 依赖 LangChain | messages 常用 add_messages |
出现背景:无 Saver 时每次 invoke 从本次 input 起跑,多轮对话断裂。
2. 图拓扑
节点表
| 节点名 | 职责 | 读 State | 写 State |
|---|---|---|---|
chat |
追加回复 | messages |
messages |
边表
| 源 | 目标 | 类型 |
|---|---|---|
| START | chat | 固定 |
| chat | END | 固定 |
3. invoke 生命周期(两次 invoke)
1 | compile(checkpointer=memory) 把 InMemorySaver 挂到 CompiledStateGraph |
图从不直接读 memory.storage;只调用 Saver 接口。
4. 原理
4.1 compile 如何绑上 Saver
InMemorySaver() 在进程里建三本账(皆为 dict / defaultdict):
| 账本 | 键 | 存什么 |
|---|---|---|
storage |
[thread_id][checkpoint_ns][checkpoint_id] |
序列化后的 checkpoint 本体 + metadata + 父 id |
blobs |
(thread_id, ns, channel, version) |
各 State 字段(如 messages)的序列化值 |
writes |
(thread_id, ns, checkpoint_id) |
本拍各节点的 pending writes(容错用) |
builder.compile(checkpointer=memory) 把该实例赋给 Pregel 的 self.checkpointer。之后:
| 图方法 | 实际打到 Saver |
|---|---|
invoke / stream 调度前 |
get_tuple(config) |
| 每个 superstep 结束 | put(...);节点完成时 put_writes(...) |
get_state(config) |
get_tuple → 包成 StateSnapshot |
get_state_history(config) |
list(config),新→旧 |
update_state(config, values) |
再 put 一条 source=update 的新 checkpoint |
未传 checkpointer 时 invoke 仍能跑,但第 2 次不会看到第 1 次的 messages。
4.2 一次 invoke 的存取链路
cfg = {"configurable": {"thread_id": "user-42"}}。省略的 checkpoint_ns 运行时补 ""。
invoke(input, cfg)取出thread_id="user-42"。无thread_id且绑了 Saver → 报错(无法定位行)。- 读:
memory.get_tuple(cfg)。无checkpoint_id时取storage["user-42"][""]里 id 最大 的一条(ULID 字典序即最新);有 id 则精确命中。命中后用serde.loads_typed解 checkpoint,再按channel_versions从blobs拼回channel_values。未命中返回None,channels 用本次 input。 - 节点
chat返回{"messages": [AIMessage(...)]};add_messages追加,不覆盖。 - 写:
put把channel_values拆进blobs,本体写入storage[thread_id][ns][checkpoint["id"]],并把 config 里旧的checkpoint_id记为 parent。返回的 config 带上新 id。put_writes把本拍节点输出挂到同一三维键。 - 到达 END,
invoke返回合并后的 state。 get_state(cfg)再走第 2 步,不跑节点。
少了第 2 步 → 每次都从空 messages 起。
第 2 步换了 thread_id → storage 另一棵空树,看起来像「忘了」。
第 4 步只改内存、进程退出 → 三本账全没,这就是「仅开发可用」。

4.3 thread_id 与 ns、id 的分工
复合主键 (thread_id, checkpoint_ns, checkpoint_id):
| 维 | 谁填 | 作用 |
|---|---|---|
thread_id |
调用方 | 会话 / 租户隔离 |
checkpoint_ns |
默认 "";子图由框架改写 |
同一会话内主图 vs 子图 |
checkpoint_id |
Saver 每次 put 生成 |
该抽屉里的历史版本 |
多租户只换 thread_id(如 org:user)。get_tuple 先用 thread_id 进 storage 第一层,再进 ns。
5. 最小可运行示例
1 | from typing import Annotated, TypedDict |
6. 执行追踪
| 调用 | get_tuple |
put 后 messages |
最后 AI |
|---|---|---|---|
第1次 invoke user-42 |
None |
2 | reply#1 |
第2次 invoke user-42 |
上一拍 2 条 | 4 | reply#3 |
get_state(user-42) |
最新 4 条 | 不写 | reply#3 |
invoke user-99 |
None |
2 | reply#1 |
线性图 START → chat → END 一次 invoke 会落多枚 checkpoint(input / 节点后);get_state 只看最新。
重要配置参数
| 参数(API 名) | 类型 / 默认值 | 功能说明 | 作用与影响 | 参考起点 / 常用范围 | 配置指导 |
|---|---|---|---|---|---|
compile(checkpointer=...) |
BaseCheckpointSaver / None |
把 Saver 挂到 Pregel | 不传则无跨 invoke 记忆 | InMemorySaver() |
生产换 Sqlite/Postgres |
InMemorySaver() |
进程内三本账 | 实现 get_tuple / put / list |
重启即空 | 开发、单测 | 勿多 worker 共享 |
configurable.thread_id |
str,有 Saver 时必填 | storage 第一层键 |
换 ID = 新会话 | UUID / org:user |
多租户只改这一维 |
configurable.checkpoint_ns |
str,默认 "" |
storage 第二层 |
主图空串 | 子图自动 | 勿当租户键 |
configurable.checkpoint_id |
str,可选 | 定点 get_tuple |
不传取 id 最大 | 时间旅行 | 与 thread/ns 同时匹配 |
invoke(input, config) |
先读后写 | 调度前 get_tuple,每拍 put |
只传增量 messages | 多轮 | 靠 add_messages |
get_state(config) |
→ StateSnapshot |
再调 get_tuple,不跑节点 |
看 values / next |
调试 | 读 .config 不是 .configurable |
memory.get_tuple(config) |
→ CheckpointTuple / None |
Saver 底层读 | 图内部用这个 | 排障 | 业务优先 get_state |
7. 易踩坑
- compile 未传 checkpointer:
invoke能跑,第二次没有第一次的 messages。 - 换 thread_id 却期待旧对话:
get_tuple走进另一棵空树。 - 生产用 InMemorySaver:扩缩容 / 重启 / 多副本各持一份内存。
- 业务代码翻
memory.storage:键和序列化格式会变;用get_state/get_tuple。
小结
- 图只调用 Saver:读走
get_tuple,走put/put_writes。 thread_id是第一层键;同一 ID 多次 invoke 自动续跑。- InMemorySaver 三本账在进程内;上线换 Sqlite/Postgres,接口不变。