886 字
4 分钟
Agent 工程(四):只追加存储如何保住 Checkpoint 因果链

Checkpoint 存储的首要目标不是节省空间,而是 不破坏执行历史的因果关系。只追加记录能够保留每次推进的父节点和顺序,但由此产生的中间节点约束、并发冲突和存储增长必须显式处理。

一张表就能表达链#

最小模型可以由会话、Checkpoint 标识、父节点和状态载荷组成:

session_id
checkpoint_id
parent_checkpoint_id
sequence
payload
created_at

每次推进执行 INSERT,旧节点不做原地更新。恢复读取会话末端节点,审计沿 parent_checkpoint_id 回溯,分叉则从任一历史节点创建新的后继。

只追加的价值不是实现简单,而是任何状态都保留“从哪里来”的证据。若直接覆盖当前状态,系统只能看到结果,无法判断它经历过哪些工具调用、重试和分支。

中间节点不能按普通历史删除#

链上的中间节点同时承担父子关系和恢复语义。删除一个仍被后继引用的节点,会让后续状态失去来源;只保留首尾节点,则无法判断中间副作用是否已经发生。

可行的清理方式不是随意删除,而是显式压缩:

  1. 选择一个仍然有效的边界节点;
  2. 把恢复所需状态汇总为新的基线;
  3. 让后续链从该基线继续;
  4. 在确认审计与回放需求允许后,再清理此前记录。

压缩改变了可追溯粒度,因此必须是产品策略,而不能只是数据库定时任务。

并发会把正常分叉变成故障#

有意从历史节点创建两个后继,是分支功能;两个执行者同时推进同一会话末端,则通常是并发错误。两者在表结构上都表现为“一个父节点有多个子节点”,必须借助运行语义区分。

我的默认做法是为会话维护单一写入所有权,并在插入时校验预期父节点:

读取末端 A
执行一步
仅当末端仍为 A 时插入 B

如果另一个执行者已经插入了新末端,当前写入失败并重新读取,而不是静默制造两条都自称为主线的链。需要真正分支时,则使用新的分支标识或子会话标识。

我的选择:先用完整快照换正确性#

每个节点保存完整状态最容易恢复,但累计存储会随着会话增长迅速放大;增量记录更节省空间,却让恢复、迁移和损坏修复更复杂。

在状态规模尚可控时,我会先使用完整快照和只追加表,把恢复正确性、并发约束和版本迁移做清楚。只有监控确认存储或写入成为瓶颈后,才引入基线加增量,并保留周期性完整节点控制恢复链长度。

这项选择的代价是空间和序列化成本。状态结构升级时还需要明确版本字段和迁移逻辑;语言原生序列化虽然方便,却可能带来跨版本兼容和不可信反序列化风险。存储格式必须服务于长期恢复,而不是只服务于当前进程。

下一篇转向输入边界:上下文工程为什么首先是一项注意力分配问题