Créer un chatbot LLM : streaming, compression, mémoire
Création pas à pas d’un chatbot : choix du modèle, arrêt côté fournisseur, budget de contexte mesuré, compression en mémoire et recherche web.
Nos choix
prix en direct| Modèle | Verdict | Prix |
|---|---|---|
| Gemini 3.6 Flash Rapide, avec un vrai contexte de 1M (aiguille retrouvée à 972K), et le réglage du raisonnement au minimum a réduit le coût mesuré par appel de 91-97% sur des tâches conversationnelles en une seule étape. | Meilleur choix par défaut | dès $1.5/M |
| DeepSeek V4 Flash Le choix compatible avec le chat le moins cher de cette page, avec la plus forte remise sur la lecture du cache du tableau : relire un long historique ne coûte donc presque rien. C’est aussi le modèle de résumé par défaut du MVP. | Choix économique | dès $0.138/M |
| Claude Sonnet 5 La meilleure cohérence des quatre pour la persona et le style rédactionnel. Note sur le budget : un texte identique est converti en 41% de tokens de plus que sur Sonnet 4.6 ; comparez donc le nombre de tokens, pas les prix affichés. | Choix qualitatif | dès $2/M |
| GLM-5.2 Un tour d’appel d’outil à chaud a coûté $0.0009 contre $0.0051 sur claude-opus-4-8. La latence médiane d’un tour à chaud était de 6.6s ; réservez-le donc aux parcours où l’utilisateur s’attend à attendre. | Appels d’outils économiques | dès $1.4/M |
Sommaire
- Que doit faire un chatbot MVP ?
- Comment l’ensemble est-il structuré ?
- Quels modèles proposer dans le sélecteur ?
- Que voit réellement le modèle à chaque tour ?
- Comment fonctionne le prompt cache, et quels modèles le prennent en charge ?
- Comment savoir si la limite de contexte approche ?
- Que se passe-t-il à la limite ?
- Comment la mémoire persiste-t-elle entre les sessions ?
- Que se passe-t-il lorsque l’utilisateur clique sur Stop ?
- Que se passe-t-il lorsqu’un tour échoue ?
- Comment la recherche web fonctionne-t-elle sans boucle d’outils dans votre code ?
- Comment afficher le markdown sans casser le rendu pendant le streaming ?
- Combien coûte une conversation ?
- Où se trouvent tous les réglages ?
- Qu’est-ce qui manque, et où l’ajouter ?
- À lire aussi
Ce guide présente un chatbot complet, exécutable en local en deux minutes environ et dont le code se lit en un après-midi : un serveur FastAPI, une page statique, aucune base de données ni étape de build. Nous détaillons la conception de chaque sous-système, l’ordre des opérations à chaque tour et les problèmes rencontrés pendant le développement. Le code source complet se trouve sur github.com/synthorai-io/use-cases, dans chatbot/. Toutes les requêtes passent par un seul endpoint compatible OpenAI. Le choix du modèle se fait donc dans une liste déroulante, sans intégration distincte pour chacun.
git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env # put your API key in it
cd chatbot
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn server:app --reload

