LangGraph 取消运行后状态为什么丢失?Streaming、Checkpoint 与恢复一致性实战
本文基于 Python 3.11.15、LangGraph 1.2.9、langgraph-checkpoint-sqlite 3.1.0 与本地 AsyncSqliteSaver。17 个案例不调用模型或外部 API。关闭本地 async iterator 用于模拟客户端在第三个可见更新后停止消费,不代表所有 Agent Server、反向代理或分布式 Checkpointer 的取消语义。
适合谁读
- ● 正在处理 LangGraph 流式取消、客户端断线和 Checkpoint 恢复一致性的开发者。
- ● 需要判断取消发生在哪个持久化边界、哪些状态已经提交的后端与平台工程师。
- ● 准备为长任务增加幂等恢复、状态审计和故障注入测试的 Agent 团队。
LangGraph 取消运行后状态为什么丢失?Streaming、Checkpoint 与恢复一致性实战
用户已经在页面上看到了半段回答,点击“停止生成”后,这半段内容却在下一次刷新或发送消息时消失了。这个现象很容易被归因于前端状态管理,也容易被误判为 async durability 不可靠。但我把问题缩小到 LangGraph 1.2.9、Python 3.11.15 和本地 SQLite Checkpointer 后,得到的结论更具体:
前端已经接收到的 Stream,不等于 LangGraph 已经提交的 Checkpoint。真正决定状态能否恢复的,不只是 sync、async 或 exit,而是这段进度有没有跨过一个完成的 Graph Step,也就是节点是否已经返回了状态更新。
我用两种图结构、四种 durability 和两种退出方式跑了 16 组流式实验,再补了一组 interrupt() 恢复实验。完整运行时,所有组合都是 6 个可见片段、6 个 checkpoint 片段;在第三个片段后取消时,单个长节点的四种 durability 都出现了“UI 看到 3 个,Checkpoint 保存 0 个”,而把进度拆成独立 graph step 后,四种模式都恢复到了 3 个。interrupt() 的结果也说明了另一条边界:它确实保存了此前完成的状态,但恢复时会从被中断节点的开头重新执行,所以 interrupt() 之前的代码跑了两次。
这篇文章不讨论模型好坏,也不依赖任何 LLM。所有文本片段、SQLite 写入、事件日志和断言都来自可重复执行的本地 Fixture。核心问题只有一个:当用户取消一个正在流式运行的 LangGraph 任务时,怎样让“用户最后看到的内容”和“系统下一次能恢复的权威状态”保持一致?
测试任务、环境与边界
我没有直接拿一个聊天模型做实验,因为模型响应速度、Provider 流式协议、网络抖动和 Token 批次都会引入额外变量。实验使用六段固定文本:
LangGraph | streams | visible | progress | before | checkpoint.
每段之间等待 60 毫秒。用户侧在看到第三段后关闭异步流,用来模拟浏览器断开、客户端停止消费或应用主动取消后的最小行为。实验不需要 API Key,也不会调用 LangSmith、OpenAI 或任何外部服务。
测试环境固定为:
| 项目 | 版本或口径 |
|---|---|
| Python | 3.11.15 |
| LangGraph | 1.2.9 |
| langgraph-checkpoint-sqlite | 3.1.0 |
| Checkpointer | AsyncSqliteSaver |
| 外部模型调用 | 0 |
| 外部 API 调用 | 0 |
| 流格式 | version="v2" |
| 流模式 | custom + updates |
| 取消点 | 第 3 个可见片段后 |
| Durability | default、sync、async、exit |
四种 durability 中,default 没有显式传值。LangGraph 当前参考文档说明它默认采用 async:已完成步骤的变化在下一步执行时异步持久化;sync 会在下一步开始前完成持久化;exit 则在 Graph 退出时持久化。这个定义很重要,因为它描述的是已形成的状态变化何时写入,并没有承诺节点内部任意一段流式输出都会自动变成 Graph State。
我设计了三组任务。
第一组是“单个长节点”。节点内部通过 get_stream_writer() 连续发出六个 custom 片段,直到最后才一次性返回 emitted_chunks 和 final_text。这接近很多聊天应用的写法:一个节点负责整段模型生成,前端持续显示 Token,但 Graph 只在节点结束时拿到完整返回值。
第二组是“分步节点”。每次只追加一个片段并返回状态,然后通过条件边再次进入同一个节点。它看起来更啰嗦,也会产生更多 checkpoint 和 write 记录,但每个可见片段都对应一个完成的 Graph Step。
第三组使用 interrupt()。prepare 节点先返回待审批对象,approval 节点在 interrupt() 前记录一次副作用标记,暂停后使用同一个 thread_id 和 Command(resume=True) 恢复。这个实验不是为了比较速度,而是验证“暂停保存”和“节点原地续跑”是不是一回事。
本次没有测试 LangSmith Agent Server 的 disconnect_mode、远程 Postgres Checkpointer、浏览器代理断连、多个进程竞争同一 Thread,也没有调用真实模型的 Token Stream。因此,文章能证明本地 LangGraph Runtime 与 SQLite Checkpointer 的状态边界,不能把结论扩大成所有托管部署的协议一致性结论。

