Aller au contenu principal

Module 3 — Composition de chaînes en LCEL

Un ticket extrait, une politique à consulter, une décision à formuler : notre assistant de notes de frais enchaîne rarement une seule opération. Le LangChain Expression Language (LCEL) permet de composer ces étapes avec l'opérateur |, de les paralléliser quand c'est possible, et d'obtenir gratuitement le flux, les lots et l'asynchrone. Ce module est purement mécanique — chaque bloc de code ci-dessous se colle dans un fichier et tourne tel quel.

Le contrat Runnable

Un Runnable est un objet qui expose .invoke, .stream, .batch (plus leurs versions asynchrones). Un modèle chat, un gabarit de consigne, un analyseur, un récupérateur, une fonction Python passée à RunnableLambda : tout est un Runnable. L'opérateur | en enchaîne deux : la sortie du gauche devient l'entrée du droit.

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

consigne = ChatPromptTemplate.from_template("Résumez en une phrase : {texte}")
chaine = consigne | ChatOpenAI(temperature=0) | StrOutputParser()

print(chaine.invoke({"texte": "Long ticket de restaurant..."}))

Trois nœuds composés. La chaîne accepte n'importe quel dictionnaire dont les clés correspondent aux variables du gabarit.

RunnablePassthrough : ajouter sans détruire

Une chaîne perd rapidement l'entrée initiale : après le premier |, seule la sortie du gabarit circule. RunnablePassthrough sert à transporter l'entrée originale à travers le pipeline, ou à l'enrichir sans la remplacer :

from langchain_core.runnables import RunnablePassthrough

chaine = (
RunnablePassthrough.assign(politique=lambda x: recuperer_politique(x["question"]))
| consigne_rembo
| modele
| StrOutputParser()
)

chaine.invoke({"question": "Le taxi vers l'aéroport est-il remboursable ?"})

RunnablePassthrough.assign(k=fn) ajoute une clé au dictionnaire courant en appelant fn sur l'entrée entière, sans toucher aux clés déjà présentes. C'est le patron de conception du RAG (module 5) : injecter le contexte récupéré avant la consigne finale.

RunnableParallel : lancer plusieurs branches en même temps

Certaines étapes sont indépendantes et peuvent tourner en parallèle. Décider si un ticket est conforme demande à la fois la politique de remboursement et l'historique de l'agent : deux récupérations différentes, deux appels réseau, la même latence quand on les lance ensemble.

from langchain_core.runnables import RunnableParallel

preparation = RunnableParallel(
politique=recuperateur_politique,
historique=recuperateur_historique,
question=RunnablePassthrough(),
)

chaine = preparation | consigne_arbitrage | modele | StrOutputParser()

Sous le capot, RunnableParallel lance ses branches avec asyncio.gather. Deux appels à 800 ms n'en prennent plus que 800 ms au lieu de 1 600. La sortie est un dictionnaire dont les clés sont les noms des branches, prêt à alimenter un ChatPromptTemplate avec les mêmes variables.

Branchement conditionnel

Toutes les questions ne méritent pas le même traitement. Une salutation ne demande pas de récupération ; une question sur la politique en demande une ; une demande d'ajout de ligne dans le tableur déclenche un outil. RunnableBranch route selon un prédicat :

from langchain_core.runnables import RunnableBranch

routeur = RunnableBranch(
(lambda x: "bonjour" in x["question"].lower(), reponse_salutation),
(lambda x: "ajouter" in x["question"].lower(), chaine_outil),
chaine_rag_defaut,
)

routeur.invoke({"question": "Bonjour, j'ajoute mon repas d'hier"})

Le dernier argument est la branche par défaut. Un routage plus fin passe généralement par un premier appel LLM de classification qui produit un route: "rag" | "outil" | "salutation", puis un branchement dessus.

Flux, lots et asynchrone

Le vrai retour sur investissement de LCEL apparaît ici. Une chaîne construite avec | expose sans effort supplémentaire :

  • .stream(...) rend la sortie fragment par fragment. Pour une interface utilisateur, cela transforme les 3 secondes d'attente en un texte qui s'écrit en direct.
  • .batch([...], config={"max_concurrency": 5}) traite plusieurs entrées avec un pool contrôlé. Ré-annoter 500 tickets prend quelques minutes au lieu d'une heure séquentielle.
  • .ainvoke, .astream, .abatch exposent les mêmes verbes en asynchrone pour un serveur FastAPI (cours 40) ou un consommateur de file.
async for fragment in chaine.astream({"question": "Quel est le plafond nuit d'hôtel ?"}):
print(fragment, end="", flush=True)

Aucune ligne de gestion de tâches asynchrones à écrire ; le pipeline propage le flux jusqu'à l'appel modèle qui, chez OpenAI comme chez Anthropic, sait streamer nativement.

Le piège du parallèle inutile

RunnableParallel n'accélère que les branches indépendantes. Deux appels au même modèle avec la même entrée ne gagnent rien, sinon un doublement du coût. Avant de paralléliser, se poser la question : est-ce que les branches consomment des ressources différentes ? Un appel modèle et une requête base de données, oui. Deux appels au même modèle, non — c'est un .batch déguisé.

Composer plutôt que sous-classer

Une chaîne devient longue quand on tente d'y encapsuler des embranchements dans une classe maison. LCEL préfère la composition explicite : cinq lignes verticales qui se lisent, chacune un Runnable. Une chaîne qui dépasse dix lignes est presque toujours un signe qu'il faut la découper en sous-chaînes nommées.

En résumé

  • Tout est Runnable : .invoke, .stream, .batch (et leurs versions asynchrones) sont garantis, l'opérateur | compose deux Runnable en pipeline.
  • RunnablePassthrough.assign(clé=fn) enrichit le dictionnaire courant sans écraser l'entrée initiale — c'est le patron du RAG.
  • RunnableParallel lance des branches indépendantes en concurrence via asyncio.gather ; réserver au vrai parallèle, pas à deux appels du même modèle.
  • Le flux (stream), les lots (batch) et l'asynchrone (a*) sont exposés gratuitement par la composition LCEL.

Module suivant : brancher des documents réels — chargeurs PDF, web et bureautique, puis découpage avec métadonnées.