Skip to content
Charles Shao
Go back

LangGraph:用 SqliteSaver 关了进程再续

创建于:
v1.0.0
–views

在上一篇通过 Checkpointer 实现了暂停的实践中,我们在追投扣款前调用 interrupt() 成功实施了拦截,并在用户确认后通过 Command(resume=...) 恢复了执行。然而,当时的两次 invoke 调用均紧凑地写在同一个 main() 函数内,中间的进程根本无法中断。原因在于 InMemorySaver 的本质是驻留在内存中的字典,一旦进程结束、对象被回收,所有状态记录随之丢失。

回顾我们在手搓上线那篇文章中的设计,审计日志是被持久化写入到 audit/<run_id>.json 文件中的,因此即使关闭了进程,后续依然可以通过 python agent.py --confirm 读回状态并接着跑。为了在现代框架中对齐这种工业级的体验,本文要解决的核心痛点就是:让状态记录彻底脱离内存的束缚。我们将实现第一次运行在节点停住时直接退出(比如追投单要等主管第二天上班再审批),随后第二次启动一个全新的 Python 进程,依然能够从容完成续跑。

整张图的拓扑结构无需做任何修改。interrupt() 依然放置在 boost_adgroup 函数内部,确认后同样通过 Command(resume=...) 放行,且 tools 节点依然会遵循从头重跑的机制。我们只需做两处关键改动(且必须同步进行):一是将 Checkpointer 替换为能够写入本地文件的 SqliteSaver,以确保停住时的上下文和 messages 历史不会丢失;二是将白名单和追投单数据从模块级变量提升至 State 结构之中。如果不执行第二步,当新进程启动并重跑节点时,由于内存状态已被重置,工具便会认不出之前搜索过哪些广告组,从而错误地回灌「不在本次搜索结果里」的拦截报错。

Table of contents

Open Table of contents

一、突破内存限制:让状态快照持久化落盘

在上一篇中,我们已经明确了 Checkpointer 的定位:它并不是图中的一个实体节点,而是 compile 时的一个核心配置参数。InMemorySaver() 会在当前进程中创建一个对象,对象内部维护着一个字典,并严格按照 thread_id 区分存储各条执行线。当我们通过 invoke 跑图时,每当 agent 或 tools 节点执行完毕(或是遇到 interrupt() 停住),框架就会自动往这份字典里记下一笔快照。你在自己的业务代码中是看不到诸如 storage[thread_id] = state 这种显式赋值的,因为这一切都由 LangGraph 在底层包办。

然而,字典的生命周期依附于对象,而对象又依附于当前的 Python 进程。一旦进程退出、内存被回收,这份状态快照就彻底消亡了。如果此时新开一个进程,再次调用 InMemorySaver(),得到的将是一个全新的空字典。此时,即使你传入名为 cp1024-boost-2026-11-01 的 thread_id,对新字典而言这也只是一个陌生的键:之前搜索过的广告组、停滞的节点状态、以及 interrupt() 抛出的追投信息,全都无迹可寻。

正因如此,上一篇的两次 invoke 必须写在同一次 main() 运行内。只有这样才能在一个连续的进程里勉强讲清楚“停住再续”的概念,但这显然无法对齐手搓时代那句硬气的“关了进程再 --confirm”。要想彻底断开进程还能接续,状态记录就必须脱离内存,实现持久化落盘。

上一篇还留下了一个关键的伏笔:落盘存储的其实是图上定义的那个 State 结构,而当时它仅仅包含 messages 字段。至于 seen_adgroup_ids 和 ORDERS,当时仍被定义为模块级变量。在单次持续运行中,它们确实够用;但只要进程一结束,这些数据依然会被清空。因此,在引入文件存储的同时,这两处状态也必须跟着一起迁移。

二、切换至 SqliteSaver:打造持久化的状态账本

SqliteSaver 同样也是一种 Checkpointer,因此它依然作为 compile 的参数传入,依然按照 thread_id 进行记录,get_state 也依然是去它里面翻找记录。唯一改变的是存储介质:从进程内的字典变成了一个本地的 SQLite 文件。