官方持久化模型:Checkpoint 写在 Super-step 边界
LangGraph 官方持久化文档把 Checkpoint 定义为某个 Thread 在执行过程中的状态快照,并明确说明完整 StateSnapshot 形成于 Super-step 边界。运行中也可能存在 per-task pending writes:同一 Super-step 中,如果某个节点已经成功完成,而另一个节点失败,成功节点的写入可以被保留,恢复时不必重新计算;但这些 task writes 不等于一个可用于任意时间点恢复的完整 Checkpoint。
这一区分解释了很多“前端看到、数据库却没有”的现象。
Stream 是一个交付通道。stream_mode="custom" 可以让节点内部随时向消费者发送自定义进度;messages 可以发送模型 Token;updates 可以在节点完成后发送状态增量;checkpoints 则在 Checkpoint 创建时发送事件。它们可以发生在不同时间,也承担不同责任。
Checkpoint 是权威恢复状态。下一次调用使用相同 thread_id 时,Checkpointer 读取的是已经提交的状态,而不是浏览器内存里最后渲染的字符串,更不会自动知道用户已经读到了哪个 Token。
因此,一个长节点可能存在三个同时成立的事实:
- 模型或工具仍在节点内部运行;
- 前端已经收到了若干 Stream 事件;
- 节点尚未返回,Graph State 还没有这批更新。
此时点击停止,前端终止显示没有问题;真正的问题是,产品是否把“最后显示的 Stream”误当成了“已经持久化的会话历史”。如果下一次渲染以后端 Checkpoint 为准,界面就会回到最后一个完成步骤。
官方 Issue #5672 描述的正是这种用户体验:流式内容已经显示,取消后重新同步,后台仍只有上一个 Checkpoint,于是可见内容消失。该 Issue 最初报告的是 LangGraph Platform / API 0.3.31。本文没有声称复现了同一服务端实现缺陷,而是在 1.2.9 的本地 Runtime 上验证了更底层的状态边界:未完成节点发出的自定义 Stream 不会因为消费者看见了它,就自动进入节点返回值或 Checkpoint。

实验一:单个长节点取消后,3 个可见片段全部未进入 Checkpoint
长节点的核心代码很短:
async def long_streaming_node(state: StreamState) -> StreamState:
writer = get_stream_writer()
emitted = []
for token in TOKENS:
emitted.append(token)
writer({
"kind": "visible_chunk",
"token": token,
"visible_text": "".join(emitted),
})
await asyncio.sleep(0.06)
return {
"status": "completed",
"emitted_chunks": emitted,
"final_text": "".join(emitted),
"step_index": len(emitted),
}
如果让它自然完成,default、sync、async、exit 四种组合都得到相同业务结果:消费者收到 6 个片段,get_state() 读取到 6 个片段,final_text 完整,UI 与 Checkpoint 一致。不同之处主要出现在数据库写入节奏:default、sync、async 的 SQLite 中记录了 3 个 Checkpoint 和 10 条 Writes;exit 只有 1 个最终 Checkpoint,没有中间 Writes。这个差异符合 durability 的设计目的,但不能被理解成某种性能排名。
当消费者在第三个 custom 片段后关闭流,结果发生了分裂:
| Durability | UI 可见片段 | Checkpoint 片段 | 丢失片段 | 是否一致 |
|---|---|---|---|---|
| default | 3 | 0 | 3 | 否 |
| sync | 3 | 0 | 3 | 否 |
| async | 3 | 0 | 3 | 否 |
| exit | 3 | 0 | 3 | 否 |
四种模式都没有保存节点内部的 emitted。原因不是 sync 没生效,而是 sync 没有一个已经完成的节点更新可以同步提交。节点准备在循环结束后返回状态,但第三段时运行已经被关闭,返回语句从未执行。对 Graph 来说,visible_text 只存在于 custom event 中,不是 State Update。
SQLite 仍然不是空的。default、sync、async 的取消案例各有 2 个 Checkpoint 和 7 条 Writes,说明 Thread 输入和运行基础信息已经写入;exit 有 1 个 Checkpoint。可这些记录里没有三段文本,因为节点没有把它们返回给 State。这一点比“数据库有没有写”更重要:写入存在,不代表用户正在看的业务字段已经成为权威状态。
这组失败案例直接否定了一个常见修复:“把 durability 从 async 改成 sync 就好了。”sync 能缩短完成步骤进入持久层的时间窗口,却不能把节点局部变量变成 Checkpoint。只改参数,不改状态边界,用户依然会看到回滚。

