两个真实生产故障均无异常日志:LangGraph 会丢弃未在 TypedDict state 中声明的字段(location 被静默丢弃导致筛选失效),以及 human-approval interrupt 位置不当导致流程永远挂起。两个 bug 均以「absence 而非 error」呈现,极难排查。
首发于 aideazz.xyz — 同步发布于此,带有规范链接。
来自 AIdeazz AI Lab 的现场笔记 — 来自真实生产系统的事故整理。2026 年 6 月 23 日。
一次 LangGraph 管道中的两个静默故障 — 一个被剥离的状态键,以及一个从未恢复的暂停。
VibeJobHunter 的 LangGraph 管道报告运行正常,却什么都没产出。合适的职位被评分、被路由,但从未到达 Telegram 卡片或 HubSpot 交易。此外,职位一直在 iron-clad 匹配门上失败,但失败原因与正在被读取的职位不匹配。两个故障都没有抛出异常、没有记录警告、也没有改变退出码。在任何人会想到去查看的地方,这个管道看起来都是健康的。
同一个状态机中的两个独立故障,都表现为"缺失"而非"错误"。首先,LangGraph 会剥离任何未在管道的 TypedDict state 中声明的键,而门依赖的 location 字段被传入了但从未声明 — 所以它在节点之间被丢弃了,每个职位都被基于一个静默变为空的值进行判断。其次,人工审批的中断位于提交节点之前,这在机器人自动申请、不可逆发送需要人工授权时是正确的。机器人后来改为了 LEAD 模式,在该模式下提交节点不再执行任何申请操作 — 它只是把职位展示给 Elena 让她自己申请。同样的中断现在导致每个符合条件的职位在唯一一个能展示它的步骤之前就暂停了,而且没有任何线程恢复,因为在那种模式下没有东西在等待批准。
在状态 schema 中声明图携带的每个键,并对那些在运行时缺失表现不明显的字段加上注释,这样冷读文件的人就不会移除它们。让中断条件化 — 只有当 AUTO_APPLY_ENABLED 为真时才会应用 interrupt_before,这样暂停只存在于人类决策阻止不可逆操作的路径上,而 LEAD 模式则直接通过提交节点运行到通知。
两个故障都在 2026 年 6 月 23 日被发现并修复,两个修复都在生产环境中运行。Commit 20e5710,"在 JobState TypedDict 中声明 location — LangGraph 剥离了它,导致 iron-clad 失效",以及 Commit 4806a7e,"在 LEAD 模式禁用 submit_node 中断 — 职位永远无法展示的真正原因"。携带它们的管道于 2026 年 4 月 26 日添加,至今仍在 langgraph 1.0.6 和 langgraph-checkpoint-sqlite 3.0.3 上运行 — 七个节点,从门到评分到路由,分支到提交、外联或丢弃,最终汇聚到通知,使用 AsyncSqliteSaver checkpointer,每个职位按 job id 作为 key 分配一个线程。
在有状态图中,失败是沉默的。一个你没有声明的键被丢弃了,一个你暂停的线程没有完成,但两者都不会抛出。把状态 schema 当作接口契约,把每个中断当作必须在系统可以运行的每种模式中都被证明能够恢复的东西 — 一个在一个模式中正确的守卫,在它所守卫的步骤不再做危险事情的模式中就变成了陷阱。
命名一种失败模式,才能让人在新的地方识别出相同的形状,在它再耗费一个周末之前。
系统做了合理的事,但没有告诉任何人。
这是代价最高的一类 bug,因为时钟一直在走,而每个人都认为一切正常。
静默失败不是崩溃。崩溃很响,会被修复。静默失败是一个组件做出合理的局部决策 — 丢弃这条消息、跳过这条记录、返回一个空字符串 — 但没有告知任何下游。从外部看,一个完美运行的系统和一个完全死掉的系统可能产生完全相同的观察:什么都没发生。
防御不是"添加更多日志"。而是使健康状态可证明,这样"什么都没发生"就能与"本来就不应该发生任何事"区分开来。两件事能做到这一点:
记录结果,而不是尝试。"正在发送通知"什么都告诉不了你。"通知已送达 (id 4661)"与"通知被拒绝 400"告诉了你一切。
运行金丝雀检测。通过真实路径按计划推送一个合成事务,当它没有从另一端出来时就大声喊叫。没有金丝雀,你就只能依靠客户来报告你的中断。
这篇笔记是生产工程教训持续 wiki 的一个条目 — 每个概念都链接到教会它的那个事故 — 在 aideazz.xyz/ai-ops-wiki.html。
这些报告中不包含任何客户数据、凭证、主机名或内部记录标识符。