Topic and BinaryOperatorAggregate: accumulating channels and reducers
Responsibilities
LastValue only overwrites, but real agent state often needs to accumulate: message lists append, counters add, todos merge. LangGraph offers two paths for this — Topic (Topic class:23) is a pub/sub-style list channel, and BinaryOperatorAggregate (BinaryOperatorAggregate class:65) is a general channel that accepts any binary operator (reducer). Both inherit directly or indirectly from BaseChannel (BaseChannel:19).
BinaryOperatorAggregate is the implementation behind declarations like Annotated[list, add_messages]. At compile time StateGraph calls _is_field_binop (_is_field_binop:1890-1908), treats the last two-argument callable in the Annotated metadata as a reducer, and instantiates BinaryOperatorAggregate(typ, reducer). Each update folds new values into the current value via operator (update:123-144).
Topic is more commonly used for internal channels: every Pregel instance ships with a Topic(Send, accumulate=False) channel named __pregel_tasks (TASKS channel:809) to hold Send fan-out. When accumulate=False it clears every step — the previous step's Send list is dropped once prepare_next_tasks consumes it, and the next step starts collecting fresh.
The shared trait is "update accepts multiple values and folds them by some rule" — that is the fundamental difference from LastValue, which accepts only one value.
Design motivation
- Reducer is the universal interface for state merging: different fields have wildly different merge semantics —
messagesdedupes by ID and appends,counteradds,tagsunions. Rather than writing a channel class for each, provide a general channel that takes a binary operatoroperator: (Value, Value) -> Valueand let the user define the merge rule.add_messages(add_messages:60-66) is one such reducer: merge by message ID, append on new ID, replace on existing ID. - Rehydrate after type erasure:
_strip_extras(_strip_extras:22-28) peels off typing wrappers likeAnnotated/Required/NotRequired, then usestyp()to instantiate a default value. Abstract types likecollections.abc.Sequencecannot be instantiated directly, sotyp fallback:83-88maps them back tolist/set/dict(typ instantiation:82-92). Topic.accumulatedual mode: whenaccumulate=Trueit is a cross-step accumulating list —updateextends new values in (update:77-85); whenaccumulate=Falseit clears the old value first, thenextends, becoming "only values produced this step". The latter is exactly what theTASKSchannel needs: aSendis valid for one step and discarded once consumed._flattensupports mixed writes:Topic.updateaccepts a mixed sequence ofValue | list[Value](_flatten:15-20), so a node can write oneSendor[Send, Send]in one go, and the channel flattens it uniformly.Overwritebypass: reducer channels also support a "direct overwrite" semantics: theOverwritedataclass (Overwrite:980) or the dict{"__overwrite__": value}(OVERWRITE key:95) is recognized by_get_overwrite(_get_overwrite:31-51) and bypasses the reducer to assign directly inupdate. At most oneOverwriteis allowed per step; a second one raisesInvalidUpdateError(overwrite dedup:132-138).
Key files
Topic class:23-41— class definition, generic parametersSequence[Value]/Value | list[Value]/list[Value]; the constructor takes anaccumulatetoggle._flatten:15-20— utility: flattens a mixedValue | list[Value]sequence into anIterator[Value].Topic.copy:56-61— overridescopyto reuse a copy of the values list directly, skipping checkpoint serialization.Topic.checkpoint / from_checkpoint:63-75—checkpointreturnsself.valuesdirectly;from_checkpointis also compatible with the legacy tuple form.Topic.update:77-85— core logic: whenaccumulate=False, clear first thenextend; whenaccumulate=True, justextend; an empty flat sequence returnsFalse.Topic.get / is_available:87-94— empty list raisesEmptyChannelError;is_availableis overridden asbool(self.values).Binop class:65-92— class definition +__init__: takes a binaryoperatorcallable, initializes the stored value withtyp()._get_overwrite:31-51— recognizes threeOverwriterepresentations (dataclass, sentinel-keyed dict, dict restored from JSON)._operators_equal:54-62— special lambda comparison: same-named lambdas are treated as equal, so recompiling the graph does not flag channels as changed.Binop.update:123-144— the real reducer entry point: the first value seeds, subsequent values each calloperator(self.value, value); theOverwritebypass is the exception._is_field_binop:1890-1908— at compile time, recognizes the two-argument callable inAnnotated[..., reducer]metadata and instantiates aBinaryOperatorAggregate.TASKS channel:805-809—Pregel.__init__hardcodesTopic(Send, accumulate=False)for the__pregel_taskschannel; user overrides are not allowed.add_messages:60-66— typical reducer: merges two lists by message ID, appending on new ID, replacing on existing ID.
Data flow
BinaryOperatorAggregate.update is the heart of the reducer channel (update:123-144):
def update(self, values: Sequence[Value]) -> bool:
if not values:
return False
if self.value is MISSING:
self.value = values[0]
values = values[1:]
seen_overwrite: bool = False
for value in values:
is_overwrite, overwrite_value = _get_overwrite(value)
if is_overwrite:
if seen_overwrite:
msg = create_error_message(
message="Can receive only one Overwrite value per super-step.",
error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
)
raise InvalidUpdateError(msg)
self.value = overwrite_value
seen_overwrite = True
continue
if not seen_overwrite:
self.value = self.operator(self.value, value)
return TrueTwo branches to note: if the channel is empty, the first value seeds directly (skipping one reducer call, since the reducer needs two inputs); afterwards, each value that is not an Overwrite becomes self.value = operator(self.value, value). Once an Overwrite appears, every subsequent non-overwrite value is discarded — this guarantees that the "overwrite" semantics will not be reverted by a later reducer.
How StateGraph compiles Annotated[list[AnyMessage], add_messages] into BinaryOperatorAggregate is in _is_field_binop:1890-1908:
def _is_field_binop(typ: type[Any]) -> BinaryOperatorAggregate | None:
if hasattr(typ, "__metadata__"):
meta = typ.__metadata__
if len(meta) >= 1 and callable(meta[-1]):
sig = signature(meta[-1])
params = list(sig.parameters.values())
if (
sum(
p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
for p in params
)
== 2
):
return BinaryOperatorAggregate(typ, meta[-1])
else:
raise ValueError(
f"Invalid reducer signature. Expected (a, b) -> c. Got {sig}"
)
return NoneIt uses inspect.signature to verify that the last metadata entry is a two-argument callable; if the signature does not match, it raises. That is why a two-argument function like add_messages(left, right) can serve as a reducer while a single-argument function is rejected.
Topic.update takes a different, shorter path (update:77-85):
def update(self, values: Sequence[Value | list[Value]]) -> bool:
updated = False
if not self.accumulate:
updated = bool(self.values)
self.values = list[Value]()
if flat_values := tuple(_flatten(values)):
updated = True
self.values.extend(flat_values)
return updatedNote the return value when accumulate=False: if the old value was non-empty, it returns True even when the new sequence is empty (because clearing is itself a change), and Pregel will bump channel_versions. This is the opposite of LastValue, which returns False on an empty sequence — reflecting the different semantics these channels attach to "empty write".
The reducer channel within one Pregel superstep:
Boundaries and failure modes
- Three
Overwriteforms coexist:overwrite forms:44-50accepts theOverwritedataclass, the{"__overwrite__": value}dict, and the{"type": "__overwrite__", "value": ...}dict restored from JSON. The third branch is there so that a round-trip through the LangGraph API server's orjson serialization does not lose semantics. - At most one
Overwriteper step:overwrite dedup:132-138raisesInvalidUpdateErroron the second occurrence, because "overwrite" appearing more than once in a single step is contradictory. - Non-overwrite values after an
Overwriteare silently discarded:discard after overwrite:142-143if not seen_overwritedecides whether to call the reducer; onceseen_overwrite=True, subsequent values neither enter the reducer nor raise. This is intentional — "overwrite" means we do not want to keep the previous merge result, and we do not need later values either. - Lambda reducer equality:
_operators_equal:54-62treats all lambdas as equal, because in Python each<lambda>is a new object; comparing by identity would make__eq__never true and break graph caching. Topic(accumulate=False)returnsTrueon empty write:clear returns True:79-80— as long as the old value was non-empty, clearing counts as a change. This causesapply_writesto bumpchannel_versionsand trigger any node depending on this channel. If you useTopicdirectly, be aware that it does not handle empty writes as silently asLastValue.Topiclegacy checkpoint compatibility:tuple compatibility:70-74checksisinstance(checkpoint, tuple); old versions stored checkpoints as(typ, values)tuples, the new version storeslistdirectly. This block is there to avoid breaking old archives.- Reducer signature requires two arguments:
reducer signature check:1894-1907usesinspect.signatureto count positional parameters and raises if the count is not two. Functions with variadic*argsare rejected — this nails down "reducer must be strictly(a, b) -> c". add_messagesis a factory:_add_messages_wrapper:41-57wraps with@_add_messages_wrapper; the resulting function can be called directly asadd_messages(left, right)or asadd_messages(format="langchain-openai")to return a partial. This is a common extension pattern for reducers — adding runtime parameters to a reducer.
Summary
BinaryOperatorAggregate is the unified implementation for "fields with a reducer"; Topic is the two forms of "list accumulation": cross-step accumulating or per-step clearing. Together with /channels/last-value they make up the three pillars of the LangGraph channel system, all built on the interface defined in /channels/base-channel. Reducers like add_messages are declared via Annotated[..., reducer] in /graph/state-graph and called from apply_writes in /pregel/algo into update. Topic(accumulate=False) as the __pregel_tasks channel is the carrier of Send fan-out, see /subgraph/send-command.
See official docs: LangGraph 文档 · README.