Cours Python · Module 10: IA et modèles de langage

Chatbots - IA conversationnelle

22 min de lecture
Dans cette leçon7

Le soir, au campement, tu poses deux questions d'affilée à ton script. La première : "Je suis en safari au Kenya." La deuxième : "Quels animaux vais-je voir ici ?" Le modèle répond poliment et longuement, sauf qu'il ignore complètement où se trouve ce "ici" - il t'énumère des pandas, des tigres et des ours polaires dans le même paragraphe. Rien n'est cassé : tu n'as pas perdu ta clé API, tu n'as pas écorché le nom du modèle. C'est simplement ainsi que ça fonctionne, et tant que tu ne l'auras pas compris, chacun de tes chatbots aura la mémoire d'un moucheron.

Dans cette leçon, tu vas construire un Darwin capable de tenir une vraie conversation. D'abord à la main, sur une simple liste Python, pour que tu voies tout le mécanisme sans la moindre once de magie. Ensuite la même chose, mais avec les briques toutes prêtes de LangChain. Pour finir, tu lui ajouteras un savoir qui n'existe nulle part dans le modèle - le contenu du guide de ta réserve - et tu exposeras l'ensemble comme un service dont toute la station de recherche pourra se servir.

Un modèle qui ne se souvient de rien

Commençons par voir le problème de tes propres yeux. Ci-dessous, tu envoies deux requêtes indépendantes au même modèle, dans le même script, l'une après l'autre. La première phrase indique où tu te trouves, la seconde pose une question qui n'a aucun sens sans cette information. Observe bien ce qui atterrit exactement dans le champ messages lors du second appel : une seule et unique phrase, sans la moindre trace de la précédente. client est l'objet client que tu connais des leçons précédentes, et chat.completions.create est ce même chemin d'appel que tu as déjà pratiqué.

1from openai import OpenAI
2
3client = OpenAI(api_key="your-api-key")
4
5premiere = client.chat.completions.create(
6    model="gpt-5",
7    messages=[{"role": "user", "content": "Je suis en safari au Kenya."}]
8)
9
10deuxieme = client.chat.completions.create(
11    model="gpt-5",
12    messages=[{"role": "user", "content": "Quels animaux vais-je voir ici ?"}]
13)

Faisons l'inventaire de ce qui n'a pas changé entre ces deux appels : le client est le même, la clé la même, le modèle le même, le processus Python le même, et la variable premiere est toujours bien assise dans la mémoire de ton programme. Rien n'a changé, strictement rien - et c'est précisément le point. Le serveur qui a traité la seconde requête n'a aucune idée que la première ait jamais existé. L'API de chat est sans état : chaque requête est un colis indépendant, et le modèle ne voit que ce que tu as mis dedans.

C'est pourquoi la réponse "le modèle se souvient tout seul de toutes les conversations" est fausse. Un modèle, c'est un jeu de poids figé - il n'enregistre rien une fois sa réponse terminée, et il n'a nulle part où l'enregistrer. Tout aussi fausse est la réponse "ce n'est pas possible", puisque tout le reste de cette leçon consiste précisément à le faire. La vraie réponse est bien moins spectaculaire que ce que l'on imagine : la mémoire d'un chatbot, c'est le stockage de l'historique des messages de ton côté et son envoi avec chaque nouvelle requête.

La mémoire n'est qu'une simple liste

Puisque le modèle ne voit que le contenu du champ messages, il te suffit de tenir toi-même le journal de la conversation et de l'envoyer en entier à chaque fois. Ce journal est une liste de dictionnaires, et chaque dictionnaire possède deux clés : "role" dit qui a parlé, "content" porte le texte. Les rôles sont au nombre de trois. "system" est l'instruction destinée au modèle, c'est-à-dire qui il doit être et comment il doit se comporter - elle occupe toujours la tête de liste. "user", ce sont tes messages. "assistant", ce sont les réponses du modèle. Commençons par la seule instruction système, avant même la première question.

1messages = [
2    {"role": "system", "content": "Tu es Darwin, un guide de safari sympathique."}
3]

Voilà toute la mémoire de ton chatbot au démarrage : une banale liste Python avec un dictionnaire dedans. Aucune base de données, aucune bibliothèque, aucun état conservé du côté des serveurs d'OpenAI. L'instruction système se place en position zéro et y reste jusqu'à la fin de la conversation - c'est le seul message que tu n'effaces jamais, car c'est lui qui maintient Darwin dans son rôle de guide au lieu de le laisser redevenir un assistant générique.

