文章目录
-
- [零基础入门 LangChain 与 LangGraph(八):真正让 Agent"活起来"------持久化、记忆、人机交互与时间旅行](#零基础入门 LangChain 与 LangGraph(八):真正让 Agent“活起来”——持久化、记忆、人机交互与时间旅行)
- 一、学会建图之后
-
- [1.1 会建图,不等于能做真实系统](#1.1 会建图,不等于能做真实系统)
- [1.2 复杂 Agent 的本质问题,其实都是"状态生命周期问题"](#1.2 复杂 Agent 的本质问题,其实都是“状态生命周期问题”)
-
- [1. 记忆,本质是状态能不能保留](#1. 记忆,本质是状态能不能保留)
- [2. 人工介入,本质是状态能不能暂停再恢复](#2. 人工介入,本质是状态能不能暂停再恢复)
- [3. 时间旅行,本质是状态能不能回溯和重放](#3. 时间旅行,本质是状态能不能回溯和重放)
- [二、什么是 LangGraph 的持久化?](#二、什么是 LangGraph 的持久化?)
-
- [2.1 持久化不是"顺手存一下变量",而是把执行过程存活下来](#2.1 持久化不是“顺手存一下变量”,而是把执行过程存活下来)
- [2.2 LangGraph 的持久化,其实分成两层](#2.2 LangGraph 的持久化,其实分成两层)
- [2.3 三者怎么区分](#2.3 三者怎么区分)
- [三、线程级持久化:Thread 和 Checkpoint 到底是什么?](#三、线程级持久化:Thread 和 Checkpoint 到底是什么?)
-
- [3.1 这里的 Thread 不是操作系统线程](#3.1 这里的 Thread 不是操作系统线程)
- [3.2 Checkpoint 是什么?把它理解成"自动存档点"](#3.2 Checkpoint 是什么?把它理解成“自动存档点”)
- [3.3 为什么线程级持久化特别重要?](#3.3 为什么线程级持久化特别重要?)
-
- [1. 防止长流程中途全丢](#1. 防止长流程中途全丢)
- [2. 支持多轮会话的短期记忆](#2. 支持多轮会话的短期记忆)
- [3. 支持调试、回放和状态检查](#3. 支持调试、回放和状态检查)
- [四、怎么真正用起来?先从 checkpointer 开始](#四、怎么真正用起来?先从 checkpointer 开始)
-
- [4.1 持久化不是凭空开启的,必须在编译图时显式传入](#4.1 持久化不是凭空开启的,必须在编译图时显式传入)
- [4.2 怎么选持久化后端?](#4.2 怎么选持久化后端?)
- [4.3 第一次执行和第二次执行,到底差在哪?](#4.3 第一次执行和第二次执行,到底差在哪?)
- 五、状态快照
-
- [5.1 获取当前状态:`get_state()`](#5.1 获取当前状态:
get_state()) - [5.2 获取完整历史:`get_state_history()`](#5.2 获取完整历史:
get_state_history()) - [5.3 重放(Replay):从某个检查点继续跑](#5.3 重放(Replay):从某个检查点继续跑)
-
- [1. 输入是 `None`](#1. 输入是
None) - [2. 配置里要包含 `checkpoint_id`](#2. 配置里要包含
checkpoint_id)
- [1. 输入是 `None`](#1. 输入是
- [5.4 更新状态:`update_state()` 不是简单赋值,而是"生成新分支"](#5.4 更新状态:
update_state()不是简单赋值,而是“生成新分支”) -
- [1. 更新状态不会覆盖掉原始历史](#1. 更新状态不会覆盖掉原始历史)
- [2. `Overwrite` 是绕过 reducer 的强制覆盖](#2.
Overwrite是绕过 reducer 的强制覆盖)
- [5.1 获取当前状态:`get_state()`](#5.1 获取当前状态:
- [六、只有 Checkpoint 还不够:为什么还需要 Store?](#六、只有 Checkpoint 还不够:为什么还需要 Store?)
-
- [6.1 线程级持久化只能解决"这次会话别忘",但解决不了"以后都记得"](#6.1 线程级持久化只能解决“这次会话别忘”,但解决不了“以后都记得”)
- [6.2 `Checkpoint` 和 `Store` 的区别](#6.2
Checkpoint和Store的区别)
- [七、Store 怎么用?](#七、Store 怎么用?)
-
- [7.1 Store 本质上就是长期记忆仓库](#7.1 Store 本质上就是长期记忆仓库)
- [7.2 最基本的两步:`put()` 和 `search()`](#7.2 最基本的两步:
put()和search())
- [八、把 Store 真正接进 LangGraph:长期记忆是怎么参与推理的?](#八、把 Store 真正接进 LangGraph:长期记忆是怎么参与推理的?)
-
- [8.1 只存不取没有意义,关键是"先提取,再存,再在后续调用时读出来"](#8.1 只存不取没有意义,关键是“先提取,再存,再在后续调用时读出来”)
- [8.2 后续 LLM 调用前,直接把长期记忆拼进系统提示词](#8.2 后续 LLM 调用前,直接把长期记忆拼进系统提示词)
- [8.3 编译图时,`checkpointer` 和 `store` 可以同时存在](#8.3 编译图时,
checkpointer和store可以同时存在)
- [九、语义搜索版 Store:长期记忆不只是"按键查",还能"按意思查"](#九、语义搜索版 Store:长期记忆不只是“按键查”,还能“按意思查”)
-
- [9.1 为什么长期记忆最后也会走向 Embedding?](#9.1 为什么长期记忆最后也会走向 Embedding?)
- [9.2 典型配置方式](#9.2 典型配置方式)
- 十、应用层的记忆管理:短期记忆不是越多越好
-
- [10.1 记忆不是只会越堆越多,真正难的是怎么管](#10.1 记忆不是只会越堆越多,真正难的是怎么管)
- [10.2 修剪消息:只保留最近最有价值的上下文](#10.2 修剪消息:只保留最近最有价值的上下文)
- [10.3 删除消息:有时不是裁剪,而是明确清空](#10.3 删除消息:有时不是裁剪,而是明确清空)
- [10.4 总结消息:一种实用管理方式](#10.4 总结消息:一种实用管理方式)
- [十一、人机交互:怎么让 Agent 跑到一半停下来等我?](#十一、人机交互:怎么让 Agent 跑到一半停下来等我?)
-
- [11.1 `interrupt()` 的本质:主动打断执行并保存现场](#11.1
interrupt()的本质:主动打断执行并保存现场) - [11.2 为什么人机交互能力这么关键?](#11.2 为什么人机交互能力这么关键?)
- [11.1 `interrupt()` 的本质:主动打断执行并保存现场](#11.1
- 十二、中断的四条黄金法则
-
- [12.1 只传可序列化的简单数据](#12.1 只传可序列化的简单数据)
- [12.2 不要把 `interrupt()` 包在宽泛的 `try/except` 里](#12.2 不要把
interrupt()包在宽泛的try/except里) - [12.3 中断前的副作用必须幂等,或者干脆放到中断后](#12.3 中断前的副作用必须幂等,或者干脆放到中断后)
-
- [1. 中断前只做幂等操作](#1. 中断前只做幂等操作)
- [2. 把真正副作用放到中断后](#2. 把真正副作用放到中断后)
- [12.4 同一个节点里多个中断,顺序必须固定](#12.4 同一个节点里多个中断,顺序必须固定)
- 十三、四种最常见的人机交互模式
-
- [13.1 批准 / 拒绝](#13.1 批准 / 拒绝)
- [13.2 查看并编辑状态](#13.2 查看并编辑状态)
- [13.3 在工具里中断](#13.3 在工具里中断)
- [13.4 循环验证人类输入](#13.4 循环验证人类输入)
- [十四、时间旅行: LangGraph 最酷的调试能力](#十四、时间旅行: LangGraph 最酷的调试能力)
-
- [14.1 什么叫时间旅行?](#14.1 什么叫时间旅行?)
- [14.2 为什么这个能力特别适合调试 Agent?](#14.2 为什么这个能力特别适合调试 Agent?)
- [14.3 时间旅行四步法](#14.3 时间旅行四步法)
- 十五、"短期记忆、长期记忆、人机协作、时间旅行"
-
- [15.1 它们表面是四种能力,本质上都建立在"状态可控"之上](#15.1 它们表面是四种能力,本质上都建立在“状态可控”之上)
- 十六、本篇总结
零基础入门 LangChain 与 LangGraph(八):真正让 Agent"活起来"------持久化、记忆、人机交互与时间旅行
💬 开篇 :上一篇我已经把 LangGraph 最重要的第一层地基搭起来了:它不是普通的链式调用,而是一个用
State、Node、Edge组织起来的有状态工作流系统。但如果我只停在"会建图",其实还远远不够。因为真实的 AI 应用一旦开始变复杂,真正难的往往不是"下一步走哪条边",而是另外几个更硬核的问题:
- 程序中断了,之前执行到哪里了,能不能接着跑?
- 用户昨天说过的话,今天新开一个会话,系统还能不能记住?
- 某一步必须让我人工确认,流程能不能停下来等我?
- 某次执行走错了,我能不能回到中间某个状态重新跑?
👍 这一篇要解决的问题 :
LangGraph 为什么不只是"工作流图框架",而是一个真正能支撑复杂 Agent 系统长期运行的底层运行时?
🚀 这一篇的目标:我会把 LangGraph 最硬核、也最有工程价值的一层能力讲透:
- 什么是持久化(Persistence)
- 什么是 Thread、Checkpoint、Store
- 短期记忆和长期记忆到底分别靠什么实现
interrupt()和Command(resume=...)怎样把"人"插进 Agent 流程- 所谓时间旅行(Time Travel),到底是不是可回放、可修改、可重跑的调试机制
一、学会建图之后
1.1 会建图,不等于能做真实系统
上一篇里,我已经会做这些事了:
- 定义
State - 写
Node - 加
Edge - 用条件边做分支和循环
- 把一个任务建模成工作流图
这当然很重要。
但真实系统一落地,很快就会碰到几个更现实的问题。
比如一个客服 Agent:
bash
识别问题 -> 收集订单号 -> 调用知识库 -> 给出方案 -> 等待用户确认 -> 必要时人工接管
如果这个流程运行到一半:
- 进程挂了
- 服务重启了
- 用户过了 3 小时才回来继续
- 经理要中途审批
- 某一步结果有问题,需要回到上一步重做
那这时候你就会发现:
真正难的已经不是"图怎么画",而是"图运行过程中产生的状态怎么活下来"。
这正是 LangGraph 和普通"流程图工具"拉开差距的地方。
1.2 复杂 Agent 的本质问题,其实都是"状态生命周期问题"
很多看起来很高级的 Agent 能力,本质上最后都会落到"状态"上:
1. 记忆,本质是状态能不能保留
- 单轮会话里的上下文,是短期状态
- 跨会话还能记住用户偏好,是长期状态
2. 人工介入,本质是状态能不能暂停再恢复
- 先运行到一半
- 保存当前状态
- 等人类给出指令
- 再从保存点继续往下走
3. 时间旅行,本质是状态能不能回溯和重放
- 回到某个历史检查点
- 修改当时的某个值
- 从那里重新跑出一条新分支
它不是只帮我"组织流程",而是帮我"托管流程运行过程中的状态生命线"。
二、什么是 LangGraph 的持久化?
2.1 持久化不是"顺手存一下变量",而是把执行过程存活下来
LangGraph 的持久化,不是简单地把几条聊天记录写进数据库,而是把整个工作流执行过程中的关键状态保存下来。
这里的"状态"不是一句抽象空话,它往往包括:
- 当前
State的值 - 已经走过哪些节点
- 下一步要执行哪个节点
- 对话消息历史
- 工具调用结果
- 某些中间计算结果
- 当前线程标识
- 当前检查点标识
也就是说,LangGraph 保存的不是"聊天记录备份",而是:
一个可恢复执行的运行时快照。
2.2 LangGraph 的持久化,其实分成两层
LangGraph 持久化能力
线程级持久化
跨会话持久化
Thread
Checkpoint
Store
第一层:线程级持久化
它解决的是:
同一次工作流执行,会不会丢状态?
也就是:
- 执行到一半能不能保存
- 重启后能不能恢复
- 能不能回看这次执行的历史过程
- 能不能从中间某个点继续跑
这一层的核心对象就是:
ThreadCheckpoint
第二层:跨会话持久化
它解决的是:
不同会话之间,能不能共享长期信息?
也就是:
- 用户今天告诉你他爱吃汉堡
- 明天开一个新会话
- 系统还能记得他的偏好
这一层的核心对象就是:
Store
2.3 三者怎么区分
| 概念 | 理解 |
|---|---|
| Thread | 一次独立的执行会话 |
| Checkpoint | 这次会话某一时刻的状态快照 |
| Store | 会话之外长期存在的数据仓库 |
三、线程级持久化:Thread 和 Checkpoint 到底是什么?
3.1 这里的 Thread 不是操作系统线程
LangGraph 里的 Thread:
不是 pthread,不是 std::thread,不是操作系统调度单位。
它更准确的意思是:
一条独立的工作流执行线,或者一段独立的会话。
最常见的区分方式就是:
python
config = {"configurable": {"thread_id": "chat_1"}}
这里的 thread_id 只是一个标识符,用来告诉 LangGraph:
- 这是哪一条会话
- 这条会话对应的历史状态在哪
- 这次执行应该接到谁后面继续
所以你必须把它和系统线程彻底分开理解。
3.2 Checkpoint 是什么?把它理解成"自动存档点"
如果 Thread 是一整条执行线,
那 Checkpoint 就是这条执行线上的某个时刻的快照。
例如一个图这样运行:
bash
START -> llm_call -> tool_node -> llm_call -> END
那每走完一步,都可能留下一个检查点。
每个检查点大概保存了:
- 当前
values - 下一步节点
next - 当前配置
config - 元数据
metadata - 父检查点
parent_config - 创建时间
你可以把它想成一个对象:
python
StateSnapshot(
values={...}, # 当前状态值
next=("tool_node",), # 下一步要走的节点
config={...}, # 当前线程和检查点配置
metadata={...}, # 附加元信息
parent_config={...}, # 父检查点
created_at="..."
)
它的本质不是"聊天记录",而是:
恢复执行所需的最小完整现场。
3.3 为什么线程级持久化特别重要?
因为它至少解决了三个非常现实的问题。
1. 防止长流程中途全丢
如果流程很长,甚至中间还依赖外部 API、人工确认、异步回调,
那"一口气从头跑到尾"这种假设根本不成立。
没有持久化,一中断就得从头来。
有持久化,就能从上一次检查点恢复。
2. 支持多轮会话的短期记忆
同一个 thread_id 下再次调用图时,
系统就能知道:
- 前面聊过什么
- 当前状态是什么
- 该从哪里继续
3. 支持调试、回放和状态检查
因为只要有了检查点,你就可以:
- 看当前状态
- 看历史状态
- 回到某个状态重新跑
- 修改状态后再继续跑
这基本已经把工作流调试从"猜内部过程"推进成"直接看内部过程"。
四、怎么真正用起来?先从 checkpointer 开始
4.1 持久化不是凭空开启的,必须在编译图时显式传入
LangGraph 不会自动替你持久化,前提是你要在
compile()时传入checkpointer。
python
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
这里的意思很直白:
InMemorySaver():把检查点存在内存里compile(checkpointer=...):告诉图,运行时状态要交给这个后端保存
这种方式特别适合:
- 本地学习
- Demo 验证
- 单进程调试
但它也有明显限制:
程序一停,内存里的检查点就没了。
所以它适合开发阶段,不适合生产阶段。
4.2 怎么选持久化后端?
| 场景 | 持久化后端 | 特点 |
|---|---|---|
| 本地学习 / 调试 | InMemorySaver |
最简单,程序退出后丢失 |
| 本地持久化实验 | SQLite 类方案 | 本地磁盘保存,便于实验 |
| 生产环境 | PostgresSaver |
适合稳定存储和多实例使用 |
4.3 第一次执行和第二次执行,到底差在哪?
这一步最关键的,是 thread_id。
例如:
python
from langchain_core.messages import HumanMessage
config = {"configurable": {"thread_id": "1"}}
result = graph.invoke(
{"messages": [HumanMessage(content="今天天气怎么样?")]},
config
)
如果 "1" 这个线程之前不存在,那它就是一次新会话。
但如果后面你再次用同一个 thread_id:
python
result2 = graph.invoke(
{"messages": [HumanMessage(content="那我需要带伞吗?")]},
config
)
那 LangGraph 就不是"重新开始",而是会:
- 找到 thread_id =
"1"对应的历史状态 - 加载最后一个检查点
- 把新输入接到原有上下文之后
- 从恢复点继续执行
也就是说,同一个 thread_id 下:
你得到的是"接着聊",而不是"重新聊"。
这就是线程级持久化实现短期记忆的最直观方式。
五、状态快照
5.1 获取当前状态:get_state()
如果一个图已经用了 checkpointer 编译,
那你就可以随时拿到某个线程当前的最新状态快照:
python
snapshot = graph.get_state(config)
print(snapshot)
比较关注的几个字段:
values:当前状态值next:下一步要执行的节点config:线程和检查点配置metadata:一些执行元信息
所以 get_state() 干的事情不是"给你聊天记录",而是:
给你工作流当前运行现场。
5.2 获取完整历史:get_state_history()
python
history = list(graph.get_state_history(config))
for state in history:
print(state)
它返回的是一整条历史检查点链。
也就是说,你不仅能知道"现在是什么状态",还能知道:
- 一开始是什么状态
- 第一步后变成什么
- 第二步后变成什么
- 中间经过了哪些节点
- 每一步的下一跳是什么
LangGraph 在这里直接把:
"过程本身"变成了一等可观察对象。
这对复杂 Agent 来说,意义非常大。
5.3 重放(Replay):从某个检查点继续跑
假设你已经跑完一条流程,
并且通过 get_state_history() 找到了中间某个你关心的状态:
python
to_replay = some_checkpoint
这时你可以直接用它当配置重新调用:
python
result = graph.invoke(None, config=to_replay.config)
1. 输入是 None
因为你不是开启新执行,而是从已有检查点继续执行。
2. 配置里要包含 checkpoint_id
也就是指定:
- 哪个线程
- 哪个历史检查点
这样 LangGraph 才知道你要从哪里往后跑。
这就是所谓的"重放"。
从历史快照恢复,然后沿着后续节点重新执行。
5.4 更新状态:update_state() 不是简单赋值,而是"生成新分支"
如果我找到了历史中的某个状态,
但我不想按原样继续,而是想修改里面某个值怎么办?
这时就可以用:
python
from langgraph.types import Overwrite
new_config = graph.update_state(
selected_state.config,
{"messages": Overwrite([HumanMessage(content="今天北京的天气如何?")])}
)
然后再继续执行:
python
result = graph.invoke(None, config=new_config)
这里最关键的理解有两点:
1. 更新状态不会覆盖掉原始历史
它不是把旧检查点硬改掉,而是:
基于旧检查点创建一条新的历史分支。
这和 Git 分支特别像。
2. Overwrite 是绕过 reducer 的强制覆盖
如果某个字段原本有 reducer,比如消息列表默认是追加的,
那你直接返回一个新列表,它可能会按 reducer 规则去合并。
如果你想要的是:
- 不追加
- 不合并
- 直接整块替换
那就要用 Overwrite(...)。
这一点非常重要,因为"状态更新"和"状态覆盖"在 LangGraph 里是两种不同的语义。
六、只有 Checkpoint 还不够:为什么还需要 Store?
6.1 线程级持久化只能解决"这次会话别忘",但解决不了"以后都记得"
假设同一个用户在周一说:
bash
我叫李华,我最爱吃汉堡
如果这条信息只保存在某个 thread_id = day_1 的线程里,
那周二新开一个线程:
bash
thread_id = day_2
系统是不会天然知道周一那条信息的。
为什么?
因为线程级持久化的作用范围就是:
只保证同一个线程内的执行状态不丢。
它并不是跨线程共享仓库。
所以如果你要的是:
- 用户画像
- 偏好信息
- 长期历史
- 跨会话共享知识
那就必须引入 Store。
6.2 Checkpoint 和 Store 的区别
| 能力 | Checkpoint | Store |
|---|---|---|
| 作用范围 | 单线程 / 单会话 | 跨线程 / 跨会话 |
| 主要用途 | 保存执行现场 | 保存长期知识 |
| 数据特点 | 时间线快照 | 结构化长期数据 |
| 典型内容 | 消息历史、节点位置、中间状态 | 用户偏好、档案、长期记忆 |
七、Store 怎么用?
7.1 Store 本质上就是长期记忆仓库
最简单的写法:
python
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
但如果只是这样看,它很像一个普通内存对象。
它真正的关键在于:怎么组织数据。
LangGraph 里,Store 很强调 namespace 的概念。
因为它要解决的问题不是"放一条数据",而是:
- 这条记忆属于谁?
- 属于哪一类?
- 跟其他记忆怎么隔离?
- 后续怎么检索?
所以最常见的组织方式是元组:
python
namespace = ("user_123", "preferences")
这表示:
- 用户:
user_123 - 记忆类型:
preferences
如果你想继续细分:
python
("user_123", "preferences", "food")
("user_123", "preferences", "music")
("user_123", "conversations", "2026-04")
7.2 最基本的两步:put() 和 search()
存入一条长期记忆:
python
import uuid
user_id = "user_123"
namespace = (user_id, "preferences")
memory_id = str(uuid.uuid4())
memory_value = {"favorite_food": "汉堡", "allergy": "花粉"}
store.put(namespace, memory_id, memory_value)
后面查询:
python
all_memories = store.search(namespace)
for mem in all_memories:
print(mem.value)
这一套写法的思路清晰:
namespace:这条记忆放在哪个文件夹memory_id:这条记忆自己的唯一标识memory_value:具体内容
把Store理解成:
一个带命名空间的长期 KV/文档记忆层。
八、把 Store 真正接进 LangGraph:长期记忆是怎么参与推理的?
8.1 只存不取没有意义,关键是"先提取,再存,再在后续调用时读出来"
这是一个特别典型的流程:
用户发来消息
提取用户信息
写入 Store
后续 LLM 调用前读取 Store
把长期记忆拼进提示词
生成更个性化回复
这时候图里通常会多出一个专门节点,负责做"信息抽取 + 长期记忆写入"。
例如:
python
from typing import Optional
from pydantic import BaseModel, Field
class Person(BaseModel):
name: Optional[str] = Field(default=None, description="用户名字")
favourite_food: Optional[list[str]] = Field(default=None, description="最喜欢的食物")
然后在节点里:
python
def extract_profile(state, config, *, store):
user_id = config["configurable"]["user_id"]
# 假设这里已经通过结构化输出提取出了信息
profile = {
"name": "李华",
"favourite_food": ["汉堡"]
}
store.put((user_id, "profile"), "profile", profile)
return {}
注意这里的函数签名:
configstore
都是通过节点参数注入进来的。
这意味着一旦你在 compile() 时传入了 store,
节点里就可以天然读写长期记忆。
8.2 后续 LLM 调用前,直接把长期记忆拼进系统提示词
比如:
python
def llm_call(state, config, *, store):
user_id = config["configurable"]["user_id"]
memories = store.search((user_id, "profile"))
profile = memories[0].value if memories else {}
system_prompt = (
"你是一个乐于助人的助手。"
f"以下是这个用户的长期信息:{profile}"
)
response = model.invoke(
[{"role": "system", "content": system_prompt}] + state["messages"]
)
return {"messages": [response]}
这样系统就能做到:
- 在新线程里
- 即使没有当前会话历史
- 也能通过 Store 记住用户画像
这就是真正的跨会话长期记忆。
8.3 编译图时,checkpointer 和 store 可以同时存在
这是一个特别关键的组合:
python
graph = builder.compile(
checkpointer=checkpointer,
store=store
)
从这一步开始:
checkpointer负责短期记忆和执行快照store负责长期记忆和跨会话共享
也就是说:
短期记忆 + 长期记忆,这套组合才真正像一个"活着的系统"。
九、语义搜索版 Store:长期记忆不只是"按键查",还能"按意思查"
9.1 为什么长期记忆最后也会走向 Embedding?
如果长期记忆只是简单键值:
- 喜欢什么
- 叫啥名字
- 有什么标签
那普通 search(namespace) 已经够用了。
但真实场景往往更复杂:
- 以前聊过哪些话题
- 哪条历史偏好和当前问题最相关
- 用户以前提过哪些"不喜欢的东西"
- 哪条记忆最应该在当前时刻被召回
这时候就会自然走向:
不是精确匹配,而是语义检索。
所以 Store 也可以加 Embedding 索引。
9.2 典型配置方式
例如:
python
store = InMemoryStore(
index={
"embed": init_embeddings("openai:text-embedding-3-small"),
"dims": 1536,
"fields": ["$"]
}
)
- 用嵌入模型给记忆做向量化
- 向量维度是多少
- 哪些字段参与嵌入
后面就可以这样查:
python
result = store.search(
(user_id,),
query="用户喜欢吃什么",
limit=2
)
这时就不再是"查 key",而是"查语义最相关的长期记忆"。
系统的长期记忆能力就会明显更像"人"。
十、应用层的记忆管理:短期记忆不是越多越好
10.1 记忆不是只会越堆越多,真正难的是怎么管
很多人第一次学记忆系统,最容易有个朴素想法:
既然要记,那就全记下来不就好了?
可一旦真的这么做,很快就会遇到三个问题:
- 上下文窗口是有限的
- 历史消息太长会拖慢推理
- 太多无关历史反而会干扰当前回答
所以应用层还要学的是:
- 怎么裁剪
- 怎么删除
- 怎么总结
这才叫"记忆管理"。
10.2 修剪消息:只保留最近最有价值的上下文
典型思路:
python
from langchain_core.messages.utils import trim_messages
messages = trim_messages(
state["messages"],
strategy="last",
token_counter=model,
max_tokens=128,
start_on="human",
end_on=("human", "tool"),
)
strategy="last":优先保留最近的内容max_tokens=128:最多留这么多 tokenstart_on="human":尽量从用户消息开始end_on=("human", "tool"):结尾落在更合理的位置
10.3 删除消息:有时不是裁剪,而是明确清空
如果某些历史消息已经完全没必要保留,可以删除。
例如删除最早几条:
python
from langchain_core.messages import RemoveMessage
def call_model(state):
messages = state["messages"]
if len(messages) > 6:
return {
"messages": [RemoveMessage(id=m.id) for m in messages[:6]]
}
response = model.invoke(messages)
return {"messages": [response]}
如果要清空全部:
python
from langgraph.graph.message import REMOVE_ALL_MESSAGES
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}
这里要特别小心:
删除是不可逆的。
所以删除之前一定要想清楚:
这条对话未来还要不要被系统拿来推理。
10.4 总结消息:一种实用管理方式
裁剪和删除虽然有效,但都有一个问题:
信息可能真丢了。
所以很多系统更喜欢做"摘要替换"。
思路是:
- 历史消息够多时
- 先让模型生成一份总结
- 把旧消息删掉
- 保留这份摘要 + 最近少量新消息
状态通常会扩展成这样:
python
from langgraph.graph import MessagesState
class State(MessagesState):
summary: str
然后总结节点:
python
def summarize_conversation(state: State):
summary = state.get("summary", "")
if summary:
summary_message = (
f"这是目前为止的对话摘要:{summary}\n\n"
"请基于上面的摘要和新消息扩展总结:"
)
else:
summary_message = "请总结上面的对话:"
messages = state["messages"] + [{"role": "user", "content": summary_message}]
response = model.invoke(messages)
return {
"summary": response.content,
"messages": [RemoveMessage(id=m.id) for m in state["messages"][:-1]]
}
这时候系统没有保留全部原文,
但保留了高密度关键信息。
是一种很实用的短期记忆管理方式。
十一、人机交互:怎么让 Agent 跑到一半停下来等我?
11.1 interrupt() 的本质:主动打断执行并保存现场
LangGraph 的思路是:
- 在节点里调用
interrupt() - 当前执行立刻暂停
- 现场通过持久化机制保存下来
- 外部传入
Command(resume=...) - 图从暂停点继续往下执行
python
from typing import TypedDict
from langgraph.types import interrupt, Command
class State(TypedDict):
input: str
output: str
def hello_node(state: State):
human = interrupt("暂停,是否继续?")
if human == "yes":
return {"output": "你好,我是你的贴心助手!"}
else:
return {"output": "拜拜"}
编译图时必须带 checkpointer:
python
graph = builder.compile(checkpointer=InMemorySaver())
第一次调用:
python
config = {"configurable": {"thread_id": "human_1"}}
first = graph.invoke({"input": "hi"}, config=config)
print(first)
此时不会直接给你最终结果,而会返回一个带 __interrupt__ 的状态。
恢复执行:
python
second = graph.invoke(Command(resume="no"), config=config)
print(second["output"])
11.2 为什么人机交互能力这么关键?
因为真实世界很多动作并不适合全自动。
例如:
- 发邮件前我要最后看一眼
- 转账前必须审批
- 模型写的文案需要我人工改一下
- 提取出的结构化信息需要我确认一下
- 某条工具调用太危险,我想先拦一下
这时候如果系统只能"一跑到底",
那它要么风险太高,要么根本没法上线。
把 AI 流程从纯自动化,推进成了可监督自动化。
十二、中断的四条黄金法则
12.1 只传可序列化的简单数据
中断时传出去的内容,本质上要能被保存下来。
所以你最好只传:
- 字符串
- 数字
- 布尔
- 简单字典
- 简单列表
例如:
python
response = interrupt({
"question": "请输入用户信息",
"fields": ["name", "email", "age"]
})
这类写法是安全的。
但你不要传:
- 函数
- 类实例
- 数据库连接
- 文件句柄
- 线程锁
因为这些东西没法稳定序列化。
12.2 不要把 interrupt() 包在宽泛的 try/except 里
因为 interrupt() 内部本质上依赖一种特殊的"抛出中断信号"的机制。
如果你这样写:
python
try:
interrupt("请输入名字")
except Exception:
...
那你自己的 except 很可能会把这个特殊中断先捕掉,
导致 LangGraph 根本感知不到应该暂停。
所以要么:
- 不要包住
interrupt() - 要么只捕获非常明确的特定异常
这一点如果不提前记住,后面非常容易踩坑。
12.3 中断前的副作用必须幂等,或者干脆放到中断后
因为节点恢复时:
发起中断的那个节点,会从头再执行一遍。
这就意味着,如果你在中断前做了非幂等副作用,比如:
- 发消息
- 扣款
- 追加日志
- 插入数据库新行
那恢复时它可能又执行一次,结果就重复了。
所以安全写法通常有两种:
1. 中断前只做幂等操作
比如 upsert。
2. 把真正副作用放到中断后
例如:
python
def node_a(state):
approved = interrupt("Approve this change?")
if approved:
db.create_audit_log(...)
return {"approved": approved}
这就比"先插日志再 interrupt"安全得多。
12.4 同一个节点里多个中断,顺序必须固定
因为 LangGraph 恢复时,是按中断出现顺序去匹配 resume 值的。
例如:
python
name = interrupt("What's your name?")
age = interrupt("What's your age?")
city = interrupt("What's your city?")
这三个中断的顺序是:
- 索引 0
- 索引 1
- 索引 2
如果你把第二个中断写进条件分支,
导致某次执行有它、某次执行没有它,那恢复时索引就会错乱。
所以结论非常简单:
一个节点里多次
interrupt(),顺序必须稳定,数量最好也稳定。
十三、四种最常见的人机交互模式
13.1 批准 / 拒绝
这是最典型的场景:
- 转账前审批
- 删除数据前确认
- 发邮件前审核
- 调危险工具前批准
例子:
python
from typing import Literal, Optional, TypedDict
from langgraph.types import Command, interrupt
class ApprovalState(TypedDict):
action_details: str
status: Optional[Literal["等待", "批准", "拒绝"]]
def approval_node(state: ApprovalState) -> Command[Literal["proceed", "cancel"]]:
decision = interrupt({
"question": "批准此操作?",
"details": state["action_details"],
})
return Command(goto="proceed" if decision else "cancel")
def proceed_node(state: ApprovalState):
return {"status": "批准"}
def cancel_node(state: ApprovalState):
return {"status": "拒绝"}
这个模式的本质就是:
先暂停,把决定权交给人,再根据人的决定路由后续节点。
13.2 查看并编辑状态
有些场景不是"同不同意",而是:
- 模型先给一版草稿
- 人来改
- 改完后流程继续
这时候就不是 True/False,而是把修改后的内容作为 resume 值传回来。
python
def review_node(state):
updated = interrupt({
"instruction": "查看并编辑此内容",
"content": state["generated_text"],
})
return {"generated_text": updated}
这特别适合:
- 文档审核
- 结构化数据修正
- 营销文案微调
- 代码片段人工修订
13.3 在工具里中断
不是在图节点里停,而是在工具函数内部停。
例如发邮件工具:
python
@tool
def send_email(to: str, subject: str, body: str):
response = interrupt({
"action": "发送邮件",
"to": to,
"subject": subject,
"body": body,
"message": "同意发送这封邮件吗?",
})
if response.get("action") == "同意":
return f"已发送给 {to}"
return "用户取消邮件"
这样一来,工具本身就自带"需要人工确认"的能力。
它可以在任何图里复用,而不需要每个图都重写一遍审批逻辑。
13.4 循环验证人类输入
这个场景也很常见,比如填表单:
- 输入年龄
- 如果非法,继续提示
- 合法才往下走
例如:
python
def get_age_node(state):
prompt = "你多大了?"
while True:
answer = interrupt(prompt)
if isinstance(answer, int) and answer > 0:
return {"age": answer}
prompt = f"'{answer}' 不是有效年龄,请输入正整数。"
这个模式特别适合:
- 参数校验
- 表单填写
- 多轮人工确认
- 受控的人机协作流程
十四、时间旅行: LangGraph 最酷的调试能力
14.1 什么叫时间旅行?
最简单的定义就是:
回到某个历史检查点,从那里重新看、重新改、重新跑。
它不是玄学,也不是"科幻名字"。
本质上它依赖的,就是前面讲过的:
get_state_history()checkpoint_idupdate_state()invoke(None, config=...)
所以时间旅行说白了就是:
- 找到历史某个状态
- 读取它
- 必要时改它
- 从它继续执行
14.2 为什么这个能力特别适合调试 Agent?
因为 Agent 流程天然有三个特点:
- 路径复杂
- 结果可能非确定
- 出错位置往往不在最后一步
例如一个两步图:
generate_topic
write_joke
最后笑话不好笑,问题可能在:
- 第一步主题生成太泛
- 第二步写笑话提示词太弱
- 中间状态有问题
如果没有时间旅行,你只能:
- 改代码
- 从头再跑
- 重新等待整个过程
但如果有时间旅行,你可以:
- 直接回到
generate_topic之后的检查点 - 看看当时到底生成了什么主题
- 手工把主题改掉
- 从那里继续跑第二步
这就把调试复杂流程的效率提升了一个级别。
14.3 时间旅行四步法
第一步:正常执行一次流程
python
config = {"configurable": {"thread_id": "1"}}
result = graph.invoke({}, config)
第二步:取历史检查点
python
states = list(graph.get_state_history(config))
for state in states:
print(state.config["configurable"]["checkpoint_id"], state.next, state.values)
第三步:选一个中间状态,必要时修改
python
selected_state = states[1]
new_config = graph.update_state(
selected_state.config,
values={"topic": "程序员调试代码时的趣事"}
)
第四步:从那个点继续执行
python
new_result = graph.invoke(None, new_config)
print(new_result["joke"])
这四步本质上就是:
bash
执行 -> 找快照 -> 改状态 -> 重跑
十五、"短期记忆、长期记忆、人机协作、时间旅行"
15.1 它们表面是四种能力,本质上都建立在"状态可控"之上
- 短期记忆靠
Thread + Checkpoint - 长期记忆靠
Store - 人机协作靠
interrupt + resume - 时间旅行靠
Checkpoint + update_state + replay
看起来像四块知识,其实底层都是同一件事:
让状态不只是运行时临时变量,而是框架级、可观察、可恢复、可编辑的实体。
这就是 LangGraph 真正和普通"流程拼装"拉开层次的地方。
它是一套非常完整的运行时控制系统。
十六、本篇总结
看完本篇,我想你应该能明白, LangGraph 为什么能从"会建图"进一步走到"能支撑真实 Agent 系统长期运行"。
核心结论:
-
LangGraph 的持久化不只是保存聊天记录,而是保存可恢复执行的运行时状态。
它真正保存的是工作流现场,而不是普通文本历史。
-
线程级持久化由
Thread和Checkpoint组成。
Thread代表一次独立执行会话,Checkpoint代表这次执行过程中的某个状态快照。 -
同一个
thread_id可以让图在短期内"接着聊、接着跑"。这就是短期记忆和断点恢复的基础。
-
get_state()、get_state_history()、重放与update_state(),把调试能力直接提升到了运行时层。你不仅能看当前状态,还能回看历史、修改状态并从中间继续执行。
-
只有
Checkpoint还不够,跨会话长期记忆必须靠Store。
Checkpoint解决"别断档",Store解决"别失忆"。 -
短期记忆并不是越多越好,真正重要的是管理。
修剪、删除、总结,都是为了让上下文既不丢关键信息,也不被历史噪声拖垮。
-
interrupt()和Command(resume=...)让 LangGraph 真正具备了人机交互能力。Agent 不再只能"一口气跑到底",而可以在关键节点停下来等人接管。
-
时间旅行不是噱头,而是复杂工作流调试的核心能力。
它让我们能从历史检查点读取、修改并重新执行流程。
到这一篇为止,我终于理解了 LangGraph 最硬核的一层价值:它不是只会"组织流程",而是能把流程的状态、记忆、暂停、恢复和回放,全都提升成框架级能力。
💬 下一篇承接方向:上一篇解决"图怎么建",这一篇解决"图怎么长期活着跑"。再往后,我会正式进入 LangGraph 的其他核心能力和更贴近真实项目的高级用法,比如预构建能力、更复杂的 Agent 设计方式,以及怎样把这些能力真正落到一个完整 AI 系统里。