Aller au contenu principal

Module 5 — Diffusion progressive des réponses

Un utilisateur qui attend dix secondes devant une interface vide croit que l'application est en panne. Un utilisateur qui voit la réponse s'écrire jeton par jeton, même pendant dix secondes, accepte l'attente sans s'en plaindre. Cette différence de perception est la seule raison pour laquelle tous les assistants publics diffusent leurs réponses en flux continu. Gradio rend cette diffusion trivialement facile grâce aux générateurs Python.

Le générateur, une fonction qui rend plusieurs fois la main

Un générateur Python est une fonction qui utilise yield à la place — ou en plus — de return. À chaque yield, elle rend une valeur puis suspend son exécution. À l'appel suivant, elle reprend là où elle s'était arrêtée, jusqu'au yield suivant ou à la fin de la fonction. C'est exactement le modèle mental dont Gradio a besoin pour la diffusion : la fonction rend le premier morceau de la réponse, Gradio l'affiche, la fonction rend le deuxième, Gradio complète, et ainsi de suite jusqu'à ce que la fonction se termine.

Concrètement, dans gr.ChatInterface ou dans un événement .click d'un gr.Blocks, il suffit que la fonction retourne un générateur au lieu d'une chaîne. Gradio détecte la différence, itère sur le générateur, et met à jour le composant de sortie à chaque valeur produite. Il n'y a pas d'API supplémentaire à apprendre — la même fonction, avec yield au lieu de return, active la diffusion progressive.

Un premier générateur, mot par mot

Pour comprendre le mécanisme sans complexité de modèle, on prépare une fonction qui rend la réponse un mot à la fois, avec une petite pause pour simuler un calcul.

import time
import gradio as gr

def repondre(message, historique):
reponse = f"Vous avez dit : « {message} ». Je vous écoute avec attention."
accumulateur = ""
for mot in reponse.split():
accumulateur += mot + " "
yield accumulateur
time.sleep(0.08)

demo = gr.ChatInterface(fn=repondre, type="messages", title="Écho progressif")
demo.launch()

Le point à ne pas rater est que chaque yield produit la réponse complète accumulée jusqu'ici, pas seulement le nouveau mot. Gradio remplace le contenu du composant à chaque nouvelle valeur ; si vous ne renvoyez que le mot courant, l'utilisateur voit défiler un mot après l'autre en effaçant les précédents. L'accumulateur est donc obligatoire. C'est un piège fréquent quand on démarre.

Diffuser une génération de modèle avec TextIteratorStreamer

Sur un vrai modèle Hugging Face, la génération se fait avec model.generate, qui bloque jusqu'à la fin. Pour diffuser, on utilise TextIteratorStreamer de transformers, qui expose la sortie sous forme d'itérateur au fur et à mesure de la génération, exécutée dans un thread en arrière-plan.

import threading
import gradio as gr
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer

nom = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
tokenizer = AutoTokenizer.from_pretrained(nom)
modele = AutoModelForCausalLM.from_pretrained(nom)

CONSIGNE = "Vous répondez en français, en trois phrases au plus."

def repondre(message, historique):
conversation = [{"role": "system", "content": CONSIGNE}] + historique
conversation.append({"role": "user", "content": message})
entree = tokenizer.apply_chat_template(
conversation, add_generation_prompt=True, return_tensors="pt"
)

diffuseur = TextIteratorStreamer(
tokenizer, skip_prompt=True, skip_special_tokens=True
)
parametres = dict(
input_ids=entree,
streamer=diffuseur,
max_new_tokens=200,
do_sample=False,
)
thread = threading.Thread(target=modele.generate, kwargs=parametres)
thread.start()

accumulateur = ""
for morceau in diffuseur:
accumulateur += morceau
yield accumulateur

demo = gr.ChatInterface(fn=repondre, type="messages", title="Assistant diffusé")
demo.launch()