实验二:把进度变成 Graph Step 后,取消时可以恢复 3 个片段
第二个图没有在一个节点里完成全部六段,而是每次只处理一段:
async def durable_step(state: StreamState) -> StreamState:
index = int(state.get("step_index", 0))
chunks = list(state.get("emitted_chunks", []))
chunks.append(TOKENS[index])
return {
"status": "running" if index + 1 < len(TOKENS) else "completed",
"emitted_chunks": chunks,
"final_text": "".join(chunks),
"step_index": index + 1,
}
节点返回后,条件边检查 step_index。没到 6 就再次执行 durable_step,达到 6 才进入 END。消费者不再把任意 custom event 当作权威内容,而是读取每个 updates 事件中的已提交状态增量。
完整运行时,四种 durability 仍然全部得到 6/6。default、sync、async 每个案例产生 8 个 Checkpoint 和 35 条 Writes,比长节点明显更多。这个写放大不是缺陷,它是把恢复粒度从“一整段回答”缩小成“一个片段”的直接成本。生产环境不应该真的为每个 Token 建一个 Graph Step;这里故意使用极小粒度,只为把边界测清楚。
第三次更新后关闭流,结果变成:
| Durability | UI 可见片段 | Checkpoint 片段 | 丢失片段 | 是否一致 |
|---|---|---|---|---|
| default | 3 | 3 | 0 | 是 |
| sync | 3 | 3 | 0 | 是 |
| async | 3 | 3 | 0 | 是 |
| exit | 3 | 3 | 0 | 是 |
default、sync、async 都留下 4 个 Checkpoint 和 21 条 Writes。exit 的表现值得单独说明:它在流关闭、Graph 退出时保存了当前状态,因此也得到 3 个片段,但数据库只有 1 个 Checkpoint 和 5 条 Writes。不能据此写成“exit 平时也会逐步保存”;本次看到的是取消导致 Graph 退出时的最终提交。若进程被强杀、事件循环没有完成退出清理,结果可能不同,这不在本地 Fixture 的证明范围内。
这组对照说明,恢复一致性来自两个条件:
- 用户看到的进度已经被节点返回为 State Update;
- Runtime 有机会把该完成步骤写入 Checkpointer,或者在退出时完成提交。
把进度拆成 Graph Step 是最直接的办法,但不是唯一办法。长任务还可以把业务进度写入独立事件表、任务表或对象存储,由 run_id + sequence 形成追加日志;Graph Checkpoint 只保存最后确认的 sequence。关键不是“所有 token 都塞进 checkpoint”,而是产品必须定义哪个存储是可见进度的权威来源。

