LastValue : le canal par défaut à écriture écrasante
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 pourquoiStateGraph(fallback:1857) compile les champs sans reducer enLastValue. - Refuser la perte silencieuse : si deux nœuds écrivent
countdans le même superpas,LastValuene choisit pas arbitrairement un gagnant : il lèveINVALID_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. MISSINGplutôt queNone: la valeur initiale estlanggraph._internal._typing.MISSING(value = MISSING:29), pour queNonepuisse être une valeur légale.get()lèveEmptyChannelErrorquand la valeur estMISSING(get:69-72). Cela reste cohérent avec l'implémentation par défaut deBaseChannel, maisis_availableest surchargée en une seule ligneself.value is not MISSING(is_available:74-75) pour éviter un try/except à chaque appel.copymoins cher quefrom_checkpoint: bien que la classe parente définissecopy = from_checkpoint(checkpoint()),copyest surchargée ici (copy:44-48) pour partager simplement la même référencevalue— un scalaire n'a pas besoin de deep-copy.from_checkpoint(from_checkpoint:50-54) décide selon la brancheMISSINGs'il faut assigner.- Visibilité différée de
LastValueAfterFinish: certains signaux de contrôle (par ex. le goto d'unCommand) ne doivent pas être vus immédiatement parprepare_next_tasks, sinon ils se déclencheraient eux-mêmes au sein du superpas et formeraient un cycle.LastValueAfterFinishutilise un flagfinished(finished flag:93-95) :get()ne renvoie la valeur que sifinished=True(get gated:145-148),finish()est appelé par Pregel lors du bilan (finish:138-143), etconsume()efface après une consommation (consume:130-136).
Fichiers clés
LastValue class:20-25— définition, générique surBaseChannel[Value, Value, Value]: type stocké, type d'écriture et type de checkpoint sont identiques.__init__:27-29— à la constructionvalue = MISSING, et uniquementMISSINGtant qu'il n'a pas été écrit.ValueType / UpdateType:34-42— les deux renvoientself.typ, le type passé à la déclaration.copy:44-48— réutilise directement la référencevalue, pas de deep-copy pour un scalaire.from_checkpoint:50-54— reconstruit une instance depuis une valeur de checkpoint ;MISSINGsignifie repartir à vide.update:56-67— contrainte centrale : séquence vide renvoieFalse, longueur > 1 lèveInvalidUpdateError, sinon stocke la dernière valeur et renvoieTrue.get / is_available:69-75— interface de lecture ;MISSINGlèveEmptyChannelError;is_availablesurchargée en une ligne.LastValueAfterFinish:81-95— variante : ajoute un flagfinished, différant la visibilité jusqu'àfinish().LastValueAfterFinish.update:122-128— à l'écriture, remetfinishedà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,StateGraphfait remonter tout champ sans reducer versLastValue(annotation).
Flux de données
Toute la logique de LastValue.update tient en quelques lignes (update:56-67) :
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 TrueNotez 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 :
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 fallbackEssais en cascade : managed value (par ex. gestion spéciale dans MessagesState) → instance explicite de BaseChannel → Annotated[..., 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-64lèveInvalidUpdateErroravec le codeINVALID_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-58renvoieFalse, en cohérence avec le contratBaseChannel;apply_writessaute alors la mise à jour dechannel_versions. __eq__ne compare que le type, pas la valeur :__eq__:31-32renvoieisinstance(value, LastValue), donc deux instancesLastValuesont 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.MISSINGvsNone:Noneest une valeur légale ; seulMISSINGsignifie « jamais écrit » (get / is_available:70-75). Si votre champ métier autoriseNone, écrivez-le sans crainte, le canal ne le traitera pas comme un vide.- Une nouvelle écriture sur
LastValueAfterFinishannule la visibilité :LAF.update:122-128commence parself.finished = False. Autrement dit, si une écriture intervient aprèsfinish(), le canal redevient « non publié » et attend le prochainfinish()pour être lu. LastValueAfterFinish.consumeest no-op tant quefinishedest faux : voirLAF.consume:130-136. Les valeurs « écrites mais pas encorefinish-é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