En bref

  • Passage plus rapide du prototype à la production: comme l'indique la documentation de Langchain, « LCEL est une méthode déclarative permettant de combiner facilement des chaînes entre elles. LCEL a été conçu dès le départ pour permettre la mise en production de prototypes sans modification du code ».

  • Création de chaînes personnalisées: LCEL simplifie le processus de création de chaînes personnalisées grâce à une nouvelle syntaxe.

  • Diffusion en continu et traitement par lots prêts à l'emploi: LCEL vous offre gratuitement des fonctionnalités de traitement par lots, de diffusion en continu et asynchrone.

  • Interface unifiée: elle offre une parallélisation automatique, des fonctionnalités de typage et toutes les fonctionnalités que LangChain pourrait développer à l'avenir.

  • LCEL représente l'avenir de LangChain: LCEL apporte un regard neuf sur le développement d'applications basées sur les grands modèles de langage (LLM). Je vous recommande vivement de l'utiliser pour votre prochain projet LLM.

En moins d'un an, LangChain est devenue l'une des bibliothèques Python les plus utilisées pour interagir avec les grands modèles de langage (LLM), mais elle servait principalement à réaliser des preuves de concept (POC), car elle ne permettait pas de créer des applications complexes et évolutives.
Tout a changé en août 2023 avec la sortie de LangChain Expression Language (LCEL), une nouvelle syntaxe qui comble le fossé entre les POC et la production. Cet article vous guidera à travers les tenants et aboutissants de LCEL, en vous montrant comment il simplifie la création de chaînes personnalisées et pourquoi vous devez l'apprendre si vous développez des applications LLM!

Prompts, modèles de langage de grande envergure et chaînes : rafraîchissons-nous la mémoire

Avant de nous plonger dans la syntaxe de LCEL, je pense qu’il est utile de nous rafraîchir la mémoire sur certains concepts de LangChain, tels que les LLM, les prompts ou encore les chaînes.

LLM: Dans LangChain, un LLM est une abstraction du modèle utilisé pour générer des compléments, comme OpenAI GPT-3.5, Claude, etc.

Consigne: Il s'agit de l'entrée de l'objet LLM, qui posera des questions au LLM et lui indiquera ses objectifs.

Chaîne: il s'agit d'une séquence d'appels à un modèle de langage (LLM) ou de toute étape data .

class="lazyload

Maintenant que les définitions sont réglées, imaginons que nous voulions créer une entreprise ! Il nous faut un nom vraiment sympa et accrocheur, ainsi qu'un modèle économique pour gagner de l'argent !

Exemple — Nom de l'entreprise et modèle économique avec les anciennes chaînes

from langchain.chains import LLMChain
from langchain.prompts import PromptTemplate
from langchain_community.llms import OpenAI

USER_INPUT = "chaussettes colorées"
llm = OpenAI(temperature=0)

prompt_template_product = "Quel serait un bon nom pour une entreprise qui fabrique ?"
company_name_chain = LLMChain(llm=llm, prompt=PromptTemplate.from_template(prompt_template_product))
company_name_output = company_name_chain(USER_INPUT)

prompt_template_business = "Propose-moi la meilleure idée de modèle économique pour mon entreprise nommée : "
business_model_chain = LLMChain(llm=llm, prompt=PromptTemplate.from_template(prompt_template_business))
business_model_output = business_model_chain(company_name_output["text"])

