Skip to content

LastValue : le canal par défaut à écriture écrasante

源码版本1.2.9

Responsabilités

LastValue est la sous-classe la plus simple de BaseChannel : elle stocke une valeur et chaque écriture écrase l'ancienne (LastValue class:20-21). C'est le canal utilisé par défaut quand un champ d'état ne porte aucune annotation Annotated. Quand vous écrivez class State(TypedDict): count: int dans un StateGraph, le champ count vu à la compilation est soutenu par un LastValue(int) (fallback LastValue:1857-1859).

Sa contrainte clé est « une seule valeur par superpas ». Quand update reçoit une séquence de longueur supérieure à 1, il ne fusionne ni n'écrase : il lève InvalidUpdateError (update:56-67), avec un message incitant à utiliser Annotated[int, reducer] pour exprimer « comment folder plusieurs écritures concurrentes de nœuds sur ce même superpas ». Ce n'est pas une limite technique mais un choix sémantique : un état scalaire n'a pas de fusion raisonnable, autant expliciter l'erreur plutôt que de perdre silencieusement une valeur.

Le fichier définit aussi la variante LastValueAfterFinish (LastValueAfterFinish:81-152) : comportement proche de LastValue, mais la valeur écrite ne devient visible qu'après que l'exécution Pregel a appelé finish(), et elle est effacée après avoir été lue une fois. Elle sert pour les canaux internes de type « signal d'interruption » ou « signal de fin » qui ne doivent prendre effet qu'en fin d'exécution.