Tu ajoutes maintenant la question de l'utilisateur. Pour ajouter un élément à la fin d'une liste, on utilise la méthode .append(, dans laquelle tu glisses un nouveau dictionnaire portant le rôle "user". L'ordre des clés s'écrit toujours de la même façon : d'abord "role", ensuite "content". Le texte de la question est rangé dans la variable text, donc c'est elle que tu places sous la clé "content", sans guillemets - c'est une variable, pas une chaîne de caractères.

1text = "Je suis en safari au Kenya."
2
3messages.append({"role": "user", "content": text})
4
5print(len(messages))
6print(messages[-1])

À l'exécution, tu verras 2 puis {'role': 'user', 'content': 'Je suis en safari au Kenya.'} - la liste a gagné un élément, et [-1] désigne le dernier d'entre eux. Décomposons cette ligne unique, parce que tu vas l'écrire des centaines de fois : messages (le nom de la liste), .append( (la méthode qui ajoute à la fin), {"role": "user", (ouverture du dictionnaire et rôle), "content": text} (le contenu et la fermeture du dictionnaire), ) (fermeture de l'appel de méthode). Exactement dans cet ordre, car le dictionnaire doit se refermer avant la parenthèse d'append.

Il vaut la peine de remarquer ce que cette opération n'a pas fait. Elle n'a rien envoyé sur le réseau, elle n'a coûté aucun token et elle n'a informé le modèle de quoi que ce soit. Seul le contenu d'une variable de ton programme a changé. Au passage, une autre réponse tentante tombe : les cookies du navigateur. Un cookie vit dans le navigateur de l'utilisateur et n'arrive jamais jusqu'au modèle - au mieux, il sait transporter un identifiant de session, mais le journal de la conversation, lui, doit rester du côté serveur et être joint à la requête. Le canal HTTP ne connaît pas la notion de "message précédent".

Enfermons à présent un tour de conversation complet dans une fonction. Elle ajoute la question à la liste, envoie la liste entière au modèle, extrait le texte de la réponse par le chemin que tu connais, reponse.choices[0].message.content, puis remet cette réponse dans la liste sous le rôle "assistant".

1def demander(text):
2    messages.append({"role": "user", "content": text})
3
4    reponse = client.chat.completions.create(
5        model="gpt-5",
6        messages=messages
7    )
8
9    contenu = reponse.choices[0].message.content
10    messages.append({"role": "assistant", "content": contenu})
11    return contenu

Le coeur de cette fonction, c'est l'écriture messages=messages : ce qui part vers le modèle, c'est tout l'historique accumulé, et non la seule question du moment. Le second append est tout aussi important et c'est celui que l'on oublie le plus souvent. Si tu n'enregistrais que les questions, le modèle ne verrait pas ses propres paroles et, au bout de quelques tours, il se mettrait à répéter les mêmes réponses ou à se contredire. La conversation doit être complète des deux côtés.

Cela a un prix, et mieux vaut le connaître tout de suite. Au vingtième tour, tu envoies au modèle les dix-neuf échanges précédents, tu paies tous ces tokens à chaque requête, et l'historique finira par ne plus tenir dans la fenêtre de contexte. C'est pourquoi les vrais chatbots taillent dans le journal : ils gardent l'instruction système plus la quinzaine de derniers messages, et résument ou jettent les plus anciens. La mémoire n'est pas gratuite - elle est simplement à ta charge.

ChatMessageHistory, la mémoire version LangChain

La liste tenue à la main est parfaite pour comprendre le mécanisme, mais dans un vrai projet elle se couvre vite de code : élagage, plusieurs conversations en parallèle, conversions de format. LangChain propose pour cela une brique toute faite nommée ChatMessageHistory. Avant de l'importer, lis la ligne d'import de gauche à droite, car son ordre ne varie jamais : from → langchain_community.chat_message_histories → import → ChatMessageHistory. D'abord from désigne le module, ensuite import désigne le nom que tu en extrais. L'adresse du module raconte elle aussi quelque chose : langchain_community est le paquet des intégrations développées par la communauté, et chat_message_histories est un module écrit au pluriel, parce qu'il abrite plusieurs sortes d'historiques - en mémoire, dans un fichier, en base de données. La classe, à la fin, prend des majuscules, le module reste en minuscules, et cette paire ne peut pas être intervertie.

