LangGraph 检查点丢状态的修法
节点缓存命中也必须保存增量写入,LangGraph 1.2.15 补上这条路径,并阻止写入失败后的检查点继续推进,排查时应比较持久化状态而不只看流式输出。
正文
先区分执行结果与保存结果
LangGraph 1.2.15 修复了一类容易漏诊的状态丢失问题:节点命中缓存后,结果参与了本轮运行,却没有按实际执行节点的路径保存写入,导致依赖写入历史恢复的 DeltaChannel 缺少这一段状态。它同时修复了写入或检查点保存失败后,后续检查点仍然推进的问题。对开发者的直接影响是,不能拿“页面显示过结果”证明状态已保存,也不能只把 durability 改成 sync 就认为缓存命中的恢复问题已经解决。
这次变化来自 2026 年 10 月 12 日发布的 Python 包 langgraph==1.2.15,对应官方修复记录 #9259、#9235 与 #9258。这里讨论的是 Python 运行时,不把同名 SDK、CLI 或 JavaScript 包的版本混为一谈。来源均于 2026 年 10 月 14 日打开核对,具体修复入口见文末的参考来源。
如果症状是模型请求已经结束,但程序仍在等待下一段输出,可以先读站内的 LLM 流式输出工程排查,区分传输故障与状态恢复故障。这里聚焦后一类:运行时看到的内容与通过 get_state() 或 get_state_history() 读回的内容不一致。
为什么普通字段完整,增量字段却少一段
官方 DeltaChannel 实现说明,它把写入作为增量保留,并在需要恢复时,从最近的快照与后续写入重新构建状态。它不是把每一步的完整值都复制进检查点。与之对照,普通聚合通道可以在后续检查点中保存聚合后的值。因此,同一次节点输出经过两种通道保存,遗漏写入时会出现不同结果。
把流程缩成三个节点,分别叫 prepare、cached 和 finish。第一次运行负责填充节点缓存,第二次用另一个 thread_id、相同输入再次运行。中间节点命中缓存,不执行函数体,但返回值仍应进入两条状态通道。旧路径只是把缓存结果应用或发送出来,没有像真正执行的节点那样调用 put_writes。
结果是,普通列表字段可以读回三个标签,增量字段却只读回首尾两个标签。这个缺口位于“缓存结果如何进入检查点”的环节,不是模型忘记历史,也不是聊天前端遗漏了渲染。官方 #9259 的修复让缓存命中同样保存节点写入,同时保留缓存命中的事件标记。
还有一个不能省略的边界:保存检查点写入,并不等于把相同结果再写回节点缓存。修复加入了区分缓存命中的标记,避免这次读取重新启动缓存的有效期。否则,频繁读取会改变缓存过期行为。对于接了自定义 saver 的服务,缓存命中后产生保存写入是这次修复的预期行为,不应把这条操作删掉作为“优化”。
用不调用模型的脚本验证缓存路径
先在隔离环境中固定版本,避免生产环境与排查脚本实际使用了不同依赖。下面的示例只使用内存缓存和内存 saver,不需要 API Key,也不会发起模型请求。
python -m venv .venv-langgraph-check
. .venv-langgraph-check/bin/activate
python -m pip install 'langgraph==1.2.15'
python -m pip check
验证关键不是断言 invoke() 有没有返回,而是同时证明“中间节点确实命中缓存”和“保存后的增量状态没有丢失”。将以下内容保存成 verify_cache.py 后执行。
import operator
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.cache.memory import InMemoryCache
from langgraph.channels.delta import DeltaChannel
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import START, StateGraph
from langgraph.types import CachePolicy
def collect(current, batches):
return current + [value for batch in batches for value in batch]
class State(TypedDict):
delta: Annotated[list[str], DeltaChannel(collect)]
snapshot: Annotated[list[str], operator.add]
for mode in ("sync", "async", "exit"):
executed = []
def worker(label):
def apply(_state):
executed.append(label)
return {"delta": [label], "snapshot": [label]}
return apply
builder = StateGraph(State)
builder.add_node("prepare", worker("prepare"))
builder.add_node("cached", worker("cached"), cache_policy=CachePolicy())
builder.add_node("finish", worker("finish"))
builder.add_edge(START, "prepare")
builder.add_edge("prepare", "cached")
builder.add_edge("cached", "finish")
graph = builder.compile(
cache=InMemoryCache(), checkpointer=InMemorySaver()
)
prime = {"configurable": {"thread_id": "prime"}}
check = {"configurable": {"thread_id": "check"}}
initial = {"delta": [], "snapshot": []}
graph.invoke(initial, prime, durability=mode)
graph.invoke(initial, check, durability=mode)
assert executed == ["prepare", "cached", "finish", "prepare", "finish"]
for saved in graph.get_state_history(check):
assert saved.values.get("delta", []) == saved.values.get("snapshot", [])
print(mode, graph.get_state(check).values)
这里的增量 reducer 接收一批写入,因此需要先展开 batches。不要把普通字段的二元 operator.add 直接当成 DeltaChannel 的批量 reducer。官方实现还要求 reducer 确定且不依赖写入分批方式,因为实时推进和历史回放可能使用不同批次。
本次于 2026 年 10 月 14 日做了独立的本地功能验证,环境为 Linux、Python 3.12.3,分别安装 langgraph==1.2.14 与 langgraph==1.2.15,使用 InMemorySaver 和 InMemoryCache。验证矩阵覆盖 sync、async、exit,以及 invoke()、ainvoke() 两个入口;没有进行耗时、吞吐或成功率评测。
在这个确定性案例中,1.2.14 的第二个线程读回增量列表时缺少 cached,普通列表仍包含它;1.2.15 的两条列表一致。两版中间节点均只在第一个线程执行,说明差异不来自缓存未命中。这个结果只能证明示例中的缓存写入路径,不能替代 PostgreSQL、自定义 saver 或多进程部署的验证。InMemorySaver 的数据也不会跨进程重启保留。
数据库写入失败时,不能继续保存后续检查点
另一条修复 #9235 处理的是保存失败。DeltaChannel 恢复依赖祖先链上的写入,如果某次增量写入没有落下,后面的检查点却照常保存,读回状态就可能永久缺少该段。普通字段保留了完整值,又会掩盖这个差异。
官方修复说明拆出了三个具体问题。第一,同步路径等待写入任务结束,却没有验证任务是否成功。第二,前一个检查点保存失败后,后面的保存仍然执行,形成指向未保存父检查点的链。第三,后台保存任务启动时才领取待等待的写入列表,可能把另一个检查点的写入拿走,而不是等待自己的写入。
1.2.15 把写入依赖在提交保存任务时就固定下来,并要求相关写入和前一次保存都成功后,才能保存后续检查点。失败会继续作为运行错误抛出,但线程停在具有完整写入的检查点,不再用缺口作为后续状态的基础。这是运行时的依赖与错误传播修复,不是把所有 saver 操作合并成一个数据库事务,也不是对进程崩溃恢复作出无限制保证。
可按官方 test_delta_channel_write_failures.py 的思路验证自己的 saver:让 put_writes() 或 aput_writes() 对某个指定节点的写入只失败一次;检查运行抛出错误、已保存状态没有出现两条通道分歧,再验证恢复。同步服务必须测同步入口,异步服务必须测异步入口,不能只测数据库最终有没有一条记录。
恢复输入也要区分模式。官方这组测试里,sync 与 async 使用相同线程并传入 None 恢复;exit 在该失败场景下没有提前保存可恢复的检查点,因此重新提供初始输入。不要把某个测试里的恢复方式直接套到所有业务流程。涉及写文件、发通知或变更外部系统的节点,还应单独验证重复执行的影响;修复检查点并不自动消除外部副作用。
update_state 的影响范围要读到限制条件
#9258 修复了 update_state(..., as_node="__input__") 的写入位置。旧路径把输入写入存到新生成的检查点,而增量通道读取的是祖先检查点上的写入,不读取当前检查点自身的这批写入。因此,除非恰好形成快照,状态读回时就可能缺少这次输入。
但这条故障不能扩大成“所有 StateGraph.update_state() 都丢输入”。修复记录明确限定了输入路径:原始 Pregel 图的输入通道本身是 DeltaChannel 时才会命中;StateGraph 的输入先经过起始通道,不属于这条输入通道触发路径。
修复还处理了较旧检查点上的更新,避免把已经前进线程的分支污染,并统一了消息 ID 的补齐逻辑。对于支持编辑历史状态、从历史检查点分叉或人工修改消息的应用,验证目标应包括“目标分支有这次变更”“其他分支没有这次变更”和“消息标识在重复读取后保持一致”。不能只看更新接口返回了成功。
升级时要检查运行时,也要检查 saver
建议先确认业务进程真正导入的包版本,再在依赖文件中固定并验证 1.2.15。这里确认了 1.2.14 与 1.2.15 在本地缓存案例中的差异,没有逐一测试更早版本,所以不把全部旧版本都列为已验证的受影响范围。也不要只升级名称相近的 langgraph-sdk,就认为 Python 图运行时已经更新。
python -c "from importlib.metadata import version; print(version('langgraph'))"
python -m pip install 'langgraph==1.2.15'
python -m pip check
接着检查实际使用的 checkpointer。官方文档要求自定义实现正确保存任务写入,并支持增量通道所需的历史读取。上层运行时修复不会补回之前已经丢掉的业务状态,也不能修正一个主动删掉祖先写入的 saver。
尤其要核对保留策略:增量通道的状态不是单个检查点里的自包含值。删除祖先检查点或写入前,需要保留存活检查点依赖的写入链,或者先为保留点生成完整快照。复制线程时也不能只复制最新检查点,而丢掉恢复所需的祖先链。否则,升级以后仍可能出现相似症状,但根因已经不同。
最后,把验收条件写成状态断言,而不是日志描述:缓存命中不丢写入,保存失败不推进残缺链,历史更新不污染另一分支。若还要核对模型服务的接口入口,可以从 模型与平台目录 查文档,但此处的修复发生在本地图运行时与保存层,更换 API 地址不能补回缺失的写入。
参考来源
以下一手来源均于 2026 年 10 月 14 日打开核对,分别用于确认发布版本、修复机制、适用范围与 saver 契约。
- LangGraph 1.2.15 官方发布记录 —— 确认 2026 年 10 月 12 日发布及所包含的修复。
- PR #9259:缓存命中也要保存节点写入 —— 说明缓存路径遗漏与避免重置缓存有效期的机制。
- PR #9235:保存失败后停止推进检查点 —— 说明写入依赖、父检查点错误传播和失败恢复测试。
- PR #9258:update_state 输入写入与分支处理 —— 确認原始 Pregel 输入通道的触发限制。
- 1.2.15 的缓存回归测试 —— 提供双通道与跨线程缓存命中验证方法。
- 1.2.15 的写入失败回归测试 —— 区分不同持久化模式下的恢复输入。
- 1.2.15 的 DeltaChannel 实现 —— 确认批量 reducer 与增量回放语义。
- LangGraph 官方 Checkpointers 文档 —— 确认自定义 saver、增量通道保留与复制线程的要求。
- LangGraph 官方 Persistence 文档 —— 区分检查点、长期 store 与内存 saver 的边界。