Que doit faire un chatbot MVP ?
Huit choses. Chacune évite un problème précis :
| Fonctionnalité | Problème évité | Emplacement |
|---|---|---|
| Conversations persistantes (liste, recherche, renommage) | un chat qui oublie tout au rechargement est une démo, pas un outil | storage.py |
| Choix du modèle, modifiable en cours de conversation | utiliser pour chaque question un modèle tarifé pour les cas les plus difficiles revient trop cher | config.py |
| Personnalité modifiable, avec des presets | le system prompt fait partie du produit ; le modifier ne doit pas nécessiter de déploiement | presets/ |
| Streaming, avec un bouton Stop qui atteint le fournisseur | attendre le premier token sans retour donne l’impression que l’application est bloquée ; un faux bouton Stop laisse la facturation continuer | server.py |
| Budget de contexte, avec compression à la limite | chaque modèle possède une fenêtre limitée ; l’atteindre silencieusement rend le bot « moins intelligent » | context.py |
| Mémoire à long terme | devoir se représenter à chaque session est l’un des défauts les plus pénibles d’un chatbot | context.py + .data/memory.md |
| Recherche web et récupération de pages | les connaissances du modèle s’arrêtent à son entraînement, alors que les questions posées en chat concernent souvent l’actualité | tools.py |
| Coût visible par tour et par session | la facture augmente avec l’historique ; impossible d’optimiser ce qu’on ne mesure pas | server.py |
La recherche web et la récupération de pages méritent plus d’explications, car ce sont souvent les premières fonctions supprimées des listes de fonctionnalités d’un MVP. Les connaissances d’un modèle conversationnel s’arrêtent à la date de fin de son entraînement, plusieurs mois avant aujourd’hui. Or les questions posées portent très souvent sur l’état actuel : tarifs, versions, sorties ou « X prend-il déjà en charge Y ? ». Un chatbot qui répond à partir de ses seules données d’entraînement n’est pas simplement moins performant : il donne une réponse fausse avec assurance, sans permettre à l’utilisateur de savoir qu’elle est obsolète. Le problème ne se limite pas aux informations manquantes. Pendant le développement, nous avons constaté qu’un modèle sans date de référence considère la fin de son entraînement comme la date actuelle. Il génère alors de mauvaises recherches en intégrant une année dépassée dans ses requêtes. Pour un chatbot, la recherche d’informations n’est pas un bonus. C’est ce qui distingue une interface vers un instantané figé d’un véritable assistant.
Pourquoi deux outils plutôt qu’un ? La recherche et la récupération d’une page ne répondent pas à la même question. Une recherche renvoie des extraits de quelques centaines de caractères qui se contredisent régulièrement ; elle indique ce qui existe. La récupération charge une page complète et indique ce qu’elle dit exactement, ce qui permet de vérifier une valeur précise. Le premier outil découvre, le second vérifie. Le modèle décide à chaque tour s’il doit utiliser l’un ou l’autre. Un tour qui n’en a besoin ne coûte donc rien de plus, et des plafonds limitent le nombre d’utilisations dans les autres cas.
Les autres fonctions attendues sont également présentes : Regenerate, rendu markdown qui ne casse pas pendant le streaming et procédures de récupération spécifiques à chaque erreur. Les sections suivantes reprennent la liste dans l’ordre approximatif d’exécution d’une requête dans le code.
Comment l’ensemble est-il structuré ?
Trois éléments : une page statique, six petits fichiers Python et la gateway. La page envoie un message. Le serveur construit le contexte, le compresse si nécessaire, renvoie la complétion en streaming via des server-sent events, puis enregistre l’usage mesuré. Les conversations sont stockées dans des fichiers JSON lisibles avec cat.
static/index.html the chat UI (vanilla JS: picker, budget bar, markdown, activity trail)
server.py routes; the per-turn pipeline: project → compress → stream → record
context.py token budgeting, compression call, memory file, message assembly
tools.py the /v1/messages transport used by tool-enabled turns
storage.py one JSON file per conversation under .data/ (settings included)
config.py env-driven settings: model lineup, budgets, prompts
presets/ system-prompt presets, one .txt each
Le pipeline exécuté à chaque tour dans server.py est la partie essentielle. Chaque message utilisateur suit les cinq mêmes étapes, détaillées dans les sections suivantes :
user message
│
▼
[1] project the next request's size last measured prompt_tokens
│ + estimate(new message, pessimistic)
│ + completion reserve
▼
[2] over budget? ──yes──▶ compress old turns ──▶ rolling summary
│ └───────▶ durable facts ──▶ memory.md
▼
[3] assemble and send [persona][memory][summary][date][history]
│ cache mark after the system blocks
▼
[4] stream SSE back to the page delta / reasoning / search / fetch /
│ compression notice / warning / error
▼
[5] record measured usage usage.prompt_tokens becomes step [1]'s
input on the next turn
La boucle se referme : la valeur mesurée à l’étape 5 sert de référence à l’étape 1 du tour suivant. Le budget repose ainsi sur le décompte réel de l’API, et non sur une estimation locale.
Il faut connaître dès le départ un choix d’architecture : les tours simples utilisent /v1/chat/completions, tandis que ceux où la recherche web ou la récupération de pages est activée passent par /v1/messages. Ce n’est pas une préférence de style. La section consacrée aux outils présente les mesures qui imposent cette séparation.
Quels modèles proposer dans le sélecteur ?
Sept modèles sont définis dans .env et regroupés par catégorie dans le sélecteur (rapides et économiques, équilibrés, frontier). Vous pouvez ajouter depuis Settings n’importe quel id servi par la gateway ; les ids ajoutés sont conservés dans .data/models.json. Le modèle par défaut compte davantage que la liste elle-même. Le MVP démarre sur Gemini 3.6 Flash, avec le niveau de raisonnement réglé au minimum. Les réponses conversationnelles sont des tâches en une étape. Sur ce type de tâche, reasoning_effort: "minimal" a réduit le coût mesuré par appel de 91 à 97% par rapport au réglage par défaut, sans différence perceptible pour le lecteur. Ce paramètre est défini par modèle dans la configuration, car tous les modèles ne l’acceptent pas :
# config.py — extra request params per model
MODEL_PARAMS: dict[str, dict] = {
"gemini-3.6-flash": {"reasoning_effort": "minimal"},
}
DeepSeek V4 Flash remplit deux rôles : option économique dans le sélecteur et modèle de synthèse par défaut pour la compression. La synthèse est elle aussi une tâche en une étape qui tolère un niveau de qualité inférieur. Claude Sonnet 5 convient lorsque la qualité rédactionnelle est au cœur du produit. Pour établir le budget, comparez le nombre de tokens plutôt que le prix affiché : un même texte produit 41% de tokens supplémentaires par rapport à Sonnet 4.6. GLM-5.2 se distingue dans les parcours qui utilisent beaucoup d’outils : un tour avec appel d’outil et cache chaud a coûté $0.0009, contre $0.0051 avec Claude Opus 4.8. Sa latence médiane de 6.6s à chaud reste cependant perceptible dans une interface de chat.
Les capacités sont mesurées pour chaque modèle, jamais supposées. Le code tient compte des différences au lieu de les masquer. Dans cette sélection, seuls DeepSeek et GLM diffusent leur raisonnement sous forme de reasoning_content. Les modèles Claude ne renvoient aucun bloc de réflexion via cette gateway, même lorsque le paramètre correspondant est activé. L’interface ne propose donc un panneau « Réflexion » en direct que lorsqu’un tel contenu peut exister.
Changer de modèle en cours de conversation n’impose aucune modification structurelle. L’historique utilise des messages indépendants du fournisseur, au format {"role", "content"}. Une même conversation peut donc commencer sur un modèle économique, puis passer au modèle de meilleure qualité lorsqu’une question difficile arrive. Le seul coût est invisible : le changement de modèle abandonne le prompt cache du modèle précédent. Le premier tour suivant relit donc tout le contexte au tarif à froid.
Que voit réellement le modèle à chaque tour ?
Un prompt en couches, assemblé à un seul endroit et dans un ordre volontaire : les éléments les plus stables d’abord.
| Couche | Source | Fréquence de modification |
|---|---|---|
| System prompt (personnalité) | presets/*.txt ou texte libre | jamais, sauf modification manuelle |
| Mémoire à long terme | .data/memory.md | rarement, lors de l’ajout de faits |
| Synthèse glissante | compression | uniquement lors d’une compression |
| Date de référence | horloge du serveur | chaque jour |
| Instructions des outils | Settings, uniquement pour les tours avec outils | rarement |
| Tours récents | conversation | à chaque tour |
La règle consiste à placer d’abord les éléments les plus stables afin de favoriser le cache, détaillé dans la section suivante. La personnalité ne change jamais, la mémoire rarement, la synthèse uniquement lors d’une compression et l’historique à chaque tour. Chaque couche est donc placée après celles qui changent moins souvent.
La date de référence a une fonction précise et une place bien choisie. Sans elle, un modèle considère la fin de son entraînement comme le présent. Il produit des requêtes de recherche avec une année dépassée et interprète un tableau de versions comme si sa dernière ligne était encore actuelle. Cette référence reste toutefois une date, pas un timestamp. Elle se trouve dans le préfixe du prompt ; une précision inférieure à la journée invaliderait donc le cache à chaque requête. Elle vient après la personnalité, la mémoire et la synthèse, qui restent ainsi en cache lors du passage à minuit.
Comment fonctionne le prompt cache, et quels modèles le prennent en charge ?
Le principe est simple : les fournisseurs mettent en cache le préfixe strictement identique d’une requête, puis le resservent avec une forte remise. La première écriture entraîne un petit surcoût. Le chat est particulièrement adapté à ce mécanisme, car chaque requête reprend la précédente et lui ajoute deux messages. L’historique complet forme donc naturellement un préfixe stable. Le surcoût d’écriture est amorti dès le tour suivant. Sur les modèles Anthropic, l’écriture coûte 1.25x pour un TTL de 5 minutes, contre environ 0.1x en lecture ; l’économie côté écriture est mesurée ici. Les marqueurs cache_control ont réduit le coût mesuré de 88 à 89% sur trois gammes Claude. Dans une conversation contenant déjà une vraie mémoire et une synthèse, les tours suivants coûtent environ un dixième du premier tour à froid.
Le terme « cache » recouvre toutefois deux mécanismes. Les fournisseurs se répartissent en deux groupes, tous deux représentés ici :
| Modèle | Type de cache | Action requise | Hits signalés dans |
|---|---|---|---|
| Famille Claude | explicite : points de rupture cache_control | placer le marqueur | cache_read_input_tokens |
| DeepSeek V4 Flash | implicite : préfixe automatique | aucune | prompt_tokens_details.cached_tokens |
| GLM-5.2 | implicite : préfixe automatique | aucune | prompt_tokens_details.cached_tokens |
| Gemini 3.6 Flash | implicite ; le marqueur est accepté puis ignoré | aucune méthode ne fonctionne | non signalés via cette gateway |
Le cache explicite, utilisé par Anthropic, oblige à définir le point de rupture, mais indique précisément le résultat : les tokens écrits et lus sont séparés, ce qui permet de vérifier la remise à chaque tour. Le cache implicite, courant avec les API au format OpenAI, ne demande aucun marqueur. Il se déclenche lorsque le préfixe se répète, selon les décisions du fournisseur. La seule preuve disponible après coup est un champ cached_tokens ; sa fiabilité varie fortement selon les fournisseurs. Le MVP gère les deux systèmes avec la même requête : il ordonne le prompt du plus stable au plus variable, comme l’exige le cache implicite, et envoie toujours le marqueur explicite, que les autres modèles ignorent sans effet indésirable.
L’emplacement du marqueur explicite vient de mesures, pas de la documentation. Sur cette gateway, cache_control est respecté dans un bloc système et silencieusement ignoré ailleurs. Le schéma fréquent consistant à marquer le dernier message utilisateur, afin de mettre tout l’historique en cache, renvoie zéro token mis en cache et facture le plein tarif. Le MVP place donc le marqueur à la fin de la section système. Le préfixe mis en cache comprend la personnalité, la mémoire et la synthèse. Deux conséquences en découlent. D’abord, le cache ne s’active qu’une fois le seuil minimal du modèle franchi, environ 1,024 tokens. Une conversation récente ne met donc rien en cache, tandis qu’une conversation avec suffisamment de mémoire et une synthèse met tout ce préfixe en cache. Ensuite, toute modification du préfixe a un coût : modifier le system prompt invalide le cache dès le premier octet, la compression remplace la synthèse et impose une lecture à froid détaillée plus loin, et changer de modèle abandonne entièrement le cache précédent.
Le choix du TTL suit partout la même logique de configuration. Le cache de 5 minutes par défaut couvre une conversation active. Le niveau de 1 heure couvre un utilisateur revenu après une pause, au prix d’un surcoût d’écriture doublé. CACHE_TTL=1h suffit pour effectuer le changement. Sa rentabilité dépend du nombre d’utilisateurs qui reviennent réellement dans l’heure.
Comment savoir si la limite de contexte approche ?
Il faut mesurer plutôt qu’estimer. Le seul décompte exact pour le modèle interrogé est la valeur usage.prompt_tokens renvoyée par l’API lors de la requête précédente. Les estimateurs locaux sont trompeurs d’un fournisseur à l’autre, avec des écarts importants : un même texte anglais produit 41% de tokens d’écart entre deux modèles d’un même fournisseur, d’après nos mesures, et davantage entre fournisseurs. Le MVP n’utilise un décompte local que pour l’unique contenu que l’API n’a pas encore vu : le message en cours d’envoi. Cette estimation est volontairement arrondie à la hausse :
# context.py
def needs_compression(last_prompt_tokens, pending_text, message_count, budget=None):
if message_count <= config.KEEP_RECENT_MESSAGES:
return False # nothing old enough to fold away
projected = (
last_prompt_tokens # measured, last response
+ estimate_tokens(pending_text) # estimated, pessimistic
+ config.MAX_COMPLETION_TOKENS # worst-case reply
)
return projected > (budget or config.CONTEXT_BUDGET_TOKENS)
Une surestimation déclenche la compression un tour trop tôt, au prix d’un appel de synthèse peu coûteux. Une sous-estimation dépasse la fenêtre et provoque une requête en échec ou silencieusement tronquée. Cette asymétrie justifie l’arrondi vers le haut.
La mesure elle-même recèle un piège : les fournisseurs ne s’accordent pas sur l’inclusion des tokens mis en cache dans prompt_tokens. Certains renvoient le prompt complet, d’autres seulement la partie non mise en cache. Une erreur ici n’est pas anodine, car cette valeur sert de référence au budget. Si le décompte omet la taille du cache, la compression ne se déclenche jamais et la fenêtre finit par déborder sans signalement. Le serveur normalise les valeurs : si prompt_tokens est inférieur au nombre de tokens déclarés comme mis en cache, il ne peut pas représenter tout le prompt, donc les différentes parties sont additionnées. L’estimation interne reste également une valeur plancher pour la mesure. Le contenu réel du champ usage selon les fournisseurs mérite un article entier ; Anatomie de l’usage des tokens LLM le détaille.
Le budget par défaut est de 102,400 tokens, une valeur rarement atteinte par une conversation normale. Il s’agit d’un budget de fonctionnement, pas de la limite du modèle. Réglez-le au seuil où le coût d’un tour devient excessif. Pour observer le mécanisme, ouvrez Settings et réduisez la fenêtre à quelques milliers de tokens pour une conversation, car le budget est défini par conversation, puis collez plusieurs longs messages. La barre de contexte dans l’en-tête s’appuie sur la valeur mesurée et montre les différentes parties qui remplissent la fenêtre : personnalité, mémoire, synthèse et historique.

Que se passe-t-il à la limite ?
Le contexte est compressé, pas tronqué. Une troncature efface le début de la conversation, ce que l’utilisateur interprète comme une perte d’intelligence du bot. À la place, tous les messages sauf les plus récents, les 8 derniers par défaut, sont regroupés dans une synthèse glissante produite par le modèle de synthèse économique. Cette synthèse est ensuite incluse sous forme de bloc système. La fenêtre récente reste intacte, ce qui conserve le ton immédiat du bot ; seul l’historique ancien subit une compression avec perte. Rien ne disparaît sans avertissement : lorsqu’une compression se déclenche, la conversation affiche une notification indiquant le nombre de messages et la taille de la synthèse.
Une compression complète se déroule ainsi :
before (projected next request 103.1k > 102.4k budget)
[persona][memory][date][ m1 ..................... m34 │ m35 ....... m42 ]
old enough to fold last 8, kept
one summary call to deepseek-v4-flash:
in: prior summary + m1 ... m34
out: {"summary": "one dense paragraph: topics, decisions,
open questions, promises",
"facts": ["prefers Python", "timezone is UTC+8"]}
after
[persona][memory + new facts][summary][date][ m35 ....... m42 ]
unchanged grows rarely replaced verbatim
Trois choix dans l’appel de compression assurent l’essentiel de sa fiabilité :
- La nouvelle synthèse absorbe la précédente. Lors de la deuxième compression, la première synthèse est traitée avec les nouveaux messages devenus anciens. Le résultat reste un seul paragraphe glissant au lieu d’une chaîne de synthèses de synthèses qui grossit indéfiniment.
- Un échec n’annule pas le tour. Si l’appel de synthèse échoue, le serveur supprime les tours les plus anciens sans les résumer, indique précisément à l’utilisateur ce qui a été perdu, puis répond tout de même au message. Une mémoire imparfaite vaut mieux qu’un chatbot indisponible. Le chemin d’erreur invisible en démo est souvent celui qui déclenche une alerte en production.
- Le prompt est configurable, avec une seule protection. Les instructions données au modèle de synthèse déterminent ce que la conversation retient. Le prompt de compression est donc modifiable pour chaque conversation. Le serveur refuse toutefois une version qui ne contient plus le placeholder
{transcript}: un tel prompt ne résumerait rien et l’erreur n’apparaîtrait que bien plus tard, lors du premier dépassement. Même logique pourCOMPRESS_MAX_TOKENS: la limite de sortie prévoit une marge supérieure à la longueur demandée par le prompt, car tous les tours suivants hériteraient d’une synthèse tronquée en pleine phrase.
Côté cache, la compression impose exactement une relecture à froid. Le bloc de synthèse change, donc tout ce qui suit le bloc mémoire sort du cache pendant une requête. Le nouveau préfixe, plus court, est ensuite remis en cache. Le calcul réel est donc le suivant : un appel de synthèse et une lecture à froid permettent de réduire le coût de tous les tours suivants grâce à un contexte plus court et de nouveau chaud.
Comment la mémoire persiste-t-elle entre les sessions ?
Le bot utilise trois espaces mémoire, chacun avec une portée et une durée différentes. Tous les choix de cette section découlent de cette séparation :
| Stockage | Portée | Durée de vie | Taille transmise |
|---|---|---|---|
| Tours récents, intacts | conversation actuelle | jusqu’à leur intégration par compression | texte complet des 8 derniers messages |
| Synthèse glissante | conversation actuelle | supprimée avec la conversation | un paragraphe de moins de 200 mots |
Faits dans memory.md | toutes les conversations | jusqu’à leur suppression manuelle | quelques lignes |
La synthèse et les faits peuvent sembler proches, mais leur durée de validité diffère, d’où leur séparation. La synthèse glissante suit la conversation : décisions, questions ouvertes et engagements de l’assistant. Elle disparaît à juste titre avec cette conversation. Les faits durables sur l’utilisateur, comme « préfère Python » ou « fuseau horaire UTC+8 », restent vrais la semaine suivante et ne doivent pas disparaître avec la session.
Le prompt de compression demande les deux résultats en un seul appel et renvoie du JSON : une chaîne summary et un tableau facts. Les faits sont ajoutés à .data/memory.md, chargé par chaque conversation sous forme de bloc système. L’extraction a lieu pendant la compression plutôt que dans une passe séparée pour réduire les coûts. À ce moment-là, un modèle relit déjà les anciens tours ; l’extraction réutilise donc les tokens déjà payés. Un appel produit deux sorties.
Le MVP déduplique par correspondance exacte des lignes, ce qui montre vite ses limites. Une compression peut enregistrer « User prefers Python over Node.js », puis une autre ajouter « The user prefers Python over Node.js. ». Une déduplication sémantique demanderait d’utiliser des embeddings ou un LLM pour comparer chaque fait candidat au contenu existant. C’est une vraie fonctionnalité avec un coût réel ; le MVP conserve donc cette méthode simpliste et l’assume. Le fichier mémoire est en markdown pour qu’une personne puisse le lire et le nettoyer. Settings l’expose dans une zone de texte modifiable, qui sert aussi à rendre la mémoire transparente : ce que le bot sait de vous tient dans un fichier que vous pouvez ouvrir.
Que se passe-t-il lorsque l’utilisateur clique sur Stop ?
Le fournisseur arrête la génération. C’est toute la fonctionnalité, et elle manque à de nombreuses interfaces de chat : masquer la sortie pendant que la complétion continue revient à payer des tokens que personne ne lira.
La chaîne comporte trois maillons. Pendant le streaming d’une réponse, le bouton Send devient Stop, au même emplacement et sans confirmation. Un clic annule le fetch du navigateur. Entre chaque événement, le serveur vérifie si le client s’est déconnecté. Lorsqu’il le détecte, il sort de la boucle de streaming et ferme la connexion en amont, ce qui arrête le fournisseur. Le texte déjà reçu est conservé avec l’état interrupted et affiché avec une bordure en pointillés dans la conversation. Fermer l’onglet ou perdre la connexion déclenche la même chaîne, car le serveur voit ces cas comme un seul événement.
La persistance doit ici être strictement exécutée une seule fois. Le code le gère explicitement : la sauvegarde s’effectue dans la boucle de streaming en cas de fin normale, dans le gestionnaire d’exception si le flux est interrompu, et dans le bloc finally lors d’une déconnexion brutale, lorsque le framework annule le générateur et que rien ne s’exécute après la boucle. La fonction est idempotente : le premier chemin exécuté l’emporte.
Stop fonctionne avec Regenerate. Vous pouvez interrompre une réponse indésirable, puis la relancer sans ressaisir la question. Regenerate supprime les dernières réponses de l’assistant, y compris une réponse interrompue, afin que la conversation se termine de nouveau sur le message utilisateur, puis génère une nouvelle réponse avec le modèle actuel. La liste déroulante s’appliquant au tour suivant, Stop puis Regenerate permettent également de reposer la même question à un modèle plus puissant.
Que se passe-t-il lorsqu’un tour échoue ?
Six cas sont possibles, chacun avec sa propre procédure de récupération plutôt qu’un unique message d’erreur rouge. classify() dans server.py associe les exceptions reçues en amont à un type d’échec ; l’interface associe ensuite ce type à une action :
| Échec | Affichage pour l’utilisateur | Récupération |
|---|---|---|
| Erreur réseau | cause indiquée explicitement | bouton Retry |
| Limite de débit | délai provenant de retry-after, s’il existe | bouton Retry |
| Mauvaise clé API | nom exact de la variable d’environnement à corriger | modifier .env |
| Filtre de contenu | « le même texte sera de nouveau refusé » | Edit & resend |
| Flux interrompu | réponse partielle conservée et marquée comme interrompue | Retry |
| Échec de la compression | avertissement précisant ce qui a été supprimé | aucune action nécessaire ; le tour continue |
Deux invariants couvrent l’essentiel. Premièrement, le message utilisateur n’est jamais perdu. Si la requête échoue avant le premier événement, le serveur retire le message de la conversation et l’interface remet le brouillon dans la zone de saisie, évitant ainsi un double envoi lors d’une nouvelle tentative. Si le flux casse après l’arrivée de texte, la réponse partielle est conservée et signalée. Deuxièmement, une réponse refusée propose rewind, pas retry. Le serveur retire le message utilisateur de l’historique et le replace dans la zone de saisie pour qu’il soit modifié, car renvoyer à l’identique un contenu refusé échouera indéfiniment.
Une règle de stockage plus discrète soutient les deux précédentes : aucun tour vide de l’assistant n’est enregistré. Renvoyer en amont un message assistant vide casse l’alternance utilisateur/assistant imposée par certains fournisseurs. L’erreur finit par apparaître sur un tour ultérieur en désignant le mauvais message, ce qui la rend particulièrement difficile à diagnostiquer. Le MVP retire les contenus vides du format transmis et refuse d’enregistrer les réponses sans texte. Exception : si le tour contient une recherche ou un raisonnement utile à conserver, l’activité est stockée, puis le texte vide est retiré avant l’envoi.
Comment la recherche web fonctionne-t-elle sans boucle d’outils dans votre code ?
La gateway exécute les outils côté serveur, ce qui déplace la responsabilité du traitement. Avec le function calling classique, votre code gère une boucle : le modèle renvoie un bloc tool_calls, votre application exécute l’outil, ajoute un message tool_result, puis renvoie toute la conversation, une fois par appel. Avec les outils côté serveur, la gateway prend cette boucle en charge. La requête déclare synthorai:web_search ou synthorai:web_fetch, puis la gateway s’interpose entre le LLM et les fournisseurs d’outils. Elle transmet les appels du modèle aux API tierces de recherche et de récupération, puis réinjecte les résultats dans son contexte. Ces outils n’ont rien de magique : chaque recherche et chaque récupération correspond à un appel vers une API externe, d’où leur facturation à l’utilisation. Cette base de code n’effectue aucun aller-retour tool_result ; elle se contente d’afficher les événements :
browser server (tools.py) Synthorai gateway LLM / tool providers
│ POST /chat │ │
├──────────────▶│ declare tools + │
│ │ budget note │
│ ├────────────────────▶│── question + tools ──▶ [LLM]
│ │ │◀── tool_use: search ── [LLM]
│ │ │── query ─────────────▶ [search API]
│ │ search results │◀── results ─────────── [search API]
│ SSE: search ◀─┤◀────────────────────┤── results ───────────▶ [LLM]
│ │ │◀── tool_use: fetch ─── [LLM]
│ │ │── URL ───────────────▶ [fetch API]
│ │ fetch result │◀── page text ───────── [fetch API]
│ SSE: fetch ◀──┤◀────────────────────┤── page text ─────────▶ [LLM]
│ SSE: delta ◀──┤◀────────────────────┤◀── answer tokens ───── [LLM]
│ SSE: done │ │
La répartition des rôles est claire. Le modèle décide s’il faut lancer une recherche, si un extrait suffit ou si une page doit être récupérée, puis quand arrêter les outils et rédiger la réponse. La gateway exécute les appels vers le fournisseur de recherche ou de récupération et renvoie les résultats au modèle. Votre serveur ne fait qu’afficher les événements qui traversent le flux. Les deux outils sont activés par défaut. Un tour qui n’en a pas besoin ne coûte rien de plus : un calcul reste gratuit, tandis qu’une question commençant par « quelle est la valeur actuelle… » déclenche une recherche. La boucle côté gateway est elle aussi bornée. Si la boucle de recherche côté serveur s’interrompt à sa propre limite d’itérations (pause_turn), le transport renvoie le tour pour le reprendre, au maximum trois fois.
Quatre enseignements tirés du développement comptent davantage que le fonctionnement nominal :
- L’endpoint détermine ce qui est visible. Les deux endpoints exécutent la recherche et la facturent, d’après des mesures effectuées le 2026-08-04 sur les canaux Anthropic et Gemini. Pourtant, seul
/v1/messagesexpose la recherche elle-même : la requête et les URLs des résultats arrivent sous forme de blocs typés que le code peut afficher et stocker. Avec/v1/chat/completions, la même recherche reste invisible : réponse HTTP 200, texte commençant par « Based on the search results… », mais aucune citation ni annotation, en streaming comme hors streaming. Une recherche payée mais impossible à auditer constitue une dégradation silencieuse, la pire forme d’échec : rien ne signale d’erreur, seule la provenance disparaît. C’est la seule raison d’être detools.py, utilisé comme second transport plutôt que d’ajouter un champ à la requête normale. - Le budget doit être indiqué au modèle en toutes lettres. Les plafonds, par défaut 3 recherches et 2 récupérations par tour, sont appliqués silencieusement par la gateway. Un modèle qui les ignore planifie comme si les outils étaient illimités et peut épuiser son dernier appel en plein raisonnement. Le tour se termine alors par « Laissez-moi chercher… » sans fournir de réponse. Le MVP injecte une phrase qui précise le budget. Sa formulation influe directement sur le coût mesuré : lors des tests, sans aucune consigne, le modèle a consommé tout le budget d’outils avant de s’arrêter en pleine phrase ; avec une consigne trop stricte, il a abandonné en demandant à l’utilisateur de lire lui-même la page ; avec la formulation retenue, il a évité la recherche, récupéré directement les deux pages de référence et produit le résultat le moins cher des trois. Cette consigne est modifiable dans Settings ; changez-la et observez le coût par tour.
- Les plafonds sont le seul frein. Les deux outils sont facturés à l’utilisation, et le modèle décide du nombre d’appels. Pendant les tests, un seul tour sans limite a lancé trois recherches et deux récupérations avant même la facturation du premier token de sortie.
- Dégradez la fonctionnalité plutôt que d’interrompre le tour. Deux erreurs reçoivent le même traitement : retirer l’outil, réessayer une fois, puis informer l’utilisateur. La récupération web exige une autorisation associée à la clé. Sans elle, toute la requête échoue avec
web_fetch_not_enabledau lieu de continuer sans l’outil. Gemini accepte quant à lui la déclaration de l’outil, puis échoue dès qu’il tente de l’appeler avecFunction call is missing a thought_signature. Dans les deux cas, annuler le tour utilisateur à cause d’un outil facultatif est le mauvais compromis.
Toutes les actions du modèle pour produire une réponse, réflexion, recherche et lecture, s’affichent en direct dans un journal d’activité au-dessus du message. Celui-ci est replié sur une ligne discrète (« Réflexion pendant 5s · Recherche web · 2 pages lues ») et peut être déplié en chronologie avec les requêtes, les domaines des résultats et le texte de raisonnement. Le journal est enregistré avec le message, ce qui est essentiel : s’il disparaît au rechargement, il ne permet pas d’auditer la réponse ultérieurement. Les citations apparaissent sous la réponse sous forme de boutons numérotés par domaine.

Comment afficher le markdown sans casser le rendu pendant le streaming ?
Le texte accumulé est séparé en deux parties : un préfixe stable qui peut être rendu sans risque, et une fin qui ne le peut pas encore. Le rendu d’une structure incomplète fait sauter la mise en page lorsque le token suivant la ferme. Le renderer s’arrête donc à la dernière ligne complète. Si une balise d’ouverture de bloc de code n’a pas encore de fermeture, tout le contenu à partir de cette balise reste en texte brut jusqu’à sa fermeture. La bulle n’est recalculée que lorsque le préfixe stable s’allonge réellement, ce qui évite de reconstruire le DOM à chaque token. Les blocs de code disposent d’un bouton de copie. Le renderer tient dans une centaine de lignes de JavaScript vanilla, sans bibliothèque. Ce choix décrit les besoins d’un MVP, pas la valeur des bibliothèques markdown.
Combien coûte une conversation ?
Le coût correspond à ce que la gateway indique dans usage, à condition que le MVP interprète correctement ces données. Deux problèmes de normalisation se sont révélés essentiels :
- Les noms de champs varient selon les fournisseurs. Rien que pour les tokens en cache, les modèles Anthropic renvoient
cache_read_input_tokens, DeepSeek et GLM uniquementprompt_tokens_details.cached_tokens, et Gemini aucun des deux. La ligne de statistiques lit le champ disponible afin que « mis en cache » conserve le même sens. - Un coût absent ne vaut pas zéro. La gateway renvoie
costsur certains tours et l’omet sur d’autres, notamment, de manière reproductible, lorsqu’un outil est déclaré mais pas utilisé. Les tours sans champ de coût sont comptés et affichés comme « +N non déclarés » au lieu d’être additionnés comme des zéros. Un total qui omet silencieusement certains tours ressemble à une facture complète alors qu’il ne représente qu’un minimum.
L’en-tête affiche les totaux cumulés de la session : entrée, sortie, part mise en cache, recherches, récupérations, coût et nombre de tours. Chaque réponse affiche aussi ses propres chiffres. La part mise en cache est la mesure à surveiller : elle montre le fonctionnement réel du cache sur votre conversation.
Où se trouvent tous les réglages ?
Dans un seul panneau Settings. Le choix important à reprendre est leur portée : presque tous les paramètres s’appliquent à une seule conversation.

Seuls deux réglages sont globaux, car ils décrivent l’utilisateur plutôt qu’une conversation : la liste des modèles, où vous pouvez ajouter n’importe quel id servi par la gateway et qui est conservée dans .data/models.json, ainsi que le fichier de mémoire à long terme, présenté sous forme de zone de texte modifiable. Tous les autres paramètres concernent la conversation ouverte : le system prompt, avec les presets de presets/ dans une liste déroulante, le budget de contexte, l’activation de la recherche web et de la récupération de pages, les instructions des outils et le prompt de compression. Deux conversations peuvent donc utiliser côte à côte des personnalités, budgets et outils différents tout en partageant les mêmes modèles. Vous pouvez ainsi reproduire les résultats de ce guide au lieu de simplement nous croire.
Chaque champ possède un petit indicateur d’information, visible au survol et épinglable par clic, qui précise le coût d’une modification. La plupart de ces réglages ont un impact financier invisible dans l’interface : modifier la personnalité invalide le cache dès le premier octet et impose donc un tour à froid ; reformuler les instructions des outils change le coût par tour ; réduire le budget déclenche plus tôt la compression. Le sélecteur de modèle dans la barre supérieure est le seul réglage enregistré immédiatement, car changer de modèle en cours de conversation est une action à part entière, pas une modification de configuration.
Qu’est-ce qui manque, et où l’ajouter ?
Ces omissions sont volontaires. Chacune dispose d’un point d’intégration clair :
- Authentification et multi-utilisateur — ajoutez une couche de session devant les routes. Les conversations possèdent déjà des ids ; les rattacher à un utilisateur demande donc un préfixe de nom de fichier, pas une refonte. En attendant, cet outil doit rester en local : chaque requête consomme votre clé API, ne l’exposez donc pas tel quel sur Internet.
- Limitation de débit — même emplacement et même raison : elle protège un déploiement partagé, ce qui n’est pas encore le cas.
- RAG — les documents récupérés doivent être placés après la synthèse et avant les messages récents. Le contenu volatil arrive tard dans le préfixe afin de ne pas invalider les blocs de personnalité et de mémoire mis en cache. C’est ici que les modèles dotés d’un contexte de 1M exploitent réellement leur fenêtre.
- Function calling côté client — ajoutez à la boucle de streaming une branche
tool_callset un exécuteur. L’analyse de GLM-5.2 détaille les différences de contrat entre fournisseurs qui vous attendent. Les outils côté gateway présentés plus haut évitent volontairement cette boucle. - Déduplication sémantique de la mémoire — générez un embedding pour chaque fait candidat, comparez-le au contenu existant et ne conservez que les nouveautés. Le format du fichier mémoire reste inchangé.
- Coloration syntaxique — le bouton de copie est la fonction réellement utilisée ; la coloration relève du choix de bibliothèque d’un vrai frontend.
- Vraie base de données —
storage.pyne contient qu’une poignée de fonctions. Les porter vers SQLite prend un après-midi. Tant qu’il n’y a pas d’utilisateurs, les fichiers JSON sont précisément le bon choix.
À lire aussi
- Meilleur LLM par cas d’usage en 2026 : coûts du chat, du RAG et des agents — la formule de coût optimisée par cette implémentation, appliquée à plusieurs types de charge.
- Gemini 3.6 Flash : le réglage de raisonnement qui multiplie le coût par 30 — les mesures qui justifient le niveau de raisonnement minimal.
- Le tokenizer de Claude Sonnet 5 — pourquoi il faut comparer le nombre de tokens entre modèles plutôt que les tarifs.
- Anatomie de l’usage des tokens LLM — ce que
usageindique réellement selon le fournisseur. - Appels d’outils avec GLM 5.2 — coûts des tours à chaud et particularités du contrat pour l’extension de function calling.