小白 / Xiaobai
开发者 · 产品构建者
持续构建 AI 工程系统、开发者工具与长期数字资产。
关于作者与 XBSTACK →
LangGraph 第一个 Checkpoint 前崩溃会丢任务吗?EmptyInputError 与 Accepted Run 恢复实战
复现 LangGraph 第一个 Checkpoint 前进程崩溃:0 Checkpoint、EmptyInputError 与 accepted run 丢失,并验证 application-owned acceptance ledger 的恢复边界。
LangGraph 第一个 Checkpoint 前崩溃会丢任务吗?EmptyInputError 与 Accepted Run 恢复实战
一个后台任务已经被你的 API 接受,客户端也拿到了 job_id,但真正进入 LangGraph 后,进程在第一次 Checkpoint 落盘前直接崩溃。重启之后,你拿着原来的 thread_id 调 invoke(None, config),得到的不是“从上次状态继续”,而是:
EmptyInputError: Received no input for __start__
更麻烦的是,如果你的业务层没有额外记录,这条任务可能既没有成功记录,也没有失败记录,甚至没有一个可以证明“它曾经被接受”的 durable record。
这不是“Checkpoint 写慢一点”这么简单。它暴露的是一个更靠前的边界:Graph 的持久化开始之前,业务系统是否已经把这次任务接纳变成了一个可恢复事实。
XBSTACK 基于上游 LangGraph Issue #8764 做了独立本地复现。结果很清楚:在 LangGraph 1.2.11 + langgraph-checkpoint-sqlite 3.1.1 下,把子进程固定杀死在第一次 SqliteSaver.put() 之前,基线得到 0 个 Checkpoint、0 个用户副作用;fresh process 使用同一个 thread_id 执行 invoke(None, ...) 时抛出 EmptyInputError。加入一个应用层 Acceptance Ledger 后,Graph 仍然没有任何 Checkpoint,但系统至少知道该任务是 accepted,还能取回原始 payload,显式重放后完成任务。
这篇文章只回答一个问题:如果 LangGraph Run 在第一个 durable checkpoint 前崩溃,怎样避免“任务已经被业务接受,但运行记录从系统里消失”?
先区分三个不同的问题
这个问题很容易和另外两类 LangGraph 故障混在一起。
第一类是“流式内容已经展示,但取消后 Checkpoint 没保存”。XBSTACK 之前在 LangGraph 取消运行状态丢失实验 里验证过:UI Stream 可以领先于 Graph State,节点没有返回时,用户看见的 partial output 不一定已经进入 Checkpoint。
第二类是“已经存在 Checkpoint,但恢复后重复执行或副作用重复”。这属于幂等、pending writes、节点重跑和外部系统对账问题。相关恢复边界可以继续看 LangGraph 错误恢复、重试与超时实战;如果问题集中在 Saver 与持久化层,则参考 LangGraph Checkpointer 选型与 SQLite/Redis 持久化。
本文处理的是第三类,而且更早:第一次 Checkpoint 还没有成功写入,进程已经死了。 此时恢复端没有一个旧状态可以读取。
上游 Issue #8764 把这个边界描述得很准确:后台/异步调用在调用方看来可能已经 accepted,但如果进程死在第一个 durable checkpoint 之前,后续恢复既没有 Checkpoint,也没有 durable failure marker。当前 Issue 仍处于 Open 状态,因此本文不会把应用层方案写成官方修复。
测试环境与成功标准
为了不让模型延迟、网络波动和 Provider API 干扰结果,整个实验不调用任何 LLM 或外部 API。
| 项目 | 本次环境 |
|---|---|
| Python | 3.10.2 |
| LangGraph | 1.2.11 |
| langgraph-checkpoint-sqlite | 3.1.1 |
| Checkpointer | SqliteSaver |
| durability | sync |
| 故障方式 | 第一次 SqliteSaver.put() 前 SIGKILL |
| 外部模型/API | 0 |
| 对照组 | 无 Acceptance Ledger / 有 Acceptance Ledger |
PyPI 在本文发布时显示 LangGraph 1.2.11 为当前稳定版本。本文的本地 fixture 与上游 Issue 报告使用的是同一个 LangGraph 主版本和相同的 SQLite Checkpointer 版本。
成功标准也不是“程序不报错”,而是四件事:
- 能否确定任务曾被业务接纳;
- 能否判断 Graph 是否已经形成 durable checkpoint;
- 能否在不知道进程内存状态的情况下决定是否重放;
- 重放后是否只产生一次业务副作用并进入 completed。
实验一:故意死在第一次 SqliteSaver.put() 前
fixture 通过继承 SqliteSaver,把故障点固定在第一次 put():
class CrashBeforeFirstPut(SqliteSaver):
def put(self, *args, **kwargs):
global seen_first_put
if not seen_first_put:
seen_first_put = True
os.kill(os.getpid(), signal.SIGKILL)
return super().put(*args, **kwargs)
Graph 本身只有一个最小节点。节点真正执行时会写一条 effect,因此副作用计数可以判断用户代码是否已经跨过执行边界。
def node(state):
with open(effect_file, "a", encoding="utf-8") as f:
f.write("effect\n")
return {"done": True}
调用使用:
app.invoke(
{"done": False},
{"configurable": {"thread_id": "accepted-run"}},
durability="sync",
)
这里最关键的不是 sync,而是故障发生得比第一次持久化更早。即使你要求同步 durability,进程仍然可能在首次 put() 完成之前被系统杀死。
基线结果:0 Checkpoint、0 副作用、无法 invoke(None) 恢复
第一次运行被 SIGKILL 后,父进程重新打开同一个 SQLite 文件,再使用相同 thread_id:
app.invoke(None, config, durability="sync")
本地验证结果:
| 指标 | 基线结果 |
|---|---|
| 子进程退出码 | -9 |
| 恢复前 durable checkpoints | 0 |
| 用户副作用 | 0 |
| fresh-process resume | EmptyInputError |
| 错误文本 | Received no input for __start__ |
这说明当前 Thread 对 Checkpointer 来说实际上没有历史状态。None 的语义是“从已有状态继续”,但这里既没有已有状态,也没有新的 START 输入,因此恢复失败。
这和“Checkpoint 有了但内容不完整”是不同故障:这里根本没有第一个 Checkpoint。
为什么 durability="sync" 也救不了这个窗口
LangGraph 的持久化文档和 durability 语义解决的是“状态变化在什么边界、以什么时机持久化”。sync 的价值是让已经形成的状态更新在后续执行前完成持久化。
但任何持久化 API 都有一个物理边界:调用真正完成之前,进程可能被 SIGKILL、容器可能被强制回收、主机可能断电。本文把 crash point 精确放在第一次 SqliteSaver.put() 的入口,就是为了验证这个最早窗口。
因此,生产设计里不能把下面两个概念当成同一件事:
- Business accepted:你的 API 已经告诉调用方“任务已接收”;
- Graph durable:LangGraph 至少已经形成一份可恢复的持久化状态。
如果前者先发生,而系统没有独立 ledger,那么两个时间点之间就是“accepted but not yet durable”的空窗。
实验二:在进入 LangGraph 前写 Application Acceptance Ledger
第二组实验不修改 LangGraph 的持久化语义,只在调用 Graph 之前多做一个业务层动作:持久化最小任务记录。
fixture 使用独立 SQLite 表:
create table jobs (
job_id text primary key,
status text not null,
payload text not null
)
进入 Graph 前先写:
job_id = accepted-run
status = accepted
payload = {"done": false}
然后仍然在第一次 SqliteSaver.put() 前执行同样的 SIGKILL。
重启后,Graph 一侧仍然是 0 Checkpoint,invoke(None, ...) 仍然抛 EmptyInputError。这正是这个实验的重要点:Acceptance Ledger 没有“修好” LangGraph,也没有伪造一个 Checkpoint。
它做的只是让应用层知道:
job exists
status == accepted
checkpoint_count == 0
original payload exists
于是恢复逻辑可以把状态从 accepted 转成 recovery_required,再显式使用原始 payload 启动一次新的 Graph 执行。
对照结果:任务从“不可见丢失”变成“可识别、可重放”
两组实验最终结果如下:
| Case | Crash 后 Checkpoint | invoke(None) | 是否有 Ledger | 是否重放 | 重放后 Checkpoint | 副作用 | 最终 Ledger |
|---|---|---|---|---|---|---|---|
| baseline | 0 | EmptyInputError | 否 | 否 | 0 | 0 | - |
| acceptance ledger | 0 | EmptyInputError | accepted | 是 | 3 | 1 | completed |
第二组最终得到 3 个持久化 Checkpoint、1 次业务副作用和 completed 状态。
这里应该特别避免一个错误结论:不能说“加一张 jobs 表就不会丢任务”。真实生产系统里,重放是否安全,取决于外部副作用是否发生、是否可查询、是否有稳定幂等键,以及每次 attempt 是否有可对账记录。
本文的节点副作用发生在第一个 Checkpoint 之后,因此故障注入时副作用计数是 0,显式重放只执行一次。这是一个受控 fixture,不代表所有业务节点都有同样顺序。
生产系统应该怎么设计 Durable Admission
如果你的 LangGraph Run 是同步请求,而且调用方始终等到 Graph 至少建立第一个 durable state,风险相对容易观察。但只要进入后台任务、队列、异步 API、fire-and-forget 或 worker 模式,就应该明确设计 admission lifecycle。
一个最小状态机可以是:
accepted
↓
starting
↓
checkpointed
↓
running
↓
completed
故障分支则至少包括:
accepted + zero checkpoint -> recovery_required
checkpointed + interrupted -> resume/reconcile
side_effect_unknown -> manual_reconcile
replay_failed -> failed
Acceptance Ledger 至少记录什么
推荐最少保留:
job_id:业务任务唯一标识;thread_id:LangGraph Thread 标识;status:accepted / recovery_required / completed / failed;- 最小可重放 payload,而不是整个敏感会话;
idempotency_key:外部写操作的稳定幂等键;attempt:第几次执行;accepted_at/started_at/completed_at;- 外部业务对象 ID 或 reconciliation reference。
如果 payload 含有敏感信息,应按业务安全边界加密、脱敏或只保存可重新获取数据的引用。
恢复时不要“看到 0 Checkpoint 就自动重放”
这是整个方案最重要的安全边界。
正确判断不是:
if checkpoint_count == 0:
replay()
而应该类似:
if job.status == "accepted" and checkpoint_count == 0:
if side_effect_status_is_known_safe(job.idempotency_key):
mark_recovery_required(job.id)
replay(job.payload)
else:
mark_manual_reconcile(job.id)
因为有些系统的外部副作用可能发生在 Checkpoint 之前,或者副作用已经发送到远端但本地 ACK 丢失。此时盲目重放会把“任务丢失”变成“重复扣款、重复发邮件、重复下单”。
所以 Acceptance Ledger 解决的是可见性和决策依据,幂等和对账解决的是重放安全性。
这次实验没有证明什么
本次结果有明确边界:
- 没有测试
PostgresSaver、Redis 或远程 Checkpointer; - 没有测试 LangGraph Platform / Agent Server 的 durable execution 实现;
- 没有模拟 Kubernetes graceful shutdown,只使用了不可捕获的
SIGKILL; - 没有测试“副作用先发生、Checkpoint 后失败”的重复执行场景;
- 没有测试并发 worker 对同一个
job_id的抢占和 fencing; - 没有证明上游 Issue #8764 已经修复。
因此本文能支持的结论是:在 LangGraph 1.2.11 + SqliteSaver 的受控本地环境里,第一次 durable checkpoint 之前存在可复现的 0-checkpoint 崩溃窗口;应用层 Acceptance Ledger 可以把“完全不可见的 accepted run”变成可识别、可分类并在满足安全条件时显式重放的任务。
我会怎么判断一个系统是否需要 Acceptance Ledger
如果满足下面任意两项,我会把它视为必要设计,而不是“以后再优化”:
- API 在后台执行开始前就返回 202 / accepted;
- 用户会在数分钟或数小时后回来查询任务;
- 运行过程跨进程、容器或 worker;
- 外部副作用不可重复;
- 任务需要计费、审计或 SLA;
- 不能接受“偶发任务凭空消失但没有失败记录”。
如果只是一个同步、短生命周期、可立即重试、没有外部副作用的本地工具,则不一定需要单独 ledger。
可复现实验与上游状态
XBSTACK 已把最小复现和 Acceptance Ledger 对照实验公开:
- XBSTACK LangGraph first-checkpoint acceptance-ledger repro
- LangGraph upstream Issue #8764
- LangGraph persistence documentation
- LangGraph PyPI
实验运行完成时会输出:
LANGGRAPH_FIRST_CHECKPOINT_REPRO_PASS
机器可读结果位于仓库的 results/verification.json 和 results/checks.json。
结论
这次复现最有价值的不是 EmptyInputError 本身,而是把系统里的“接受”拆成了两个不同事实。
你的 API 说 accepted,不等于 LangGraph 已经 durable。
如果后台 Run 在第一次 Checkpoint 前崩溃,没有外部 admission record 的系统可能既无法 resume,也不知道这条任务应该被归类成 failed、lost 还是 never started。应用层 Acceptance Ledger 能补上这个可见性缺口:先记录“我接受了什么”,再让 LangGraph 管理 Graph State;恢复时把两者对账,而不是把 Checkpoint 当成唯一的任务存在证明。
对生产 AI Agent 来说,真正可靠的恢复链路应该是:durable admission → checkpointed execution → idempotent effects → reconciliation。任何一层都不能替代另一层。
继续按生产级 LangGraph 路线读,不再重复看泛入门
这一类文章统一沉淀到 LangGraph 专题页,按状态隔离、Checkpointer、HITL、失败恢复、Observability、Supervisor/Worker、Subgraph 和 Memory 顺序阅读。
继续阅读
返回专题 →AI 工程周报
只发真正改变工程判断的变化、故障、实验和新资产。
参与讨论
问题、验证与勘误
登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。