Skip to content
Charles Shao
Go back

LangGraph:用 checkpointer 停住,确认之后再续

–views

在上一篇文章中,我们将底层的 while 循环重构成了两个物理节点,天气查询和订房操作挂载在同一张状态图上。然而,当时的每次 invoke 调用都是从 START 一口气跑到 END,进程一结束,所有执行状态随之丢失。正如在架构概述中提到的:LangGraph 可以在图的每一步执行完毕后生成一份 checkpoint 快照。这对标了手搓框架里按 run_id 写审计日志的设计,只不过在这里它不仅用于事后复盘,更用于在进程崩溃或触发拦截时恢复执行状态。本篇的核心目标,便是将这层至关重要的“触发中断与状态续跑”机制接入我们的业务链路中。

我们要实现的工程目标与手搓上线那一段完全一致。在手搓版本中,book_hotel 作为一个具有副作用的写操作,被赋予了极高的安全级别:如果没有获得用户的显式确认,它绝不执行;而一旦确认放行,系统便会携带着同一份 messages 历史再次进入循环。在 LangGraph 的体系下,解决思路也是同源的,即在真正扣减库存前触发中断,耐心等待人工审批。用户不必守着终端等待,过一段时间后,依然可以带着同一份状态快照回来,继续驱动节点流转完成预订。

图的拓扑结构无需大幅修改。上一篇已经确立了 agent 节点负责询问模型、tools 节点负责执行并回灌的基础架构,所以这里既不需要额外画一个「确认」节点,也不必拉出「未确认走另一边」的分支。触发中断的逻辑直接写在 book_hotel 函数内部:当白名单、房型、日期等业务校验全部通过、订单尚未落库的那一刻,调用 interrupt()。此时,tools 节点正处于执行该函数的过程中,执行流运行到这里会导致整个节点陷入停滞,并顺理成章地将“订哪家”、“多少钱”等关键参数带出循环,展示给用户进行核对。待用户批准之后,执行流依旧会重返 tools 节点,将这次未竟的订房操作彻底闭环。

Table of contents

Open Table of contents

一、引入 Checkpointer:精准记录停滞断点

在上一篇的代码中,我们的 compile() 函数并未传入任何参数。图虽然能够顺畅流转,但每走完一步,状态就被无情丢弃了:当时的 messages 历史以及最后停滞在哪个节点,都没有任何地方进行持久化记录。要想让图能够真正“停得住”,就必须引入一个专门负责记录「上下文聊到哪了、执行卡在哪一步了」的核心角色。这份重任便落在了 Checkpointer 的肩上。需要特别注意的是,它并不是图中的第三个实体节点,你绝不需要通过 add_node 来添加它。与上一篇直接在立图、加节点、连线之后调用 compile() 不同,这里我们需要多传一个配置参数,将负责读写状态的对象强绑定给编排好的图。

InMemorySaver 是这类状态存储机制中最轻量级的一种,它纯粹依赖于内存。InMemorySaver() 会在当前进程中实例化一个对象,该对象内部维护了一份字典,专门按照 thread_id 来隔离存储各条执行线的状态记录。这份字典在初始化时自然是空空如也的。调用 compile(checkpointer=...) 仅仅是将这个存储对象挂载给图,图在后续的整个生命周期中都会牢牢持有它。哪怕你将其简写成一行 compile(checkpointer=InMemorySaver()),其底层逻辑依然是完成了实例化与挂载动作。

checkpointer = InMemorySaver()  # 空存储,对照手搓代码里按 run_id 写入审计日志的逻辑
graph = builder.compile(checkpointer=checkpointer)  # 它不是 add_node 添加的节点,而是 compile 的核心配置参数

config = {"configurable": {"thread_id": THREAD_ID}}  # 对标手搓里的 run_id
first = graph.invoke({...}, config)  # 跑图;框架在底层顺手往存储里记下状态快照
second = graph.invoke(Command(resume="确认"), config)  # 传入相同的 thread_id,取出最新一笔快照从容续跑

