BaseChannel abstraction: channels as state primitives
Responsibilities
In the LangGraph runtime, a "channel" is the smallest unit of state. A Pregel instance compiled from a StateGraph does not directly hold the user's dict; it holds a set of BaseChannel instances — one channel per key — and each channel is responsible for storing values, merging writes, exposing reads, and serializing its current state into a checkpoint. BaseChannel is the abstract base class shared by every channel type (BaseChannel:19).
It does not store data itself (__slots__ only declares key and typ, see __slots__:22-26). It only mandates four abstract members that subclasses must implement: the ValueType and UpdateType type attributes, and the update, get, and from_checkpoint actions (abstract methods:60-99). It also provides checkpoint, is_available, consume, finish, and copy with default implementations that either fall back to get() or return False as a no-op.
In other words, BaseChannel pins down "how state is stored, how it changes, and how it leaves a trace" into one interface. The Pregel engine only needs to call update / get / checkpoint — it does not need to know whether a LastValue, Topic, or BinaryOperatorAggregate is doing the merging behind the scenes. That is how the engine and the shape of state stay fully decoupled.
Design motivation
Why model state as "channel" objects instead of passing a dict between nodes?
- Unified write-merge semantics: within the same superstep, multiple nodes may concurrently write to the same key. A dict cannot express "list appends, scalars overwrite, counters add" — these are different merge rules. Channels funnel every strategy through a single
update(values: Sequence[Update]) -> bool; subclasses decide how to fold. - Immutable intra-step view: a node reads a snapshot taken at step start; values written by other nodes during the same step only become visible next step.
getis a read of the current value;updateis only invoked at step boundaries byapply_writes(apply_writes:317-323), which rules out races by construction. - Serializable: every channel can
checkpoint()to a serializable value (checkpoint:49-58) and rebuild itself viafrom_checkpoint(from_checkpoint:60-65). TheBaseCheckpointSaverfamily persists an entire thread's state to SQLite/Postgres through these two methods. - Lifecycle hooks:
consumeandfinish(consume / finish:101-121) give special channels (such asTopic(accumulate=False)orLastValueAfterFinish) a chance to mutate their state at step boundaries or run end. The default no-op signals that most channels do not need this layer of semantics. - Self-describing types: the
ValueType/UpdateTypeattributes (ValueType / UpdateType:28-36) let both compile time and runtime reflect on "what this channel stores, what writes it accepts".StateGraphuses them in_is_field_channel:1862-1887to recognizeAnnotated[..., SomeChannel]declarations.
Key files
BaseChannel class:19-26— abstract base class definition, generic parametersValue / Update / Checkpoint,__slots__only declareskeyandtyp.ValueType / UpdateType:28-36— two abstract properties subclasses use to declare the stored and written value types.checkpoint:49-58— default implementation: returnsself.get(), orMISSINGfor an empty channel.from_checkpoint:60-65— abstract method: build an equivalent new channel from a checkpoint value; subclasses must implement it.get:69-73— abstract read interface; an empty channel raisesEmptyChannelError.is_available:75-85— default implementation callsget()and catchesEmptyChannelError; subclasses usually override with a cheaper check.update:89-99— abstract write interface, invoked by Pregel once at the end of each superstep, in arbitrary order; returnsTrueif the channel was mutated.consume / finish:101-121— lifecycle hooks, default no-op returningFalse.apply_writes:315-345— the single entry point through which Pregel merges all of a step's task writes back into channels;update/consume/finishare all scheduled here._is_field_channel:1862-1887— at compile timeStateGraphusesisinstance(item, BaseChannel)to turnAnnotated[..., channel]into a channel instance.
Data flow
Channel writes happen inside apply_writes: Pregel groups the step's writes per channel and then calls update (apply_writes:317-323):
# Apply writes to channels
updated_channels: set[str] = set()
for chan, vals in pending_writes_by_channel.items():
if chan in channels:
if channels[chan].update(vals) and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
# unavailable channels can't trigger tasks, so don't add them
if channels[chan].is_available():
updated_channels.add(chan)
# Channels that weren't updated in this step are notified of a new step
if bump_step:
for chan in channels:
if channels[chan].is_available() and chan not in updated_channels:
if channels[chan].update(EMPTY_SEQ) and next_version is not None:
checkpoint["channel_versions"][chan] = next_version
if channels[chan].is_available():
updated_channels.add(chan)Note the second block: channels not written by any task still receive update(EMPTY_SEQ). This is an implicit contract of the BaseChannel interface — a subclass's update must accept an empty sequence, typically returning False to signal "no change". The "clear every step" semantics of Topic(accumulate=False) rely on exactly this empty call: an empty update lets it drop the previous step's value so it becomes invisible next step.
The full channel lifecycle within a single Pregel tick:
Boundaries and failure modes
EmptyChannelError: raised byget()when the channel has never been written (get:70-73). The defaultis_availableimplementation catches this exception to decide availability; subclasses that can decide more cheaply should override it.MISSINGsentinel: whenget()raisesEmptyChannelError,checkpoint()returnslanggraph._internal._typing.MISSINGrather thanNone(checkpoint fallback:55-58). This wayNonecan be a legitimate stored value and is not confused with "empty".updatereturnsFalse: aFalsereturn means the channel did not change, so Pregel does not bumpchannel_versionsand subsequentprepare_next_taskswill not trigger any node because of this channel (update call:319); this is the foundation of Pregel's convergence check.- Empty-sequence invocation:
apply_writesalso callsupdate(EMPTY_SEQ)on unchanged channels (EMPTY_SEQ update:329). TheBaseChannel.updatecontract is that empty input returnsFalse, butTopic(accumulate=False)will clear itself in this case and may returnTrue— so subclasses cannot assume "empty input = no-op". consumeandfinishdefault to no-op (consume / finish:101-121): returningFalsemeans most channels do not participate in lifecycle management.apply_writescallsfinishat the end ofbump_step(finish call:338); only special channels likeLastValueAfterFinishuse it to implement "only visible after the run ends" semantics.copydefaults to going through checkpoint:BaseChannel.copydefaults toself.from_checkpoint(self.checkpoint())(copy:40-47). If checkpointing is expensive for a subclass (say, deep-copying a large list), it should overridecopywith a cheaper implementation —Topic.copydoes exactly that.
Summary
BaseChannel pins "how state is stored, merged, and serialized" into one interface. The Pregel engine only needs to call it in the fixed order update → is_available → get → checkpoint → finish and it will run against any channel implementation. For concrete implementations see /channels/last-value and /channels/topic-binop; how Pregel reads and writes channels is covered in /pregel/algo; how the checkpoint layer persists the return value of checkpoint() is covered in /checkpoint/base-saver.
See official docs: LangGraph 文档 · README.