← tous les articles

Ce qui sort du cluster quand je pose une question à mon agent

16 septembre 2026 · 9 min de lecture

iallmragmcpsecuritedocker-swarm

Je voulais un agent qui connaisse mon cluster. Pas un chatbot : quelque chose à qui demander « pourquoi le nœud rpi4 a rempli son disque la nuit dernière ? » depuis un téléphone, au lieu d’ouvrir une session ssh. Dans ma tête, le plan était simple — je lui donne ma documentation, il apprend, il répond.

C’est la première phrase qu’il a fallu corriger, et elle contenait deux erreurs.

« Apprendre » ne veut pas dire « donner des documents »

Un modèle de langage est figé. Ses connaissances datent de son entraînement, il ne garde aucun souvenir d’un appel à l’autre, et rien de ce que je lui envoie ne le modifie. Lui confier ma documentation ne lui apprend rien : ça la lui met sous les yeux, le temps d’une question.

Le mécanisme réel s’appelle RAG, et il tient en deux temps :

  • l’indexation, une fois : mes fichiers Markdown sont découpés en morceaux, chacun converti en vecteur — une liste de nombres qui code le sens du passage — puis rangés dans Postgres avec pgvector ;
  • la question, à chaque fois : ma phrase devient un vecteur à son tour, on cherche les morceaux les plus proches, et on les joint à la question envoyée au modèle.

D’où la conséquence contre-intuitive, et plutôt bonne nouvelle : l’agent est à jour dès que je reconstruis le corpus. Il ne réapprend pas, il relit. Un modèle entraîné sur mon cluster de juin réciterait une infra qui n’existe plus.

Le vocabulaire, lui, m’a coûté deux soirées, parce que quatre mots proches désignent quatre choses distinctes : le modèle écrit le texte ; le contexte est ce qu’on lui met sous les yeux à l’instant T ; l’agent est un modèle plus des instructions plus des outils ; un outil est une fonction qu’il a le droit d’appeler — et MCP n’est que le protocole standard pour brancher ces outils.

Le schéma que j’avais faux

Ma première version du dessin plaçait la décision de chercher dans le cluster. Faux, et l’erreur n’est pas cosmétique :

         ma question
              │
              ▼
  [ chat.zebbox.net ]────────────── 1 ─────────────▶ [ le fournisseur ]
              ▲                  question seule            │
              │                                            │ « il me faut la doc »
              │◀───────────────── outil ───────────────────┘
              │
   recherche locale (pgvector)  ── 2 ──▶  question + extraits ──▶ [ le fournisseur ]
              ▲                                                        │
              └──────────────────── la réponse ◀───────────────────────┘

C’est le modèle, chez le fournisseur, qui décide d’utiliser l’outil de recherche. Une question à laquelle il répond en s’appuyant sur ma doc, c’est donc deux allers-retours sortants, pas un. Et c’est exactement ce qui transforme un sujet de curiosité en sujet d’infrastructure : la vraie question n’est pas « est-ce que c’est intelligent ? », c’est qu’est-ce qui quitte la maison, et à quel moment ?

Le plan gratuit se paie en données

Pour m’entraîner, j’ai commencé avec un fournisseur européen en formule gratuite. En lisant les conditions plutôt que la page d’accueil : sur ce palier, les entrées et sorties de l’API peuvent servir à entraîner leurs modèles. C’est un échange honnête — c’est le prix — mais il décide de l’architecture : ma documentation réelle ne peut pas passer par là.

D’où une étape que je n’avais pas prévue : anonymiser le corpus. Un script applique une table de correspondance explicite — le domaine, les noms d’hôtes, les adresses, les noms de bases — et produit une copie jumelle où rpi51 devient node-b. Puis un test relit le résultat et échoue s’il y retrouve quoi que ce soit de réel, y compris ce que la table ignore encore : adresses privées, e-mails, chaînes hexadécimales longues, UUID.

Un test qui n’a jamais échoué ne prouve rien. Je l’ai donc attaqué : un corpus truffé d’une vraie IP, d’un vrai nom d’hôte, d’un e-mail et de l’UUID du tunnel. Dix détections sur une ligne. Bien.

Et c’est là que le sol s’est dérobé.

La fuite était dans le nom du fichier

L’agent cite ses sources. Il les cite par nom de fichier. Or mon corpus est écrit à plat, chaque chemin devenant un nom : docs/nodes/rpi51.md → docs__nodes__rpi51.md.

Le contenu était impeccablement anonymisé. Le nom de fichier, lui, criait le nom du serveur — dans chaque réponse.

Pire : mon test venait de relire ce fichier et l’avait déclaré propre. La raison est d’une bêtise magnifique. Je cherchais les noms réels avec une frontière de mot, \brpi51\b. Dans une expression régulière, _ est un caractère de mot. Entre deux tirets bas, rpi51 n’a donc aucune frontière — le motif ne peut pas matcher. Le test regardait droit vers la fuite sans la voir.