Trois éléments cruciaux. skip_prompt=True évite que le diffuseur renvoie les jetons de la question — sinon la réponse commence par une redite de l'entrée. Le threading.Thread est indispensable : model.generate bloque, or nous voulons itérer sur diffuseur pendant que la génération avance, ce qui exige que les deux vivent en parallèle. skip_special_tokens=True masque les balises de fin (</s>, <|endoftext|>) qui ne doivent pas apparaître à l'utilisateur.

Diffuser une réponse d'API

Les grandes API supportent la diffusion via un paramètre stream=True et retournent un itérateur de fragments. La conversion vers un générateur Gradio est directe.

import os
import gradio as gr
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def repondre(message, historique):
messages = historique + [{"role": "user", "content": message}]
flux = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
stream=True,
)
accumulateur = ""
for morceau in flux:
delta = morceau.choices[0].delta.content
if delta:
accumulateur += delta
yield accumulateur

demo = gr.ChatInterface(fn=repondre, type="messages", title="Assistant OpenAI diffusé")
demo.launch()

Le test if delta est nécessaire car certains fragments contiennent des métadonnées vides (rôle, fin de flux). Sans lui, le générateur produit une chaîne inchangée à plusieurs reprises, ce qui ne casse rien mais gaspille des mises à jour d'interface.

Interruption : le bouton « Annuler »

gr.ChatInterface affiche automatiquement un bouton « Annuler » quand une génération est en cours. Cliquer dessus lève une exception GeneratorExit dans le générateur, qui remonte au thread de génération. Sur openai, la connexion est coupée. Sur transformers, il faut prévoir une fonction d'arrêt dans les paramètres de generate (via StoppingCriteria) ou accepter que la génération continue en arrière-plan jusqu'à sa fin naturelle, en ignorant simplement le résultat.

Pour un gr.Blocks construit à la main, le paramètre cancel_button d'un événement .click ajoute un bouton d'annulation. La logique côté serveur reste la même : GeneratorExit, gestion propre des ressources ouvertes (fichiers, connexions), et retour silencieux.

Cadencer la diffusion et l'ergonomie perçue

Un jeton par jeton peut être trop rapide et donner un effet stroboscopique désagréable. Un mot par mot est souvent plus lisible. Un time.sleep(0.02) entre deux yield sur le premier extrait de ce module est là pour cette raison ; sur un vrai modèle, la latence naturelle de la génération remplace ce délai.

À l'inverse, sur un modèle très rapide qui génère cinquante jetons par seconde, envoyer chaque jeton à Gradio surcharge le réseau. On peut alors accumuler par lots de trois ou cinq jetons avant chaque yield — ce que fait implicitement TextIteratorStreamer avec son paramètre timeout. L'équilibre à viser est cent à deux cents mises à jour par seconde, ce qui correspond à la fréquence perceptible par l'œil.

Latence perçue vs latence réelle

La diffusion ne réduit pas le temps total de génération : elle change seulement le moment où l'utilisateur voit le premier caractère. C'est cette « latence perçue » (temps jusqu'au premier jeton, time to first token) qui domine la satisfaction perçue. Sur un modèle qui met dix secondes à générer 200 jetons, voir le premier jeton en un demi-seconde suffit à passer d'une démo « lente » à une démo « fluide » aux yeux de l'utilisateur.

En résumé

  • Un générateur Python (yield à la place de return) active automatiquement la diffusion progressive dans Gradio ; chaque yield remplace le contenu du composant, ce qui impose de rendre la réponse accumulée et non le seul nouveau fragment.
  • TextIteratorStreamer de transformers combiné à un threading.Thread diffuse une génération locale ; skip_prompt=True et skip_special_tokens=True évitent la redite de la question et les balises internes.
  • Les API modernes exposent stream=True et un itérateur de fragments ; le test if delta filtre les métadonnées vides.
  • La diffusion change la perception, pas la latence réelle ; viser cent à deux cents mises à jour par seconde et gérer proprement l'annulation par GeneratorExit.

Le module suivant s'attaque à un problème apparemment mineur mais crucial pour la première impression : les exemples préremplis, ces boutons cliquables qui laissent l'utilisateur essayer une démo sans savoir quoi taper.