真正往字典里写入记录的动作,发生在调用 graph.invoke(..., config) 驱动节点流转的过程中。invoke 的本职工作依然是跑图,它并不是一个专门的保存函数;只是因为现在图上挂载了 Checkpointer,每当 agent 或 tools 节点跑完一次(或者是遇到 interrupt() 触发中断时),框架就会在底层按照 thread_id 自动记下一笔状态。业务脚本里看不到诸如 storage[thread_id] = state 这种显式赋值的代码,这一切由 LangGraph 的框架层代劳。

在第一次调用 invoke 时,系统其实会密集地记下好几笔状态,而不是只在图运行到终点才写一次:agent 问完模型记一笔,tools 搜完地点和酒店回灌时记一笔,agent 再次请求模型记一笔,直到 tools 运行到 book_hotel 触发 interrupt() 停滞时再记下最后一笔,然后整个 invoke 才会正式返回。这里的变量 first 仅仅代表停住那一刻的返回结果(其中包含了特殊的 __interrupt__ 标记);而执行全链路的状态记录都已经保存在了 InMemorySaver 对象中。后续调用 get_state(config) 就是去查这份记录,而不是试图从 first 这个短暂的返回值里拆解出什么状态。

这里的 thread_id 与手搓框架里的 run_id 是一对一的严密映射关系:传入同一个 ID 就是在原有线路上接着跑,换一个 ID 就是新开一条平行的时间线。如果不挂载 Checkpointer,那么 interrupt() 抛出的中断信号就会因为无处记录而直接引发报错崩溃。在本篇的示例中,只要当前的 Python 进程还在运行,第二次 invoke 就能顺利续跑;但一旦进程结束、对象被回收,所有状态记录随之丢失。考虑到脚本里两次 invoke 写在同一次运行里,用 InMemorySaver 来演示停住的机制已经足够了。如果业务要求在运行结束后、甚至重启进程后还能无缝接续,那就需要换成支持落盘的 SqliteSaver,只要保证 thread_id 能够对齐即可。

需要强调的是,落盘记录的仅仅是图上定义好的那份 State 结构,而在本篇中,State 里依然只有 messages 这一个字段。像白名单 seen_hotel_ids 这种数据目前还只是函数体内的局部变量,它不会跟着 Checkpointer 一起被持久化,只要进程一结束,这些内存里的变量自然就丢失了。

二、将拦截防线设在修改库存之前

进入 book_hotel 函数后,首先需要执行上一篇中提到的那些基础校验:包括判断目标 ID 是否在搜索白名单内、请求的房型是否存在,以及入住日期是否合法。当这些前置规则全部校验通过、但在真正写入订单修改库存之前,调用 interrupt()。此举会将待订的酒店名称、具体房型以及计算出的总价等核心参数带出执行流,展示给用户进行核对。需要说明的是,大模型是绝对看不见这一段交互逻辑的,对它而言,它仅仅是发出了调用 book_hotel 工具的指令而已。

pending = {
    "tool": "book_hotel",
    "hotel_id": hotel_id,
    "hotel_name": hotel["name"],
    "room_type": room_type,
    "check_in": checked,
    "nights": nights,
    "total": unit * nights,
    "currency": "RMB",
}
# 对标手搓框架里“未确认不订”的安全逻辑。第一次运行到这里就会被精准拦截;续跑时则会将你在终端输入的决定返回给 decision 变量。
decision = interrupt(pending)
if decision != "确认":
    return {"error": f"未确认,不下单:{decision}"}
# 真正写入订单的操作,必须严格放置在这行判断的后面。

当 interrupt() 第一次被触发时,整个图的执行就此陷入停滞,随即 invoke 函数也会直接返回。由 interrupt() 带出的那份包含待办参数的字典,正是你在终端里看到打印在「—— 停住 ——」分界线下方的内容。在用户完成打字输入后,第二次调用 invoke 会携带着 Command(resume=...) 重新进入图。此时,停滞的那个节点(在本例中即为 tools 节点)会从头再跑一遍,而不是从上一轮 interrupt() 所在的行数直接往下执行。这意味着,在 interrupt() 上方撰写的白名单和日期等校验逻辑会被原样再走一次;只不过这一次,当流再次触达 interrupt() 时,它不会再造成阻塞,而是直接吐出你在终端敲下的那句「确认」(或其它字符),随后继续往下执行。