Correctif en deux lignes : le contrôle relit maintenant __ comme un / avant de tester, et le nom de fichier passe par la même table de correspondance que le contenu. Mais la leçon dépasse la regex : j’avais défini la surface de fuite comme « le contenu des documents », et elle comprenait leurs noms.

Deux autres surprises, plus vénielles, la même semaine. Le modèle s’est mis à citer ses sources sous forme de liens Markdown — résolus par le navigateur contre l’URL du chat, donc 404 en chaîne ; il fallait lui interdire explicitement, dans ses instructions, de transformer un nom de fichier en lien. Et l’API du fournisseur annonçait 46 modèles dont elle n’en servait que quatre en formule gratuite, les autres répondant par un refus de quota : la liste des modèles utilisables ne se lit pas dans la documentation, elle se découvre en appelant chacun.

La règle dans le code, pas dans ma vigilance

Vient la phase adulte : la vraie documentation, avec une clé payante dont les conditions excluent l’entraînement. Deux corpus coexistent donc, l’anonymisé et le réel — et le risque évident est d’envoyer le second au mauvais destinataire, un soir de fatigue.

Alors la règle n’est pas dans ma tête, elle est dans le dépôt. Chaque agent est décrit par un fichier qui nomme son fournisseur, son modèle et quel corpus il a le droit de recevoir :

real_corpus_providers: ['anthropic']   # la liste blanche

agents:
  - key: mistral
    provider: Mistral
    corpus: anonymised     # jamais autre chose
  - key: claude
    provider: anthropic
    corpus: real

L’outil de synchronisation refuse toute autre combinaison avant qu’un seul octet ne parte, et la vérification existe en double : une fois côté script, une fois côté pilote, pour qu’elle tienne encore si quelqu’un contourne le premier. Puis — même discipline que pour le test de fuite — je l’ai éprouvée en la faisant échouer, en pointant volontairement l’agent gratuit vers le corpus réel :

REFUSING: agent 'mistral' wants the REAL corpus but its provider 'Mistral'
          is not in real_corpus_providers (anthropic)

Un garde-fou qu’on n’a jamais vu refuser est une décoration.

Reste à être honnête sur ce que cette liste blanche n’est pas. Ce n’est pas l’affirmation qu’un fournisseur est sûr et l’autre non. Les documents sortent quand même, sont journalisés, sont conservés quelque temps pour la détection d’abus, et une politique que je ne contrôle pas peut changer sans que rien ne m’alerte. Une case à cocher chez un tiers n’est pas un contrôle que je possède. Le seul verrou mécanique est ailleurs : les vecteurs sont calculés en local, sur mes propres machines. À l’indexation, rien ne sort — quel que soit le fournisseur.

Le cluster vivant, et la pièce que j’avais oubliée

Dernière étape : brancher l’agent sur l’état courant. Grafana publie son propre serveur MCP, donc une seule connexion couvre Prometheus et Loki — métriques et logs — à travers les sources que Grafana possède déjà. En lecture seule, et verrouillé deux fois : les outils d’écriture ne sont pas exposés, et le jeton de service porte le rôle « lecteur ». Vérifié comme le reste : lecture 200, écriture 403.

Et en le branchant, la pièce manquante m’a sauté aux yeux. Le résultat d’un outil part chez le fournisseur exactement comme le reste du contexte. Une question répondue par ce canal expédie de vraies valeurs, de vrais noms d’hôtes, de vraies lignes de log — précisément ce que l’anonymisation existe pour éviter. Ma liste blanche protégeait les documents et laissait la porte de service grande ouverte.

Second garde-fou, écrit à côté du premier : un agent ne reçoit d’outils vivants que s’il a aussi droit au corpus réel. Éprouvé de la même façon, en le faisant refuser.

La leçon

Sur ces sujets, la question d’ingénierie n’est pas « quel modèle est le meilleur ? » mais « quelle est exactement la frontière de mon cluster, et par où la traverse-t-on ? ». J’ai trouvé trois passages que je n’avais pas dessinés : les noms de fichiers, qui voyagent avec le contenu ; la décision de chercher, qui se prend chez le fournisseur et non chez moi ; et les résultats d’outils, qui sortent comme tout le reste. À chaque fois, la protection qui a tenu était celle que j’avais écrite dans le code et éprouvée en la faisant échouer — jamais celle qui reposait sur ma vigilance, ni sur la promesse d’un tiers.

Le reste est du plaisir : demander « rpi4 est-il à l’étroit, et qu’avait-on décidé à ce sujet ? » et recevoir une mesure prise à l’instant plus l’entrée de feuille de route qui explique pourquoi. La documentation dit comment c’est construit, les outils disent comment ça se porte. Il aura fallu une fuite dans un nom de fichier pour que je comprenne où passe la ligne entre les deux.

Tout le homelab est public — corpus, garde-fous et tests de fuite compris.


📬 La newsletter homelab

Les nouveaux articles + retours d'expérience self-hosting, sans spam. Désinscription en un clic.