Skip to content

LastValue: el canal por defecto de escritura sobrescribiente

源码版本1.2.9

Responsabilidades

LastValue es la subclase más simple de BaseChannel: almacena un único valor y cada escritura sobrescribe el anterior (LastValue class:20-21). Es «el canal que se usa por defecto cuando un campo de state no lleva ningún Annotated»; si en StateGraph escribes class State(TypedDict): count: int, el campo count que ve el compilador es por dentro un LastValue(int) (fallback LastValue:1857-1859).

Su restricción clave es «solo se puede escribir un valor por paso». Cuando update recibe una secuencia de longitud mayor que 1, no fusiona ni sobrescribe: lanza directamente InvalidUpdateError (update:56-67), indicando al usuario que debe usar Annotated[int, reducer] para expresar «cómo fold cuando varios nodos escriben en el mismo paso». Esta restricción no es técnica sino semántica: un estado escalar no tiene forma razonable de fusionarse, así que mejor explotar antes que perder valores en silencio.

Además, este archivo define una variante LastValueAfterFinish (LastValueAfterFinish:81-152): se comporta como LastValue, pero los valores escritos solo son visibles después de que toda la ejecución Pregel invoque finish(); tras un consumo se vacía. Se usa en canales internos como «señal de interrupción» o «señal de fin» que solo surten efecto al final de la ejecución.

Motivación de diseño

  • Sobrescribir es el único default razonable para estado escalar: enteros, cadenas, booleanos no tienen semántica de «fusión». Para un campo count: int, sobrescribir el valor viejo con el nuevo es el comportamiento más intuitivo, por eso StateGraph compila los campos sin reducer anotado como LastValue en fallback:1857.
  • Rechazar la pérdida silenciosa de valores: si dos nodos escriben count en el mismo superpaso, LastValue no elige arbitrariamente un ganador; lanza INVALID_CONCURRENT_GRAPH_UPDATE (InvalidUpdateError:60-64). El mensaje indica explícitamente «Use an Annotated key to handle multiple values», empujando al usuario hacia la solución correcta: añadir un reducer, ver /channels/topic-binop.
  • MISSING en lugar de None: el valor inicial es langgraph._internal._typing.MISSING (value = MISSING:29), de modo que None es un valor legítimamente almacenable. get() lanza EmptyChannelError cuando MISSING (get:69-72). Esto es coherente con la implementación por defecto de BaseChannel, pero aquí is_available se sobreescribe como una sola línea self.value is not MISSING (is_available:74-75) para evitar try/except en cada llamada.
  • copy más barato que from_checkpoint: aunque la clase base por defecto hace copy = from_checkpoint(checkpoint()), aquí se sobreescribe copy (copy:44-48) para compartir directamente la misma referencia a value — un escalar no necesita deep-copy. from_checkpoint (from_checkpoint:50-54) decide según la rama MISSING si asigna o no.
  • Visibilidad retrasada en LastValueAfterFinish: ciertas señales de control (p. ej. el goto de Command) no pueden hacerse visibles a prepare_next_tasks inmediatamente al escribir, de lo contrario se autodispararían dentro del mismo paso formando un ciclo. LastValueAfterFinish utiliza un flag finished (finished flag:93-95); get() solo devuelve cuando finished=True (get gated:145-148), finish() lo invoca Pregel al cierre (finish:138-143) y consume() vacía tras un único consumo (consume:130-136).