正因为存在节点重跑的机制,真正写入订单的操作必须严格放置在 interrupt() 的后面。如果你将其写在前面,就会出现“人还没点头,节点刚一重跑就擅自把订单下了”的严重业务事故。此外,考虑到节点重跑的特性,如果同一单在之前的步骤中已经预订成功过,再次跑到这里时应当直接返回已有的订单结果,而不是重复下两次单。这就需要我们在设计上引入幂等键机制,对照手搓时代的思路,本篇将这些成功记录保存在了一份独立的 ORDERS 字典里。

这种中断与恢复的模式与手搓时代有着本质的区别。在手搓框架里,充当护栏的逻辑是在正式 dispatch 调用工具之前就把 book_hotel 强行拦下来了;确认之后,框架会人为追加一条包含「确认」字样的 user 消息,逼迫模型再次推理并重新发起对订房工具的调用。而在本篇的架构中,确认操作是深度融入在同一次工具调用周期里的:当 interrupt() 接收到确认指令并返回后,book_hotel 会依靠自身逻辑把订单落库并完成回灌,大模型绝对不会因为用户的确认动作而被迫再做一轮多余的询问。

另外需要注意的一个并发细节是:如果大模型在同一轮推理中,将天气查询和 book_hotel 两个工具请求一并抛出,那么这两个动作在物理上同属于当前的这一次 tools 节点执行。当节点因为订房校验遇到 interrupt() 而中途停滞时,整个节点其实并未跑完,所以先行执行的天气查询的回灌信息根本还来不及写入 messages 列表中。只有在用户确认放行,整个节点成功完成重跑之后,天气信息才会再次被查询,订房操作也才真正落单。对于仅仅是读取操作的天气查询,多查一次无伤大雅;但对于涉及写操作的订房,这就显得至关重要。这也能完美解释第五节轨迹中的现象:在触发停住之前,终端里是绝对看不到天气回灌消息的,一切都要等到确认之后才会最终浮现。

图还是 agent 和 tools。book_hotel 里 interrupt 停住,确认后 Command(resume) 回到 tools,节点从头重跑。

三、续跑必须带上同一个 thread_id

在第一次调用 invoke 时,我们传入了用户目标和初始的 messages 列表,随着图的运行,流最终精准停滞在了 interrupt() 处。关键在于第二次调用续跑时:绝对不能再画蛇添足地传入一份全新的 messages 结构,否则系统会误将其视为一次重新开启的对话。正确的姿势是仅传入 Command(resume=...),而这里面包裹的正是你在终端敲下的那行回复,它会原路穿透回 interrupt() 的阻塞点。

需要澄清的是,续跑机制并不是依靠你向其投喂某条特定日志的 ID 来定位的。起决定作用的是 config 中的 thread_id,它负责指明当前要激活的是哪一条故事线。在没有额外指定具体的 checkpoint_id 时,框架会自动在这条线上提取最新的一笔快照——也就是我们第一次停滞在 interrupt() 处时记录下的那一份。Command(resume=...) 的语义非常明确:从最近一次停住的地方接续执行,并把你键入的决策字符串传送回那个悬停的 interrupt()。至于每笔记录内部隐含的 checkpoint_id,那是由框架自行生成的,在当前这篇“暂停即续”的简单场景下暂时派不上用场。只有当你试图让时间倒流、回到这条执行线更早之前的某一步时,才需要在 config 里附加上这个参数。

当你输入「确认」后,book_hotel 便会一路向下执行,完成订单写入,并回灌「已为您预订成功」的消息。待整个 tools 节点顺利结束,图的控制权重新交还回 agent,模型基于这条成功的执行回灌给出最终回答。而如果你输入了其他字符,工具则会回灌「未确认,不下单」;模型读到这条拒信后也会如实汇报预订失败,不会产生任何下单的副作用。如前所述,如果你在续跑时误换了一个全新的 thread_id,等待你的将是一份空白的记录——在这个平行宇宙里,前面搜过的酒店名单、上一秒停滞的节点状态全都不复存在。

