在上一篇通过 Checkpointer 实现了暂停的实践中,我们在订房前调用 interrupt() 成功实施了拦截,并在用户确认后通过 Command(resume=...) 恢复了执行。然而,当时的两次 invoke 调用均紧凑地写在同一个 main() 函数内,中间的进程根本无法中断。原因在于 InMemorySaver 的本质是驻留在内存中的字典,一旦进程结束、对象被回收,所有状态记录随之丢失。
回顾我们在手搓上线那篇文章中的设计,审计日志是被持久化写入到 audit/<run_id>.json 文件中的,因此即使关闭了进程,后续依然可以通过 python agent.py --confirm 读回状态并接着跑。为了在现代框架中对齐这种工业级的体验,本文要解决的核心痛点就是:让状态记录彻底脱离内存的束缚。我们将实现第一次运行在节点停住时直接退出,随后第二次启动一个全新的 Python 进程,依然能够从容完成续跑。
整张图的拓扑结构无需做任何修改。interrupt() 依然放置在 book_hotel 函数内部,确认后同样通过 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(),得到的将是一个全新的空字典。此时,即使你传入名为 xian-suite-2026-10-01 的 thread_id,对新字典而言这也只是一个陌生的键:之前搜索过的酒店、停滞的节点状态、以及 interrupt() 抛出的订房信息,全都无迹可寻。
正因如此,上一篇的两次 invoke 必须写在同一次 main() 运行内。只有这样才能在一个连续的进程里勉强讲清楚“停住再续”的概念,但这显然无法对齐手搓时代那句硬气的“关了进程再 --confirm”。要想彻底断开进程还能接续,状态记录就必须脱离内存,实现持久化落盘。
上一篇还留下了一个关键的伏笔:落盘存储的其实是图上定义的那个 State 结构,而当时它仅仅包含 messages 字段。至于 seen_hotel_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_hotel_ids 对应着白名单,orders 记录着订单,而特殊的 __interrupt__ 则保存着停住时抛出的那份待定信息。这里的 checkpoint_id 依旧是由框架自动生成的,本篇中我们只需取最新的一笔即可;只有当你想时间回溯到更早的某一步时,才需要在 config 中带上特定的 ID。
官方原生的 SqliteSaver 默认将 value 和 checkpoint 列设为 BLOB 类型,内部存储的是 msgpack 格式。如果直接用 Navicat 强行打开,看到的是一堆无法阅读的二进制乱码,这显然对不上手搓时代那份可以直接视检的 JSON 日志。为了解决这个问题,我们自定义了 TextSqliteSaver。它继承自 SqliteSaver,我们并没有去魔改底层的 put / get 逻辑、版本号校验或是中断状态管理,而仅仅是改变了序列化写出的格式:将列类型调整为 TEXT,内容以 JSON 字符串形式落盘。续跑时,依然交由 Saver 将其反序列化读回。如此一来,打开 seen_hotel_ids 那一行,就能清晰地看到 ["HT-001", "HT-002", "HT-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_hotel_ids 和 ORDERS 是写在模块顶部的,在同一次运行里完全没问题:search_hotels 搜完往集合里添加,book_hotel 再读取。但当新进程启动再次 import 时,这两个变量虽然还在,值却会被无情地重置为空。
正如上一篇所述,从 interrupt() 续跑时,tools 节点会从头再走一遍,而不是从 interrupt() 所在的那一行接着往下执行。这意味着 book_hotel 一进来就会先去查验白名单。如果白名单还停留在模块变量里,新进程拿到的就是一个空集合。此时,哪怕 messages 历史记录中明明已经包含了搜索结果,工具也会回灌报错:「hotel_id 不在本次搜索结果里」。订单数据同理:由于代表幂等键的历史订单不在文件中,节点重跑时就可能会导致同一笔订单被重复写入。
需要牢记的是,Checkpointer 落盘存储的仅仅是图上的那个 State 对象。不在 State 里的数据,自然不会出现在文件中,新进程也就无从读回。因此,白名单和订单必须被收编为 State 的字段,与 messages 同进同退。
class State(TypedDict):
messages: Annotated[list, add_messages]
seen_hotel_ids: list[str]
orders: dict[str, dict]
在这里,messages 依然使用 add_messages 聚合器来实现新消息的追加,而不是整表覆盖。而 seen_hotel_ids 和 orders 由于没有指定 reducer,每次 update 都会直接替换上新的完整数据。注意,在第一次调用 invoke 时,必须带上空列表和空字典,这两个字段才会被真正初始化;如果不写,后续读取 state.get("seen_hotel_ids") 就会得到 None。
通常情况下,@tool 装饰的函数默认只负责回灌。当函数返回一个普通的字典时,ToolNode 会将其封装成一条 ToolMessage,并追加到 messages 列表中。但现在白名单等状态字段并不在这条回灌信息的结构内,必须另辟蹊径。因此,search_hotels 和 book_hotel 需要改为返回 Command(update=...),以实现一边回灌消息,一边修改 State 的其它字段。
# 白名单写进 State,才会进 sqlite,新进程启动时才能精准读到。
return Command(
update={
"seen_hotel_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 注入函数中,book_hotel 就能顺利读取 state["seen_hotel_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,因为确认之后执行流回到的依然是同一轮的 book_hotel,而不是像手搓那样追加一条 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,旧文件就会被清空,等于直接作废了上一次的停滞进度。
五、对照手搓看差异
| 手搓确认与续跑机制 | 上一篇(内存存储) | 本篇(SQLite 持久化) |
|---|---|---|
audit/<run_id>.json,关了进程还在 | InMemorySaver,生命周期绑定进程 | checkpoints.sqlite,关了进程依然完好 |
python agent.py 再 --confirm | 同一次运行里两次 invoke | python agent.py 再 --resume |
| 记忆写在循环外,读回审计时一并读取 | 模块变量,新进程中被重置为空 | seen_hotel_ids 和 orders 被收编入 State |
| 确认等同于新 user 消息,模型重做推断 | Command(resume=...) 回到同一次调用 | 机制一致,仅跨越了物理进程的边界 |
在手搓框架中,审计文件主要负责记录当时的 messages 历史,而白名单和订单则另存在循环外的高级记忆模块里。执行 --confirm 续跑时,这两份数据会被一并读回,然后再人工追加一条包含“确认”的 user 消息,驱动模型再调一次 book_hotel。而在 LangGraph 的文件持久化方案里,State 就包揽了一切:对话历史、白名单和订单全部封装在同一份快照里。确认动作不再被当作新的 messages 处理,book_hotel 将直接从 interrupt() 的断点处接续完成,模型也不会因为用户的确认而被迫再做一轮多余的推断。
整体的循环拓扑并没有改变,中断拦截依然发生在 book_hotel 内部。之所以现在能够跨进程从容续跑,完全归功于状态快照和白名单双双脱离了内存的束缚。
六、跑起来看轨迹
完整代码在 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
目标依旧不变:「西安现在天气怎么样?再帮我订一间豪华套房,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']
State.seen_hotel_ids ['HT-001', 'HT-002', 'HT-003']
—— 停住 ——
{'tool': 'book_hotel', 'hotel_id': 'HT-002', 'hotel_name': '西安香格里拉', 'room_type': 'suite', 'check_in': '2026-10-01', 'nights': 2, 'total': 800, 'currency': 'RMB'}
已写入 checkpoints.sqlite(thread_id=xian-suite-2026-10-01)。进程退出。
续跑:python agent.py --resume
从停住前的信息可以看到:地点和酒店已经成功回灌,白名单也已经安稳存入 State。下一轮原本规划了天气和订房两个调用,但执行流断在了订房环节。此时既没有天气温度,也没有预订成功的提示——这说明 tools 节点并未完全跑完。由于天气工具和 book_hotel 同属于这一次的节点调用,在节点中断时,天气的回灌消息还来不及被写入 messages 列表中。停滞处打印出来的那几行信息,仅仅是通过 interrupt() 带出来供人审批的参数,绝不是模型给出的最终回答。此刻,Python 进程已经彻底结束。如果你用 Navicat 打开 writes 表,可以在 seen_hotel_ids 对应的 value 里找到那三个搜索出来的 ID;而 __interrupt__ 行中记录的,正是上面那份待确认的订房明细。
此时你可以另开一个终端,或者干脆等当前进程完全退出后,再执行:
python agent.py --resume
—— 新进程读回 ——
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']
State.seen_hotel_ids ['HT-001', 'HT-002', 'HT-003']
next=('tools',)
—— 停住(从文件读回)——
{'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": 23.4, "apparent_temperature": 26.8, "humidity": 70, "wind_speed": 2.8}
ToolMessage {"status": "ok", "hotel_id": "HT-002", "hotel_name": "西安香格里拉", "room_type": "suite", "check_in": "2026-10-01", "nights": 2, "total": 800, "currency": "RMB"}
AIMessage 西安现在的天气是 23.4 度,体感温度为 26.8 度,湿度 70%,风速 2.8 米每秒。您于 2026 年 10 月 1 日在西安香格里拉订了一间豪华套房,住了 2 晚,每晚 400 元,总价为 800 元。
State.seen_hotel_ids ['HT-001', 'HT-002', 'HT-003']
State.orders {'HT-002:2026-10-01': {'status': 'ok', 'hotel_id': 'HT-002', 'hotel_name': '西安香格里拉', 'room_type': 'suite', 'check_in': '2026-10-01', 'nights': 2, 'total': 800, 'currency': 'RMB'}}
从读回的第一段快照可以看到,一切都和停住前一致:之前搜索过的信息依然健在,白名单也完好无损,且下一跳 next 指向的仍然是 tools 节点。这便是“关了进程还要续”的核心魅力——我们依仗的不再是某一次短暂 invoke 的返回值,而是持久化的 SQLite 文件。在你输入“确认”放行后,被拦下的天气回灌消息、预订成功的结果,以及大模型给出的终答才终于得以呈现;同时,State.orders 字典里也多出了 HT-002:2026-10-01 这个键。值得一提的是,这两条 ToolMessage 都是在确认发生后,tools 节点从头重跑时才正式被追加进 messages 列表的。终答里播报的温度数据与本次天气的回灌自然一致,而 hotel_id 则始终源于前几轮的历史搜索。
| 故障现象 | 排查思路 |
|---|---|
--resume 报错提示没有相关文件 | 尝试先不带参数运行一次代码,等它跑出停住结果并正常退出后再试 |
| 读回来的快照是空的,像是新开了一轮 | 核对 thread_id 拼写是否一致;确认是否误传了新的 messages 参数 |
| 确认后依然报错回灌「不在本次搜索结果里」 | 检查 seen_hotel_ids 是否成功提升进了 State 结构,还是依然驻留在模块级变量中 |
| 用户尚未输入确认,系统就已经自动预订成功 | 重点检查代码中写订单的操作,是否不小心越过了 interrupt() 拦截线 |
| 用户确认一次,系统却生成了两笔相同的订单 | 确认 orders 是否成功加进了 State;排查节点重跑时,是否在校验逻辑中漏掉了“存在即返回结果”的幂等性处理 |
在 Navicat 里面看到的 value 仍然是一堆二进制乱码 | 检查 Navicat 是否连错了旧版文件;官方版本的确默认使用 BLOB,需要确认运行的确实是基于 TextSqliteSaver 改造的那版代码 |
| 第一次运行就报错 disk I/O | 极有可能是 Navicat 还霸占着连接锁;请将同级目录下的 -wal / -shm 临时文件一并删掉再重新跑 |
有了持久化落盘的 SQLite 账本,Checkpointer 的能力其实才露出一角。下一篇,我们将在这份落盘快照的基础上更进一步:不再只是被动续跑,而是倒序查阅历史、在任意 superstep 断点重跑,甚至人工改写状态、分叉出一条平行的新路径。而在高层用法里,这一套同样适用:人工确认会被统一挂载到 Middleware 上,底层依托的依然是这个机制(见日常入口篇)。