小白 / Xiaobai

小白 / Xiaobai

开发者 · 产品构建者

持续构建 AI 工程系统、开发者工具与长期数字资产。

关于作者与 XBSTACK →
LangGraph 第一次持久化 Checkpoint 前进程崩溃,应用层 Acceptance Ledger 仍保留 Accepted Run 的故障恢复示意

LangGraph 第一个 Checkpoint 前崩溃会丢任务吗?EmptyInputError 与 Accepted Run 恢复实战

复现 LangGraph 第一个 Checkpoint 前进程崩溃:0 Checkpoint、EmptyInputError 与 accepted run 丢失,并验证 application-owned acceptance ledger 的恢复边界。

发布 · 2026-09-0610 分钟阅读XBSTACK 原创
#LangGraph#Checkpoint#EmptyInputError#SQLite#Recovery#Background Jobs#AI Agent#Idempotency

LangGraph 第一个 Checkpoint 前崩溃会丢任务吗?EmptyInputError 与 Accepted Run 恢复实战

一个后台任务已经被你的 API 接受,客户端也拿到了 job_id,但真正进入 LangGraph 后,进程在第一次 Checkpoint 落盘前直接崩溃。重启之后,你拿着原来的 thread_idinvoke(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。

项目本次环境
Python3.10.2
LangGraph1.2.11
langgraph-checkpoint-sqlite3.1.1
CheckpointerSqliteSaver
durabilitysync
故障方式第一次 SqliteSaver.put()SIGKILL
外部模型/API0
对照组无 Acceptance Ledger / 有 Acceptance Ledger

PyPI 在本文发布时显示 LangGraph 1.2.11 为当前稳定版本。本文的本地 fixture 与上游 Issue 报告使用的是同一个 LangGraph 主版本和相同的 SQLite Checkpointer 版本。

成功标准也不是“程序不报错”,而是四件事:

  1. 能否确定任务曾被业务接纳;
  2. 能否判断 Graph 是否已经形成 durable checkpoint;
  3. 能否在不知道进程内存状态的情况下决定是否重放;
  4. 重放后是否只产生一次业务副作用并进入 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 checkpoints0
用户副作用0
fresh-process resumeEmptyInputError
错误文本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 Checkpointinvoke(None, ...) 仍然抛 EmptyInputError。这正是这个实验的重要点:Acceptance Ledger 没有“修好” LangGraph,也没有伪造一个 Checkpoint。

它做的只是让应用层知道:

job exists
status == accepted
checkpoint_count == 0
original payload exists

于是恢复逻辑可以把状态从 accepted 转成 recovery_required,再显式使用原始 payload 启动一次新的 Graph 执行。

对照结果:任务从“不可见丢失”变成“可识别、可重放”

两组实验最终结果如下:

CaseCrash 后 Checkpointinvoke(None)是否有 Ledger是否重放重放后 Checkpoint副作用最终 Ledger
baseline0EmptyInputError00-
acceptance ledger0EmptyInputErroraccepted31completed

第二组最终得到 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 对照实验公开:

实验运行完成时会输出:

LANGGRAPH_FIRST_CHECKPOINT_REPRO_PASS

机器可读结果位于仓库的 results/verification.jsonresults/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 Hub

继续按生产级 LangGraph 路线读,不再重复看泛入门

这一类文章统一沉淀到 LangGraph 专题页,按状态隔离、Checkpointer、HITL、失败恢复、Observability、Supervisor/Worker、Subgraph 和 Memory 顺序阅读。

继续阅读

返回专题 →
LangGraph 取消运行后状态为什么丢失?Streaming、Checkpoint 与恢复一致性实战LangGraph 取消运行后状态为什么丢失:LangGraph 流式运行取消后,为什么用户已看到的内容会在刷新时消失?本文用 LangGraph 1.2.9、SQLite Checkpointer、16 组流式矩阵与 interrupt 恢复实验,验证 Super-step、durability、partial state 和幂等边界。LangGraph Checkpoint 恢复后时间为什么会错一小时?ZoneInfo / fold 丢失问题复现与临时方案LangGraph checkpoint 恢复后时间错一小时怎么排查?本文独立复现 JsonPlusSerializer round-trip 后 ZoneInfo 退化为固定 UTC offset、fold=1 变成 0,并展示 DST 跨日运算从 09:00 变成 10:00 的风险与应用层临时方案。LangGraph aupdate_state 报 Ambiguous update 怎么解决?1.2.11 同步/异步差异实测LangGraph 1.2.11 中,update_state 正常但 aupdate_state 报 InvalidUpdateError: Ambiguous update, specify as_node。本文复现 Issue #8714,解释同步/异步推断差异,并验证显式 as_node 的临时方案。LangGraph Checkpointer 实战:MemorySaver、SQLite、Redis 怎么选?LangGraph Checkpointer 实战:实战讲解 LangGraph Checkpointer 状态持久化选型,包括 MemorySaver / InMemorySaver、SQLite、Redis、Postgres 的适用场景、优缺点、thread_id 设计、状态恢复、Human-in-the-loop、失败恢复和生产部署建议。

AI 工程周报

只发真正改变工程判断的变化、故障、实验和新资产。

评论与补充证据

参与讨论

问题、验证与勘误

登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。

登录评论 审核后公开
正在加载评论区…