← News
Jérémie Doucy·

Pourquoi nos agents classent chaque échange une fois la réponse donnée

À chaque réponse d’un agent de la Plateforme Bayes, la plateforme a besoin de quelques informations sur cette réponse. Il lui faut un titre pour la conversation, les catégories que nos partenaires suivent dans leurs tableaux de bord, et les passages de la base de connaissances sur lesquels la réponse s’appuie, qui alimentent le panneau des sources.

Pendant deux mois, nous les avons calculées pendant la génération de la réponse, grâce à un outil que le modèle devait appeler. Mi-septembre, nous les avons confiées à un appel dédié, lancé une fois la réponse terminée. Cet article décrit les deux approches et ce qui nous a fait changer.

Un outil obligatoire dans la boucle

Nos agents appellent déjà des outils : ils cherchent dans la base de connaissances, remplissent des formulaires, passent la main à des sous-agents. En ajouter un de plus semblait naturel. Il prenait en arguments le titre, les catégories et les identifiants des passages cités, et quand le modèle l’appelait en même temps qu’il répondait, la classification ne coûtait rien.

Sur un prompt de production d’environ 15 000 tokens, les petits modèles appelaient pourtant rarement l’outil d’eux-mêmes. À température 0, le taux allait de 0/8 sur la famille Gemini 3.5 à 3/8 sur Gemini 3.6 Flash. Face à un simple « bonjour », il n’y a rien à résumer, et le modèle jugeait l’outil hors sujet, ce qui se comprend.

Forcer l’appel ne résout rien. Avec toolChoice: "required", tous les fournisseurs testés (Gemini, Gemma 4 sur vLLM, Claude) produisent l’appel et abandonnent la réponse texte. Aucun mode ne permet d’appeler l’outil et de répondre dans la même génération.

Nous avons donc construit une solution hybride. L’outil a été renommé mandatory_tool, et ce seul changement de nom a fait passer le taux d’appel de Gemini 3.6 Flash de 3/8 à 8/8, l’effet le plus fort de toute notre campagne sur les prompts. Quand le modèle l’oubliait malgré tout, une génération de rattrapage forcée partait après le stream. L’utilisateur avait déjà sa réponse à ce moment-là, donc perdre le texte de cette génération supplémentaire n’avait aucune importance.

Cela fonctionnait. Chaque échange avait son rapport, quel que soit le fournisseur.

Notre erreur

Nous avions confié au modèle qui répond une tâche sans rapport avec la conversation, et les petits modèles en ont vite montré le coût.

Le symptôme le plus net était la fuite d’appels. Il arrivait qu’un modèle écrive l’appel sous forme de texte au lieu de l’émettre, et l’utilisateur voyait mandatory_tool{…} au milieu de la réponse. Entre juillet et septembre, chaque motif que notre filtre de sortie a dû apprendre à retirer venait de ce même outil, et chaque correctif ajoutait une expression régulière.

L’outil encombrait aussi l’échange. Avec plusieurs appels obligatoires dans le même tour (un formulaire à remplir, un signal de passage de relais, le rapport), les modèles se sont mis à poser deux questions à la fois ou à sauter un champ du formulaire. Côté code, la moitié du pipeline de streaming servait à maintenir ce seul rapport en vie. Il fallait des schémas dynamiques et une comptabilité des exécutions, une condition d’arrêt dédiée et une section de protocole ajoutée au prompt de chaque agent.

Répondre d’abord, classer ensuite

La nouvelle approche suit une règle : la boucle de réponse ne porte que des outils qui interagissent avec la conversation. Tout ce que la plateforme veut savoir sur la réponse est calculé ensuite, par deux mécanismes.

Les deux temps d'un échangePendant la réponseAprès la réponseMessage de l'utilisateurBoucle de réponsebase documentaire, formulaires, MCP, sous-agentsRéponse diffusée et enregistréemarqueurs de citation retirésAppel de classificationsortie structurée, température 0SourcesTitre de la conversationCatégoriesFin du passage de relais[c1]en secours, si rienn'est cité

Les sources en citations dans le texte

Quand la recherche dans la base de connaissances renvoie des passages, elle attribue à chacun un alias court (c1, c2…). Le modèle cite un passage en écrivant son alias entre crochets juste après la phrase concernée : [c1], [c1, c3]. Il recopie deux caractères qu’il vient de lire. C’est le principe qu’utilisent Perplexity, le grounding de Gemini et les citations de Claude et de Cohere.

Un extracteur retire les marqueurs du texte affiché à l’utilisateur et du message enregistré, et note les alias cités. La difficulté tient au streaming : un marqueur peut arriver coupé entre deux fragments. L’extracteur garde donc en attente tout ce qui suit un [ non fermé, jusqu’à l’arrivée du ] ou la fin du stream :

const openBracket = pending.lastIndexOf("[")
if (
  openBracket !== -1 &&
  pending.length - openBracket < MAX_MARKER_LENGTH &&
  !pending.includes("]", openBracket)
) {
  safeUntil = openBracket
}