sync、async、exit 到底改变了什么
在这次实验里,durability 影响的是写入时机和记录数量,不是状态语义。
sync 的承诺最强:完成步骤的状态先持久化,再开始下一步。它适合审批、支付、生产写入前的关键边界,代价是每一步都等待存储完成。若 Checkpointer 延迟高,Graph 吞吐会直接受到影响。
async 是默认模式:已完成步骤的状态在下一步运行时异步写入。它通常能在持久性和吞吐之间取得平衡,但进程突然退出时,后台写任务是否已经完成会形成一个窗口。Issue #5672 在 2026 年的讨论中也有人指出,取消路径上的异步清理与 pending write 刷新可能影响结果。不过本文没有修改 Runtime,也没有模拟进程在清理任务完成前被杀,因此只把它作为官方仓库中的问题线索,不把社区分析写成本地已验证结论。
exit 只在 Graph 退出时持久化。适合失败后可以整体重跑、无需中间恢复的短任务,不适合把每一步都当成审计事实的流程。我们的长节点完整运行和分步完整运行都只产生一个最终 Checkpoint;分步取消也在退出时得到 3 个片段。若应用要求断电、进程崩溃或容器被强制回收后仍恢复到最后一个小步骤,不能只依赖 exit。
把三种模式放回生产设计,可以得到一个更实用的选择表:
| 场景 | 推荐思路 | 原因 |
|---|---|---|
| 高风险审批前状态 | sync + 独立节点 | 下一步开始前确认写入 |
| 普通多步骤 Agent | 默认/async + 幂等节点 | 吞吐和恢复平衡 |
| 可整体重跑的短计算 | exit | 减少中间持久化 |
| Token 级显示 | Stream + 应用事件日志 | 不应为每个 Token 建 Checkpoint |
| 长工具任务进度 | 业务任务表 + sequence | 跨进程、跨页面恢复 |
| 不可逆副作用 | 幂等键 + 权威业务库 | Checkpoint 不能替代业务事务 |
所以,排查“停止生成后内容消失”时,正确顺序不是先换 durability,而是先回答三个问题:
- 消失的内容属于 Stream Event 还是 Graph State?
- 它所在的节点是否已经返回?
- 用户刷新时读取的是 Checkpoint、业务事件表,还是浏览器内存?
只有第一步是 State Update,第二步跨过完成边界,第三步从同一权威来源读取,durability 参数才有意义。

interrupt() 和普通取消不是一回事
普通取消的含义通常是“停止当前运行”。interrupt() 的含义是“在一个可恢复位置暂停,等待外部输入”。官方文档要求配置 Checkpointer 和稳定的 thread_id;恢复时使用同一个 Thread,并把 Command(resume=...) 的值作为 interrupt() 的返回值。
实验图先执行 prepare:
async def prepare(state):
return {
"prepared_payload": "draft-action-v1",
"status": "prepared",
}
随后进入审批节点:
def approval(state):
side_effect_events.append("before_interrupt")
approved = interrupt({
"question": "Approve draft-action-v1?",
"request_id": state["request_id"],
"prepared_payload": state["prepared_payload"],
})
side_effect_events.append("after_interrupt")
return {
"approved": bool(approved),
"status": "approved" if approved else "rejected",
}
第一次调用停在 interrupt()。读取 Checkpoint 时,prepared_payload 已经存在,下一节点位置也被保存。使用同一个 thread_id 恢复后,最终状态变为 approved。
但事件计数非常关键:
| 事件 | 次数 |
|---|---|
before_interrupt | 2 |
after_interrupt | 1 |
这和官方文档一致:恢复不是从 Python 调用栈中 interrupt() 的下一行继续,Runtime 会从该节点开头重新执行,然后用已提供的 resume value 匹配 interrupt()。所以它保存的是 Graph 状态和执行位置,不是整个进程的内存栈。
这条规则会直接影响生产副作用。如果在 interrupt() 前先调用“创建订单”“发送邮件”或“扣减库存”,恢复时可能重复执行。安全写法有三种:
- 把副作用放到
interrupt()之后; - 把副作用拆成独立节点,让它拥有明确 Checkpoint 边界;
- 必须提前执行时,使用稳定的
operation_id做幂等 upsert,而不是每次创建新记录。
官方 Issue #6792 和 #7361 进一步说明,子图、特定 checkpoint 和 replay/resume 的组合仍需要逐版本验证。本文不把这些 Issue 当成本地结果,但它们提醒开发者:只要流程包含嵌套子图、多个中断或指定 checkpoint_id,就必须用真实任务测试哪些节点会重跑,不能只凭“已经有 Checkpointer”推断副作用只执行一次。