1from langchain_community.chat_message_histories import ChatMessageHistory
2
3historique = ChatMessageHistory()
4historique.add_user_message("Je suis en safari au Kenya.")
5historique.add_ai_message("Genial ! Le Kenya est un paradis pour observer les animaux.")
6
7print(len(historique.messages))

Cela affiche 2. C'est la même liste que tout à l'heure, simplement habillée en objet : add_user_message correspond à l'ajout du rôle "user", add_ai_message au rôle "assistant", et le champ .messages donne accès aux messages accumulés. Remarque ce qui n'est pas là : aucun appel au modèle, aucune clé API, aucun coût. ChatMessageHistory est un carnet de notes, pas un interlocuteur - il conserve le compte rendu, et rien d'autre.

Les quatre étapes de la construction d'un chatbot

Le reste de la leçon consiste à assembler un bot qui fonctionne, et il vaut la peine de retenir tout de suite l'ordre dans lequel cela se fait. Première étape : définir le prompt système, c'est-à-dire l'identité de Darwin. Deuxième étape : configurer la mémoire. Troisième étape : créer la chaîne, celle qui relie le prompt, le modèle et la mémoire en un seul ensemble. Quatrième étape : implémenter la boucle de conversation, qui alimente tout cela en questions successives. Impossible d'intervertir quoi que ce soit - la chaîne n'a rien à relier tant que le prompt et la mémoire n'existent pas, et la boucle n'a rien à appeler tant que la chaîne n'existe pas.

Première étape. ChatPromptTemplate.from_messages construit un modèle de conversation à partir d'une liste d'éléments, et MessagesPlaceholder est un élément spécial marquant le trou dans lequel l'historique viendra tomber. Le paramètre variable_name donne un nom à ce trou. La dernière entrée est la question courante de l'utilisateur, insérée sous le nom input.

1from langchain_openai import ChatOpenAI
2from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
3
4llm = ChatOpenAI(model="gpt-5", temperature=0.7)
5
6system = """Tu es Darwin, un guide de safari sympathique.
7Tu reponds avec enthousiasme et tu partages des anecdotes sur les animaux.
8Tu veilles toujours a la securite des touristes."""
9
10prompt = ChatPromptTemplate.from_messages([
11    ("system", system),
12    MessagesPlaceholder(variable_name="history"),
13    ("human", "{input}")
14])

Le modèle de conversation compte trois éléments, et chacun joue un rôle différent. L'instruction système est fixe et se répète à chaque tour - c'est elle qui fait que Darwin parle avec enthousiasme et veille à la sécurité. Le placeholder est vide à la première question et grossit à chaque nouvel échange. Le couple ("human", "{input}") est la place réservée à la question du moment. Le nom "history" n'est pas choisi au hasard et devra, dans un instant, correspondre au caractère près au réglage history_messages_key - un décalage sur ce seul nom est la cause la plus fréquente d'un bot qui a l'air de marcher et qui pourtant ne se souvient de rien.

Deuxième étape. La mémoire doit distinguer les interlocuteurs, car Darwin sera utilisé par plusieurs personnes à la fois. Tu vas t'appuyer sur un simple dictionnaire store, dont la clé est un identifiant de session et la valeur un objet ChatMessageHistory distinct. La fonction get_session_history reçoit un identifiant et renvoie l'historique de cette conversation-là, en le créant au premier contact.

1store = {}
2
3def get_session_history(session_id: str):
4    if session_id not in store:
5        store[session_id] = ChatMessageHistory()
6    return store[session_id]

Tu croiseras sur internet une variante plus courte, où l'on remplace le dictionnaire par un unique historique global partagé par tout le monde. Je te recommande la version avec le dictionnaire, car l'autre raccourcit le code de deux lignes mais introduit deux vrais problèmes : deux personnes se retrouvent avec un journal commun, si bien que Darwin répond au touriste B en s'appuyant sur les propos du touriste A, et par-dessus le marché il devient impossible d'effacer une conversation sans les effacer toutes. Le même session_id désigne toujours la même conversation, un autre session_id ouvre une page blanche.

