Skip to content

LastValue: the default overwrite channel

源码版本1.2.9

Responsibilities

LastValue is the simplest subclass of BaseChannel: it stores one value, and every write overwrites the old one (LastValue class:20-21). It is the default channel used when a state field has no Annotated annotation. When you write class State(TypedDict): count: int in a StateGraph, the count field seen at compile time is backed by a LastValue(int) (fallback LastValue:1857-1859).

Its key constraint is "at most one value per step". When update receives a sequence longer than 1, it neither merges nor overwrites — it raises InvalidUpdateError (update:56-67), prompting the user to use Annotated[int, reducer] to express "how multiple concurrent writes in this step should be folded". This is not a technical limitation but a semantic choice: scalar state has no reasonable merge behavior, so rather than silently dropping values it errors out explicitly.

The same file also contains a variant, LastValueAfterFinish (LastValueAfterFinish:81-152): it behaves like LastValue, but a written value only becomes visible after finish() is called at the end of the entire Pregel run, and is cleared after being read once. It is used for "interrupt signals" or "end signals" — internal channels that only take effect at the end of a run.

Design motivation

  • Overwrite is the only sensible default for scalar state: integers, strings, booleans have no "merge" semantics. For a field like count: int, the intuitive behavior when a new value arrives is to overwrite the old one, so StateGraph compiles any field without a reducer annotation to LastValue in fallback:1857.
  • Refuse to silently drop values: if two nodes write count in the same superstep, LastValue does not pick a winner — it raises INVALID_CONCURRENT_GRAPH_UPDATE (InvalidUpdateError:60-64). The error message explicitly points to "Use an Annotated key to handle multiple values", steering the user toward the correct fix — adding a reducer, see /channels/topic-binop.
  • MISSING instead of None: the initial value is langgraph._internal._typing.MISSING (value = MISSING:29), so None is a legitimate stored value. When value is MISSING, get() raises EmptyChannelError (get:69-72). This mirrors the BaseChannel default, but is_available is overridden as the one-liner self.value is not MISSING (is_available:74-75) to avoid a try/except on every call.
  • copy is cheaper than from_checkpoint: although the parent defaults copy = from_checkpoint(checkpoint()), here copy is overridden (copy:44-48) to share the same value reference directly — scalars do not need deep copy. from_checkpoint (from_checkpoint:50-54) branches on MISSING to decide whether to assign.
  • LastValueAfterFinish deferred visibility: some control signals (such as Command's goto) must not be visible to prepare_next_tasks at write time, otherwise they would self-trigger within the same step and form a cycle. LastValueAfterFinish uses a finished flag (finished flag:93-95); get() only returns when finished=True (get gated:145-148), finish() is invoked by Pregel at close (finish:138-143), and consume() clears the value after it has been consumed once (consume:130-136).

Key files

  • LastValue class:20-25 — class definition, generic BaseChannel[Value, Value, Value]: stored, written, and checkpoint types are all the same Value.
  • __init__:27-29 — initializes value = MISSING; only becomes non-MISSING after a write.
  • ValueType / UpdateType:34-42 — both return self.typ, the type declared in the annotation.
  • copy:44-48 — reuses the value reference directly; scalars do not need deep copy.
  • from_checkpoint:50-54 — rebuilds an instance from a checkpoint value; MISSING means start from empty.
  • update:56-67 — core constraint: empty sequence returns False, length greater than 1 raises InvalidUpdateError, otherwise stores the last value and returns True.
  • get / is_available:69-75 — read interface; MISSING raises EmptyChannelError; is_available is overridden as a one-line check.
  • LastValueAfterFinish:81-95 — variant: adds a finished flag, deferring visibility until after finish().
  • LastValueAfterFinish.update:122-128 — resets finished on write, meaning a new write revokes the previous visibility.
  • consume / finish / get:130-148 — three hooks cooperate to implement "clear after one read" semantics.
  • fallback LastValue:1857-1859 — at compile time StateGraph falls back to LastValue(annotation) for any field without a reducer annotation.

Data flow

The entire logic of LastValue.update is just these lines (update:56-67):

python
def update(self, values: Sequence[Value]) -> bool:
    if len(values) == 0:
        return False
    if len(values) != 1:
        msg = create_error_message(
            message=f"At key '{self.key}': Can receive only one value per step. Use an Annotated key to handle multiple values.",
            error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
        )
        raise InvalidUpdateError(msg)

    self.value = values[-1]
    return True

Note that the third branch uses values[-1] rather than values[0] — because the previous check already rejected len != 1, this line only runs in the len == 1 case, where [-1] and [0] are equivalent. Returning False on an empty sequence is what lets LastValue correctly report "unchanged" when apply_writes calls update(EMPTY_SEQ) on unchanged channels (EMPTY_SEQ update:329).

How StateGraph maps an unannotated field to LastValue at compile time is in field_to_channel:1840-1859:

python
if manager := _is_field_managed_value(name, annotation):
    if allow_managed:
        return manager
    else:
        raise ValueError(f"This {annotation} not allowed in this position")
elif channel := _is_field_channel(annotation):
    channel.key = name
    return channel
elif channel := _is_field_binop(annotation):
    channel.key = name
    return channel

fallback: LastValue = LastValue(annotation)
fallback.key = name
return fallback

It tries in order: managed value (special management like in MessagesState) → explicit BaseChannel instance → Annotated[..., reducer] (BinaryOperatorAggregate, see /channels/topic-binop) → if nothing matches, fall back to LastValue.

The full read/write timeline:

Boundaries and failure modes

  • Multi-value concurrent writes raise: multi-value error:59-64 raises InvalidUpdateError with error code INVALID_CONCURRENT_GRAPH_UPDATE. This is the most common pitfall for newcomers: two nodes writing the same scalar field at once. The fix is to switch to a reducer-annotated field like Annotated[int, lambda a, b: a + b].
  • Empty write returns False: empty seq:57-58 returns False, consistent with the BaseChannel contract, so apply_writes skips the channel_versions bump.
  • __eq__ checks type, not value: __eq__:31-32 returns isinstance(value, LastValue), so any two LastValue instances are considered equal regardless of what they store. This is for deduplication at graph compile time — do not use it for value comparison.
  • MISSING vs None: None is a legitimate stored value; only MISSING means "never written" (get / is_available:70-75). If your business field allows None, write it freely — the channel will not treat it as empty.
  • A new write to LastValueAfterFinish revokes visibility: LAF.update:122-128 starts with self.finished = False. So if a write arrives after finish(), the channel re-enters the "unpublished" state and only becomes readable after the next finish().
  • LastValueAfterFinish.consume is a no-op when finished is false: see LAF.consume:130-136. A value that was "written but not yet finish-ed" is not cleared; it is only cleared after it has been consumed once — exactly what its "end signal" semantics require.

Summary

LastValue is the default shape of LangGraph state — it stores one scalar, overwrites on update, and the "one value per step" constraint raises rather than silently dropping values on multi-value writes. Understanding it means understanding how every field without a reducer annotation behaves at runtime. To see how concurrent writes are correctly merged, read on at /channels/topic-binop; for the channel interface definition go back to /channels/base-channel; for when update is actually called see /pregel/algo.

See official docs: LangGraph 文档 · README.