生产修复:把“可见进度”和“权威状态”分开设计
一个可靠的取消流程不应该只有 running 和 completed 两个状态。用户点击停止时,系统可能处在模型生成、工具调用、外部 API 已成功但本地结果未写入、Checkpointer 正在刷盘等不同阶段。简单地把任务标成 cancelled,可能会掩盖已经发生的副作用。
更稳妥的状态机至少包括:
queued
-> running
-> cancel_requested
-> cancelled_confirmed
-> completed_before_cancel
-> externally_completed_local_pending
-> cancel_failed
-> failed
-> completed
cancel_requested 表示用户意图,不等于执行已经停止。若正在调用不可取消的第三方 API,服务端必须等待结果或查询对方状态,确认后再进入最终状态。对支付、工单、邮件、文件上传等任务,externally_completed_local_pending 尤其重要:外部世界已经改变,本地 Checkpoint 却可能还没记录,直接重试会产生重复操作。
我建议把数据分成三层。
第一层是 UI Stream。它追求低延迟,可丢弃,可在页面上即时展示。每个事件至少带 run_id、sequence、event_type 和时间戳,避免前端只按字符串拼接。
第二层是应用事件日志。它记录用户已经看到且产品承诺保留的消息段、工具进度和取消请求。可以是 append-only 表:
run_events(
run_id,
sequence,
event_type,
payload_hash,
safe_payload,
created_at,
UNIQUE(run_id, sequence)
)
这里不一定保存每个 Token。可以每 200–500 毫秒合并一次,或按句子、工具阶段、节点进度写入。它的职责是页面刷新后复原“用户最后看到了什么”。
第三层是 LangGraph Checkpoint。它保存可恢复的 Graph State、下一节点、task writes 和 Thread 历史。它适合恢复工作流,不应该被迫承担所有前端渲染事件,也不能替代业务订单、审批和任务表。
取消流程可以这样实现:
- 前端发送带
run_id的 cancel 请求,不只关闭 SSE。 - 后端把任务原子更新为
cancel_requested,记录请求时间和请求者。 - Runtime 收到取消信号,停止可取消节点;不可取消工具进入状态确认。
- 关闭运行前,应用事件日志写入最后确认 sequence。
- 已完成 Graph Step 由 Checkpointer 按 durability 提交。
- 后端生成
cancelled_confirmed或其他最终状态。 - 前端重新加载时,先读取权威 Thread State,再按 run sequence 合并应用事件,不信任单个浏览器内存副本。
这里必须定义去重规则。相同 run_id + sequence 只能写一次;工具副作用使用 operation_id;恢复或重试前先查权威业务结果。否则即使 Stream 和 Checkpoint 一致,副作用仍可能重复。

