Skip to content

Stream Transformers: turning raw Pregel output into usable projections

源码版本1.2.9

Responsibilities

During each superstep, PregelLoop pushes (checkpoint_ns, mode, payload) triples into a SyncQueue via _emit (_emit:1380-1414) — these are raw "value", "update", "task", "checkpoint" events. That is the output shape of the v1 / v2 protocol: the user gets a stream of chunks. But in the v3 protocol, what users want is not chunks but "per-mode organized, single-consumer-iterable projections": run.values is a stream of complete state snapshots, run.messages is a ChatModelStream handle for LLM token streams, run.updates is a stream of node updates, and run.subgraphs is a handle for subgraph entry.

The logic that turns raw chunks into these projections lives in libs/langgraph/langgraph/stream/transformers.py (ValuesTransformer:28). Each mode has one transformer, all inheriting from the StreamTransformer abstract base class (StreamTransformer:44). They are registered by StreamMux, fed events by GraphRunStream, and each maintains its own internal StreamChannel as its projection. When you call graph.stream_events(version="v3"), what you actually get is a GraphRunStream (GraphRunStream:31); its .values / .messages / .updates / .subgraphs / .custom / .lifecycle attributes are the projection entry points of the native transformers (native attrs:78-79).

The whole transformer system is the v3 protocol's extension point — beyond the five built-ins, users can add their own transformer via Pregel.compile(stream_transformers=[...]) (stream_transformers param:783), passed to StreamMux as a factory alongside the built-ins inside _pregel_stream_v3 (factories list:3533-3544).

Design motivation

  • Native vs extension projection dual track: A transformer with _native = True has its projection mounted as a direct run.<key> attribute (_native:50-52); non-native ones go into run.extensions and are only exposed via the dict. The five built-ins are all native; user-defined ones default to extensions, and must explicitly set _native = True for a direct attribute.
  • required_stream_modes reverse-derives which modes the graph emits: A transformer declares which modes it needs (required_stream_modes:88-93), _collect_stream_modes takes the union (_collect_stream_modes:398-414), and when v3 calls the underlying stream() it passes that union as stream_mode (stream_mode=_collect_stream_modes:3549). The user does not specify stream_mode directly — instead "pulls on demand".
  • Scope filtering avoids noise: ValuesTransformer.process checks params["namespace"] != self._scope_list (ValuesTransformer.process:70-82) and only accepts events from its own scope; subgraph events are left to the subgraph's own mini-mux, so root run.values is not polluted by nested graphs.
  • Sync / async dual track with auto-detection: A transformer defaults to supports_sync = False; as long as it overrides aprocess / afinalize / afail, transformer_requires_async flags it as async-only (transformer_requires_async:308-330), and registering it on a sync mux raises immediately. To support both sync and async you must explicitly set supports_sync = True, e.g. SubgraphTransformer (supports_sync:689).
  • SubgraphTransformer goes through SubgraphRunStream, not raw events: When a subgraph call is discovered, _on_started builds a SubgraphRunStream wrapping the mini-mux (SubgraphTransformer:670-705). What the user gets is not an event stream but a handle object, on which handle.values / handle.messages / handle.subgraphs can be recursed — isomorphic to the root run interface.

Key files

Data flow

The entire v3 chain starts at PregelLoop._emit, passes through SyncQueue, GraphRunStream._pump_next, convert_to_protocol_event, and finally reaches the mux. convert_to_protocol_event directly converts v2's {type, ns, data, interrupts} into a v3 ProtocolEvent:

python
def convert_to_protocol_event(part: StreamPart) -> ProtocolEvent:
    part_dict = cast(dict[str, Any], part)
    params: _ProtocolEventParams = {
        "namespace": list(part_dict["ns"]),
        "timestamp": int(time.time() * 1000),
        "data": part_dict["data"],
    }
    if "interrupts" in part_dict:
        params["interrupts"] = part_dict["interrupts"]
    return {
        "type": "event",
        "method": part_dict["type"],
        "params": params,
    }

(convert body:20-32) Note that method is exactly v2's type — values / updates / messages / custom / tasks / checkpoints / debug. This is the dispatch key inside StreamTransformer.process; for instance, ValuesTransformer only acts on events with method == "values" (method check:70-71).

The v1 / v2 protocol takes a different path inside Pregel.stream: raw chunks come straight out of the SyncQueue and through the _output function (_output:4184-4243), which decides based on whether stream_mode is a string or a list whether to yield payload, (mode, payload), or (ns, mode, payload):

python
if version == "v2":
    ...
    yield {"type": mode, "ns": ns, "data": payload, "interrupts": ints}
elif stream_subgraphs and isinstance(stream_mode, list):
    yield (ns, mode, payload)
elif isinstance(stream_mode, list):
    yield (mode, payload)
elif stream_subgraphs:
    yield (ns, payload)
else:
    yield payload

(_output shape branches:4220-4243) This is why stream_mode=["updates", "values"] yields triples while a single string yields a single value — the same raw chunk gets shaped differently at _output. v3 does not go through _output; it goes through GraphRunStream._pump_nextmux.push → transformer.

Boundaries and failure modes

  • process must be implemented; init must return a dict: init is an @abstractmethod by default; failing to implement it breaks startup (init abstract:127-138). process defaults to raise NotImplementedError (process default:162-164); not overriding it blows up on the first pushed event.
  • Transformer order is sensitive: A transformer with before_builtins = True runs first (before_builtins:94-109), but the docstring warns that it sees tasks events before LifecycleTransformer / SubgraphTransformer consume them. Mutating event["params"]["namespace"] or the id / result / error / interrupts fields would corrupt the bookkeeping of those two — you may only observe and edit cold fields.
  • MessagesTransformer is coupled to the v2 protocol: stream_events(version="v3") forces version="v2" and CONFIG_KEY_STREAM_MESSAGES_V2=True when calling the underlying stream() (_pregel_stream_v3 stream call:3545-3551), otherwise the messages projection does not get the right payload format.
  • StreamChannel is single-consumer; iterating twice raises: run.values can only be consumed by one for loop (single-consumer note:38-40); to fan-out you must call projection.tee(n) (tee:245), at the cost of buffering.
  • schedule(on_error="raise") turns the close path into fail: on_error="raise" makes a task exception push the mux into afail during aclose (on_error:254-257); the default "log" only logs without propagating — choosing wrong lets one transformer's failure take down the whole stream.
  • SubgraphTransformer._should_track is strictly greater than scope: It only tracks subgraphs one level below the current scope (should_track:634-636); grandchild graphs are discovered recursively through handle.subgraphs. If the user skips handle.subgraphs and looks directly at root run.subgraphs, grandchild events will not appear there.

Summary

StreamTransformer is the extension point of the v3 streaming protocol: five built-in transformers turn raw (ns, mode, payload) into iterable typed projections, and users can add their own transformer via the stream_transformers parameter. For the overall dispatch mechanism see StreamMux; for how raw chunks are fed from PregelLoop into the SyncQueue see PregelLoop.

See official docs: LangGraph docs · README.