Skip to content

BaseChannel abstraction: channels as state primitives ​

源码版本1.2.9

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. get is a read of the current value; update is only invoked at step boundaries by apply_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 via from_checkpoint (from_checkpoint:60-65). The BaseCheckpointSaver family persists an entire thread's state to SQLite/Postgres through these two methods.
  • Lifecycle hooks: consume and finish (consume / finish:101-121) give special channels (such as Topic(accumulate=False) or LastValueAfterFinish) 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 / UpdateType attributes (ValueType / UpdateType:28-36) let both compile time and runtime reflect on "what this channel stores, what writes it accepts". StateGraph uses them in _is_field_channel:1862-1887 to recognize Annotated[..., SomeChannel] declarations.

Key files ​

  • BaseChannel class:19-26 — abstract base class definition, generic parameters Value / Update / Checkpoint, __slots__ only declares key and typ.
  • ValueType / UpdateType:28-36 — two abstract properties subclasses use to declare the stored and written value types.
  • checkpoint:49-58 — default implementation: returns self.get(), or MISSING for 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 raises EmptyChannelError.
  • is_available:75-85 — default implementation calls get() and catches EmptyChannelError; 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; returns True if the channel was mutated.
  • consume / finish:101-121 — lifecycle hooks, default no-op returning False.
  • apply_writes:315-345 — the single entry point through which Pregel merges all of a step's task writes back into channels; update / consume / finish are all scheduled here.
  • _is_field_channel:1862-1887 — at compile time StateGraph uses isinstance(item, BaseChannel) to turn Annotated[..., 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):

python
# 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 by get() when the channel has never been written (get:70-73). The default is_available implementation catches this exception to decide availability; subclasses that can decide more cheaply should override it.
  • MISSING sentinel: when get() raises EmptyChannelError, checkpoint() returns langgraph._internal._typing.MISSING rather than None (checkpoint fallback:55-58). This way None can be a legitimate stored value and is not confused with "empty".
  • update returns False: a False return means the channel did not change, so Pregel does not bump channel_versions and subsequent prepare_next_tasks will not trigger any node because of this channel (update call:319); this is the foundation of Pregel's convergence check.
  • Empty-sequence invocation: apply_writes also calls update(EMPTY_SEQ) on unchanged channels (EMPTY_SEQ update:329). The BaseChannel.update contract is that empty input returns False, but Topic(accumulate=False) will clear itself in this case and may return True — so subclasses cannot assume "empty input = no-op".
  • consume and finish default to no-op (consume / finish:101-121): returning False means most channels do not participate in lifecycle management. apply_writes calls finish at the end of bump_step (finish call:338); only special channels like LastValueAfterFinish use it to implement "only visible after the run ends" semantics.
  • copy defaults to going through checkpoint: BaseChannel.copy defaults to self.from_checkpoint(self.checkpoint()) (copy:40-47). If checkpointing is expensive for a subclass (say, deep-copying a large list), it should override copy with a cheaper implementation — Topic.copy does 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.