Troisième étape. L'opérateur | t'est connu depuis la leçon précédente - il colle le prompt au modèle pour en faire une chaîne. Ce qui est nouveau, c'est RunnableWithMessageHistory, une enveloppe qui, avant chaque appel, injecte l'historique dans le placeholder et qui, après la réponse, ajoute les deux messages à la bonne session. Le paramètre input_messages_key lui indique quel champ d'entrée est la question, et history_messages_key dans quel trou verser l'historique.

1from langchain_core.runnables.history import RunnableWithMessageHistory
2
3chain = prompt | llm
4
5chatbot = RunnableWithMessageHistory(
6    chain,
7    get_session_history,
8    input_messages_key="input",
9    history_messages_key="history"
10)

Remarque que la chaîne prompt | llm elle-même n'a pas changé d'un seul caractère et continue de tout ignorer de la mémoire. L'enveloppe ne la réécrit pas et ne remplace pas le modèle - elle ajoute seulement deux gestes autour : lire l'historique avant, l'écrire après. À RunnableWithMessageHistory, tu passes la fonction get_session_history elle-même, sans parenthèses, car c'est LangChain qui doit l'appeler au bon moment en lui glissant l'identifiant tiré de la configuration.

Avant d'écrire la boucle, testons le bot sur deux questions. L'identifiant de session se donne dans un dictionnaire config, sous la clé imbriquée "configurable". La méthode invoke est la même que pour n'importe quelle autre chaîne - la seule différence, c'est que tu lui joins config.

1config = {"configurable": {"session_id": "user_123"}}
2
3reponse = chatbot.invoke({"input": "Salut ! Je suis en safari !"}, config=config)
4print("Darwin:", reponse.content)
5
6reponse = chatbot.invoke({"input": "Quels animaux vivent ici ?"}, config=config)
7print("Darwin:", reponse.content)

C'est la même paire de questions qu'au début de la leçon, et cette fois la seconde a du sens - Darwin sait que "ici" désigne le safari dont il est question dans le premier message. Non pas parce que le modèle a gagné en intelligence, mais parce qu'au second appel c'est tout l'historique qui est parti vers le serveur. Si tu remplaces "user_123" par un autre identifiant, Darwin oubliera la conversation sur-le-champ, car il ira regarder dans un autre casier du dictionnaire store. La réponse se lit toujours par .content, exactement comme pour une chaîne ordinaire.

Quatrième étape. La boucle de conversation est un petit programme de console : lire une ligne saisie par l'utilisateur, vérifier que ce n'est pas une commande de sortie, l'envoyer au bot, afficher la réponse, recommencer. La fonction input attend un texte au clavier, .strip() coupe les espaces superflus, break interrompt la boucle, et continue saute au tour suivant sans exécuter le reste du corps.

1def lancer_chatbot():
2    print("Safari Chatbot - tape quit pour terminer")
3
4    config = {"configurable": {"session_id": "cli_session"}}
5
6    while True:
7        question = input("Toi: ").strip()
8
9        if question.lower() in ["quit", "exit", "q"]:
10            print("A bientot en safari !")
11            break
12
13        if not question:
14            continue
15
16        reponse = chatbot.invoke({"input": question}, config=config)
17        print("Darwin:", reponse.content)
18
19if __name__ == "__main__":
20    lancer_chatbot()

Les deux conditions se ressemblent et font pourtant des choses radicalement différentes. La première met fin au programme, la seconde se contente d'ignorer une entrée vide - si tu y mettais break, un appui malencontreux sur Entrée fermerait toute la conversation. L'écriture question.lower() permet de sortir aussi bien par quit que par QUIT. L'identifiant de session est fixe ici, puisqu'une seule personne est assise devant la console, et il est placé hors de la boucle pour que chaque tour atterrisse dans le même historique. Tu as maintenant le jeu complet : prompt système, mémoire, chaîne, boucle de conversation.

RAG, quand Darwin doit connaître ton guide

Ton bot parle merveilleusement de la savane en général, mais demande-lui les règles de sécurité en vigueur dans ta réserve et il se mettra à inventer. Il n'a aucun moyen de les connaître - le règlement du campement n'a jamais figuré dans ses données d'entraînement. Tu peux réentraîner ton propre modèle, ce qui coûte une fortune et doit être refait à chaque modification du règlement, ou bien recourir au RAG, autrement dit Retrieval Augmented Generation.