Motivation de conception

  • L'écrasement est le seul défaut raisonnable pour un état scalaire : entiers, chaînes, booléens n'ont pas de sémantique de « fusion ». Pour un champ count: int, écraser l'ancienne valeur à chaque nouvelle écriture est le comportement le plus intuitif ; c'est pourquoi StateGraph (fallback:1857) compile les champs sans reducer en LastValue.
  • Refuser la perte silencieuse : si deux nœuds écrivent count dans le même superpas, LastValue ne choisit pas arbitrairement un gagnant : il lève INVALID_CONCURRENT_GRAPH_UPDATE (InvalidUpdateError:60-64). Le message indique clairement « Use an Annotated key to handle multiple values », orientant l'utilisateur vers la bonne solution — ajouter un reducer, voir /channels/topic-binop.
  • MISSING plutôt que None : la valeur initiale est langgraph._internal._typing.MISSING (value = MISSING:29), pour que None puisse être une valeur légale. get() lève EmptyChannelError quand la valeur est MISSING (get:69-72). Cela reste cohérent avec l'implémentation par défaut de BaseChannel, mais is_available est surchargée en une seule ligne self.value is not MISSING (is_available:74-75) pour éviter un try/except à chaque appel.
  • copy moins cher que from_checkpoint : bien que la classe parente définisse copy = from_checkpoint(checkpoint()), copy est surchargée ici (copy:44-48) pour partager simplement la même référence value — un scalaire n'a pas besoin de deep-copy. from_checkpoint (from_checkpoint:50-54) décide selon la branche MISSING s'il faut assigner.
  • Visibilité différée de LastValueAfterFinish : certains signaux de contrôle (par ex. le goto d'un Command) ne doivent pas être vus immédiatement par prepare_next_tasks, sinon ils se déclencheraient eux-mêmes au sein du superpas et formeraient un cycle. LastValueAfterFinish utilise un flag finished (finished flag:93-95) : get() ne renvoie la valeur que si finished=True (get gated:145-148), finish() est appelé par Pregel lors du bilan (finish:138-143), et consume() efface après une consommation (consume:130-136).

Fichiers clés

  • LastValue class:20-25 — définition, générique sur BaseChannel[Value, Value, Value] : type stocké, type d'écriture et type de checkpoint sont identiques.
  • __init__:27-29 — à la construction value = MISSING, et uniquement MISSING tant qu'il n'a pas été écrit.
  • ValueType / UpdateType:34-42 — les deux renvoient self.typ, le type passé à la déclaration.
  • copy:44-48 — réutilise directement la référence value, pas de deep-copy pour un scalaire.
  • from_checkpoint:50-54 — reconstruit une instance depuis une valeur de checkpoint ; MISSING signifie repartir à vide.
  • update:56-67 — contrainte centrale : séquence vide renvoie False, longueur > 1 lève InvalidUpdateError, sinon stocke la dernière valeur et renvoie True.
  • get / is_available:69-75 — interface de lecture ; MISSING lève EmptyChannelError ; is_available surchargée en une ligne.
  • LastValueAfterFinish:81-95 — variante : ajoute un flag finished, différant la visibilité jusqu'à finish().
  • LastValueAfterFinish.update:122-128 — à l'écriture, remet finished à False : une nouvelle écriture annule la visibilité précédente.
  • consume / finish / get:130-148 — trois hooks combinés pour la sémantique « effacer après une lecture ».
  • fallback LastValue:1857-1859 — à la compilation, StateGraph fait remonter tout champ sans reducer vers LastValue(annotation).

Flux de données

Toute la logique de LastValue.update tient en quelques lignes (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

Notez que la troisième branche écrit values[-1] et non values[0] — comme on a déjà rejeté len != 1, on ne peut tomber que sur len == 1, où [-1] et [0] coïncident. Le retour False sur séquence vide est crucial : il s'accorde avec l'appel update(EMPTY_SEQ) que apply_writes fait sur les canaux inchangés (EMPTY_SEQ update:329), pour que LastValue renvoie correctement « inchangé » quand on l'appelle à vide.

Comment StateGraph compile-t-il les champs non annotés en LastValue ? Voir 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

Essais en cascade : managed value (par ex. gestion spéciale dans MessagesState) → instance explicite de BaseChannelAnnotated[..., reducer] (BinaryOperatorAggregate, voir /channels/topic-binop) → à défaut, LastValue.

Séquence complète de lecture/écriture :

Limites et échecs

  • Erreur immédiate sur écriture concurrente multi-valeurs : multi-value error:59-64 lève InvalidUpdateError avec le code INVALID_CONCURRENT_GRAPH_UPDATE. C'est l'écueil classique pour les débutants : deux nœuds écrivant en même temps sur un même champ scalaire. La correction est de passer à un champ avec reducer, par ex. Annotated[int, lambda a, b: a + b].
  • L'écriture vide renvoie False : empty seq:57-58 renvoie False, en cohérence avec le contrat BaseChannel ; apply_writes saute alors la mise à jour de channel_versions.
  • __eq__ ne compare que le type, pas la valeur : __eq__:31-32 renvoie isinstance(value, LastValue), donc deux instances LastValue sont considérées égales quelles que soient leurs valeurs. C'est pour la déduplication à la compilation du graphe ; ne pas l'utiliser pour comparer des valeurs.
  • MISSING vs None : None est une valeur légale ; seul MISSING signifie « jamais écrit » (get / is_available:70-75). Si votre champ métier autorise None, écrivez-le sans crainte, le canal ne le traitera pas comme un vide.
  • Une nouvelle écriture sur LastValueAfterFinish annule la visibilité : LAF.update:122-128 commence par self.finished = False. Autrement dit, si une écriture intervient après finish(), le canal redevient « non publié » et attend le prochain finish() pour être lu.
  • LastValueAfterFinish.consume est no-op tant que finished est faux : voir LAF.consume:130-136. Les valeurs « écrites mais pas encore finish-ées » ne sont pas effacées ; elles ne le sont qu'après avoir été consommées une fois — exactement la sémantique voulue pour un « signal de fin ».

Résumé

LastValue est la forme par défaut de l'état LangGraph : scalaire, écriture écrasante, contrainte « une valeur par superpas » qui lève une erreur plutôt que de perdre silencieusement des données. Le comprendre, c'est comprendre le comportement à l'exécution de tous les champs sans reducer. Pour la fusion correcte d'écritures concurrentes, poursuivre sur /channels/topic-binop ; pour l'interface du canal, revenir à /channels/base-channel ; pour le moment où update est appelé, voir /pregel/algo.

Voir la documentation officielle : documentation LangGraph · README