L’appel d’outils (tool calling) et le streaming attendent de vous des choses opposées.
Le streaming veut transmettre les tokens au navigateur dès leur arrivée. L’appel d’outils veut examiner la complétion, décider si le modèle a demandé un outil, l’exécuter, puis relancer la boucle. Le problème, c’est que vous ne savez quel type d’échange vous traitez qu’en le générant : aucun champ de la requête n’annonce « celui-ci sera une réponse ».
Il existe donc une façon évidente d’écrire la boucle, et presque tout le monde l’écrit au moins une fois.
À quoi ressemble la boucle
loop:
completion = create(messages, tools) // sans streaming
if completion has tool calls:
run them, append results, continue
else:
break
// on sait maintenant que c'est une réponse : on recommence, en streaming
stream = create(messages, tools) // en streaming
forward every delta to the browser
À la lecture, ça paraît raisonnable. L’appel sans streaming sert de sonde : simple à suivre, facile à tester dans une condition, et il garde le chemin du streaming simple, puisqu’au moment où le streaming commence, vous savez que rien d’autre ne peut arriver.
Seulement, cette boucle génère aussi deux fois chaque réponse ordinaire.
Le premier appel produit une réponse complète : le modèle ne sait pas qu’il sert de sonde. Vous jetez cette réponse, vous reposez la question et vous streamez la seconde. L’utilisateur attend les deux.
J’ai livré exactement ce code, et il a survécu à plusieurs versions, parce que rien n’y paraît faux. Aucune erreur. La latence, c’est « un peu lent, c’est un LLM ». La facture, « à peu près ce qu’on attend de l’IA ». Les tests passent, parce qu’ils vérifient le texte final, et le texte final est correct.
Ce que ça coûte vraiment
Deux choses, et la seconde est pire que la première.
Les tokens. Un échange sans appel d’outil paie la réponse entière deux fois, en sortie comme en entrée. Et l’entrée devient la moitié la plus chère dès que vous envoyez un prompt système, un aperçu du centre d’aide et l’historique de la conversation. Or les réponses sont le cas le plus courant : salutations, relances, demandes de précision, tout ce que le modèle peut traiter avec ce qu’il a déjà.
Le délai avant le premier caractère. C’est celui que les utilisateurs ressentent. La sonde doit se terminer entièrement avant que l’appel en streaming commence : le visiteur attend donc toute une génération devant une bulle vide, et c’est seulement ensuite que les caractères apparaissent. Vous avez mis en place le streaming précisément pour supprimer cette attente, puis vous l’avez replacée juste devant.
Pour corriger le bug, arrêtez de sonder. Streamez chaque tour, et traitez « du texte est arrivé » et « un outil a été demandé » comme deux choses qui peuvent sortir du même appel, parce que c’est bien le cas.
Streamer un tour qu’il faudra peut-être abandonner
Ce qui bloque, c’est qu’un tour en streaming paraît risqué : et s’il se révèle être un appel d’outil alors que vous avez déjà commencé à envoyer du texte au navigateur ?
Ça n’a aucune importance. Un tour qui appelle un outil n’émet en général aucun texte, et quand il en émet, c’est un commentaire que l’utilisateur doit voir de toute façon (« Je vérifie dans la documentation »), suivi de l’appel. Transmettez les deltas à mesure qu’ils arrivent. Si des appels d’outils apparaissent dans le même tour, exécutez-les et poursuivez la boucle ; le texte du tour suivant prolonge simplement le même message.
Ce qui change, c’est que votre boucle reconstitue désormais un tour à partir de fragments au lieu de lire un objet terminé. Quatre détails font tout le travail :
Les appels d’outils arrivent en morceaux, repérés par leur index. Le premier fragment d’un appel porte index, id et le nom de la fonction ; chaque fragment suivant ne porte que index et le morceau suivant du JSON des arguments. Vous accumulez le tout dans une table indexée par index et concaténez les chaînes d’arguments. Rien d’autre n’est stable : ni l’ordre, ni la position dans le tableau.
La raison d’arrêt arrive dans son propre fragment. finish_reason arrive dans un fragment dont le delta est vide : si vous ne regardez que les fragments qui portent du contenu, vous ne la verrez jamais, et vous ne saurez jamais que la réponse a été coupée à la limite de tokens.
La consommation arrive après tout le reste. Demandez-la avec stream_options: { include_usage: true }, et elle arrive dans un dernier fragment dont le tableau choices est vide. Un code qui lit chunk.choices[0].delta sans vérifier lèvera une exception sur le seul fragment qui vous dit combien l’échange a coûté.
Un tour peut commenter, puis appeler un outil. Si du texte est arrivé dans ce tour et que le tour précédent en a aussi produit, il faut un saut de paragraphe entre les deux, sinon deux phrases générées à une seconde d’intervalle se collent sur une seule ligne.
Aucun de ces détails n’est difficile. Mais quand vous vous trompez, aucun ne fait de bruit, et c’est pour ça que la version avec sonde paraît plus sûre qu’elle ne l’est.
Ce que vous gagnez en prime : le bouton d’arrêt
Voici l’argument qui m’a convaincu, au-delà de l’argent.
Tant que vous sondez, vous ne pouvez pas proposer de bouton d’arrêt. La requête de l’utilisateur n’est liée à rien d’annulable : la génération en cours finira de toute façon à la poubelle, et celle qui compte n’a pas encore commencé. L’annulation ne devient possible que lorsqu’il y a exactement une génération en cours par échange, et que c’est celle qui est transmise.
Une fois la boucle correcte, l’arrêt tient en quelques lignes : un AbortSignal du bouton jusqu’au fetch, et côté serveur une vérification qui arrête d’écrire quand le client est parti. Sortir de la boucle met aussi fin à la requête en amont : le modèle cesse réellement de générer au lieu de terminer une réponse que personne ne lira.
Mais facturez correctement, car c’est là qu’une implémentation naïve perd de l’argent sans bruit, dans l’autre sens. Si vous enregistrez la consommation de tokens à la fin d’un échange réussi, un échange arrêté n’enregistre rien, et vous venez d’offrir chaque token que le modèle a dépensé avant que l’utilisateur appuie sur le bouton d’arrêt. Enregistrez la consommation dans un finally, pour que le même chemin s’exécute, que l’échange soit allé au bout, ait échoué ou ait été abandonné en pleine phrase :
try {
// ... tours en streaming, yield des deltas
} finally {
billOnce(tokensSpentSoFar);
}
En JavaScript, sortir d’un for await qui parcourt un générateur asynchrone appelle return() sur ce générateur, qui reprend à son dernier yield et exécute son finally. L’annulation n’est pas un cas particulier à ne pas oublier : c’est le chemin normal de dépilement, à condition de placer la facturation là où ce chemin passe.
Enregistrez ensuite ce que l’utilisateur a vu. Une réponse arrêtée reste une réponse : un demi-paragraphe sur son écran et, si vous conservez les transcriptions, un demi-paragraphe dans votre tableau de bord, marqué comme arrêté. Si vous n’enregistrez rien, vos propres logs ne correspondent plus à la conversation que la personne a réellement eue.
Comment savoir si vous avez ce bug
Comptez les complétions, pas les tokens. Loguez une ligne par appel en amont avec l’identifiant de l’échange, posez à votre assistant une question qui ne demande aucun outil (« salut » ou « que sais-tu faire ? ») et regardez combien de complétions ce seul échange a produites.
S’il y en a deux, vous payez l’une d’elles pour rien.
Le chat IA de Kookee streame désormais une seule complétion par tour, et le bouton d’envoi devient un bouton d’arrêt pendant qu’une réponse arrive. Pour le widget, voir Installer le widget de chat IA sur votre site ; pour l’autre piège de cette partie de la stack, voir Pourquoi la liste de « sources » de votre chatbot IA est sans doute fausse.