Le RAG est une technique qui enrichit les réponses du modèle avec des informations issues de tes documents : avant de poser la question, tu y cherches les passages les plus pertinents et tu les colles dans le prompt en guise de contexte. Mettons tout de suite trois malentendus au clair. Le RAG n'est pas un format de fichier - tes documents peuvent être en .txt, en .pdf ou en .md, la technique s'en moque. Ce n'est pas non plus un type de réseau de neurones - il ne change ni l'architecture ni les poids du modèle, qui reste tout du long exactement ce qu'il était. Et ce n'est pas davantage un protocole d'API - il n'existe aucun endpoint ni aucun standard de communication portant ce nom, tout le travail se déroulant dans ton code, avant même que tu n'envoies une requête tout à fait ordinaire.

Le pipeline compte cinq étapes dans un ordre immuable. Charger les documents. Les découper en morceaux, c'est-à-dire faire du chunking. Créer les embeddings de ces morceaux. Rechercher les morceaux similaires à la question. Générer une réponse avec le contexte ainsi obtenu. Les trois premières étapes s'exécutent une seule fois, au démarrage ; les deux dernières se répètent à chaque question.

La deuxième étape est d'ordinaire celle qui pose le plus de problèmes, alors regardons-la en Python pur, sans aucune bibliothèque. Ci-dessous, tu découpes un texte en tranches de cent caractères, mais - et c'est le point important - avec un chevauchement de vingt caractères : chaque morceau suivant commence vingt caractères avant la fin du précédent.

1texte = "Les lions sont plus actifs a l'aube et au crepuscule. " * 8
2
3taille = 100
4chevauchement = 20
5morceaux = []
6
7debut = 0
8while debut < len(texte):
9    morceaux.append(texte[debut:debut + taille])
10    debut += taille - chevauchement
11
12print(len(texte))
13print(len(morceaux))
14print(morceaux[0][-20:] == morceaux[1][:20])

Le résultat est 432, 6 et True. La dernière ligne est la plus intéressante : la queue du premier morceau est littéralement le même texte que le début du second. C'est à cela que sert le chevauchement - avec une découpe franche, une phrase coupée en deux ne figurerait nulle part en entier et le moteur de recherche n'aurait aucune chance de la retrouver. Remarque aussi que le pas de la boucle vaut taille - chevauchement, soit quatre-vingts et non cent : c'est pourquoi 432 caractères donnent six morceaux et non cinq. Dans un vrai projet, tu n'écris pas cela à la main - tu utilises RecursiveCharacterTextSplitter, qui s'efforce en plus de couper aux frontières des paragraphes et des phrases plutôt qu'au milieu d'un mot.

Retour à LangChain, où nous réalisons les étapes une et deux. TextLoader lit un fichier texte sur le disque, et sa méthode load renvoie une liste d'objets de type Document - ce n'est pas une simple chaîne de caractères, mais le contenu accompagné de métadonnées, par exemple le nom du fichier source. RecursiveCharacterTextSplitter prend deux paramètres que tu viens de découvrir dans la version manuelle : chunk_size est la longueur d'un morceau en caractères, chunk_overlap est le chevauchement.

1from langchain_community.document_loaders import TextLoader
2from langchain.text_splitter import RecursiveCharacterTextSplitter
3
4loader = TextLoader("safari_guide.txt")
5documents = loader.load()
6
7splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
8chunks = splitter.split_documents(documents)

Après ces quatre lignes, chunks contient une liste de quelques dizaines de morceaux du guide, chacun long d'environ mille caractères, avec un chevauchement de deux cents - autrement dit vingt pour cent du texte se répète d'un voisin à l'autre. La méthode s'appelle split_documents et non split_text, parce qu'elle reçoit des documents et renvoie des documents, en reportant les métadonnées sur chaque morceau. Rien de tout cela n'est encore parti vers le moindre modèle - c'est une simple opération sur du texte, effectuée entièrement sur ta machine.

Troisième étape : les embeddings. Un embedding est une liste de nombres décrivant le sens d'un morceau - des textes de sens voisin reçoivent des vecteurs situés près les uns des autres, même s'ils n'ont aucun mot en commun. OpenAIEmbeddings est le modèle qui transforme un texte en un tel vecteur, et FAISS est un index qui stocke ces vecteurs et sait trouver en un éclair le voisinage le plus proche d'un point donné.

1from langchain_openai import OpenAIEmbeddings
2from langchain_community.vectorstores import FAISS
3
4embeddings = OpenAIEmbeddings()
5vectorstore = FAISS.from_documents(chunks, embeddings)