需要明确的是,SQLite 并不是一项需要独立启动的后台数据库服务,它没有主机名也没有端口号。通过 Python 标准库的 sqlite3.connect(路径) 即可直接打开文件,所有的读写操作都在当前进程内完成;进程退出后,连接自然关闭,但文件实体安然无恙。你不需要在机器上执行 brew services 来维持它的运行,使用 Navicat 查阅时,也仅仅是打开这份本地文件,而非连接某台数据库服务器。

conn = sqlite3.connect(DB_PATH, check_same_thread=False)
graph = builder.compile(checkpointer=TextSqliteSaver(conn))

这里的 DB_PATH 就存放在脚本同级目录下,命名为 checkpoints.sqlite。这就好比手搓系列里的 audit/<run_id>.json:关了进程,文件依旧在。thread_id 在这里继续对标手搓时的 run_id:传入相同的 ID 就是接着跑,传入新的 ID 则是另开一条线。至于 check_same_thread=False,这是专门给这根连接设置的参数。LangGraph 内部可能会跨线程进行读写操作,而 SQLite 默认一根连接只允许创建它的那个线程访问,如果不配置这个参数,运行图时就会抛出线程安全报错。

invoke 的本职工作依然是跑图。因为已经挂载了 Checkpointer,每当走完 agent 或 tools(或是停在 interrupt() 时),框架就会向文件中记下一笔快照,而不是只在整张图跑完才写一次。所以在第一次 invoke 结束后,数据库中会留下多行快照:搜计划、搜广告组、再次询问模型、在追投处停住,每步都有相应的严密记录。随后调用的 get_state(config) 则是去读取该文件,并按照 thread_id 提取最新的一笔状态,它并不依赖上次 invoke 的返回值。毕竟,上一次的返回值早已在之前的进程中消亡了。

在文件中,主要包含两张表。checkpoints 按照 thread_id 与 checkpoint_id 的组合,存储每一笔完整的快照信息;而 writes 则记录着这一笔中各频道的详细变动,其中 channel 是字段名,value 则是具体内容。使用 Navicat 打开 writes 表并按照 channel 查看:messages 对应着回灌消息,seen_adgroup_ids 对应着白名单,orders 记录着追投单,而特殊的 __interrupt__ 则保存着停住时抛出的那份待定信息。这里的 checkpoint_id 依旧是由框架自动生成的,本篇中我们只需取最新的一笔即可;只有当你想时间回溯到更早的某一步时,才需要在 config 中带上特定的 ID。

官方原生的 SqliteSaver 默认将 value 和 checkpoint 列设为 BLOB 类型,内部存储的是 msgpack 格式。如果直接用 Navicat 强行打开,看到的是一堆无法阅读的二进制乱码,这显然对不上手搓时代那份可以直接视检的 JSON 日志。为了解决这个问题,我们自定义了 TextSqliteSaver。它继承自 SqliteSaver,我们并没有去魔改底层的 put / get 逻辑、版本号校验或是中断状态管理,而仅仅是改变了序列化写出的格式:将列类型调整为 TEXT,内容以 JSON 字符串形式落盘。续跑时,依然交由 Saver 将其反序列化读回。如此一来,打开 seen_adgroup_ids 那一行,就能清晰地看到 ["AG-001", "AG-002", "AG-003"];而 messages 虽然包裹着一层 lc 信封,但我们依然能直观地查阅 System / AI / Tool 的 content。

强烈建议不要自己徒手实现一份基于 JSON 的 Checkpointer。如果是为了让人看,通过 Navicat 查阅 TEXT 列的 writes 表,或是直接在终端打印 get_state 的结果就已经完全足够了。但如果想要让图能够顺利续跑,状态数据就必须按照底层协议从 Saver 中精准读回。自己去完整实现那套复杂的框架协议,远比直接沿用内置机制要困难得多。

