Aller au contenu principal

Module 10 — Projet : démonstration d'un modèle de langage

Le fil rouge se referme ici. Les neuf modules précédents ont introduit les briques une par une ; ce module assemble un assistant conversationnel de bout en bout, publié sur un espace Hugging Face, avec la diffusion progressive du module 5, les exemples préremplis du module 6, la file d'attente du module 7, et un mécanisme de collecte des retours utilisateurs, plus un compteur de coût. Chaque décision est justifiée à haute voix pour servir de patron réutilisable sur vos propres démonstrations.

Le cahier des charges

Un assistant francophone spécialisé dans l'explication de notions techniques, branché sur une API OpenAI compatible. L'utilisateur pose une question, la réponse s'écrit jeton par jeton, trois exemples sont proposés au premier chargement pour amorcer la découverte, l'historique persiste pendant la session, un pouce vert ou rouge après chaque réponse écrit dans un fichier de retours consultable par l'auteur du Space, et le coût cumulé de la session est affiché en petit sous le champ de saisie. La démonstration est publique, la clé d'API est un secret du Space, et un premier utilisateur au réveil accepte une attente de 30 secondes.

Cet assistant est modeste par ses fonctionnalités, ambitieux par sa cohérence : il tient dans un seul fichier de 100 lignes, expose un vrai comportement de production (retours, coût, sécurité) et se prête à d'infinies variantes.

app.py complet

import os
import time
import json
from pathlib import Path

import gradio as gr
from openai import OpenAI

# --- Configuration ---
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
MODELE = "gpt-4o-mini"
COUT_ENTREE = 0.15 / 1_000_000 # USD par jeton d'entrée
COUT_SORTIE = 0.60 / 1_000_000 # USD par jeton de sortie
FICHIER_RETOURS = Path("/data/retours.jsonl")
FICHIER_RETOURS.parent.mkdir(parents=True, exist_ok=True)

CONSIGNE = (
"Vous êtes un tuteur francophone concis. "
"Vous expliquez les notions techniques en trois paragraphes au plus, "
"avec un exemple concret. "
"Vous dites 'je ne sais pas' quand la réponse dépasse votre connaissance."
)

# --- Fonctions ---
def repondre(message, historique, cout_cumule):
messages = [{"role": "system", "content": CONSIGNE}] + historique
messages.append({"role": "user", "content": message})

flux = client.chat.completions.create(
model=MODELE, messages=messages, stream=True,
stream_options={"include_usage": True}, temperature=0.2,
)

accumulateur = ""
usage = None
for morceau in flux:
if morceau.usage is not None:
usage = morceau.usage
continue
delta = morceau.choices[0].delta.content
if delta:
accumulateur += delta
yield accumulateur, cout_cumule

if usage is not None:
cout_appel = usage.prompt_tokens * COUT_ENTREE + usage.completion_tokens * COUT_SORTIE
cout_cumule = cout_cumule + cout_appel
yield accumulateur, f"Coût cumulé de la session : {cout_cumule*100:.2f} centimes USD"

def enregistrer_retour(donnees: gr.LikeData, historique):
if donnees.index >= len(historique):
return
tour = historique[donnees.index]
with FICHIER_RETOURS.open("a", encoding="utf-8") as f:
f.write(json.dumps({
"date": time.strftime("%Y-%m-%d %H:%M:%S"),
"message": tour["content"] if tour["role"] == "assistant" else "",
"note": "positif" if donnees.liked else "negatif",
}, ensure_ascii=False) + "\n")

# --- Interface ---
with gr.Blocks(title="Tuteur francophone") as demo:
gr.Markdown("# Tuteur francophone\nPosez une question technique en français.")
chatbot = gr.Chatbot(type="messages", label="Conversation", height=420)
entree = gr.Textbox(placeholder="Votre question...", label="")
cout = gr.Markdown("Coût cumulé de la session : 0.00 centimes USD")
cout_state = gr.State(value=0.0)

exemples = gr.Examples(
examples=[
"Explique la différence entre TCP et UDP à un étudiant.",
"Pourquoi utilise-t-on la validation croisée en apprentissage automatique ?",
"Quelles sont les limites d'un modèle de langage sur des faits récents ?",
],
inputs=entree,
)

def envoyer(message, historique, cout_val):
historique = historique + [{"role": "user", "content": message}, {"role": "assistant", "content": ""}]
for reponse, cout_txt in repondre(message, historique[:-1], cout_val):
historique[-1]["content"] = reponse
yield historique, "", cout_val, cout_txt if isinstance(cout_txt, str) else cout_val

entree.submit(
fn=envoyer,
inputs=[entree, chatbot, cout_state],
outputs=[chatbot, entree, cout_state, cout],
)
chatbot.like(fn=enregistrer_retour, inputs=chatbot, outputs=None)

demo.queue(default_concurrency_limit=4, max_size=20)
demo.launch()

Ce que fait chaque bloc

La configuration rassemble en tête toutes les variables qui changeraient d'un déploiement à l'autre : nom du modèle, tarifs, chemin du fichier de retours, consigne système. En regrouper la modification dans un bloc facilite la reprise du code par un collègue.

