Command : primitif trois-en-un resume / goto / update
Responsabilités
Command est le « primitif de contrôle trans-graphe » de LangGraph, défini dans libs/langgraph/langgraph/types.py(Command class:759). Une instance de Command peut porter simultanément trois sémantiques — resume (reprendre l'exécution depuis une interruption), goto (indiquer vers quel nœud sauter ensuite), update (écrire une valeur dans les canaux d'état du graphe courant). Une fonction de nœud peut renvoyer Command(...), ou l'appelant peut le fournir comme entrée de graph.invoke(Command(...)) ; Pregel le décompose en pending writes écrits dans les canaux.
Sa position se situe entre le nœud et la boucle principale Pregel : le nœud produit un Command, Pregel utilise map_command(map_command:56) pour décomposer ses champs en triplets (task_id, channel, value) écrits dans les pending writes ; au tour suivant, prepare_next_tasks les voit naturellement et ordonnance en conséquence — goto devient un Send écrit dans le canal TASKS, update écrit directement dans le canal correspondant, resume passe par le canal RESUME pour être traité par interrupt().
Motivation de conception
- Expression unifiée des « effets de bord » : en plus d'écrire dans l'état, un nœud peut vouloir « sauter vers un nœud suivant non par défaut », « transmettre un message au graphe père » ou « reprendre une interruption » — auparavant dispersées dans plusieurs API,
Commandles regroupe en un type de retour unique ; la signature-> Command[Literal["node_a", "node_b"]]permet aussi à la compilation de valider les cibles de saut autorisées. Command.PARENTfranchit la hiérarchie de graphes : un nœud dans un sous-graphe peutreturn Command(graph=Command.PARENT, update=...)pour écrire dans l'état du graphe père —map_commandlèveraise InvalidUpdateError("There is no parent graph")sigraph == "__parent__"(PARENT guard:58) ; l'écriture effective dans le père est traitée dans le_first` du graphe père à la réception des writes remontés par le sous-graphe.gotoaccepteSend:Command(goto=Send("node", arg))équivaut à déclencher un fan-out (dispersion) via une tâche PUSH ;goto="node"équivaut àgoto=Send("node", START)(déclenche le PULL de ce nœud). Ainsi,Commandincorpore la sémantique des conditional edges dans la valeur de retour du nœud, sans avoir à appeleradd_conditional_edgesséparément.resumeà entrée unique :Command(resume=...)est le seul chemin de reprise pourinterrupt()— il accepte aussi bien une valeur uniqueCommand(resume="answer")qui se route automatiquement vers le seul interrupt en attente, qu'un dictCommand(resume={interrupt_id: value, ...})pour reprendre plusieurs interrupts simultanément.updates'appuie sur le reducer :Command(update={"messages": [msg]})passe par le reducer du canal — si le canalmessagesest configuré avecoperator.add, cette update est un append et non un écrasement. Le wrapperOverwritepermet de court-circuiter le reducer(Overwrite:938), via le chemin d'écriture directe du canalBinaryOperatorAggregate.
Fichiers clés
Command class:759—@dataclass class Command(graph, update, resume, goto), le génériqueNreprésente le type du nom de nœud goto.Command fields:780— définitions et docstrings des champsgraph/update/resume/goto._update_as_tuples:791— décomposeupdateen[(channel, value), ...]; supporte dict, list[tuple] et annotated dataclass.map_command:56— fonction centrale qui décomposeCommanden une séquence de pending writes.PARENT guard:58— erreur « pas de graphe père » quandcmd.graph == Command.PARENT.goto as sends:62—cmd.gotodécomposé en liste deSend; lesSendsont écrits dans le canalTASKS, lesstrdansbranch:to:<node>pour déclencher START.resume write:72—cmd.resumeécrit dans le canalRESUME, lu parinterrupt()à la ré-entrée.update writes:74—cmd.updatedécomposé en(channel, value)écrit dans le canal correspondant, via le reducer.Command resume mapping:904— entrée où le graphe père, après avoir reçuCommand(resume=...), route le resume vers le pending interrupt correspondant.Overwrite:938— wrapperOverwrite(value=...), utilisé avec un canalBinaryOperatorAggregatepour écrire en direct en contournant le reducer.
Flux de données
map_command est le cœur du décodage de Command(map_command:56) :
def map_command(cmd: Command) -> Iterator[tuple[str, str, Any]]:
"""Map input chunk to a sequence of pending writes in the form (channel, value)."""
if cmd.graph == Command.PARENT:
raise InvalidUpdateError("There is no parent graph")
if cmd.goto:
if isinstance(cmd.goto, (tuple, list)):
sends = cmd.goto
else:
sends = [cmd.goto]
for send in sends:
if isinstance(send, Send):
yield (NULL_TASK_ID, TASKS, send)
elif isinstance(send, str):
yield (NULL_TASK_ID, f"branch:to:{send}", START)
else:
raise TypeError(
f"In Command.goto, expected Send/str, got {type(send).__name__}"
)
if cmd.resume is not None:
yield (NULL_TASK_ID, RESUME, cmd.resume)
if cmd.update:
for k, v in cmd._update_as_tuples():
yield (NULL_TASK_ID, k, v)Le task_id est toujours NULL_TASK_ID — ces writes n'appartiennent à aucune tâche spécifique, ce sont des « sorties » au niveau du graphe : le Send du goto va dans le canal TASKS (prepare_next_tasks le verra au tour suivant et l'ordonnancera comme une tâche PUSH) ; le str du goto passe par le canal branch:to:<node> (canal dédié aux conditional edges, écrire START signifie « déclencher l'entrée de ce nœud ») ; resume va dans le canal RESUME, consommé par interrupt() à la ré-entrée du nœud ; chaque (k, v) de update va dans le canal d'état correspondant, fusionné par le reducer.
Quand Command est l'entrée de graph.stream(...), PregelLoop._first(Command resume mapping:904)le convertit d'abord en pending writes, puis les regroupe par task_id pour écrire danscheckpoint_pending_writes, et prepare_next_tasksles traite naturellement. QuandCommandest la valeur de retour d'un nœud,attach_nodeaccroche un mapper_get_updatesauxwritersdu nœud(<SrcLink path="libs/langgraph/langgraph/graph/state.py" lines="1431" label="attach_node"/>), qui extrait l'update depuisCommand._update_as_tuples()pour l'écrire dans les canaux d'état, tandis que goto et resume sont routés via le mapper_control_branchvers le canalbranch:to:etRESUME`.
Limites et échecs
Command.PARENTéchoue sur le graphe racine :map_commandlève directementraise InvalidUpdateError("There is no parent graph")(PARENT guard:58) —Command.PARENTn'a de sens que dans un nœud de sous-graphe ; un appel depuis le graphe racine échoue.- Restriction de type pour
goto:gotoaccepte uniquementSend/strou une séquence de ceux-ci ; tout autre type lèveraise TypeError(goto type:69) — les entiers, dicts, etc. ne sont pas autorisés. - resume exige un seul pending si l'id n'est pas spécifié :
Command(resume="single")lèveRuntimeErrors'il y a plusieurs pending interrupts(multi interrupt error:917) ; il faut alorsCommand(resume={id: value, ...}). updatepasse par le reducer, pas d'écriture directe : le canalBinaryOperatorAggregate(par ex.Annotated[list, operator.add]) passe par défaut par le reducer, et plusieurs updates sont fusionnés ; pour contourner le reducer, utiliserOverwrite(value=...)(Overwrite:938), mais plusieursOverwritesur un même canal au cours d'un même superpas lèvent uneInvalidUpdateError.Commandmutuellement exclusif avecentrypoint.final: une fonction@entrypointrenvoieentrypoint.final(value=..., save=...), qui passe en interne par les canauxEND/PREVIOUS; renvoyerCommandpasse par les canaux de contrôle — les deux ne peuvent pas être utilisés simultanément, la fonction entrypoint doit renvoyer soitCommandsoitfinal, pas mélanger.Commandvide échoue : quand les trois champs deCommand()sont tousNone/(),map_commandne yield rien, et_firstdétectant des writes vides lèveraise EmptyInputError("Received empty Command input")(empty command:928).
Résumé
Command est le primitif trois-en-un resume / goto / update, décomposé en writes de canaux via map_command, puis naturellement traité au tour suivant par prepare_next_tasks. Il unifie conditional edges, interrupt resume et mise à jour d'état en une seule valeur de retour. Le interrupt() associé se voit dans /interrupt/interrupt, et la sémantique de fan-out de Send dans /subgraph/send-command. Voir la documentation officielle : LangGraph 文档 · README