在 Navicat 中新建连接时,只需选择 SQLite,连接类型设定为「现有数据库文件」,然后将其指向这份 checkpoints.sqlite,密码留空即可。当连接着数据库运行第一次代码时,由于脚本内部会主动删除旧文件重建,这可能导致文件锁冲突;因此,建议先断开 Navicat 连接,等第一次运行停住并退出进程后,再重新连上查看。如果在同级目录下发现了残留的 checkpoints.sqlite-wal 或 -shm 文件,多半是因为上一次进程没有干净退出,连同主文件一并删掉即可。

三、白名单与追投单必须提升至 State

仅仅把 Checkpointer 换成文件还远远不够。上一篇的 seen_adgroup_ids 和 ORDERS 是写在模块顶部的,在同一次运行里完全没问题:search_adgroups 搜完往集合里添加,boost_adgroup 再读取。但当新进程启动再次 import 时,这两个变量虽然还在,值却会被无情地重置为空。

正如上一篇所述,从 interrupt() 续跑时,tools 节点会从头再走一遍,而不是从 interrupt() 所在的那一行接着往下执行。这意味着 boost_adgroup 一进来就会先去查验白名单。如果白名单还停留在模块变量里,新进程拿到的就是一个空集合。此时,哪怕 messages 历史记录中明明已经包含了搜索结果,工具也会回灌报错:「adgroup_id 不在本次搜索结果里」。追投单数据同理:由于代表幂等键的历史追投单不在文件中,节点重跑时就可能会导致同一笔追投被重复扣款。

需要牢记的是,Checkpointer 落盘存储的仅仅是图上的那个 State 对象。不在 State 里的数据,自然不会出现在文件中,新进程也就无从读回。因此,白名单和追投单必须被收编为 State 的字段,与 messages 同进同退。

class State(TypedDict):
    messages: Annotated[list, add_messages]
    seen_adgroup_ids: list[str]
    orders: dict[str, dict]

在这里,messages 依然使用 add_messages 聚合器来实现新消息的追加,而不是整表覆盖。而 seen_adgroup_ids 和 orders 由于没有指定 reducer,每次 update 都会直接替换上新的完整数据。注意,在第一次调用 invoke 时,必须带上空列表和空字典,这两个字段才会被真正初始化;如果不写,后续读取 state.get("seen_adgroup_ids") 就会得到 None。

通常情况下,@tool 装饰的函数默认只负责回灌。当函数返回一个普通的字典时,ToolNode 会将其封装成一条 ToolMessage,并追加到 messages 列表中。但现在白名单等状态字段并不在这条回灌信息的结构内,必须另辟蹊径。因此,search_adgroups 和 boost_adgroup 需要改为返回 Command(update=...),以实现一边回灌消息,一边修改 State 的其它字段。

# 白名单写进 State,才会进 sqlite,新进程启动时才能精准读到。
return Command(
    update={
        "seen_adgroup_ids": seen,
        "messages": [_tool_message(payload, tool_call_id)],
    }
)

一旦改用 Command 返回,ToolNode 就不再替你自动打包 ToolMessage 了,你需要手动将回灌消息放进 update["messages"] 中。此外,构建 ToolMessage 时必须带上当前的 tool_call_id,以便模型能够将其与前面的那条 AIMessage 对应起来。由于这个 ID 模型既不能传也看不见,我们需要在参数上标注 InjectedToolCallId,框架便会在调用时将当前的 ID 自动注入进来。

读取白名单时,则需要借助 InjectedState 注解。它同样标注在参数上,不会暴露给 LLM Schema,模型自然也就看不见,更不会在 tool_calls 里凭空编造一个 state=... 参数。通过这种方式,框架将当前的 State 注入函数中,boost_adgroup 就能顺利读取 state["seen_adgroup_ids"] 作为白名单,并在确认之后将追投单写入 state["orders"]。当新进程打开文件时,这两份关键数据都安然躺在那里。节点重跑时,白名单校验依然能够通过,不会再出现误报;而对于已经提交成功过的追投单,遇到重复处理时也会直接返回已有结果。