Le fichier de retours est écrit dans /data/, le seul répertoire persistant d'un espace Hugging Face avec Persistent storage activé. Écrire ailleurs (dans le répertoire de travail) fonctionne, mais les fichiers disparaissent à chaque redémarrage — c'est-à-dire chaque mise à jour du code, chaque mise en veille suivie d'un réveil. Pour de vrais retours utilisables, /data/ est indispensable.

La fonction repondre utilise stream=True pour la diffusion progressive et stream_options={"include_usage": True} pour recevoir le décompte de jetons à la fin du flux. Sans cette option, l'API OpenAI ne renvoie pas les usages en mode diffusé, et le coût cumulé reste à zéro. Le calcul de coût multiplie les jetons par les tarifs constants ; à mettre à jour à chaque changement de tarif.

La fonction envoyer est la glue entre gr.Blocks et le générateur repondre. Elle ajoute manuellement les deux messages (utilisateur, assistant vide) à l'historique, puis remplace le contenu du dernier au fur et à mesure de la diffusion. Cette gestion à la main est nécessaire quand on veut plus qu'un gr.ChatInterface — ici, la mise à jour du composant cout en même temps que le chatbot.

L'événement chatbot.like est le mécanisme de retour. Le composant gr.Chatbot affiche un pouce vert et un pouce rouge sous chaque message de l'assistant ; un clic déclenche cet événement avec un gr.LikeData qui contient l'index du message et si le pouce est positif. On récupère le contenu correspondant et on écrit une ligne JSON dans le fichier de retours. Le format JSONL (une ligne JSON par retour) est le format naturel pour ce cas : facile à ajouter, facile à lire ligne par ligne dans un notebook d'analyse.

La file d'attente est configurée avec default_concurrency_limit=4 — quatre appels OpenAI en parallèle, ce qui est confortable puisque le calcul est côté API distante et notre serveur ne fait qu'attendre. max_size=20 protège de l'engorgement en cas de partage viral inattendu.

Le README.md et le requirements.txt

Le fichier README.md de l'espace décrit ce que fait la démonstration, ses limites (« ce tuteur peut se tromper sur des faits postérieurs à sa date d'entraînement, vérifiez systématiquement les informations critiques ») et le fonctionnement du bouton de retour. Cette section prose est aussi importante que le code : elle est la première chose que voit un visiteur avant de cliquer.

Le requirements.txt liste gradio==4.44.0 et openai>=1.30. Figer la version de gradio évite qu'une mise à jour introduise des changements incompatibles dans la mise en page ; laisser openai avec une contrainte souple permet d'accepter les correctifs de bugs sans intervention manuelle.

La collecte des retours en pratique

Le fichier /data/retours.jsonl s'accumule au fil des visites. Pour l'analyser, l'auteur du Space se connecte via le terminal intégré de Hugging Face (fonction récente), copie le fichier localement, et l'ouvre dans pandas :

import pandas as pd
retours = pd.read_json("retours.jsonl", lines=True)
retours["note"].value_counts()
retours[retours["note"] == "negatif"].head(20)

Un rapide comptage donne le taux de satisfaction global. Une lecture manuelle des 20 premiers retours négatifs révèle les motifs récurrents (« la réponse est trop longue », « le modèle a inventé une commande ») qui doivent guider la prochaine itération de la consigne système ou du choix de modèle.

Les cinq limites qu'il faut afficher

Un utilisateur informé pardonne les défauts ; un utilisateur non informé les subit. Le README.md du projet doit lister explicitement cinq limites. Le modèle peut halluciner des faits précis, particulièrement des chiffres et des dates. Sa connaissance s'arrête à une date d'entraînement fixe. La latence dépend de la charge de l'API et peut fluctuer. Le coût limite le nombre d'utilisateurs simultanés (10 000 utilisateurs actifs coûteraient significativement plus que le budget d'un projet personnel). L'assistant oublie entre deux sessions : recharger la page vide l'historique.

Cette liste évite les questions récurrentes et positionne la démonstration comme un artefact honnête plutôt que comme un produit fini.

Un projet vivant se met à jour

Publier une démonstration n'est pas la conclusion, c'est le début. Vérifier les retours une fois par semaine, ajuster la consigne système en conséquence, et republier une nouvelle version tous les mois transforme la démonstration en produit qui apprend de son usage. Cette boucle de retour est le vrai enseignement du module.

En résumé

  • Une démonstration LLM prête pour la production tient dans un seul app.py d'une centaine de lignes, mais assemble diffusion, exemples, file d'attente, retours et coût — chacune de ces pièces vient d'un module précédent.
  • stream_options={"include_usage": True} est indispensable pour recevoir le décompte de jetons en mode diffusé et calculer le coût cumulé.
  • L'événement chatbot.like avec gr.LikeData recueille les pouces verts et rouges ; écrire dans /data/retours.jsonl (persistant sur Hugging Face) puis analyser dans pandas guide les itérations suivantes.
  • Le README.md doit afficher les limites (hallucination, date de coupure, latence, coût, oubli entre sessions) : un utilisateur informé pardonne, un utilisateur non informé subit.

Le récapitulatif final synthétise les dix modules, propose une comparaison entre Gradio et Streamlit (cours 38), et annonce l'examen final.