Archivos clave

  • LastValue class:20-25 — definición de la clase, genérica BaseChannel[Value, Value, Value]: tipo de valor almacenado, tipo de escritura y tipo de checkpoint son todos Value.
  • __init__:27-29 — al construir, value = MISSING; solo deja de ser MISSING tras una escritura.
  • ValueType / UpdateType:34-42 — ambos devuelven self.typ, el tipo proporcionado en la declaración.
  • copy:44-48 — reutiliza directamente la referencia value; un escalar no necesita deep-copy.
  • from_checkpoint:50-54 — reconstruye una instancia desde un valor de checkpoint; MISSING indica empezar desde vacío.
  • update:56-67 — la restricción central: secuencia vacía devuelve False, longitud mayor que 1 lanza InvalidUpdateError, en caso contrario guarda el último valor y devuelve True.
  • get / is_available:69-75 — interfaz de lectura; MISSING lanza EmptyChannelError; is_available se sobreescribe como una comprobación de una línea.
  • LastValueAfterFinish:81-95 — variante: añade el flag finished, retrasa la visibilidad hasta después de finish().
  • LastValueAfterFinish.update:122-128 — al escribir, resetea finished a la vez, lo que significa que una nueva escritura revoca la visibilidad anterior.
  • consume / finish / get:130-148 — los tres hooks coordinan la semántica «visible una vez, luego se vacía».
  • fallback LastValue:1857-1859 — al compilar, StateGraph recurre a LastValue(annotation) para campos sin reducer anotado.

Flujo de datos

Toda la lógica de LastValue.update está en estas líneas (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

Nótese que en la tercera rama se escribe values[-1] en vez de values[0] — como ya se comprobó len != 1, esta línea solo se ejecuta en el caso len == 1, donde [-1] y [0] son equivalentes. Devolver False para la secuencia vacía es crucial: se coordina con la llamada update(EMPTY_SEQ) sobre canales no modificados dentro de apply_writes (EMPTY_SEQ update:329), para que LastValue devuelva correctamente «sin cambios» cuando se le invoca vacío.

Cómo StateGraph mapea los campos sin anotación a LastValue en compilación se describe en 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

Se intenta en orden: managed value (gestión especial como en MessagesState) → instancia explícita de BaseChannelAnnotated[..., reducer] (BinaryOperatorAggregate, ver /channels/topic-binop) → si nada coincide, se recurre a LastValue.

La secuencia completa de lectura/escritura:

Límites y fallos

  • Escritura concurrente multi-valor: error directo: multi-value error:59-64 lanza InvalidUpdateError con código INVALID_CONCURRENT_GRAPH_UPDATE. Es la trampa más habitual para principiantes: dos nodos escriben en paralelo en el mismo campo escalar. La solución es declarar el campo con reducer, p. ej. Annotated[int, lambda a, b: a + b].
  • Escritura vacía devuelve False: empty seq:57-58 devuelve False, coherente con el contrato de BaseChannel; apply_writes lo utiliza para saltar la actualización de channel_versions.
  • __eq__ no mira el valor sino solo el tipo: __eq__:31-32 devuelve isinstance(value, LastValue), lo que significa que dos instancias LastValue se consideran iguales sin importar lo que almacenan. Está pensado para la deduplicación durante la compilación del grafo; no lo uses para comparar valores.
  • MISSING frente a None: None es un valor legítimamente almacenable; solo MISSING indica «nunca escrito» (get / is_available:70-75). Si tu campo de negocio admite None, escríbelo sin problema: el canal no lo tratará como vacío.
  • Una nueva escritura en LastValueAfterFinish revoca la visibilidad: LAF.update:122-128 comienza con self.finished = False. Es decir, si tras finish() llega otra escritura, el canal vuelve al estado «no publicado» y debe esperar al siguiente finish() para poder leerse.
  • LastValueAfterFinish.consume es no-op cuando finished es falso: ver LAF.consume:130-136. Así, un valor «escrito pero aún no finish-ado» no se vacía; solo se vacía tras haberse consumido una vez — exactamente lo que requiere la semántica de «señal de fin».

Resumen

LastValue es la forma por defecto del estado de LangGraph: almacena un escalar, se actualiza por sobrescritura, y la restricción de un valor por paso explota al recibir múltiples en vez de perder valores en silencio. Entenderlo es entender cómo mutan en runtime todos los campos sin reducer anotado. Para ver cómo se fusionan correctamente escrituras concurrentes, continúa con /channels/topic-binop; la definición de la interfaz del canal vuelve a /channels/base-channel; el momento exacto en que se invoca update se describe en /pregel/algo.

Véase la documentación oficial: LangGraph docs · README