至于查计划、查报表这两个工具,它们依旧只负责查询回灌、不修改白名单,所以保留原本返回字典的做法即可,无需动用 Command。

四、跨越进程边界:同一个 thread_id 的接续

在上一篇中,我们在同一次运行的循环里通过 input 等待输入。而在本篇,第一次运行不需要再等用户敲键盘了:一旦 invoke 跑到 interrupt() 并将广告组名和预付金额打印出来,就会主动关闭连接,让进程退出。此时,文件里已经完整记录下停住那一刻的快照,包括 messages、白名单、下一跳节点 next=('tools',),以及通过 interrupt() 带出来的待确认字典。

python agent.py
# 停住之后进程退出。续跑:python agent.py --resume

当我们需要续跑时,启动的是一个全新的 Python 进程,而不再是同一次运行里的第二次 invoke。对比手搓时代的体验:先执行 python agent.py,再执行 python agent.py --confirm。在这里,我们将后续触发的旗标命名为 --resume,因为确认之后执行流回到的依然是同一轮的 boost_adgroup,而不是像手搓那样追加一条 user 消息让模型重新推理。

在新进程中,我们打开同一份文件,依然传入同一个 thread_id。通过调用 get_state,可以确认文件里是否还存留着这条执行线。其中 snapshot.values 保存着当时的 State 状态,snapshot.next 指示着当时停滞在哪个节点,而 snapshot.interrupts 则是为了展示给用户看的那份待确认信息。随后我们再通过 input 接收一行输入,并携带着 Command(resume=...) 指令继续跑图。

config = {"configurable": {"thread_id": THREAD_ID}}
snapshot = graph.get_state(config)  # 从文件读,绝不是从上次 invoke 的返回值里拆
second = graph.invoke(Command(resume=decision), config)

需要特别注意的是,在第二次调用 invoke 时,千万不要再画蛇添足地传入一份新的 messages。一旦传入,框架就会将其视作是一次全新的对话开启,文件里辛辛苦苦记录的那条线也就废弃了。只要 thread_id 对得上,且没有额外指定特定的 checkpoint_id,框架就会自动取出这一页上最新的一笔快照——也就是我们第一次停住时写下的那份记录。Command(resume=...) 负责将你敲下的字符原路送回给 interrupt()。随后 tools 节点从头再跑:从 State 中重新读取白名单,业务校验再走一遍;但这一次,interrupt() 不会再造成中断,而是直接返回“确认”等字符串,最后顺理成章地写入追投单。

为了保证演示的纯粹性,脚本在第一次启动时会主动删除旧的 SQLite 文件(以及旁边的 -wal 和 -shm 文件),确保执行线完全从零开始。一旦程序成功停住并退出,后续的干预就只能加上 --resume 参数来进行。如果此时你不慎又跑了一遍不带参数的 python agent.py,旧文件就会被清空,等于直接作废了上一次的停滞进度。

第一次进程写进文件后退出。第二次打开同一份、同一个 thread_id,确认后续跑。

五、对照手搓看差异

手搓确认与续跑机制上一篇(内存存储)本篇(SQLite 持久化)
audit/<run_id>.json,关了进程还在InMemorySaver,生命周期绑定进程checkpoints.sqlite,关了进程依然完好
python agent.py 再 --confirm同一次运行里两次 invokepython agent.py 再 --resume
记忆写在循环外,读回审计时一并读取模块变量,新进程中被重置为空seen_adgroup_ids 和 orders 被收编入 State
确认等同于新 user 消息,模型重做推断Command(resume=...) 回到同一次调用机制一致,仅跨越了物理进程的边界

