零基础入门 LangChain 与 LangGraph(八):真正让 Agent“活起来”——持久化、记忆、人机交互与时间旅行

文章目录

    • [零基础入门 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)
      • [5.4 更新状态:`update_state()` 不是简单赋值,而是"生成新分支"](#5.4 更新状态:update_state() 不是简单赋值,而是“生成新分支”)
        • [1. 更新状态不会覆盖掉原始历史](#1. 更新状态不会覆盖掉原始历史)
        • [2. `Overwrite` 是绕过 reducer 的强制覆盖](#2. Overwrite 是绕过 reducer 的强制覆盖)
    • [六、只有 Checkpoint 还不够:为什么还需要 Store?](#六、只有 Checkpoint 还不够:为什么还需要 Store?)
      • [6.1 线程级持久化只能解决"这次会话别忘",但解决不了"以后都记得"](#6.1 线程级持久化只能解决“这次会话别忘”,但解决不了“以后都记得”)
      • [6.2 `Checkpoint` 和 `Store` 的区别](#6.2 CheckpointStore 的区别)
    • [七、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 编译图时,checkpointerstore 可以同时存在)
    • [九、语义搜索版 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 为什么人机交互能力这么关键?)
    • 十二、中断的四条黄金法则
      • [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 最酷的调试能力)
    • 十五、"短期记忆、长期记忆、人机协作、时间旅行"
      • [15.1 它们表面是四种能力,本质上都建立在"状态可控"之上](#15.1 它们表面是四种能力,本质上都建立在“状态可控”之上)
    • 十六、本篇总结

零基础入门 LangChain 与 LangGraph(八):真正让 Agent"活起来"------持久化、记忆、人机交互与时间旅行

💬 开篇 :上一篇我已经把 LangGraph 最重要的第一层地基搭起来了:它不是普通的链式调用,而是一个用 StateNodeEdge 组织起来的有状态工作流系统。

但如果我只停在"会建图",其实还远远不够。因为真实的 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

第一层:线程级持久化

它解决的是:

同一次工作流执行,会不会丢状态?

也就是:

  • 执行到一半能不能保存
  • 重启后能不能恢复
  • 能不能回看这次执行的历史过程
  • 能不能从中间某个点继续跑

这一层的核心对象就是:

  • Thread
  • Checkpoint
第二层:跨会话持久化

它解决的是:

不同会话之间,能不能共享长期信息?

也就是:

  • 用户今天告诉你他爱吃汉堡
  • 明天开一个新会话
  • 系统还能记得他的偏好

这一层的核心对象就是:

  • 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 就不是"重新开始",而是会:

  1. 找到 thread_id = "1" 对应的历史状态
  2. 加载最后一个检查点
  3. 把新输入接到原有上下文之后
  4. 从恢复点继续执行

也就是说,同一个 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 CheckpointStore 的区别

能力 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 {}

注意这里的函数签名:

  • config
  • store

都是通过节点参数注入进来的。

这意味着一旦你在 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 编译图时,checkpointerstore 可以同时存在

这是一个特别关键的组合:

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 记忆不是只会越堆越多,真正难的是怎么管

很多人第一次学记忆系统,最容易有个朴素想法:

既然要记,那就全记下来不就好了?

可一旦真的这么做,很快就会遇到三个问题:

  1. 上下文窗口是有限的
  2. 历史消息太长会拖慢推理
  3. 太多无关历史反而会干扰当前回答

所以应用层还要学的是:

  • 怎么裁剪
  • 怎么删除
  • 怎么总结

这才叫"记忆管理"。


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:最多留这么多 token
  • start_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 总结消息:一种实用管理方式

裁剪和删除虽然有效,但都有一个问题:

信息可能真丢了。

所以很多系统更喜欢做"摘要替换"。

思路是:

  1. 历史消息够多时
  2. 先让模型生成一份总结
  3. 把旧消息删掉
  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 的思路是:

  1. 在节点里调用 interrupt()
  2. 当前执行立刻暂停
  3. 现场通过持久化机制保存下来
  4. 外部传入 Command(resume=...)
  5. 图从暂停点继续往下执行
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_id
  • update_state()
  • invoke(None, config=...)

所以时间旅行说白了就是:

  1. 找到历史某个状态
  2. 读取它
  3. 必要时改它
  4. 从它继续执行

14.2 为什么这个能力特别适合调试 Agent?

因为 Agent 流程天然有三个特点:

  1. 路径复杂
  2. 结果可能非确定
  3. 出错位置往往不在最后一步

例如一个两步图:
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 系统长期运行"。

核心结论:

  1. LangGraph 的持久化不只是保存聊天记录,而是保存可恢复执行的运行时状态。

    它真正保存的是工作流现场,而不是普通文本历史。

  2. 线程级持久化由 ThreadCheckpoint 组成。
    Thread 代表一次独立执行会话,Checkpoint 代表这次执行过程中的某个状态快照。

  3. 同一个 thread_id 可以让图在短期内"接着聊、接着跑"。

    这就是短期记忆和断点恢复的基础。

  4. get_state()get_state_history()、重放与 update_state(),把调试能力直接提升到了运行时层。

    你不仅能看当前状态,还能回看历史、修改状态并从中间继续执行。

  5. 只有 Checkpoint 还不够,跨会话长期记忆必须靠 Store
    Checkpoint 解决"别断档",Store 解决"别失忆"。

  6. 短期记忆并不是越多越好,真正重要的是管理。

    修剪、删除、总结,都是为了让上下文既不丢关键信息,也不被历史噪声拖垮。

  7. interrupt()Command(resume=...) 让 LangGraph 真正具备了人机交互能力。

    Agent 不再只能"一口气跑到底",而可以在关键节点停下来等人接管。

  8. 时间旅行不是噱头,而是复杂工作流调试的核心能力。

    它让我们能从历史检查点读取、修改并重新执行流程。

到这一篇为止,我终于理解了 LangGraph 最硬核的一层价值:它不是只会"组织流程",而是能把流程的状态、记忆、暂停、恢复和回放,全都提升成框架级能力。


💬 下一篇承接方向:上一篇解决"图怎么建",这一篇解决"图怎么长期活着跑"。再往后,我会正式进入 LangGraph 的其他核心能力和更贴近真实项目的高级用法,比如预构建能力、更复杂的 Agent 设计方式,以及怎样把这些能力真正落到一个完整 AI 系统里。

相关推荐
还是转转2 小时前
深入认识 Agent —— 实现你自己的 Agent
ai·agent
abigale032 小时前
LangChain:自定义模型・RAG 检索・Agent 原理笔记
langchain·llm·prompt·agent·rag·lcel
ToTensor3 小时前
Agent 记忆管理框架基准测试排名
人工智能·agent
jiayong233 小时前
国内外视频/图像大模型与智能体工具平台竞品对比
ai·音视频·agent
tangweiguo030519873 小时前
AI图生图完整实战:基于阿里云百炼通义万相
人工智能·langchain
小贺儿开发3 小时前
Unity3D 智能云端数字标牌系统
unity·阿里云·人机交互·视频·oss·广告·互动
维元码簿4 小时前
Claude Code 深度拆解:Agent 执行内核 3 — 从 API 调用到安全退出
ai·agent·claude code·ai coding
半部论语4 小时前
CentOS7 + pyenv 安装 Python 3.11 完整指南)
大数据·elasticsearch·python3.11