La méthode from_documents calcule l'embedding de chaque morceau et le range dans l'index. C'est le seul moment de tout le pipeline où tu paies le traitement du guide entier, et c'est pour cela que tu le fais une fois, au démarrage de l'application, et non à chaque question d'un touriste. Retiens bien qu'il s'agit d'un modèle tout à fait différent de llm : le modèle d'embedding ne converse pas et ne génère aucune phrase, il se contente de mesurer le sens.

Restent les étapes quatre et cinq, que LangChain réunit dans un seul objet. as_retriever transforme l'index en moteur de recherche, et search_kwargs avec le paramètre k fixe le nombre de morceaux les plus similaires qu'il doit renvoyer. RetrievalQA.from_chain_type relie ce moteur au modèle, chain_type="stuff" désignant la stratégie la plus simple : fourrer d'un coup tous les morceaux trouvés dans le prompt.

1from langchain.chains import RetrievalQA
2
3qa_chain = RetrievalQA.from_chain_type(
4    llm=llm,
5    chain_type="stuff",
6    retriever=vectorstore.as_retriever(search_kwargs={"k": 3})
7)
8
9resultat = qa_chain.invoke({"query": "Comment observer les lions en securite ?"})
10print(resultat["result"])

Derrière cet unique invoke, tout se produit d'un coup : la question devient un embedding, l'index sélectionne les trois morceaux du guide les plus proches, ceux-ci arrivent dans le prompt en guise de contexte, et le modèle formule une réponse. Soulignons ce qui ne s'est pas produit : le modèle n'a rien appris et, une seconde plus tard, interrogé sans RAG, il ignorera de nouveau ton règlement. Tout le savoir est arrivé par le prompt, le temps d'une requête. L'entrée se passe sous la clé "query" et le texte final se récupère sous la clé "result" - c'est un dictionnaire, donc .content ne fonctionne pas ici.

Le chatbot en tant que service

La version console est parfaite tant que l'unique utilisateur, c'est toi, devant ton portable sous la tente. Dès que le site de la réserve et l'application des guides doivent se servir de Darwin, il te faut une interface HTTP. FastAPI est un framework web dans lequel les routes se décrivent par un décorateur et la forme des données par des classes héritant de BaseModel, venue de la bibliothèque pydantic. Une telle classe vérifie les types à ta place : si quelqu'un envoie message sous forme de nombre, il obtiendra une erreur lisible avant même que ton code ne démarre.

1from fastapi import FastAPI, HTTPException
2from pydantic import BaseModel
3
4app = FastAPI()
5
6class ChatRequest(BaseModel):
7    message: str
8    session_id: str
9
10class ChatResponse(BaseModel):
11    response: str
12
13@app.post("/chat", response_model=ChatResponse)
14async def chat(request: ChatRequest):
15    config = {"configurable": {"session_id": request.session_id}}
16
17    try:
18        reponse = chatbot.invoke({"input": request.message}, config=config)
19        return ChatResponse(response=reponse.content)
20    except Exception as e:
21        raise HTTPException(status_code=500, detail=str(e))

Le plus important, c'est ce qui n'a pas changé : le même objet chatbot, le même dictionnaire store, le même invoke et le même config. Seule la provenance de la question a changé - au lieu d'input au clavier, elle arrive dans le corps d'une requête HTTP. L'identifiant de session est désormais envoyé par le client, et c'est lui qui décide dans quel casier de la mémoire la conversation atterrit ; le navigateur peut très bien garder cet identifiant dans un cookie, mais le journal de la conversation, lui, reste sur ton serveur et continue d'être joint au modèle à chaque requête. HTTPException transforme une exception en une réponse propre de statut 500, au lieu d'expédier une trace de pile au client. Le serveur se lance par la commande uvicorn main:app --reload. Sous une charge plus forte, il vaut la peine de remplacer invoke par await chatbot.ainvoke(...), pour qu'un appel lent au modèle ne bloque pas les autres requêtes.