在手搓框架中,审计文件主要负责记录当时的 messages 历史,而白名单和追投单则另存在循环外的高级记忆模块里。执行 --confirm 续跑时,这两份数据会被一并读回,然后再人工追加一条包含“确认”的 user 消息,驱动模型再调一次 boost_adgroup。而在 LangGraph 的文件持久化方案里,State 就包揽了一切:对话历史、白名单和追投单全部封装在同一份快照里。确认动作不再被当作新的 messages 处理,boost_adgroup 将直接从 interrupt() 的断点处接续完成,模型也不会因为用户的确认而被迫再做一轮多余的推断。

整体的循环拓扑并没有改变,中断拦截依然发生在 boost_adgroup 内部。之所以现在能够跨进程从容续跑,完全归功于状态快照和白名单双双脱离了内存的束缚。

六、跑起来看轨迹

完整代码在 agent-in-action/part2-agent-frameworks/langgraph/03-sqlite-resume/。你可以通过与上一层的代码进行比对,直观查看改动之处:主要包括 Checkpointer 切换成了文件存储,State 结构增加了两列关键字段,工具统一改用 Command 进行回写,以及 SQLite 中 value 字段的格式改为了可读的 JSON 文本。

cd part2-agent-frameworks/langgraph/03-sqlite-resume
python agent.py

目标依旧不变:「双11-美妆拉新 10 月 31 日的 ROI 怎么样?再帮我按激进档追投,2026 年 11 月 1 日起投两天。」程序会先搜索计划和广告组,接着准备调用报表和 boost_adgroup,并在追投校验通过处戛然而止。这一次,脚本不再傻等着你打字,打印完关键信息后便会干脆利落地退出。

—— 停住前 ——
SystemMessage        只能通过工具获取事实,不要编 ROI、campaign_id、日预算或 adgroup_id。没有 campaign_id 时先 search_campaign,再 get_campaign_report 或 search_adgroups。追投没有 adgroup_id 时先 search_adgroups,再 boost_adgroup。有依赖的下一步,等回灌之后再调,不要同一轮一起调。boost_adgroup 单独调,不要和报表或搜索同一轮。用户只是询问能追投多少或要花多少钱时不要下单。给出终答,带上 ROI、广告组名、档位、开始日期和预付金额。
HumanMessage         双11-美妆拉新 10 月 31 日的 ROI 怎么样?再帮我按激进档追投,2026 年 11 月 1 日起投两天。
AIMessage            要调 ['search_campaign']
ToolMessage          {"campaign_id": "CP-1024", "name": "双11-美妆拉新", "status": "active"}
AIMessage            要调 ['search_adgroups']
ToolMessage          {"start_date": "2026-11-01", "adgroups": [{"id": "AG-001", "name": "直播间引流-新客", "learning": true, "tiers": ["conservative", "standard"], "daily_budget": {"conservative": 180, "standard": 260}}, {"id": "AG-002", "name": "商品卡成交-老客", "learning": false, "tiers": ["conservative", "standard", "aggressive"], "daily_budget": {"conservative": 150, "standard": 220, "aggressive": 400}}, {"id": "AG-003", "name": "短视频种草-兴趣人群", "learning": true, "tiers": ["conservative"], "daily_budget": {"conservative": 110}}]}
AIMessage            要调 ['get_campaign_report', 'boost_adgroup']
State.seen_adgroup_ids  ['AG-001', 'AG-002', 'AG-003']
—— 停住 ——
{'tool': 'boost_adgroup', 'adgroup_id': 'AG-002', 'adgroup_name': '商品卡成交-老客', 'tier': 'aggressive', 'start_date': '2026-11-01', 'days': 2, 'total': 800}
已写入 checkpoints.sqlite(thread_id=cp1024-boost-2026-11-01)。进程退出。
续跑:python agent.py --resume

从停住前的信息可以看到:计划和广告组已经成功回灌,白名单也已经安稳存入 State。下一轮原本规划了报表和追投两个调用,但执行流断在了追投环节。此时既没有 ROI,也没有追投成功的提示——这说明 tools 节点并未完全跑完。由于报表工具和 boost_adgroup 同属于这一次的节点调用,在节点中断时,报表的回灌消息还来不及被写入 messages 列表中。停滞处打印出来的那几行信息,仅仅是通过 interrupt() 带出来供人审批的参数,绝不是模型给出的最终回答。此刻,Python 进程已经彻底结束。如果你用 Navicat 打开 writes 表,可以在 seen_adgroup_ids 对应的 value 里找到那三个搜索出来的 ID;而 __interrupt__ 行中记录的,正是上面那份待确认的追投明细。

