add_node / add_edge / add_conditional_edges : remplir le blueprint
Responsabilités
StateGraph expose trois méthodes centrales d'enregistrement : add_node enveloppe une fonction / Runnable en nœud (add_node:662), add_edge ajoute une arête déterministe (add_edge:915), add_conditional_edges ajoute un branchement conditionnel (add_conditional_edges:969). Toutes trois renvoient Self, ce qui permet le chaînage : builder.add_node("a", a).add_node("b", b).add_edge("a", "b").add_conditional_edges("b", route). Elles ne font qu'ajouter dans les conteneurs builder.nodes / builder.edges / builder.branches ; la traduction réelle en PregelNode + abonnements de canaux n'a lieu qu'à compile(), via attach_node / attach_edge / attach_branch.
Elles se situent tout au bord de l'API utilisateur : presque chaque ligne que vous écrivez en construisant un graphe appelle l'une de ces trois méthodes. add_node est la plus complexe — elle déduit input_schema depuis la signature de la fonction, déduit les destinations depuis un type de retour Literal[...], et enveloppe error_handler dans un nœud séparé __error_handler__{node} (injection error handler:856-870). add_edge est plus direct : soit elle va dans le set edges, soit dans waiting_edges (arêtes multi-sources, qui attendent que toutes les sources soient terminées). add_conditional_edges est très mince : elle enveloppe path dans un Runnable, puis délègue à BranchSpec.from_path (from_path:89) pour calculer le dict ends stocké dans branches[source].
Motivation de conception
Pourquoi add_node déduit-il autant de choses depuis la signature de la fonction ?
- Moins de boilerplate : quand
input_scheman'est pas passé explicitement, il est déduit de l'annotation du premier paramètre (inferred input schema:815-825) — ainsidef my_node(state: MyState): ...utilise automatiquementMyState; quand le type de retour estLiteral["a", "b", "__end__"], il est automatiquement pris comme destinations (Literal destinations:840-846), sans avoir à écriredestinations=.... - Nom de nœud optionnel : le paramètre
nodepeut être soit une chaîne, soit la fonction elle-même ; dans ce cas__name__est utilisé comme nom de nœud (inférence du nom de nœud:768-773) — pour unRunnable, c'estget_name(). error_handlerest un nœud à part (nœud error_handler:857-870) — ce n'est pas un champ du spec, mais un nœud ordinaire généré sous le nom__error_handler__{node}, avec un pointeur stocké dans le champerror_handler_node. Ainsi le handler est lui-même un nœud Pregel et bénéficie de retry / metadata / tracing comme n'importe quel nœud.- Sémantique unifiée des arêtes multi-sources :
add_edge(["a", "b"], "c")ne signifie pas « quand a finit, lance c ; quand b finit, lance c », mais « c n'est lancé qu'après que a et b ont tous les deux terminé » (sémantique multi-start:917-921), stocké dans le setwaiting_edges, puis traduit à la compilation en canalNamedBarrierValue. - La valeur de retour d'une arête conditionnelle peut omettre path_map : sans
path_map, il est déduit du type de retourLiteral["a", "b"]depath(inférence Literal path_map:103-115), ce qui fait fonctionner automatiquementdef route(state) -> Literal["a", "b"]: ...; en l'absence des deux, la visualisation du graphe supposera qu'elle peut sauter vers n'importe quel nœud (warning:994-997). - Branches de même nœud et de même nom = erreur (
branche en double:1009-1012) — un nœud peut avoir plusieurs arêtes conditionnelles, mais chacune doit avoir un nom de condition unique, carbranches[source]est un dict indexé par nom.
Fichiers clés
signature add_node:662-676— reçoitnode/action/defer/metadata/input_schema/retry_policy/cache_policy/error_handler/destinations/timeout.inférence du nom de nœud:768-790— chaîne utilisée telle quelle, fonction via__name__,Runnableviaget_name(); les mots réservésSTART/END/NS_SEP/NS_ENDdéclenchent une erreur.inférence de types depuis la signature:803-848— déduitinferred_input_schemaet les destinationsCommand[Literal[...]]du type de retour à partir des type hints de__call__.injection error_handler:856-870— génère un nœud séparé__error_handler__{node}stocké dansself.nodes, avec le champis_error_handler=True.stockage StateNodeSpec:872-907— stocke le spec dansself.nodesselon trois cas :input_schema/inferred_input_schema/self.state_schema.add_edge:915-967— source unique dans le setedges, sources multiples danswaiting_edges; la source ne peut pas êtreEND, la cible ne peut pas êtreSTART.add_conditional_edges:969-1017— enveloppepathen Runnable,BranchSpec.from_pathcalculeends, stocké dansself.branches[source][name].BranchSpec.from_path:83-120— gère les trois formes depath_map:dict/list/ inférence depuis le type de retourLiteral; déduit aussiinput_schema.add_sequence:1019-1044— enregistre en une fois une série de nœuds (sucre syntaxique, qui boucle en interne suradd_node).attach_edge:1537-1561— à la compilation : pour une arête simple, ajoute unChannelWriteaux writers du nœud source pointant vers le canal cible ; pour une arête multi-sources, enregistre un canalNamedBarrierValuecomme jointure.attach_branch:1563-1596— à la compilation : traduit unBranchSpecenChannelWrite.register_writer, et selonendsdécide vers quel canal cible écrire.
Flux de données
add_node fait réellement son travail dans ces deux passages : inférer l'input schema et les destinations du type de retour, puis stocker le spec dans self.nodes :
if (
isfunction(action)
or ismethod(action)
or ismethod(getattr(action, "__call__", None))
) and (
hints := get_type_hints(getattr(action, "__call__"))
or get_type_hints(action)
):
if input_schema is None:
first_parameter_name = next(
iter(inspect.signature(cast(FunctionType, action)).parameters.keys())
)
if input_hint := hints.get(first_parameter_name):
if isinstance(input_hint, type) and get_type_hints(input_hint):
inferred_input_schema = input_hint
if rtn := hints.get("return"):
rtn_origin = get_origin(rtn)
if rtn_origin is Union:
rtn_args = get_args(rtn)
for arg in rtn_args:
arg_origin = get_origin(arg)
if arg_origin is Command:
rtn = arg
rtn_origin = arg_origin
break
if (
rtn_origin is Command
and (rargs := get_args(rtn))
and get_origin(rargs[0]) is Literal
and (vals := get_args(rargs[0]))
):
ends = valsLe spec est ensuite stocké dans le dict self.nodes selon trois cas — input_schema explicite / inferred_input_schema déduit / repli sur self.state_schema :
if input_schema is not None:
self.nodes[node] = StateNodeSpec[NodeInputT, ContextT](
coerce_to_runnable(action, name=node, trace=False),
metadata,
input_schema=input_schema,
retry_policy=retry_policy,
cache_policy=cache_policy,
error_handler_node=handler_node_name,
ends=ends,
defer=defer,
timeout=timeout,
)add_conditional_edges en revanche est très mince — le cœur est d'envelopper path dans un Runnable puis de laisser BranchSpec.from_path calculer le dict ends :
path = coerce_to_runnable(path, name=None, trace=True)
name = path.name or "condition"
if name in self.branches[source]:
raise ValueError(
f"Branch with name `{path.name}` already exists for node `{source}`"
)
self.branches[source][name] = BranchSpec.from_path(path, path_map, True)
if schema := self.branches[source][name].input_schema:
self._add_schema(schema)
return self(implémentation add_conditional_edges:1005-1017)
Limites et échecs
- Nom de nœud en double = erreur directe (
renom:792-793) —Nodealready present, pas de surcharge autorisée. - Mots réservés pour les noms de nœuds (
reserved:794-801) —START/END/NS_SEP(|) /NS_END(:) sont interdits, les deux derniers casseraient le assemblage decheckpoint_ns. add_edgeavec sourceEND= erreur (END interdit en source:939-940), et symétriquementSTARTne peut pas être la cible (START interdit en cible:941-942) — ces deux constantes sont l'entrée et la sortie du graphe, elles ne peuvent pas être inversées.- Les arêtes multi-sources exigent que toutes les sources aient été
add_nodeau préalable (validation multi-sources:956-964) — sinonattach_edgene trouvera pas leswritersde la source à la compilation. - Deux branches de même nom sur un même nœud = erreur (
branche en double:1009-1012), mais plusieurs branches de noms distincts peuvent coexister — c'est la base du routage mixte conditionnel + déterministe. add_nodesur un graphe déjà compilé ne fait qu'un warning (compiled warning:778-782), les modifications ne sont pas répercutées sur l'instance compilée — piège classique d'ordre.
Résumé
add_node / add_edge / add_conditional_edges sont l'interface d'écriture de StateGraph, pas complexe en soi ; ce qui l'est, c'est la « magie » d'inférence du schema et des destinations depuis la signature de la fonction. Une fois ces trois méthodes comprises, vous avez essentiellement lu toute l'API mutable de StateGraph ; la suite est compile qui traduit ces specs en PregelNode, et le modèle d'exécution dans moteur Pregel.
Voir la documentation officielle : LangGraph docs · README.