Résumé

  • L'API de chat est sans état. Un chatbot ne se souvient du contexte que parce que tu stockes l'historique des messages et l'envoies avec chaque requête - le modèle ne retient rien tout seul, et les cookies du navigateur ne l'atteignent jamais.
  • L'historique est une liste de dictionnaires aux clés "role" et "content", et les rôles sont au nombre de trois : "system", "user" et "assistant".
  • L'ajout d'un message se compose de cinq éléments dans l'ordre : messages, .append(, {"role": "user",, "content": text}, ).
  • L'import de la mémoire LangChain, c'est from → langchain_community.chat_message_histories → import → ChatMessageHistory.
  • RunnableWithMessageHistory enveloppe une chaîne existante et y ajoute de lui-même l'historique désigné par le session_id tiré de config.
  • L'ordre de construction d'un chatbot : définir le prompt système, configurer la mémoire, créer la chaîne, implémenter la boucle de conversation.
  • Le RAG est une technique qui enrichit les réponses du modèle avec des informations issues de documents. Ce n'est ni un format de fichier, ni un type de réseau de neurones, ni un protocole d'API.
  • Pipeline RAG : charger les documents, découper en morceaux, créer les embeddings, rechercher les morceaux similaires, générer une réponse avec le contexte.

Retiens une seule image de cette leçon : le modèle est comme un pisteur à l'oeil infaillible et à la mémoire nulle - c'est toi qui tiens le carnet de l'expédition et c'est toi qui le lui glisses sous les yeux avant chaque question, accompagné de la bonne page du guide de la réserve.

Code de cette leçon : main.py
1# ===========================================
2# Labo Safari : Chatbot avec mémoire de conversation
3# ===========================================
4# Construis un chatbot guide de safari interactif qui
5# se souvient de l'historique de la conversation.
6
7from openai import OpenAI
8
9client = OpenAI(api_key="your-api-key-here")
10
11# --- Historique de la conversation ---
12messages = [
13    {
14        "role": "system",
15        "content": "Tu es Darwin, un guide de safari IA sympathique. "
16                   "Tu aides les visiteurs à découvrir la faune africaine. "
17                   "Garde des réponses brèves (2-3 phrases)."
18    }
19]
20
21def chat(user_message):
22    """Envoie un message et obtiens une réponse de Darwin."""
23    messages.append({"role": "user", "content": user_message})
24
25    response = client.chat.completions.create(
26        model="gpt-4",
27        messages=messages,
28        temperature=0.7,
29        max_tokens=200
30    )
31
32    assistant_reply = response.choices[0].message.content
33    messages.append({"role": "assistant", "content": assistant_reply})
34
35    return assistant_reply
36
37# --- Conversation simulée ---
38print("=== Chatbot guide de safari ===")
39print("Darwin : Bienvenue dans le Safari Python ! Demande-moi ce que tu veux.\n")
40
41conversation_turns = [
42    "Quels animaux puis-je voir dans la savane ?",
43    "Parle-moi davantage des lions.",
44    "Sont-ils dangereux pour les visiteurs ?"
45]
46
47for question in conversation_turns:
48    print(f"Visiteur : {question}")
49    reply = chat(question)
50    print(f"Darwin : {reply}\n")
51
52print(f"Longueur de la conversation : {len(messages)} messages")
53
54# TODO : Ajoute une boucle while interactive pour discuter en temps réel
55# print("\n=== Mode interactif ===")
56# print("Tape 'quit' pour quitter.\n")
57# while True:
58#     user_input = input("Toi : ")
59#     if user_input.lower() == "quit":
60#         print("Darwin : Bon voyage sur le safari !")
61#         break
62#     response = chat(user_input)
63#     print(f"Darwin : {response}\n")
64
65# TODO : Ajoute une fonction pour résumer la conversation
66# def summarize_conversation():
67#     summary_msg = [
68#         {"role": "system", "content": "Résume cette conversation de safari en 2 phrases."},
69#         {"role": "user", "content": str(messages[1:])}
70#     ]
71#     response = client.chat.completions.create(
72#         model="gpt-4", messages=summary_msg, max_tokens=100
73#     )
74#     return response.choices[0].message.content
75#
76# print(f"Résumé : {summarize_conversation()}")
77

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Comment un chatbot peut-il se souvenir du contexte de la conversation ?

  2. 2. Qu'est-ce que le RAG (Retrieval Augmented Generation) ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Utilise ChatMessageHistory et RunnableWithMessageHistory.

  • Tri horizontal

    Range les éléments :

  • Clique dans l'ordre

    Clique dans le bon ordre :

  • Tri vertical

    Range les étapes dans le bon ordre :

  • Tri vertical

    Range les étapes de la construction d'un chatbot :

Articles utiles