四、对照手搓看差异

手搓框架下的确认与续跑本篇 LangGraph 机制
run_id + 落盘的审计文件thread_id + Checkpointer 机制
利用外部护栏拦截尚未确认的 book_hotel借助 interrupt() 直接在业务函数体内触发中断
确认操作等同于追加新的 user 消息,逼迫模型再次推断利用 Command(resume=...) 在同一次工具调用周期内闭环
利用幂等键防范双重下单风险核心写入逻辑置于 interrupt() 之后;同一笔业务请求仅允许成功一次
进程完全结束后依靠 --confirm 参数激活引入 SqliteSaver 并对齐同一个 thread_id 即可跨进程接续

总的来说,核心的交互循环拓扑并未改变。大模型依然扮演着“决定下一个要调用的工具”的角色。无论是中断拦截还是后续恢复,它们都被下放为了运行时的基础设施能力,并不体现在暴露给 LLM 的 Schema 定义中。

五、跑起来看轨迹

完整的演示代码放置在 agent-in-action/part2-agent-frameworks/langgraph/02-interrupt-resume/。你可以通过与上一层的代码目录执行 Compare Files 看看 diff:增加的核心内容其实仅仅就是 Checkpointer 的挂载、interrupt 的拦截逻辑,以及第二次负责接续的 invoke 调用。

cd part2-agent-frameworks/langgraph/02-interrupt-resume
python agent.py

用户的预订目标依然是这句经典的话:「西安现在天气怎么样?再帮我订一间豪华套房,2026 年 10 月 1 日住两晚。」程序启动后,会依次检索地理位置和候选酒店,接着计划并发调用天气查询和 book_hotel,但执行流会在订房动作处戛然而止。此时脚本会挂起,耐心等待你在终端敲击键盘:只有输入「确认」二字才会放行续跑,输入任何其他字符都会导致回灌「未确认,不下单」。

—— 停住前 ——
SystemMessage        只能通过工具获取事实,不要编温度、坐标、房价或 hotel_id。没有坐标时先 search_location,再 get_current_weather。订房没有 hotel_id 时先 search_hotels,再 book_hotel。有依赖的下一步,等回灌之后再调,不要同一轮一起调。book_hotel 单独调,不要和天气或搜索同一轮。用户只是询问有没有房或多少钱时不要下单。给出终答,带上天气、酒店名、房型、入住日期和总价。
HumanMessage         西安现在天气怎么样?再帮我订一间豪华套房,2026 年 10 月 1 日住两晚。
AIMessage            要调 ['search_location', 'search_hotels']
ToolMessage          {"name": "西安", "country": "中国", "latitude": 34.25833, "longitude": 108.92861}
ToolMessage          {"check_in": "2026-10-01", "hotels": [{"id": "HT-001", "name": "西安钟楼饭店", "city": "西安", "room_types": ["standard", "deluxe"], "price_per_night": {"standard": 180, "deluxe": 260}}, {"id": "HT-002", "name": "西安香格里拉", "city": "西安", "room_types": ["standard", "deluxe", "suite"], "price_per_night": {"standard": 150, "deluxe": 220, "suite": 400}}, {"id": "HT-003", "name": "回民街文化酒店", "city": "西安", "room_types": ["standard"], "price_per_night": {"standard": 110}}]}
AIMessage            要调 ['get_current_weather', 'book_hotel']
—— 停住 ——
{'tool': 'book_hotel', 'hotel_id': 'HT-002', 'hotel_name': '西安香格里拉', 'room_type': 'suite', 'check_in': '2026-10-01', 'nights': 2, 'total': 800, 'currency': 'RMB'}
请输入「确认」下单,其他内容拒绝:确认
确认:确认
—— 续跑后 ——
SystemMessage        只能通过工具获取事实,不要编温度、坐标、房价或 hotel_id。没有坐标时先 search_location,再 get_current_weather。订房没有 hotel_id 时先 search_hotels,再 book_hotel。有依赖的下一步,等回灌之后再调,不要同一轮一起调。book_hotel 单独调,不要和天气或搜索同一轮。用户只是询问有没有房或多少钱时不要下单。给出终答,带上天气、酒店名、房型、入住日期和总价。
HumanMessage         西安现在天气怎么样?再帮我订一间豪华套房,2026 年 10 月 1 日住两晚。
AIMessage            要调 ['search_location', 'search_hotels']
ToolMessage          {"name": "西安", "country": "中国", "latitude": 34.25833, "longitude": 108.92861}
ToolMessage          {"check_in": "2026-10-01", "hotels": [{"id": "HT-001", "name": "西安钟楼饭店", "city": "西安", "room_types": ["standard", "deluxe"], "price_per_night": {"standard": 180, "deluxe": 260}}, {"id": "HT-002", "name": "西安香格里拉", "city": "西安", "room_types": ["standard", "deluxe", "suite"], "price_per_night": {"standard": 150, "deluxe": 220, "suite": 400}}, {"id": "HT-003", "name": "回民街文化酒店", "city": "西安", "room_types": ["standard"], "price_per_night": {"standard": 110}}]}
AIMessage            要调 ['get_current_weather', 'book_hotel']
ToolMessage          {"temperature": 17.6, "apparent_temperature": 18.9, "humidity": 84, "wind_speed": 1.9}
ToolMessage          {"status": "ok", "hotel_id": "HT-002", "hotel_name": "西安香格里拉", "room_type": "suite", "check_in": "2026-10-01", "nights": 2, "total": 800, "currency": "RMB"}
AIMessage            西安现在的天气是17.6度。已经为您预订了西安香格里拉的豪华套房,入住日期为2026年10月1日,共2晚,总价格为800元人民币。

