通过真实生产案例说明简单JSON持久化在状态schema变更时的静默丢失问题,及LangGraph Checkpointer的正确配置方式。
The Silent Killer: Schema Mismatch in Checkpoints
我最初在生产环境中实现 LangGraph 有状态代理的 checkpointing 时,思路很简单:定义一个 TypedDict 作为状态,然后传给 StateGraph。我用 oracledb 做持久化,把整个状态对象序列化成 JSON 字符串存到 BLOB 列里。这套方案对简单流程很有效。
后来我给状态 schema 加了一个新字段。一个看似无关紧要的小改动。代理继续运行着,日志里没有任何错误。就这样,cto-aipa 代理处理了好几周的任务,但那个新状态字段从未被持久化过。每次进程重启(从今天 81 次重启来看,这很频繁),代理就会丢失与新字段相关的进度。直到下游流程因为期待新状态而失败时,我才发现了这个问题。
问题出在一种静默的 schema 不匹配上。LangGraph 的 JsonCheckpointSaver(我底层用它配合 oracledb 作为后端)会序列化当前状态,但加载时,它只会反序列化 checkpoint 创建时原始 schema 中存在的字段。新字段在加载时被直接忽略,而当前运行时 schema 中没有的旧字段在保存时被丢弃。没有报错,没有警告——只是数据静默丢失了。
这意味着 cto-aipa 代理实际上在丢弃其工作的关键部分。cto-aipa 的 git log 显示过去 48 小时有 12 次提交,说明开发活跃、schema 频繁变动。每次变动都可能引发这种静默失败。
Checkpoint Corruption and the "One-Writer" Rule
我第二次尝试解决状态问题,用了更健壮的 oracledb schema 并对状态做了显式版本控制。我在状态里加了一个 version 字段和迁移函数。这是改进,但它引入了一类新的失败:checkpoint 损坏。
algom-stream 代理(7 天内重启了 55193 次)尤其容易受影响。这个代理处理高容量实时数据。代理的多个实例,甚至同一个代理的不同部分,都可能同时尝试更新 checkpoint。虽然 oracledb 提供事务隔离,但如果配置不当,LangGraph CheckpointSaver 抽象层并不能在应用层固有地防止竞态条件。
我观察到了 oracledb.DatabaseError 异常,涉及 checkpoint 保存时的约束违规或数据完整性问题。根本原因往往是:一个代理实例读取了 checkpoint,另一个写入了更新后的版本,然后第一个实例又尝试写入它那已经过时的更新,导致状态损坏或事务失败。algom-stream 进程会重启,尝试加载损坏的 checkpoint,失败,然后再次重启。这就解释了那极高的重启次数。
解决办法是强制执行每个代理实例的"单一写入者"规则。每个代理进程,如 algom-poll(26 天 0 次重启),必须对其 checkpoint 拥有独占访问权。这意味着:
每个代理实例使用唯一的 thread_id:LangGraph 用 thread_id 来标识 checkpoint。每个运行的代理进程必须在启动时生成一个唯一的 thread_id。
原子更新:CheckpointSaver 必须执行原子的读-改-写操作。对于 oracledb,这意味着要确保 UPDATE 语句包含一个 WHERE 子句,检查 version 或时间戳来检测并发修改。如果另一个进程更新了状态,当前进程需要重新读取最新状态并重新应用其更改。
这条"单一写入者"规则,再加上精心管理的 oracledb 事务,显著减少了 checkpoint 损坏。
The Stable Pattern: Explicit State Transitions and Immutability
algom-poll 代理运行了 26 天、0 次重启,代表了我最终确定的 LangGraph 有状态代理生产 checkpointing 稳定模式。关键洞察是把代理的状态当作一个不可变的事件账本,而不是一个可变对象。
不再直接修改状态对象,LangGraph 流水线中的每一步都发出一个 diff 或一个事件。CheckpointSaver 然后把这些事件应用到当前状态来推导新状态。这种模式通常叫做事件溯源(event sourcing)。
状态是一个字典的字典:我的状态不再是一个扁平的 TypedDict。相反,它是一个字典,其中每个键代表一种事件或数据流,其值是一个不可变记录的列表。例如:{"messages": [...], "tasks_completed": [...]}。
节点只追加,从不修改:LangGraph 图中的每个节点都设计成向这些列表追加新项。如果一个任务完成了,它就向 tasks_completed 列表追加一条 {"task_id": "xyz", "status": "completed"} 记录。它永远不会回头去修改已有记录。
Checkpoint 是完整的事件历史:oracledb 的 checkpoint 存储这些事件的完整历史。当代理加载状态时,它通过重放所有事件来重建当前状态。
幂等操作:因为节点只追加,用相同输入运行一个节点多次只会追加重复事件(如果需要可以后续过滤掉),但不会损坏状态。这使得从重启中恢复要简单得多。
这种方法解决了 schema 不匹配问题,因为新字段只是新类型的事件或事件内的新属性。"重放"机制自然会把它们纳入。它解决了损坏问题,因为并发写入只会向列表追加;它们不修改已有数据,减少了争用。
algom-poll 代理轮询外部 API 并更新内部状态从中受益匪浅。如果它重启了,它加载完整的事件历史,重建它对世界的当前视图,然后从中断处继续追加新的轮询结果。这就是它 26 天 0 次重启的原因。
Cost Implications and Oracle Cloud
在 Oracle Cloud Infrastructure(OCI)上运行这些代理,意味着每次重启、每次失败的事务、每次因状态丢失而造成的浪费计算都有直接成本。我的 n8n 流程(编排其中一些代理)运行 10 天 0 次重启,表明其稳定性。serpapi-jobs 流程 4 天 21 次重启,是另一个 checkpointing 稳定性至关重要的领域。
oracledb 数据库运行在 OCI 上,是 checkpointing 的支柱。oracledb 的成本与 CPU 和存储挂钩。低效的 checkpointing 导致任务频繁重新处理或大冗余状态写入,直接影响我的 OCI 账单。algom-poll 代理的稳定性直接转化为可预测的、更低的运营成本。
aideazz 仓库显示过去 48 小时有 12 次提交,包括 ai-ops-wiki: record the fix that shipped, not the stopgap。这反映了调试和改进这些生产系统的迭代过程。concierge-selftest.log 显示 ✅ PASS — 4 checks, 4586ms to first card,表明 concierge 代理(使用类似的状态管理模式)运行正常。
Frequently Asked Questions
Q: How do you handle large state sizes with the event-sourcing approach in oracledb? A: The oracledb BLOB column can handle large states. For extremely large states, I implement snapshotting: periodically, the full reconstructed state is saved as a new "base" checkpoint, and older events are pruned. This reduces the replay time and storage for event history.
Q: What if an agent needs to modify an existing record, not just append?
A: If a true modification is required, the "event" itself represents the modification. For example, instead of {"task_id": "xyz", "status": "completed"}, you might append {"event_type": "task_updated", "task_id": "xyz", "new_status": "completed"}. The state reconstruction logic then applies these updates in order.
Q: How do you manage schema evolution for the event types themselves? A: Each event record includes a version field. The state reconstruction logic is designed to handle different event versions, applying transformations as needed during replay. This is similar to database migrations but applied to the event stream.
Q: Does this event-sourcing approach increase latency for state reads/writes? A: Initial state loading can be slower due to replaying events. However, subsequent appends are fast. For critical paths, I cache the current state in memory and only persist the new events to oracledb, updating the in-memory state after a successful write.
— Elena Revicheva · AIdeazz · Portfolio