此时你可以另开一个终端,或者干脆等到第二天主管上班审批时,再执行:

python agent.py --resume
—— 新进程读回 ——
SystemMessage        只能通过工具获取事实,不要编 ROI、campaign_id、日预算或 adgroup_id。没有 campaign_id 时先 search_campaign,再 get_campaign_report 或 search_adgroups。追投没有 adgroup_id 时先 search_adgroups,再 boost_adgroup。有依赖的下一步,等回灌之后再调,不要同一轮一起调。boost_adgroup 单独调,不要和报表或搜索同一轮。用户只是询问能追投多少或要花多少钱时不要下单。给出终答,带上 ROI、广告组名、档位、开始日期和预付金额。
HumanMessage         双11-美妆拉新 10 月 31 日的 ROI 怎么样?再帮我按激进档追投,2026 年 11 月 1 日起投两天。
AIMessage            要调 ['search_campaign']
ToolMessage          {"campaign_id": "CP-1024", "name": "双11-美妆拉新", "status": "active"}
AIMessage            要调 ['search_adgroups']
ToolMessage          {"start_date": "2026-11-01", "adgroups": [{"id": "AG-001", "name": "直播间引流-新客", "learning": true, "tiers": ["conservative", "standard"], "daily_budget": {"conservative": 180, "standard": 260}}, {"id": "AG-002", "name": "商品卡成交-老客", "learning": false, "tiers": ["conservative", "standard", "aggressive"], "daily_budget": {"conservative": 150, "standard": 220, "aggressive": 400}}, {"id": "AG-003", "name": "短视频种草-兴趣人群", "learning": true, "tiers": ["conservative"], "daily_budget": {"conservative": 110}}]}
AIMessage            要调 ['get_campaign_report', 'boost_adgroup']
State.seen_adgroup_ids  ['AG-001', 'AG-002', 'AG-003']
next=('tools',)
—— 停住(从文件读回)——
{'tool': 'boost_adgroup', 'adgroup_id': 'AG-002', 'adgroup_name': '商品卡成交-老客', 'tier': 'aggressive', 'start_date': '2026-11-01', 'days': 2, 'total': 800}
请输入「确认」下单,其他内容拒绝:确认
确认:确认
—— 续跑后 ——
SystemMessage        只能通过工具获取事实,不要编 ROI、campaign_id、日预算或 adgroup_id。没有 campaign_id 时先 search_campaign,再 get_campaign_report 或 search_adgroups。追投没有 adgroup_id 时先 search_adgroups,再 boost_adgroup。有依赖的下一步,等回灌之后再调,不要同一轮一起调。boost_adgroup 单独调,不要和报表或搜索同一轮。用户只是询问能追投多少或要花多少钱时不要下单。给出终答,带上 ROI、广告组名、档位、开始日期和预付金额。
HumanMessage         双11-美妆拉新 10 月 31 日的 ROI 怎么样?再帮我按激进档追投,2026 年 11 月 1 日起投两天。
AIMessage            要调 ['search_campaign']
ToolMessage          {"campaign_id": "CP-1024", "name": "双11-美妆拉新", "status": "active"}
AIMessage            要调 ['search_adgroups']
ToolMessage          {"start_date": "2026-11-01", "adgroups": [{"id": "AG-001", "name": "直播间引流-新客", "learning": true, "tiers": ["conservative", "standard"], "daily_budget": {"conservative": 180, "standard": 260}}, {"id": "AG-002", "name": "商品卡成交-老客", "learning": false, "tiers": ["conservative", "standard", "aggressive"], "daily_budget": {"conservative": 150, "standard": 220, "aggressive": 400}}, {"id": "AG-003", "name": "短视频种草-兴趣人群", "learning": true, "tiers": ["conservative"], "daily_budget": {"conservative": 110}}]}
AIMessage            要调 ['get_campaign_report', 'boost_adgroup']
ToolMessage          {"campaign_id": "CP-1024", "date": "2026-10-31", "roi": 1.8, "target_roi": 1.5, "spend": 3200, "gmv": 5760, "impressions": 412000, "clicks": 9870, "cvr": 0.031}
ToolMessage          {"status": "ok", "order_id": "BO-001", "adgroup": "商品卡成交-老客", "tier": "aggressive", "start_date": "2026-11-01", "days": 2, "total": 800}
AIMessage            双11-美妆拉新 10 月 31 日 ROI 为 1.8,高于目标 1.5,当天花费 3200 元、成交 5760 元。已为「商品卡成交-老客」(AG-002)下了 aggressive 档追投,2026 年 11 月 1 日起投两天,日预算 400 元,预付 800 元,追投单号 BO-001。
State.seen_adgroup_ids  ['AG-001', 'AG-002', 'AG-003']
State.orders            {'AG-002:2026-11-01': {'status': 'ok', 'order_id': 'BO-001', 'adgroup': '商品卡成交-老客', 'tier': 'aggressive', 'start_date': '2026-11-01', 'days': 2, 'total': 800}}

