Introduction au pattern ReAct avec LangGraph

Introduction: les applications du futur sont agentiques

Le futur des applications est agentique. Avec la démocratisation des LLMs,la généralisation de la multimodalité, le fait que l’on peut mettre de plus en plus d’intelligence dans des environnements aux ressources contraintes (essor des Small Language Models), l’avènement de nombreux outils de contrôle de qualité et d’observabilité des applications basées sur des LLMs, etc… la physionomie des applications telles que nous les connaissons est en train de se transformer. Ce n’est qu’une question de temps avant que toutes nos interactions avec des systèmes informatiques ne soient régies par des agents artificiels plus flexibles et plus intelligents qu’un programme linéaire.

Une application agentique est une application qui peut:

  • effectuer des actions, avec ou sans supervision humaine
  • prendre des décisions
  • s’auto corriger
  • et bien sûr, communiquer avec l’utilisateur de manière naturelle

LangGraph est un framework qui permet de construire des applications agentiques, il se caractérise par une grande flexibilité, adaptable en théorie à tous les enjeux business possibles. Penchons-nous aujourd’hui sur un pattern à la fois simple et puissant pour atteindre ce résultat: le pattern ReAct qui décompose les problèmes en trois étapes, potentiellement cycliques:

  • « raisonnement »
  • « action »
  • « observation »

Le principe de chaîne

LangGraph est basé sur Langchain, qui est une librairie très populaire permettant d’instrumenter des LLMs dans des applications. Une _chaîne_ est une séquence ordonnée d’appels à différents outils, dont des LLMs, qui permet d’arriver à un résultat final.

Par exemple une chaîne peut correspondre au traitement suivant:

Dans cet exemple, chaque étape de la chaîne peut être réalisée par un LLM différent, spécialisé (fine-tuned) pour une tâche donnée. Ce qui rend ce paradigme très puissant, c’est qu’on peut aussi le brancher à des systèmes externes qui ne sont pas des LLMs, comme dans cet exemple:

Implémentation d’une chaîne simple avec Langchain

Ecrivons notre chaîne d’exemple précédente avec recherche web en utilisant LangChain.

# requirements.txt =>
# duckduckgo-search
# langchain-core
# langchain-ollama

import json
from langchain.chains import LLMChain
from langchain_ollama import ChatOllama
from langchain.prompts import PromptTemplate
from langchain.utilities import DuckDuckGoSearchAPIWrapper

chat_model = ChatOllama(model="hermes3", temperature=0)
json_chat_model = ChatOllama(format="json", model="hermes3", temperature=0)

query_gen_prompt = PromptTemplate(
    input_variables=["query"],
    template="""generate 3 web search engine queries to answer the query: {query}.
Output the response as a JSON with key "queries" and an array containing the queries as a value.""",
)
query_gen_chain = LLMChain(llm=json_chat_model, prompt=query_gen_prompt) | (
    lambda x: json.loads(x["text"])["queries"]
)


def generate_queries(query):
    return query_gen_chain.run(query)


summarize_search_results_prompt = PromptTemplate(
    input_variables=["search_results"],
    template="""You are a documentalist.
You are given various web search results, your task is to summarize these results into a nicely formatted and readable Markdown document.

Here are the search results, delimited by dashes:
----
{search_results}
----
""",
)
summarize_search_results_chain = LLMChain(
    llm=chat_model, prompt=summarize_search_results_prompt
)


def search_the_web(query):
    search = DuckDuckGoSearchAPIWrapper()
    return search.run(query)


search_the_web_chain = (
    query_gen_chain
    | (lambda queries: [{"query": q} for q in queries])
    | (lambda queries: [search_the_web(q["query"]) for q in queries])
    | (
        lambda results: {
            "search_results": "\n\n".join(
                [f"search result {i}:\n{r}" for i, r in enumerate(results)]
            )
        }
    )
    | summarize_search_results_chain
)

if __name__ == "__main__":
    res = search_the_web_chain.invoke(
        {"query": "what are current capabilities of LLMs?"}
    )
    print(res["text"])

On obtient de cette manière un résumé succint en Markdown de la recherche web. Comme vous pouvez le voir, il est plutôt facile d’insérer de la logique programmatique classique à n’importe quel endroit dans une chaîne avec le caractère | et de composer avec cela des chaînes impliquant des LLMs à n’importe quelle étape de la chaîne.

Les chaînes permettent déjà des processus très avancés, de par leur manipulation du langage naturel, et sont interopérables avec toutes sortes de systèmes, y compris des systèmes legacy; toutefois elles sont limitées du fait de leur caractère linéaire car:

  • elles ne permettent pas de cycles
  • une chaîne ne permet pas de s’auto-corriger
  • il est difficile de faire des branches conditionnelles dans une chaîne simple
  • l’état de la chaîne à chaque étape n’est pas conçu pour être immuable et il est donc difficile à gérer
  • etc.

J’aime me représenter les chaînes Langchain comme des « units of work », destinées à être mises en oeuvre dans des workflows plus complexes: c’est là que LangGraph entre en jeu.

Le pattern ReAct avec LangGraph

Nous allons reprendre notre chaîne précédente pour l’intégrer dans une application agentique suivant le pattern ReAct.

Dans ce pattern, on peut voir que le LLM peut décider d’utiliser les outils à sa disposition en fonction de la requête de l’utilisateur, il peut aussi décider de répondre directement à la requête s’il n’a pas besoin d’outils; enfin, le résultat de l’appel à un outil, si utilisé, est renvoyé comme input au LLM, qui peut donc le réutiliser pour décider de répondre directement ou d’utiliser à nouveau les outils.