什么时候不应该把每个进度都拆成节点
第二组实验的 3/3 很漂亮,但不能机械推广成“每个 Token 一个节点”。这样会制造大量 Checkpoint、序列化和数据库写入。在本次固定六段实验中,default、sync、async 的分步完整运行产生 8 个 Checkpoint、35 条 Writes,而长节点完整运行只有 3 个 Checkpoint、10 条 Writes。数字只说明本 Fixture 的写放大,不代表生产性能倍率,但方向非常明确:恢复粒度越细,持久化成本通常越高。
适合拆节点的,是具有业务语义的边界:
- 查询计划已经确定;
- 某个工具调用完成;
- 文档的一批页面解析完成;
- 审批材料已经生成;
- 外部对象已创建并拿到 ID;
- 长任务完成一个可独立重试的分片。
不适合拆节点的,是纯显示细节:
- 每个模型 Token;
- 动画进度百分比;
- 没有业务意义的字符块;
- 可以从结果重新推导的临时 UI 状态。
对于长文本生成,可以把 Token 继续走 Stream,同时每完成一个段落或消息对象就写应用事件表;节点完成后再把完整 AI Message 返回 Graph State。用户取消时,未完成段落可以标成 aborted_partial,明确告诉下一次会话它不是完整模型回复,而不是悄悄丢失或伪装成最终答案。
对于长工具任务,可以让工具自身维护 task_id 和阶段状态。LangGraph State 只保存 task_id、最后确认阶段和恢复策略;页面通过任务 API 读取实时进度。这样即使 Graph Runtime 重启,任务进度也不会只存在于某个节点局部变量里。
修复完成后,必须怎么回归验证
修复不能只看一次正常结束。至少建立四条自动化用例,并且把“前端最后可见 sequence”和“后端最后权威 sequence”同时写入断言。
第一条是自然完成:运行产生六个进度事件,页面显示六个,应用事件表保存六个,Graph State 也应在节点完成后包含完整结果。它确认正常路径没有因为新增取消逻辑而丢数据。
第二条是在固定 sequence 取消:例如收到第三个事件后发送 cancel 请求,等待后端返回最终状态,再重新创建客户端读取同一 thread_id。重新加载后的可见内容必须符合产品约定:要么明确保留三段并标成 partial,要么明确不保留并在 UI 中删除,不能先显示三段、刷新后无说明地回退。
第三条是取消与外部副作用竞争:让工具在取消请求前后分别完成,检查 operation_id 是否只产生一个订单、工单或文件对象。测试重点不是 Graph 返回什么,而是外部系统有没有重复事实。
第四条是进程级故障:在已完成一个业务步骤后强制结束 Worker,重新启动后用相同 Thread 恢复。这里要分别跑 sync、默认 async 和 exit,记录最后可恢复步骤,而不是根据参数说明推测。
每条用例还应核对五个 ID:thread_id 标识长期任务,run_id 标识本次尝试,sequence 标识可见事件顺序,checkpoint_id 标识恢复快照,operation_id 标识外部副作用。日志若只能看到一个 thread_id,取消、重试和恢复混在一起后仍然无法审计。
线上监控也不应只统计“取消次数”。更有用的指标包括:取消请求到确认停止的延迟、取消后 UI/Checkpoint sequence 差值、partial message 恢复成功率、重复副作用拦截次数,以及恢复后被重新执行的节点数。只有这些指标长期为零或处于可解释范围,才能说取消流程真正稳定。
实验失败、限制与不能推广的结论
这次实验至少保留了三类失败和边界。
第一,单个长节点的四个取消案例全部失败:UI 3、Checkpoint 0。它证明换 durability 不能弥补未完成节点没有返回状态的问题。
第二,最初的测试加载器使用 importlib 动态载入实验模块,却没有先注册到 sys.modules,Python 3.11 的 dataclass 类型解析因此报错。修复测试 Harness 后,回归测试通过。这个错误与 LangGraph 无关,所以没有被包装成框架缺陷,但它保留在开发记录中,避免把“测试脚本写错”误报为运行时问题。
第三,本机曾有多个遗留 Astro 开发进程占用大量文件描述符,第一次隔离安装出现 Too many open files in system。关闭项目遗留进程后,Python 3.11 环境安装成功。这同样不是 LangGraph 结论,因此正式实验只采用清理后的 3.11 运行。
此外,本次没有验证以下内容:
- Agent Server 的取消接口是否会额外刷写 partial state;
disconnect_mode="continue"与"cancel"的服务端差异;- Postgres Checkpointer 在进程崩溃时的提交窗口;
- 多节点并行 Super-step 的 pending writes;
- 子图嵌套中断的所有重放路径;
- 真实模型 Token Stream 与 Provider 中断信号;
- 反向代理断开但后端继续运行的情况;
- 每种 durability 的性能、吞吐和成本差异。
因此,不能写“LangGraph 1.2.9 仍有官方 Issue #5672 的同一个 Bug”,也不能写“stepwise 方案解决所有取消丢状态”。准确说法是:在本地 1.2.9 Runtime 中,我们复现了相同用户体验背后的基础边界;产品仍需根据托管 Runtime、Checkpointer 和业务事件存储做二次验证。