从读回的第一段快照可以看到,一切都和停住前一致:之前搜索过的信息依然健在,白名单也完好无损,且下一跳 next 指向的仍然是 tools 节点。这便是“关了进程还要续”的核心魅力——我们依仗的不再是某一次短暂 invoke 的返回值,而是持久化的 SQLite 文件。在你输入“确认”放行后,被拦下的报表回灌消息、追投成功的结果,以及大模型给出的终答才终于得以呈现;同时,State.orders 字典里也多出了 AG-002:2026-11-01 这个键。值得一提的是,这两条 ToolMessage 都是在确认发生后,tools 节点从头重跑时才正式被追加进 messages 列表的。终答里播报的 ROI 与本次报表的回灌自然一致,而 adgroup_id 则始终源于前几轮的历史搜索。

故障现象排查思路
--resume 报错提示没有相关文件尝试先不带参数运行一次代码,等它跑出停住结果并正常退出后再试
读回来的快照是空的,像是新开了一轮核对 thread_id 拼写是否一致;确认是否误传了新的 messages 参数
确认后依然报错回灌「不在本次搜索结果里」检查 seen_adgroup_ids 是否成功提升进了 State 结构,还是依然驻留在模块级变量中
用户尚未输入确认,系统就已经自动扣款追投重点检查代码中写追投单的操作,是否不小心越过了 interrupt() 拦截线
用户确认一次,系统却生成了两笔相同的追投单确认 orders 是否成功加进了 State;排查节点重跑时,是否在校验逻辑中漏掉了“存在即返回结果”的幂等性处理
在 Navicat 里面看到的 value 仍然是一堆二进制乱码检查 Navicat 是否连错了旧版文件;官方版本的确默认使用 BLOB,需要确认运行的确实是基于 TextSqliteSaver 改造的那版代码
第一次运行就报错 disk I/O极有可能是 Navicat 还霸占着连接锁;请将同级目录下的 -wal / -shm 临时文件一并删掉再重新跑

有了持久化落盘的 SQLite 账本,Checkpointer 的能力其实才露出一角。下一篇,我们将在这份落盘快照的基础上更进一步:不再只是被动续跑,而是倒序查阅历史、在任意 superstep 断点重跑,甚至人工改写状态、分叉出一条平行的新路径。而在高层用法里,这一套同样适用:人工确认会被统一挂载到 Middleware 上,底层依托的依然是这个机制(见日常入口篇)。


–views
Share this post on:

Previous Post
LangGraph:用 create_agent 挂上人确认
Next Post
LangGraph:用 checkpointer 停住,确认之后再续