DeltaChannel + RemoveMessage message compression — how to express deletions with incremental storage?

Background

I currently use add_messages for message state management. Every checkpoint stores the full message list into PostgreSQL, which causes O(n²) storage growth over long conversations.

I’d like to migrate to DeltaChannel for incremental storage. My graph also performs message compression — using RemoveMessage to delete old messages to keep context window manageable.

My Confusion

With add_messages, RemoveMessageis interpreted by the reducer and the target message is removed from the accumulated list before persisting. The full result is stored in the checkpoint.

With DeltaChannel, only the raw writes are persisted (not reducer results). When recovering, writes are replayed through the reducer. So:

1. Is RemoveMessage just a regular write in DeltaChannel, whose “delete semantics” are entirely interpreted by the reducer at replay time?

2. Since DeltaChannel stores writes incrementally and doesn’t store the “current full list”, how does message compression actually reduce storage in a DeltaChannel-based setup? The writes themselves (including past messages that have since been deleted) still exist in the write history, right? Or does the periodic _DeltaSnapshot serve as the “compaction point” where deleted messages are truly discarded?

What I Found

I see _messages_delta_reducer in langgraph/graph/message.py, which is a batching-invariant reducer designed for DeltaChannel. It handles RemoveMessage tombstoning in a single pass. However:

  • It lacks REMOVE_ALL_MESSAGES

Question

  • What’s the recommended path for combining DeltaChannel with message compression today? Should I use _messages_delta_reducer in production despite its experimental status and known gaps?

  • Are there alternative patterns to achieve both incremental storage and message pruning?

Hi @pengben945! Short answer: yes to both. A RemoveMessage is an ordinary write that the reducer interprets at replay (it tombstones the id), and the periodic _DeltaSnapshot is the compaction point.

However, there are two separate things going on here: tokens (context window) versus storage (on disk). I think it is a useful mental model to keep these two things separate.

Tokens vs. storage

  • Tokens / context window: This is about controlling the context your model sees. When you use RemoveMessage, the reducer’s output already excludes the removed messages, so your model sees the compressed window on the very next step.
  • Storage: With DeltaChannel you’re O(n) instead of O(n²) because each step persists its writes instead of re-storing the whole list. This reduces what’s stored on disk. And trimming the context window (above) is itself a write to disk — a RemoveMessage appends, it doesn’t rewrite history.

I think it is helpful to keep these two things separate in your head — one is about context, the other is about disk.

Two things that can help

That being said, there are a couple things that can help.

First, snapshot_frequency. The default is 1000, which may be too high for your use case.

  • more frequent snapshots → shallow, cheap resumes; a few more full blobs on disk
  • less frequent snapshots → less disk; deeper replays that keep re-folding tombstones

You can consider timing your snapshot frequency with your message compression.

Second, Overwrite. You spotted correctly that _messages_delta_reducer doesn’t handle REMOVE_ALL_MESSAGES. For “summarize everything, keep the last N,” write a single Overwrite(new_list) instead. The channel treats it as a reset base in both update() and replay_writes(), so the fold ignores everything before it: one write, shallower replay, cleaner than a pile of RemoveMessages.

To actually free bytes on disk (not just bound the growth), the OSS savers reclaim per thread via delete_thread; on Agent Server / LangSmith you can set checkpointer.ttl with strategy: "keep_latest" to prune older checkpoints automatically.

One caveat: both DeltaChannel and _messages_delta_reducer are beta/experimental (and that reducer is a private import), so the on-disk format may still change — pin your version, and maybe keep it off your most critical threads for now.

Hope that helps!

Docs

hi

Great answer @dariel.datoon ! Just a few source-level nuances to complete it:

  1. Separate two axes. RemoveMessage fixes context window (what the model sees); it does not shrink storage - it’s an extra tombstone write. Your O(n²) → O(n) win comes purely from DeltaChannel persisting per-step deltas instead of the full list every checkpoint - that happens whether or not you ever delete a message.

  2. Snapshot bounds replay, it doesn’t free disk. A _DeltaSnapshot value already excludes tombstoned messages, so it’s small - but the old write rows physically stay in checkpoint_writes until you actually prune. So: snapshot = shorter replay + a clean restore point; prune = the only thing that reclaims bytes.

  3. REMOVE_ALL_MESSAGES gotcha. Since _messages_delta_reducer doesn’t support it, the documented trim pattern (before_model hook emitting RemoveMessage(REMOVE_ALL_MESSAGES)) will silently do nothing. Your Overwrite(new_list) tip is exactly the right substitute - in replay_writes the last Overwrite becomes the reset base, so it also caps replay depth.

  4. Two snapshot triggers, not one. Besides snapshot_frequency (default 1000), there’s also DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT (default 5000) which forces a snapshot for channels that stopped receiving writes - worth knowing when tuning.

  5. On checkpointer.ttl. TTL / delete_thread are whole-thread lifecycle (drop the entire thread), not intra-thread compaction. To reclaim disk within a live thread you need DeltaChannel-aware prune - and it must snapshot the kept checkpoint before dropping ancestors, otherwise the delta chain is severed and channels silently reconstruct as empty (the base saver’s prune docstring warns about exactly this).

Hi @dariel.datoon, thanks so much for the detailed explanation. Really well written.

We’ve tested the Overwrite(new_list) approach you recommended in our setup and it’s working nicely — Overwrite correctly replaces the message list in a DeltaChannel-backed state, and get_state() replay reconstructs the right result.

Thanks again!