Ce mécanisme très simple permet des interactions très riches et flexibles.

Ré implémentons notre exemple précédent dans LangGraph. Cette fois, notre chaîne de recherche web sera juste un outil disponible pour l’agent:

import json
from langchain.chains import LLMChain
from langchain_core.messages import HumanMessage,SystemMessage
from langchain_ollama import ChatOllama
from langgraph.graph import MessagesState, StateGraph, START
from langgraph.prebuilt import ToolNode, tools_condition
from langchain.prompts import PromptTemplate
from langchain.utilities import DuckDuckGoSearchAPIWrapper

chat_model = ChatOllama(model="hermes3", temperature=0)
json_chat_model = ChatOllama(format="json", model="hermes3", temperature=0)

# reprenons le code de notre chaîne précédente, cette fois nous en ferons un outil
query_gen_prompt = PromptTemplate(
    input_variables=["query"],
    template="""generate 3 web search engine queries to answer the query: {query}.
Output the response as a JSON with key "queries" and an array containing the queries as a value.""",
)
query_gen_chain = LLMChain(llm=json_chat_model, prompt=query_gen_prompt) | (
    lambda x: json.loads(x["text"])["queries"]
)
summarize_search_results_prompt = PromptTemplate(
    input_variables=["search_results"],
    template="""You are a documentalist.
You are given various web search results, your task is to summarize these results into a nicely formatted and readable Markdown document.

Here are the search results, delimited by dashes:
----
{search_results}
----
""",
)
summarize_search_results_chain = LLMChain(
    llm=chat_model, prompt=summarize_search_results_prompt
)
def search_the_web(query):
    search = DuckDuckGoSearchAPIWrapper()
    return search.run(query)
search_the_web_chain = (
    query_gen_chain
    | (lambda queries: [{"query": q} for q in queries])
    | (lambda queries: [search_the_web(q["query"]) for q in queries])
    | (
        lambda results: {
            "search_results": "\n\n".join(
                [f"search result {i}:\n{r}" for i, r in enumerate(results)]
            )
        }
    )
    | summarize_search_results_chain
)
def search_the_web_node(query):
    """Search the web for the given query and output a nicely formatted and readable Markdown document.

    Use this tool if you need to search the web for current information or information that is not in your knowledge base.
    """
    return search_the_web_chain.invoke({"query": query})
tools = [search_the_web_node]
llm_with_tools = chat_model.bind_tools(tools)

# on initialise l'agent avec un message système lui donnant une personae
sys_msg = SystemMessage(
    content="You are a helpful documentalist equipped with tools such as web search."
)

# noeud du graphe content l'agent (le "cerveau" de notre graphe)
def agent(state: MessagesState):
    # l'historique des messages est passé à l'agent;
    # l'état n'est pas écrasé à chaque interaction avec `MessagesState`,
    # car chaque nouveau message (humain, agent, ou appel à des outils) est ajouté à l'historique
    return {"messages": [llm_with_tools.invoke([sys_msg] + state["messages"])]}

# construisons notre graphe
builder = StateGraph(MessagesState)
# définition des noeuds du graphe
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(tools))
# définition des liens entre les noeuds (edges)
builder.add_edge(START, "agent")
builder.add_conditional_edges(
    "agent",
    # Si le dernier message (résultat) de l'agent est un appel à un outil -> tools_condition route vers tools
    # Si le dernier message (résultat) de l'agent n'est pas un appel à un outil -> tools_condition route vers son nœud de fin (dans ce cas, le nœud agent)
    tools_condition,
)
# ceci ajoute une boucle de retour vers le nœud de l'agent,
# ce simple changement permet de créer des agents puissants !
builder.add_edge("tools", "agent")
react_graph = builder.compile()

if __name__ == "__main__":
    res = react_graph.invoke({"messages": [HumanMessage(content="what is the latest hot LLM in September 2024?")]})
    print(res)

On obtient une réponse du type =>

Based on the search results from September 2024, the latest hot LLMs include:\n\n1. OpenAI o1-preview\n2. Anthropic's Claude 3 Models\n3. Google Gemini 1.5 Pro\n4. AI21 Jamba 1.5 Large\n5. Stable LM 2\n\nThese models represent the cutting-edge of AI language understanding and generation, with significant advancements in performance across various tasks such as reasoning, question answering, and coding. The global large language model market is projected to grow substantially from $6.5 billion in 2024 to $140.8 billion by 2033, indicating the increasing importance and adoption of LLMs in different industries

Si l’agent avait estimé qu’il disposait des connaissances nécessaires pour répondre à la requête, il aurait pu répondre directement sans passer par les outils. Bien sûr, cet exemple simple ne contient pas de fact-checking anti hallucinations, nous n’avons ici qu’un seul outil (la web search), mais l’idée est juste ici de vous montrer le principe et comment on peut mettre en oeuvre des logiques relativement complexes avec des graphes simples en utilisant LangGraph.

Pour aller plus loin, on peut:

  • multiplier les outils
  • mettre des graphes dans des graphes
  • implémenter le pattern « human in the loop » pour valider d’éventuelles décisions agentiques
  • etc.

L’imagination est votre seule limite!

Nota bene: j’ai ici volontairement utilisé Ollama et des SLMs (small language models) pour vous démontrer qu’il est possible d’implémenter ce genre de système en toute confidentialité dans des machines « consumer-grade » (par exemple, mon ordinateur portable).

A bientôt pour de nouvelles aventures agentiques 🤖.

Partager l'article:

Autres articles