从停滞前打印的日志中可以清晰地看到:地点坐标和酒店列表都已经成功回灌进历史;在接下来的这一轮中,系统原本计划一并调用天气和订房工具,但无奈执行流在订房内部被硬生生截断了。此刻既没有返回实际温度,也没有出现「订成了」的成功提示——因为整个 tools 节点压根就没有跑完。你在终端里看到的那几行参数,单纯是 interrupt() 从内部强行带出来供人类审批的快照,绝不是大模型最终给出的回答。

当你在终端按下回车续跑之后,历史记录中此前搜过的那些数据依旧稳健存在。此时新增的日志是天气工具的回灌、订房成功的确认回灌,以及大模型给出的最终答复。请注意,这两条关键的 ToolMessage,是在你批准放行之后,tools 节点从头重跑的过程中才被正式写入 messages 列表的。同时,你在终答里看到的温度播报与本次回灌的真实气象数据完全吻合,而提取出的 hotel_id 也精准来源于前几轮的历史搜索结果。

故障现象排查思路
interrupt() 报错,提示缺失 Checkpointer检查 compile 环节是否漏传了挂载参数
确认后依然无法接续,像是新开了一次对话校验 thread_id 拼写是否一致;重点排查第二次 invoke 时是否误传了新的 messages 结构
用户尚未输入确认,系统就已经自动预订成功重点检查代码中写订单的操作,是否不小心越过了 interrupt() 拦截线
用户确认一次,系统却生成了两笔相同的订单排查节点重跑时,是否在校验逻辑中漏掉了“先查询状态,存在即返回结果”的防重复下单保护
进程结束后无法实现续跑本篇为了演示简便使用的是纯内存的 InMemorySaver,一旦进程结束数据便随之丢失;具体跨进程的续跑方案,请参考下一篇切换为 SqliteSaver

在使用 create_agent 的高阶编排模式下,人工确认的逻辑会被统一剥离并挂载到 Middleware(中间件)上,但其底层依托的依然是这套 interrupt() 机制。当然,如果想要让拦截白名单也享受到随查随在的待遇,就必须一并将其塞进 State 之中,以确保其能够随状态一起持久化。下一篇,我们将把内存存储升级为物理文件,挑战真正意义上的“关闭进程仍能续跑”。


–views
Share this post on:

Previous Post
LangGraph:用 SqliteSaver 关了进程再续
Next Post
LangGraph:用 StateGraph 把手搓 while 画成图