Au-delà de 48 caractères, un crochet ouvert est considéré comme du texte ordinaire et relâché. Le motif accepte aussi les variantes que produisent les petits modèles, comme des espaces en trop, un séparateur ; ou des majuscules.

Un seul appel en sortie structurée

Une fois la réponse terminée et enregistrée, un unique appel en sortie structurée produit le titre et les catégories. Il attribue aussi les sources, mais seulement si des passages ont été récupérés sans qu’aucun ne soit cité dans le texte.

Le classifieur ne voit jamais le prompt système de l’agent, qui était justement ce qui rendait l’appel d’outil peu fiable. Il lit un prompt court et identique pour tous les agents. Ce prompt contient le dernier échange et les six messages précédents (tronqués à 600 caractères), puis la liste des catégories et des extraits des passages si besoin.

Nous passons ici par la sortie structurée plutôt que par un appel d’outil, car il n’y a plus de texte à préserver : la contrainte qui nous empêchait de forcer l’outil disparaît. Elle offre aussi une garantie plus forte sur Gemma 4. Servi par vLLM, un schéma JSON est compilé en grammaire qui masque, à chaque token, tout ce qui sortirait du schéma : le modèle ne peut pas produire une catégorie absente de la liste. Les appels d’outil n’ont pas cette contrainte : vLLM laisse Gemma écrire l’appel dans son propre format et analyse le texte une fois généré, si bien qu’une valeur invalide n’apparaît qu’après coup.

Le schéma est construit à chaque échange et ne déclare que les champs utiles. Les listes fermées deviennent des enums, pour qu’un décodeur contraint ne puisse pas inventer une catégorie ou un identifiant de passage :

if (availableCategoryNames.length > 0) {
  properties.categoryNames = {
    type: "array",
    items: { type: "string", enum: availableCategoryNames },
    maxItems: MAX_CATEGORIES,
    description:
      "The complete set of categories that apply to the conversation as a whole, including earlier turns. Empty when none applies.",
  }
}

L’appel tourne à température 0 sur le modèle de l’agent lui-même, sans être bloquant. L’utilisateur a déjà sa réponse : un échec est consigné clairement dans les logs et n’interrompt jamais le stream.

Les catégories méritent un mot, car ce sont elles qui alimentent les statistiques. Nous demandons l’ensemble des catégories qui s’appliquent à toute la conversation, cinq au maximum, et elles remplacent celles de la session à chaque échange. Si un tour se trompe légèrement, le suivant corrige au lieu d’empiler les erreurs. Les résultats restent enregistrés sous les anciens noms d’outils, si bien que les conversations stockées, l’historique d’activité et les tableaux de bord n’ont pas changé.

Une étape après chaque échange

Sortir la classification de la boucle nous a donné une étape garantie après chaque échange, où poser de nouvelles questions sans toucher à la génération de la réponse. Elle a servi dès la semaine suivante.

Un sous-agent peut prendre la main sur une conversation, par exemple pour dérouler un questionnaire. Le schéma gagne alors deux champs : taskConcluded indique si la réponse clôt la partie du sous-agent, et handoffSummary résume en deux à quatre phrases ce qu’il a recueilli, à l’intention de l’agent qui reprend. Si le sous-agent oublie d’appeler son outil de conclusion, la plateforme applique la conclusion d’après la lecture du classifieur. Avec l’ancienne approche, c’était une obligation de plus dans une boucle qui en portait déjà trop.

Résultats

La nouvelle approche coûte une petite génération supplémentaire par échange, y compris pour les modèles qui produisaient autrefois le rapport gratuitement. Ceux qui ne le faisaient jamais payaient déjà la génération de rattrapage.

Nous avons lancé les tests de régression en conditions réelles sur l’ensemble du pipeline le 16 septembre :

Suite Résultat
Échange RAG, 7 modèles 7/7 : réponse sourcée, aucun marqueur restant dans le texte, sources et métadonnées enregistrées une seule fois
Prompt volumineux, 7 modèles × 4 échanges 28/28 : une classification par échange, catégories issues de la liste, un titre dès qu’il y a du contenu

Les sept modèles (Gemma 4 et six versions de Gemini, de 2.5 Flash à 3.8 Flash) ont cité leurs sources dans le texte sur le scénario RAG : l’attribution de secours par le classifieur n’a jamais été nécessaire. Sur le prompt volumineux, tous ont renvoyé un titre vide pour la salutation et la catégorie attendue pour les autres échanges.

Le pipeline a aussi perdu ses schémas dynamiques, sa comptabilité des exécutions et son épilogue de prompt, et le filtre de sortie est revenu à son rôle : protéger les outils qui s’adressent à l’utilisateur.

La suite

Les marqueurs de citation portent la position exacte de chaque source dans la réponse. La première version les retire, la prochaine étape est de les afficher en notes de bas de page. Un signal de sécurité a davantage sa place dans la classification que dans un outil que le modèle peut oublier : c’est le prochain champ que nous prévoyons d’ajouter. Enfin, le classifieur tourne sur le modèle propre à chaque agent. Un modèle plus petit et dédié coûterait moins cher, mais le choix dépend des exigences de résidence des données de chaque partenaire, et le modèle de l’agent reste donc le choix par défaut.