Une réponse qui arrive mot après mot
Une réponse en flux est une réponse qu’un modèle de langage envoie par petits morceaux pendant qu’il écrit. Elle n’arrive pas d’un seul bloc à la fin. Les applications de discussion s’en servent pour que les mots apparaissent tout de suite.
C’est utile, car une longue réponse peut durer plusieurs secondes. Avec un flux, le lecteur voit le début presque aussitôt. Votre programme doit lire les morceaux et les assembler.
À la fin de cette leçon
Remarque
Je peux lire une réponse qui arrive par morceaux et l’assembler en un seul texte.
Mots clés
| Mot | Ce que ça veut dire | Exemple |
|---|---|---|
| Flux | une réponse envoyée par morceaux pendant qu’elle s’écrit | |
| Événements du serveur | un moyen pour un serveur de pousser des lignes vers un client | |
| Ligne de données | une ligne du flux qui commence par data: et contient du JSON | |
| Delta | le nouveau morceau de texte d’une ligne de données | |
| Marqueur de fin | la dernière ligne de données, qui dit que le flux est fini | data: [DONE] |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Un flux est une suite de lignes
Pour demander un flux, ajoutez "stream": true au corps de la requête. Le serveur répond alors par des événements envoyés par le serveur. C’est du texte simple, une ligne à la fois, avec une ligne vide entre les événements.
Chaque ligne qui commence par data: porte un morceau de JSON. Le texte est dans choices[0].delta.content. La dernière ligne est data: [DONE].
Les lignes d’un flux
| La ligne commence par | Ce qu’elle contient |
|---|---|
| data: {"choices":[{"delta":{"role":"assistant"}}]} | L’ouverture : un rôle, pas encore de texte |
| data: {"choices":[{"delta":{"content":"Un"}}]} | Un morceau de texte à ajouter |
| data: {"choices":[{"delta":{},"finish_reason":"stop"}]} | La raison de la fin de la réponse |
| data: [DONE] | Le marqueur de fin, qui n’est pas du JSON |
| : keep-alive | Une ligne de commentaire à ignorer |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Voir les paquets d’octets qui arrivent
docker exec -w /home/node/project agent node -e 'const labo = require("./labo.js"); const evenement = (texte) => "data: " + JSON.stringify({ choices: [{ delta: { content: texte } }] }) + "\n\n"; const tout = evenement("Un") + evenement(", deux") + evenement(", trois.") + "data: [DONE]\n\n"; (async () => { let paquets = []; let etiquette = "Flux"; try { const reponse = await labo.envoyer({ stream: true, max_tokens: 30, messages: [{ role: "user", content: "Comptez de un à cinq." }] }); if (!reponse.ok) throw new Error("HTTP " + reponse.status); const decodeur = new TextDecoder(); for await (const octets of reponse.body) paquets.push(decodeur.decode(octets, { stream: true })); } catch (erreur) { etiquette = "Exemple enregistré"; paquets = tout.match(/[^]{1,45}/g); } console.log(etiquette + " : premiers paquets d’octets"); paquets.slice(0, 4).forEach((un, i) => console.log((i + 1) + " " + JSON.stringify(un))); console.log("... " + paquets.length + " paquets en tout."); })();'Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Un paquet du réseau n’est pas une ligne
Les octets arrivent à votre programme par paquets, et un paquet peut s’arrêter au milieu d’une ligne. Gardez donc un tampon. Ajoutez-y chaque paquet, découpez chaque ligne complète et conservez le reste pour le paquet suivant.
Seule une ligne complète peut être lue sans risque comme du JSON. Ajoutez ensuite chaque morceau de texte à la réponse.
Écrire flux.js, un lecteur de lignes entières
docker exec -w /home/node/project agent node -e 'require("fs").writeFileSync("flux.js", ["// flux.js : lit une réponse en flux sous forme de lignes complètes, quelle que soit la coupe des paquets.", "async function* lignesDe(reponse) {", " let retenu = \"\";", " const decodeur = new TextDecoder();", " for await (const octets of reponse.body) {", " retenu += decodeur.decode(octets, { stream: true });", " const parties = retenu.split(\"\\n\");", " retenu = parties.pop();", " yield* parties;", " }", " if (retenu !== \"\") yield retenu;", "}", "", "module.exports = { lignesDe };"].join("\n") + "\n")'Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Ajouter morceauxDe, qui donne le texte
docker exec -w /home/node/project agent node -e 'require("fs").appendFileSync("flux.js", ["", "// Partie 2 : envoie un appel en flux et rend les morceaux de texte, un par un.", "async function* morceauxDe(corps) {", " const reponse = await require(\"./labo.js\").envoyer({ ...corps, stream: true });", " if (!reponse.ok) throw new Error(\"HTTP \" + reponse.status);", " for await (const ligne of lignesDe(reponse)) {", " if (!ligne.startsWith(\"data: \") || ligne === \"data: [DONE]\") continue;", " const morceau = JSON.parse(ligne.slice(6)).choices?.[0]?.delta?.content;", " if (morceau) yield morceau;", " }", "}", "", "module.exports = { lignesDe, morceauxDe };"].join("\n") + "\n")'Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Une erreur fréquente
Attention
Beaucoup pensent qu’un paquet égale un événement. Ce n’est pas vrai. Un paquet peut contenir trois événements ou la moitié d’un. Votre programme est correct quand il donne le même texte, quelle que soit la coupe des octets. Mettez d’abord en tampon, puis lisez les lignes.
Afficher et assembler les morceaux
docker exec -w /home/node/project agent node -e 'const { morceauxDe } = require("./flux.js"); const exemple = ["Un", ", deux", ", trois", ", quatre", ", cinq."]; (async () => { const debut = Date.now(); let premier = null, nombre = 0, texte = "", etiquette = "Réponse du modèle"; try { const demande = { max_tokens: 40, messages: [{ role: "user", content: "Comptez de un à cinq." }] }; for await (const morceau of morceauxDe(demande)) { if (premier === null) premier = Date.now() - debut; texte += morceau; nombre++; process.stdout.write(morceau); } } catch (erreur) { etiquette = "Exemple enregistré"; texte = exemple.join(""); nombre = exemple.length; premier = null; process.stdout.write(texte); } console.log("\n" + etiquette + " : " + nombre + " morceaux, " + texte.length + " caractères."); if (premier !== null) console.log("Le premier morceau est arrivé après " + premier + " ms et la réponse entière après " + (Date.now() - debut) + " ms."); })();'Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Exemple travaillé : des lignes au texte
| Lignes de données, dans l’ordre | Morceau ajouté | Texte jusqu’ici |
|---|---|---|
| Ouverture, rôle seul | Aucun | Vide |
| content "Un" | Un | Un |
| content ", deux" | , deux | Un, deux |
| finish_reason "stop" | Aucun | Un, deux |
| data: [DONE] | Aucun | Un, deux |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Pour aller plus loin : chronométrer le premier morceau
Lancez trois fois le script d’assemblage et notez le délai du premier morceau et celui de la réponse entière.
Dites ensuite lequel des deux un lecteur remarque le plus, et pourquoi un flux aide surtout quand la réponse est longue.