最终决策:不要让 Stream 充当数据库
如果系统只是一个允许重新生成的个人 Demo,取消后丢失未完成回答可能可以接受。前端清空 partial response,下一次从最后 Checkpoint 继续,逻辑简单,也避免保存不完整内容。
如果系统面向客服、研究、财报、审批、长工具任务或任何需要审计的场景,就不能让用户看到一套状态、后端恢复另一套状态。此时至少要做到:
- 明确区分 Stream Event、Graph State 和业务事实;
- 在有业务意义的步骤返回 State Update;
- 为已承诺保留的可见进度建立应用事件日志;
- 用稳定
thread_id、run_id、sequence和operation_id关联各层; - 取消使用
cancel_requested到最终状态的确认流程; interrupt()前的副作用保持幂等,或拆到独立节点;- 用真实断连、进程重启和同一 Thread 的后续消息做回归测试。
本次实验最有价值的结论,不是某个参数“最好”,而是把一个模糊问题拆成了可验证的边界:
Stream 负责让用户尽快看到,Checkpoint 负责让 Graph 以后能恢复,业务存储负责证明外部世界发生了什么。三者可以互相引用,但不能互相冒充。
当用户点击停止时,先决定你要保留的是显示片段、工作流状态,还是已经发生的业务事实。只有把这三层分别设计清楚,取消、恢复和刷新后的一致性才不会依赖运气。
FAQ
LangGraph 设置 durability="sync" 能避免取消后状态丢失吗?
只能保证已经完成的 Graph Step 在下一步开始前持久化。若节点内部已经 Stream 了内容但尚未返回 State Update,sync 没有这部分状态可写。本地四种 durability 的长节点取消案例都是 UI 3、Checkpoint 0。
interrupt() 是否会从暂停的那一行继续执行?
不会。官方文档和本地实验都显示,被中断节点在恢复时从开头重新执行,interrupt() 前的代码会再次运行。副作用应当幂等、放到中断后,或拆成独立节点。
应不应该把每个 Token 都写入 Checkpoint?
通常不应该。Token 级 Checkpoint 会造成大量写入。更常见的做法是 Token 走 Stream,段落或消息走应用事件日志,节点完成后把完整消息写入 Graph State。
exit durability 为什么在分步取消实验中也保存了 3 个片段?
因为关闭异步流使本次 Graph 退出,Runtime 有机会在退出路径提交当前状态。它不代表运行中每一步都已持久化,也不能证明进程被强杀时仍会保存。
前端本地保存 partial response 能解决问题吗?
可以缓解当前标签页的显示回滚,但页面刷新、换设备和多实例同步仍需要后端权威存储。若产品承诺保留用户已经看到的内容,应把它写入应用事件日志,而不是只放浏览器内存。
官方资料与继续阅读
- LangGraph Persistence 官方文档
- LangGraph Streaming 官方文档
- LangGraph Interrupts 官方文档
- LangGraph Durability 参考
- 官方 Issue #5672:取消后未 Checkpoint 的 Streamed State 丢失
- 官方 Issue #6792:子图 Interrupt 恢复时任务重复执行
- 官方 Issue #7361:指定 Checkpoint Resume 与 Replay 边界
- LangGraph Checkpointer 怎么选:Memory、SQLite、Redis 与生产边界
- LangGraph Human-in-the-loop:多智能体审批流怎么做?
- LangGraph 多智能体失败恢复:Tool Error、Timeout 与重试策略
- LangGraph Observability:如何追踪每个 Agent 的决策路径?
继续按生产级 LangGraph 路线读,不再重复看泛入门
这一类文章统一沉淀到 LangGraph 专题页,按状态隔离、Checkpointer、HITL、失败恢复、Observability、Supervisor/Worker、Subgraph 和 Memory 顺序阅读。
下一步阅读
返回专题入口 →
LangGraph Checkpointer 实战:MemorySaver、SQLite、Redis 怎么选?
LangGraph Checkpointer 实战:实战讲解 LangGraph Checkpointer 状态持久化选型,包括 MemorySaver / InMemorySaver、SQLite、Redis、Postgres 的适用场景、优缺点、thread_id 设计、状态恢复、Human-in-the-loop、失败恢复和生产部署建议。
LangGraph Human-in-the-loop 实战:多智能体审批流怎么做?
LangGraph Human-in-the-loop 实战:实战讲解 LangGraph 多智能体系统中的 Human-in-the-loop 审批流设计,包括 interrupt 暂停执行、人工审批、拒绝回滚、状态恢复、Checkpointer 和 Supervisor / Worker 协作,帮助开发者构建可控、可审计的生产级 AI Agent。
LangGraph Subgraph 实战:子图、Worker State 与多 Agent 局部状态怎么设计?
LangGraph Subgraph 实战:实战讲解 LangGraph Subgraph 子图设计,包括父图与子图的边界、Worker State 局部状态、共享 State、状态传递、Supervisor / Worker 拆分、多 Agent 子图协作和生产环境中的状态隔离策略。
LangGraph Observability 实战:如何追踪每个 Agent 的决策路径?
LangGraph Observability 实战:实战讲解 LangGraph 多智能体系统中的 Observability 设计,包括 trace_id、run_id、thread_id、node_name、Agent 决策路径、Tool 调用日志、错误追踪、耗时统计和生产环境可观测性,帮助开发者定位 AI Agent 执行过程中的异常与性能瓶颈。
小白
Full-Stack AI Engineer
小白,全栈 AI 工程师,持续构建生产级 Agent 系统、产品工具与独立软件资产。
了解小白与 XBSTACK →
参与讨论
问题、验证与勘误
登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。