print(nom_de_l'entreprise_résultat)
print(modèle_économique_résultat)

>>>
>>>

C'est assez simple à suivre ; on constate certes quelques redondances, mais cela reste gérable.

Ajoutons quelques options de personnalisation en gérant les cas où l'utilisateur n'utilise pas notre chaîne comme prévu.
Peut-être que l'utilisateur saisira quelque chose qui n'a absolument rien à voir avec l'objectif de notre chaîne ? Dans ce cas, nous voulons le détecter et réagir de manière appropriée.

Exemple — Personnalisation et routage avec d'anciennes chaînes

from langchain.chains import LLMChain
from langchain.prompts import PromptTemplate
from langchain_community.llms import OpenAI
import ast

USER_INPUT = "Harrison Chase"
llm = OpenAI(temperature=0)

# —- Même code que précédemment
prompt_template_product = "Quel serait un bon nom pour une entreprise qui fabrique ?"
company_name_chain = LLMChain(llm=llm, prompt=PromptTemplate.from_template(prompt_template_product))

prompt_template_business = "Propose-moi la meilleure idée de modèle économique pour mon entreprise nommée : "
business_model_chain = LLMChain(llm=llm, prompt=PromptTemplate.from_template(prompt_template_business))

# —- Nouveau code

prompt_template_is_product = (
"Votre objectif est de déterminer si la saisie de l'utilisateur correspond à un nom de produit plausible"
"Les questions, les formules de politesse, les phrases longues, les noms de célébrités ou toute autre saisie non pertinente ne sont pas considérés comme des noms de produits"
"Saisie : n"
"Répondez uniquement par « Vrai » ou « Faux », sans rien ajouter d'autre"
)

prompt_template_cannot_respond = (
"Vous ne pouvez pas répondre à la saisie de l'utilisateur : n"
"Demandez à l'utilisateur de saisir le nom d'un produit afin que vous puissiez créer une entreprise à partir de celui-ci.n"
)

cannot_respond_chain = LLMChain(llm=llm, prompt=PromptTemplate.from_template(prompt_template_cannot_respond))
company_name_chain = LLMChain(llm=llm, prompt=PromptTemplate.from_template(prompt_template_product))
business_model_chain = LLMChain(llm=llm, prompt=PromptTemplate.from_template(prompt_template_business))
is_a_product_chain = LLMChain(llm=llm, prompt=PromptTemplate.from_template(prompt_template_is_product))

# Si l'on utilise bool sur une chaîne non vide, le résultat sera True ; il faut donc utiliser `literal_eval`
is_a_product = ast.literal_eval(is_a_product_chain(USER_INPUT)["text"])
if is_a_product:
company_name_output = company_name_chain(USER_INPUT)
business_model_output = business_model_chain(company_name_output["text"])
print(business_model_output)
else:
print(cannot_respond_chain(USER_INPUT))

Cela devient un peu plus difficile à comprendre, résumons donc :

  • Nous avons créé une nouvelle chaîne is_real_product_chain() qui permet de déterminer si la saisie de l'utilisateur peut être considérée comme un produit.

  • Nous utilisons des conditions if/else pour effectuer des branchements entre les chaînes.

Plusieurs problèmes commencent à se poser :

  • Le code est un peu redondant, car il contient beaucoup de code standard.

  • Il est difficile de déterminer à quel LLMChain se réfère ce lien ; il faut remonter la chaîne des entrées et des sorties pour le comprendre.

  • On peut facilement se tromper sur les types de sortie des chaînes ; par exemple, la sortie de la fonction `is_a_product_chain()` est une chaîne de caractères qui doit ensuite être évaluée comme un booléen.

Qu'est-ce que le langage d'expression LangChain (LCEL) ?

LCEL est une interface et une syntaxe unifiées permettant d'écrire des chaînes modulaires prêtes à l'emploi ; il y a beaucoup à approfondir pour en saisir toute la portée.

Nous allons d'abord essayer de comprendre la nouvelle syntaxe en réécrivant la chaîne présentée précédemment.

Exemple — Nom de l'entreprise et modèle économique avec LCEL

from langchain_core.runnables import RunnablePassthrough
from langchain.prompts import PromptTemplate
from langchain_community.llms import OpenAI

USER_INPUT = "chaussettes colorées"
llm = OpenAI(temperature=0)

prompt_template_product = "Quel serait un bon nom pour une entreprise qui fabrique ?"
prompt_template_business = "Donnez-moi la meilleure idée de modèle économique pour mon entreprise nommée : "

chain = (
PromptTemplate.from_template(prompt_template_product)
| llm
|
| PromptTemplate.from_template(prompt_template_business)
| llm
)

business_model_output = chain.invoke()

Beaucoup de code inhabituel, en seulement quelques lignes :

  • Il y a un opérateur | un peu bizarre entre un PromptTemplate, un LLM et un dictionnaire ?! L'opérateur | sert simplement à dire : « prends le dictionnaire à gauche et transmet-le en entrée à l'objet à droite ».

  • Pourquoi transmettons-nous la variable « product » sous forme de dictionnaire plutôt que sous forme de chaîne de caractères comme auparavant ? Si vous avez lu le point n° 1, vous savez que l'opérateur « | » attend des entrées sous forme de dictionnaires ; c'est pourquoi nous transmettons l'argument « product » sous forme de dictionnaire.

  • Pourquoi le nom de la fonction est-il RunnablePassthrough() au lieu du nom de l'entreprise ? RunnablePassthrough() est un espace réservé qui signifie : « nous n'avons pas encore le nom de l'entreprise, mais lorsque nous l'aurons, il faudra l'insérer ici ». J'expliquerai la signification du terme « Runnable » dans les parties suivantes ; pour l'instant, vous pouvez l'ignorer.

  • Pourquoi avons-nous besoin d'une méthode spécifique .invoke() plutôt que d'écrire chain() ?Nous le comprendrons dans la partie suivante, mais voici déjà un petit aperçu de la raison pour laquelle LCEL facilite l'industrialisation !

Mais est-ce vraiment plus modulable de créer des chaînes de cette manière ?
Mettons cela à l'épreuve en ajoutant la fonction is_a_product_chain() et la branche de contrôle si la saisie de l'utilisateur n'est pas correcte. Nous pouvons même définir le type de la chaîne avec Python Typing ; faisons-le par souci de bonne pratique.

Exemple — Personnalisation et routage avec LCEL

from typing import Dict
from langchain_core.runnables import RunnablePassthrough, RunnableBranch
from langchain.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain.output_parsers import BooleanOutputParser
from langchain.community.llms import OpenAI

USER_INPUT = "Harrrison Chase"
llm = OpenAI(temperature=0)

prompt_template_product = "Quel serait un bon nom pour une entreprise qui fabrique ?"
prompt_template_cannot_respond = (
"Vous ne pouvez pas répondre à la saisie de l'utilisateur : n"
"Demandez à l'utilisateur de saisir le nom d'un produit afin que vous puissiez en faire une entreprise.n"
)
prompt_template_business = "Donnez-moi la meilleure idée de modèle économique pour mon entreprise nommée : "
prompt_template_is_product = (
"Votre objectif est de déterminer si la saisie de l'utilisateur correspond à un nom de produit plausible"
"Les questions, les formules de politesse, les phrases longues, les noms de célébrités ou toute autre saisie non pertinente ne sont pas considérés comme des noms de produits"
"saisie : n"
"Répondez uniquement par « Vrai » ou « Faux », rien de plus"
)

answer_user_chain = (
PromptTemplate.from_template(prompt_template_product)
| llm
|
| PromptTemplate.from_template(prompt_template_business)
| llm
).with_types(input_type=Dict[str, str], output_type=str)

is_product_chain = (
PromptTemplate.from_template(prompt_template_is_product)
| llm
| BooleanOutputParser(true_val='True', false_val='False')
).with_types(input_type=Dict[str, str], output_type=bool)

cannot_respond_chain = (
PromptTemplate.from_template(prompt_template_cannot_respond) | llm
).with_types(input_type=Dict[str, str], output_type=str)

full_chain = RunnableBranch(
(is_product_chain, answer_user_chain),
cannot_respond_chain
).with_types(input_type=Dict[str, str], output_type=str)

print(full_chain.invoke())

Énumérons les différences :

  • La syntaxe est différente, d'accord, ça va de soi.

  • Il existe des chaînes intermédiaires définies et appelées au sein d'une chaîne plus grande, un peu comme des fonctions.

  • Les entrées et les sorties sont typées, un peu comme les fonctions.

  • Ça ne ressemble pas à du Python.

Pourquoi le LCEL est-il plus adapté à l'industrialisation ?

Si j’avais lu cet article jusqu’à ce moment précis et que quelqu’un m’avait demandé si j’étais convaincu par le LCEL, j’aurais probablement répondu non. La syntaxe est trop différente, et je pourrais sans doute organiser mon code en fonctions pour obtenir un résultat presque identique. Mais je suis là, en train d’écrire cet article, donc il doit y avoir autre chose.

Utilisation immédiate des fonctions invoke, stream et batch

En utilisant LCEL, votre chaîne bénéficie automatiquement des avantages suivants :

  • .invoke(): vous souhaitez transmettre vos données d'entrée et obtenir le résultat, ni plus ni moins.

  • .batch(): si vous souhaitez transmettre plusieurs entrées pour obtenir plusieurs sorties, la parallélisation est gérée automatiquement (ce qui est plus rapide que d'appeler invoke trois fois).

  • .stream(): cette méthode permet de commencer à afficher le début de la suggestion avant que celle-ci ne soit entièrement générée.

my_chain = prompt | llm

# ———invoke——— #
result_with_invoke = my_chain.invoke(« hello world ! »)

# ———lot——— #
result_with_batch = my_chain.batch([« hello », « world », « ! »])

# ———stream——— #
for chunk in my_chain.stream("hello world!"):
print(chunk, flush=True, end="")

Lorsque vous effectuez une itération, vous pouvez utiliser la méthode `invoke` pour faciliter le processus de développement. Mais lorsque vous affichez le résultat de votre chaîne dans une interface utilisateur, vous souhaitez diffuser la réponse en continu. Vous pouvez désormais utiliser la méthode `stream` sans avoir à réécrire quoi que ce soit.

Méthodes asynchrones prêtes à l'emploi

La plupart du temps, le front-end et le back-end de votre application sont distincts, ce qui signifie que le front-end envoie une requête au back-end. Si vous avez plusieurs utilisateurs, vous devrez peut-être traiter plusieurs requêtes simultanément au niveau du back-end.

Comme la majeure partie du code dans LangChain consiste simplement à attendre entre deux appels d'API, nous pouvons tirer parti du code asynchrone pour améliorer l'évolutivité de l'API. Si vous souhaitez comprendre pourquoi c'est important, je vous recommande de lire l'exemple « Concurrent Burgers » dans la documentation FastAPI.
Inutile de vous soucier de la mise en œuvre, car les méthodes asynchrones sont déjà disponibles si vous utilisez LCEL :

.ainvoke() / .abatch() / .astream: versions asynchrones des méthodes invoke, batch et stream.

Je vous recommande également de consulter la page « Pourquoi utiliser LCEL » de la documentation LangChain, qui contient des exemples pour chaque méthode synchrone et asynchrone.

Langchain a mis en place ces fonctionnalités « prêtes à l'emploi » en créant une interface unifiée appelée « Runnable ». À présent, pour tirer pleinement parti de LCEL, nous devons examiner de plus près cette nouvelle interface Runnable.

L'interface Runnable

Tous les objets que nous avons utilisés jusqu’à présent dans la syntaxe LCEL sont des Runnables. Il s’agit d’un objet Python créé par LangChain ; cet objet hérite automatiquement de toutes les fonctionnalités dont nous avons parlé précédemment, et bien d’autres encore. En utilisant la syntaxe LCEL, nous composons un nouveau Runnable à chaque étape, ce qui signifie que l’objet final créé sera lui aussi un Runnable. Vous pouvez en savoir plus sur cette interface dans la documentation officielle.

Tous les objets du code ci-dessous sont soit des objets de type `Runnable`, soit des dictionnaires qui sont automatiquement convertis en objets de type `Runnable` :

from langchain_core.runnables import RunnablePassthrough, RunnableParallel
from langchain.prompts import PromptTemplate
from langchain_community.llms import OpenAI

chain_number_one = (
PromptTemplate.from_template(prompt_template_product)
| llm
| # <— THIS WILL CHANGE
| PromptTemplate.from_template(prompt_template_business)
| llm
)

chain_number_two = (
PromptTemplate.from_template(prompt_template_product)
| llm
| RunnableParallel(company=RunnablePassthrough()) # <— THIS CHANGED
| PromptTemplate.from_template(prompt_template_business)
| llm
)

print(chain_number_one == chain_number_two)
>>> True

Pourquoi utilisons-nous RunnableParallel() et non pas simplement Runnable() ?

En effet, chaque Runnable contenu dans un RunnableParallel est exécuté en parallèle. Cela signifie que si votre Runnable comporte trois étapes indépendantes, celles-ci s'exécuteront simultanément sur différents threads de votre machine, ce qui améliorera gratuitement la vitesse de votre chaîne !

Inconvénients du LCEL

Malgré ses avantages, le LCEL présente toutefois certains inconvénients potentiels :

  • Non entièrement conforme à la PEP: LCEL ne respecte pas pleinement la PEP 20, le « Zen du Python », qui stipule que « l'explicite vaut mieux que l'implicite ». (Pour vérifier la conformité à la PEP 20, vous pouvez exécuter la commande « import this » dans Python.) De plus, la syntaxe de LCEL n'est pas considérée comme « pythonesque », car elle donne l'impression d'être un langage différent, ce qui pourrait rendre LCEL moins intuitif pour certains développeurs Python qui pourraient refuser de l'utiliser.

  • LCEL est un langage spécifique à un domaine (DSL): les utilisateurs doivent avoir une certaine connaissance des invites, des chaînes ou des modèles de langage de grande envergure (LLM) pour pouvoir exploiter efficacement sa syntaxe.

  • Dépendances d'entrée/sortie: les entrées intermédiaires et les sorties finales doivent être transmises du début à la fin. Par exemple, si vous souhaitez utiliser la sortie d'une étape intermédiaire comme sortie finale, vous devez la faire passer par toutes les étapes suivantes. Cela peut entraîner la présence d'arguments supplémentaires dans la plupart de vos chaînes ; ceux-ci ne seront peut-être pas utilisés, mais ils sont nécessaires si vous souhaitez y accéder via la sortie.

Conclusion

En conclusion, le LangChain Expression Language (LCEL) est un outil puissant qui apporte un regard neuf sur le développement d'applications Python. Malgré sa syntaxe peu conventionnelle, je recommande vivement d'utiliser le LCEL pour les raisons suivantes :

  • Interface unifiée: offre une interface homogène pour toutes les chaînes, facilitant ainsi l'industrialisation de votre code grâce à des modèles prêts à l'emploi pour les flux, l'asynchronisme et les solutions de secours, ainsi qu'à la typologie et aux configurations d'exécution, etc.

  • Parallélisation automatique: exécutez automatiquement plusieurs tâches en parallèle, ce qui accélère l'exécution de vos chaînes et améliore l'expérience utilisateur.

  • Modularité: elle vous permet de créer et de modifier facilement des chaînes, ce qui rend votre code plus flexible et plus adaptable.

Pour aller plus loin…

L'abstraction exécutable

Dans certains cas, je pense qu’il est important de comprendre l’abstraction mise en œuvre par LangChain pour permettre le fonctionnement de la syntaxe LCEL.

Vous pouvez facilement réimplémenter les fonctionnalités de base de Runnable comme suit :

class Runnable :
def __init__(self, func) :
self.func = func

def __or__(self, other) :
def chained_func(*args, **kwargs) :
# self.func est à gauche, other est à droite
return other(self.func(*args, **kwargs))
return Runnable(chained_func)

def __call__(self, *args, **kwargs) :
return self.func(*args, **kwargs)

def add_ten(x) :
return x + 10

def divide_by_two(x) :
return x / 2

runnable_add_ten = Runnable(add_ten)
runnable_divide_by_two = Runnable(divide_by_two)
chain = runnable_add_ten | runnable_divide_by_two
result = chain(8) # (8+10) / 2 = 9.0 devrait être la réponse
print(result)
>>> 9.0

Un Runnable est simplement un objet Python dont la méthode .__or__() a été redéfinie.
Concrètement, LangChain a ajouté de nombreuses fonctionnalités, telles que la conversion de dictionnaires en Runnable, des capacités de typage, des options de configuration, ainsi que des méthodes invoke, batch, stream et async !

Alors, pourquoi ne pas essayer LCEL pour votre prochain projet ?

Si vous souhaitez en savoir plus, je vous recommande vivement de consulter le guide pratique LangChain sur LCEL.

class="lazyload

Blog Medium Blog Artefact.

Cet article a été initialement publié sur Medium.com.
Suivez-nous sur notre Blog Medium Blog