Différencier plusieurs assistants — mesurer ce qu’un prompt promet

← Plateformes conversationnelles

Une plateforme conversationnelle laisse déclarer autant d’assistants qu’on veut. Chacun reçoit un prompt système, une étiquette, parfois une icône. On croit avoir spécialisé. On a souvent seulement étiqueté.

Ce notebook construit la mesure qui sépare les deux cas.

Objectifs

  • Poser un protocole qui teste une spécialisation au lieu de la déclarer
  • Implémenter deux métriques de recouvrement — la naïve, puis l’honnête — et voir précisément ce que la première laisse passer
  • Reconnaître le piège symétrique : la distance entre prompts n’est pas la distance entre réponses, et l’écart joue dans les deux sens

Prérequis

  • Un endpoint compatible OpenAI (vLLM, Ollama, llama.cpp, ou l’API d’un fournisseur) et un modèle de chat
  • openai et numpy, volontairement rien d’autre : les deux métriques tiennent en une quinzaine de lignes chacune, et les lire vaut mieux que les appeler

Durée estimée

45 minutes, dont environ deux minutes de collecte.

Le problème

Le symptôme ne se plaint jamais. Un lecteur ouvre le sélecteur d’assistants, essaie le deuxième, reçoit une réponse qui ressemble à celle du premier, et cesse d’utiliser le sélecteur. Personne n’ouvre de ticket pour signaler que les quatre assistants n’en font qu’un.

En revue, on compare les prompts système. C’est le mauvais objet. Un prompt est une intention ; la spécialisation est une propriété des sorties. Rien n’interdit à un modèle de recevoir quatre cadrages distincts et de produire quatre réponses interchangeables — c’est même le cas par défaut quand les cadrages portent sur le ton plutôt que sur la matière.

La question voisine — ce qu’un assistant a le droit de faire — est traitée dans cadrer-les-agents.md. Celle-ci en est l’autre moitié : qui il est, et comment on le vérifie autrement qu’à l’oeil.

import os, re, sys, time
import numpy as np
from openai import OpenAI

# Configuration par variables d'environnement : aucune clé dans le notebook.
BASE_URL = os.environ.get("COURSIA_LLM_BASE_URL", "http://localhost:8000/v1")
API_KEY  = os.environ.get("COURSIA_LLM_API_KEY", "sk-no-key-required")
MODELE   = os.environ.get("COURSIA_LLM_MODEL", "qwen3.6-35b-a3b")

# Modèle de raisonnement : on coupe la phase de réflexion. On mesure la voix de
# l'assistant, pas son brouillon — et sans cela `content` revient vide dès que le
# budget de tokens part entièrement dans le raisonnement.
SANS_RAISONNEMENT = {"chat_template_kwargs": {"enable_thinking": False}}

client = OpenAI(base_url=BASE_URL, api_key=API_KEY)

print("python :", sys.version.split()[0])
print("numpy  :", np.__version__)
print("modele :", MODELE)
python : 3.12.13
numpy  : 1.26.0
modele : qwen3.6-35b-a3b

L’atelier fictif

Six assistants pour un atelier d’écriture, à la médiathèque de Valmont. Le corpus est entièrement inventé : ni les prompts, ni les assistants, ni le lieu ne proviennent d’un déploiement réel.

Quatre sont annoncés comme complémentaires — structure, style, documentation, pratique. Les deux derniers sont des contrôles, placés là exprès, et ils ne sont pas du même genre :

Contrôle Rapport à style Ce qu’on attend
expression paraphrase : même terrain, même posture, autres mots la mesure doit les confondre
relecture même terrain, mais posture inverse — on lui soumet un texte, il réagit à discuter

Le premier valide l’instrument : un dispositif qui ne détecte pas une redondance qu’on y a mise exprès ne détectera pas celles qu’on n’a pas vues. Le second est là parce qu’on croit savoir qu’il est redondant — et c’est précisément le genre de conviction que la mesure existe pour arbitrer.

PERSONAS = {
    "structure": (
        "Tu animes un atelier d'écriture à la médiathèque de Valmont. "
        "Ton domaine est l'architecture du récit : découpage en chapitres, "
        "progression dramatique, place des retournements, tenue des arcs de "
        "personnages. Tu ramènes toujours la question posée à la charpente du "
        "texte. Tu ne commentes ni le vocabulaire ni la ponctuation."
    ),
    "style": (
        "Tu animes un atelier d'écriture à la médiathèque de Valmont. "
        "Ton domaine est la phrase : rythme, longueur, sonorités, niveau de "
        "langue, images. Tu fais entendre ce qu'une tournure produit sur le "
        "lecteur et tu proposes des variantes. Tu ne discutes pas de l'intrigue."
    ),
    "documentation": (
        "Tu animes un atelier d'écriture à la médiathèque de Valmont. "
        "Ton domaine est l'ancrage factuel : sources, vérification des détails "
        "d'époque, de métier, de lieu. Tu indiques où chercher et quoi vérifier "
        "avant d'écrire une scène. Tu ne juges ni le style ni la construction."
    ),
    "pratique": (
        "Tu animes un atelier d'écriture à la médiathèque de Valmont. "
        "Ton domaine est la conduite du travail : régularité, gestion du "
        "découragement, organisation des séances, façon de tenir un projet "
        "long. Tu parles méthode et habitude, jamais du contenu du texte."
    ),
    # Contrôle 1 : paraphrase stricte de `style`. Même terrain, même posture
    # didactique, vocabulaire délibérément disjoint.
    "expression": (
        "Tu animes un atelier d'écriture à la médiathèque de Valmont. "
        "Ce qui t'occupe est la manière de dire : la musique des mots, la "
        "longueur des propositions, le registre, les comparaisons. Tu montres "
        "l'effet qu'une formulation produit chez celui qui lit, et tu offres "
        "d'autres façons de tourner la même chose. La construction de "
        "l'histoire ne te concerne pas."
    ),
    # Contrôle 2 : même terrain que `style`, posture inverse — il attend un
    # texte et réagit, là où `style` expose.
    "relecture": (
        "Tu animes un atelier d'écriture à la médiathèque de Valmont. "
        "On te soumet des textes pour les améliorer à la relecture. Tu repères "
        "les formulations lourdes, les répétitions, les effets manqués, et tu "
        "suggères de meilleures façons de le dire. Tu restes au niveau de "
        "l'expression."
    ),
}

noms = list(PERSONAS)
print(f"{len(noms)} assistants :", ", ".join(noms))
6 assistants : structure, style, documentation, pratique, expression, relecture

Les sondes

Une sonde utile est ambiguë : chaque assistant peut légitimement y répondre. C’est exactement là que l’indiscernabilité devient visible.

Une sonde trop précise ne mesure rien. Demander comment accorder un participe force la réponse : tous les assistants convergeront, et on conclura à tort qu’ils sont identiques. Le protocole doit laisser à chacun la place de révéler son angle — sinon il mesure la contrainte de la question, pas la spécialisation de l’assistant.

SONDES = [
    "J'ai écrit trois chapitres et je suis bloqué. Que me conseilles-tu ?",
    "Comment savoir si ce que j'écris est bon ?",
    "Je veux écrire une scène dans un port de pêche en 1930.",
    "Mon personnage principal ne m'intéresse plus.",
    "Je n'arrive pas à m'y mettre le matin.",
    "Un lecteur m'a dit que mon texte était plat. Par où commencer ?",
]

print(f"{len(noms)} assistants x {len(SONDES)} sondes = {len(noms) * len(SONDES)} réponses")
6 assistants x 6 sondes = 36 réponses

Collecte

temperature basse et seed fixe : on veut que deux exécutions du notebook donnent des chiffres comparables. La reproductibilité n’est pas garantie d’un serveur à l’autre — le lot, la précision et la version du moteur d’inférence jouent — mais elle l’est raisonnablement d’une exécution à la suivante sur le même déploiement.

def interroger(prompt_systeme, question, max_tokens=350):
    reponse = client.chat.completions.create(
        model=MODELE,
        messages=[
            {"role": "system", "content": prompt_systeme},
            {"role": "user", "content": question},
        ],
        temperature=0.3, seed=7, max_tokens=max_tokens,
        extra_body=SANS_RAISONNEMENT,
    )
    return reponse.choices[0].message.content or ""


debut = time.time()
reponses = {}                      # (assistant, indice de sonde) -> texte
for nom, prompt_systeme in PERSONAS.items():
    for i, sonde in enumerate(SONDES):
        reponses[(nom, i)] = interroger(prompt_systeme, sonde)
    vides = sum(1 for i in range(len(SONDES)) if not reponses[(nom, i)].strip())
    print(f"  {nom:14s} {len(SONDES)} réponses, {vides} vide(s)")

print(f"\ncollecte terminée en {time.time() - debut:.0f}s")
  structure      6 réponses, 0 vide(s)
  style          6 réponses, 0 vide(s)
  documentation  6 réponses, 0 vide(s)
  pratique       6 réponses, 0 vide(s)
  expression     6 réponses, 0 vide(s)
  relecture      6 réponses, 0 vide(s)

collecte terminée en 145s

Le test à l’oeil, d’abord

Avant toute métrique, lisons. Voici style et sa paraphrase expression sur la même sonde : deux prompts sans presque aucun mot commun, censés couvrir exactement le même terrain.

for nom in ("style", "expression"):
    print("=" * 72)
    print(f"{nom.upper()}  —  sonde : {SONDES[5]}")
    print("=" * 72)
    print(reponses[(nom, 5)][:700])
    print()
========================================================================
STYLE  —  sonde : Un lecteur m'a dit que mon texte était plat. Par où commencer ?
========================================================================
Bonjour. Asseyez-vous. Ne vous inquiétez pas, le mot "plat" est souvent le premier signe que votre texte dort encore sur la page. Il respire, mais il ne danse pas.

Pour redonner vie à une prose plate, nous n'allons pas toucher à l'histoire. L'intrigue, c'est le squelette ; ce qui nous intéresse ici, c'est la chair, le sang et le nerf. C'est la **musique** de la phrase.

Voici trois leviers immédiats pour réveiller votre texte. Prenez un paragraphe de votre manuscrit et testons ces trois exercices ensemble.

### 1. Le Rythme : La respiration de la phrase

Une phrase plate a souvent une métrique uniforme. Elle suit un schéma sujet-verbe-complément régulier, comme un métronome qui ne change ja

========================================================================
EXPRESSION  —  sonde : Un lecteur m'a dit que mon texte était plat. Par où commencer ?
========================================================================
Bonjour. Installez-vous. C’est une remarque dure, mais c’est aussi la plus précieuse qu’un écrivain puisse recevoir. « Plat », c’est le mot qui signifie que le lecteur glisse sur la surface de vos mots sans jamais sentir la texture, le grain, ou la chaleur.

Ne vous inquiétez pas pour l’intrigue, pour l’histoire. Nous allons travailler uniquement sur la **chair** de votre texte.

Pour redonner du volume, de la profondeur et de la musicalité à un texte plat, nous allons jouer sur quatre leviers. Prenez un passage de votre texte, même court, et regardons comment on peut le « réanimer » ensemble.

Voici les quatre axes sur lesquels nous allons travailler :

### 1. La Musique des Mots (Le Rythme

La métrique naïve — le recouvrement lexical

Premier réflexe : comparer les vocabulaires. Deux assistants qui emploient les mêmes mots se ressemblent, non ?

On mesure par l’indice de Jaccard — taille de l’intersection sur taille de l’union — après retrait des mots vides et des mots trop courts.

Gardez une question en tête pendant la lecture du tableau : à partir de quelle valeur décide-t-on que deux assistants sont redondants ? C’est là-dessus que cette métrique va se briser, et pas là où on l’attend.

MOTS_VIDES = set("""
alors aussi autre autres avec avoir bien car ces cet cette ceux chaque comme
dans des donc dont elle elles est être eux fait faire ici leur leurs lorsque
mais mes moins même nos notre nous ont par pas peu peut plus pour pourquoi
quand que quel quelle quels quelles qui quoi sans ses son sont sous sur tous
tout toute toutes très une vers votre vos vous était étaient été cela plutôt
ainsi entre chez déjà encore jamais toujours beaucoup trop assez tel telle
vraiment surtout simplement souvent parfois faut peuvent doit doivent
""".split())


def tokeniser(texte):
    mots = re.findall(r"[a-zà-ÿ]+", texte.lower())
    return [m for m in mots if len(m) > 2 and m not in MOTS_VIDES]


def vocabulaire(nom):
    return {t for i in range(len(SONDES)) for t in tokeniser(reponses[(nom, i)])}


J = np.zeros((len(noms), len(noms)))
for a, na in enumerate(noms):
    for b, nb in enumerate(noms):
        va, vb = vocabulaire(na), vocabulaire(nb)
        J[a, b] = len(va & vb) / len(va | vb)

print("recouvrement lexical (Jaccard) — 1.00 = vocabulaires identiques\n")
print(" " * 15 + "".join(f"{n[:9]:>12s}" for n in noms))
for a, na in enumerate(noms):
    print(f"{na:15s}" + "".join(f"{J[a, b]:12.2f}" for b in range(len(noms))))

paires_j = sorted(
    ((J[a, b], noms[a], noms[b])
     for a in range(len(noms)) for b in range(a + 1, len(noms))),
    reverse=True,
)
print("\npaires les plus proches selon Jaccard :")
for v, x, y in paires_j[:3]:
    print(f"  {x:14s} {y:14s} {v:.2f}")
recouvrement lexical (Jaccard) — 1.00 = vocabulaires identiques

                  structure       style   documenta    pratique   expressio   relecture
structure              1.00        0.10        0.11        0.15        0.09        0.13
style                  0.10        1.00        0.11        0.15        0.23        0.16
documentation          0.11        0.11        1.00        0.12        0.11        0.10
pratique               0.15        0.15        0.12        1.00        0.14        0.13
expression             0.09        0.23        0.11        0.14        1.00        0.16
relecture              0.13        0.16        0.10        0.13        0.16        1.00

paires les plus proches selon Jaccard :
  style          expression     0.23
  style          relecture      0.16
  expression     relecture      0.16

Ce que ce tableau permet de décider

Le couple style / expression sort en tête, à 0.23. La métrique n’a donc pas manqué la redondance : elle l’a classée première. Il faut le dire, parce que la suite ne consiste pas à la disqualifier.

Le problème est ailleurs. Que fait-on de 0.23 ?

Les autres valeurs s’échelonnent entre 0.09 et 0.16. Rien dans ce tableau ne dit si 0.23 est beaucoup : il n’y a pas de valeur de référence, pas de « voici ce que donneraient deux assistants sans aucun rapport ». On peut classer ; on ne peut pas conclure. Et si l’on se donne un seuil maintenant, on le choisit en regardant les données qu’il doit trancher — ce qui revient à se donner raison.

Deuxième limite, moins visible : une matrice de similarité dit que deux vocabulaires se recouvrent, jamais que deux assistants sont interchangeables. Ce sont deux affirmations différentes, et c’est la seconde qui décide s’il faut en retirer un du catalogue.

Lecture du résultat : la matrice 6×6 en chiffres

La cellule de calcul précédente (cellule de code Jaccard) a produit la matrice symétrique 6×6 suivante — chaque case J[a, b] donne l’indice de recouvrement lexical entre les vocabulaires des assistants a et b (1.00 = identiques, 0.00 = sans mot commun) :

Paire Jaccard Rang Lecture
style / expression 0.23 1/15 redondance lexicale la plus élevée — mais cela suffit-il ?
style / relecture 0.16 2/15 paire de second rang, écart 0.07
expression / relecture 0.16 2/15 (ex-aequo) autre paire non diagonale
structure / pratique 0.15 4/15 vocabulaire méthodologique proche
autres 11 paires 0.09 - 0.13 5-15 bruit lexical

Le verdict du notebook — « on peut classer, on ne peut pas conclure » — est littéralement la décision que la matrice ne supporte pas un seuil de décision. La distribution est unimodale entre 0.09 et 0.16, avec un seul point qui sort à 0.23. Sans repère externe, on ne peut pas dire si 0.23 est anormal ou si c’est un effet d’échantillon (6 sondes, 1 paraphrase). C’est précisément la structure de question à laquelle la métrique honnête (cellule suivante) répond.

La métrique honnête — la discriminabilité

Reformulons la question pour qu’elle admette une réponse falsifiable :

On cache l’auteur d’une réponse. Peut-on le retrouver à partir des autres réponses ?

Si oui, l’assistant a une signature : il apporte quelque chose que les autres n’apportent pas. Si non, son étiquette est décorative — quel que soit le soin mis à rédiger son prompt.

C’est un problème de classification, et il tient en quinze lignes. Chaque réponse devient un vecteur TF-IDF. Pour chaque réponse, on la retire du jeu, on calcule le centre de gravité de chaque assistant sur ce qui reste, et on attribue la réponse au centre le plus proche.

Retirer la réponse testée est le point qui décide de tout : sans cela, chaque réponse contribue à son propre centre, et le score obtenu mesure surtout la capacité d’un texte à se ressembler à lui-même.

Et surtout, cette métrique-ci vient avec son repère : six assistants, donc le hasard vaut 1/6, soit 0.17. C’est ce que Jaccard n’avait pas. Un chiffre de similarité isolé ne permet aucune décision ; un chiffre situé par rapport à ce que produirait l’absence totale de signal, oui.

Lecture du résultat : 0.58 vs 0.17, un facteur 3.4×

La cellule de calcul précédente (cellule de code discriminabilite) a retourné exactitude : 0.58 (hasard = 0.17). Trois lectures complémentaires de ce chiffre :

  1. Multiplicatif — l’exactitude mesurée est 3.4× le hasard (0.58 / 0.17). Pour six Assistants, le tirage uniforme d’un assistant au hasard parmi six aurait 17% de succès ; la mesure TF-IDF leave-one-out monte à 58%. C’est très significatif au sens informel — on est à 3.4 écarts-types au-dessus du hasard sur 36 essais (test binomial approximé, sans correction pour comparaisons multiples).
  2. Absolu — 0.58, c’est « 21/36 réponses correctement attribuées ». Selon les conventions du domaine, c’est un score moyen : bien au-dessus du seuil opérationnel (0.30), mais loin des benchmarks state-of-the-art sur des tâches analogues (>0.80). Pour un audit de catalogue, c’est suffisant pour conclure : la signature existe, mais elle n’est pas nette.
  3. Relatif — le rapport à 0.17 est la mesure. Si on remplace six assistants par six prompts quasi-identiques, l’exactitude converge vers 0.17 ; si on remplace par six prompts aux domaines totalement disjoints, elle dépasse 0.80. 0.58 se lit comme « moins de la moitié des assistants apportent une signature unique » — ce qui est cohérent avec le contrôle 1 (style/expression) qui était conçu pour ne pas avoir de signature.

Pourquoi cette interprétation n’est pas possible avec Jaccard : le Jaccard classe par rang, mais ne donne pas un taux de classification. Rapports sémantiques différents (couvertures lexicales) sans repère externe. La discriminabilité, elle, transforme la question « quels assistants se ressemblent » en « combien d’erreurs fait le classifieur » — et une erreur est un chiffre absolu, indépendant de la distribution.

def matrice_tfidf(documents):
    """Chaque ligne : un document, normalisé, en pondération TF-IDF."""
    vocab = sorted({t for d in documents for t in tokeniser(d)})
    rang = {t: i for i, t in enumerate(vocab)}
    tf = np.zeros((len(documents), len(vocab)))
    for i, d in enumerate(documents):
        for t in tokeniser(d):
            tf[i, rang[t]] += 1
    tf /= np.maximum(tf.sum(axis=1, keepdims=True), 1)
    df = (tf > 0).sum(axis=0)
    idf = np.log((1 + len(documents)) / (1 + df)) + 1
    X = tf * idf
    return X / np.maximum(np.linalg.norm(X, axis=1, keepdims=True), 1e-12)


def discriminabilite(X, etiquettes):
    """Pour chaque ligne : centre le plus proche, calculé SANS cette ligne."""
    classes = sorted(set(etiquettes))
    predictions = []
    for i in range(len(X)):
        centres = []
        for c in classes:
            membres = [j for j in range(len(X)) if etiquettes[j] == c and j != i]
            centres.append(X[membres].mean(axis=0))
        C = np.array(centres)
        C /= np.maximum(np.linalg.norm(C, axis=1, keepdims=True), 1e-12)
        predictions.append(classes[int(np.argmax(C @ X[i]))])
    return predictions


cles = [(n, i) for n in noms for i in range(len(SONDES))]
X = matrice_tfidf([reponses[k] for k in cles])
vrai = [k[0] for k in cles]
predit = discriminabilite(X, vrai)

exactitude = sum(v == p for v, p in zip(vrai, predit)) / len(vrai)
print(f"exactitude : {exactitude:.2f}      (hasard = {1 / len(noms):.2f})")
exactitude : 0.58      (hasard = 0.17)
conf = {a: {b: 0 for b in noms} for a in noms}
for v, p in zip(vrai, predit):
    conf[v][p] += 1

print("lignes = auteur réel, colonnes = auteur retrouvé\n")
print(" " * 15 + "".join(f"{n[:9]:>12s}" for n in noms))
for a in noms:
    print(f"{a:15s}" + "".join(f"{conf[a][b]:12d}" for b in noms))

print("\nreconnaissance par assistant :")
for a in noms:
    print(f"  {a:15s} {conf[a][a]}/{len(SONDES)}")
lignes = auteur réel, colonnes = auteur retrouvé

                  structure       style   documenta    pratique   expressio   relecture
structure                 6           0           0           0           0           0
style                     0           0           0           0           6           0
documentation             0           0           6           0           0           0
pratique                  0           0           0           6           0           0
expression                0           6           0           0           0           0
relecture                 1           2           0           0           0           3

reconnaissance par assistant :
  structure       6/6
  style           0/6
  documentation   6/6
  pratique        6/6
  expression      0/6
  relecture       3/6

La signature de la redondance

Trois lignes propres, deux lignes qui se répondent, une ligne partagée.

structure, documentation et pratique sont reconnus 6 fois sur 6. Ceux-là apportent quelque chose que les autres n’apportent pas.

style est reconnu 0 fois sur 6 — et ses six réponses sont attribuées à expression. Symétriquement, expression est reconnu 0 fois sur 6, et ses six réponses sont attribuées à style. Cet échange complet est la signature que l’on cherchait : le classifieur ne les trouve pas seulement proches, il les prend systématiquement l’un pour l’autre. Deux étiquettes, un assistant.

C’est précisément ce que la matrice de Jaccard ne pouvait pas dire. Elle plaçait bien la paire en tête, mais « vocabulaires proches » et « substituables » sont deux constats distincts, et seul le second justifie une décision de catalogue.

Notez aussi ce que devient l’exactitude globale : 0.58, contre 0.17 pour le hasard. Très au-dessus du hasard, et pourtant deux assistants sur six sont inutilisables. Un score agrégé ne remplace pas la matrice — il moyenne ensemble ce qui marche et ce qui ne marche pas.

Lecture du résultat : reconnaissance par assistant, le verdict chiffré

La cellule de calcul précédente (cellule de code conf) a imprimé la matrice de confusion 6×6 et la reconnaissance par assistant. Voici l’extrait :

reconnaissance par assistant :
  structure       6/6
  style           0/6
  documentation   6/6
  pratique        6/6
  expression      0/6
  relecture       3/6

Le tableau de reconnaissance révèle trois régimes distincts :

  1. Signatures nettes (6/6) — structure, documentation, pratique. Trois assistants sur six ont un vocabulaire suffisamment distinct des autres pour être identifiés à chaque sonde. Ce sont les trois piliers du catalogue de l’atelier : architecture, ancrage factuel, conduite du travail. Aucun chevauchement — la discriminabilité est parfaite.
  2. Confusion totale (0/6) — style, expression. Le classifieur a systématiquement attribué les réponses de style à expression, et inversement. C’est la signature de redondance : les réponses sont interchangeables, donc le classifieur n’a aucun signal pour les séparer. Le verdict opérationnel : fusionner ces deux assistants en un seul, ou re-spécifier l’un des deux.
  3. Cas intermédiaire (3/6) — relecture. Trois réponses sur six correctement attribuées, les autres dispersées (1 vers structure, 2 vers style). C’est un assistant partiellement discriminant : sa signature tient sur la moitié de ses interventions, mais pas toutes. La cause probable : le protocole (relecture attend un texte, mais les sondes sont des questions générales) ne sollicite pas le terrain qui distingue relecture de style. Le problème est moins l’assistant que la batterie de sondes.

Pourquoi les agrégats (0.58) ne suffisent pas : si on n’avait que le score exactitude, on verrait 0.58 — un score ambigü. Le tableau de reconnaissance distribue ce score sur les six assistants, en montrant que la moitié est discriminée parfaitement, un tiers est interchangeable, et un cas est intermédiaire. Une décision de catalogue raisonnable : fusionner style et expression, garder structure/documentation/pratique, réviser les sondes pour relecture.

Le piège symétrique

On pourrait croire tenir un raccourci : comparer les prompts entre eux, et conclure. Il ne fonctionne dans aucun des deux sens.

  • Deux prompts très différents peuvent produire des réponses identiques : le modèle ignore le cadrage, ou le traduit en simple variation de ton.
  • Deux prompts presque identiques peuvent diverger nettement : une seule clause discriminante suffit à déplacer toute la réponse.

Mettons les deux classements côte à côte.

def distance_prompts(a, b):
    va, vb = set(tokeniser(PERSONAS[a])), set(tokeniser(PERSONAS[b]))
    return 1 - len(va & vb) / len(va | vb)


centres = {}
for n in noms:
    v = X[[k for k, c in enumerate(cles) if c[0] == n]].mean(axis=0)
    centres[n] = v / np.linalg.norm(v)


def distance_reponses(a, b):
    return 1 - float(centres[a] @ centres[b])


paires = [(a, b) for i, a in enumerate(noms) for b in noms[i + 1:]]
mesures = {p: (distance_prompts(*p), distance_reponses(*p)) for p in paires}

rang_prompt = {p: r for r, p in enumerate(sorted(paires, key=lambda p: -mesures[p][0]))}
rang_reponse = {p: r for r, p in enumerate(sorted(paires, key=lambda p: -mesures[p][1]))}

print(f"{'paire':34s}{'d(prompts)':>12s}{'d(réponses)':>13s}{'rangs':>9s}")
for p in sorted(paires, key=lambda p: -mesures[p][0]):
    dp, dr = mesures[p]
    bascule = f"{rang_prompt[p] + 1}->{rang_reponse[p] + 1}"
    print(f"{p[0] + ' / ' + p[1]:34s}{dp:12.2f}{dr:13.2f}{bascule:>9s}")

deplacees = sum(1 for p in paires if rang_prompt[p] != rang_reponse[p])
print(f"\n{deplacees}/{len(paires)} paires changent de rang entre les deux classements")
paire                               d(prompts)  d(réponses)    rangs
structure / expression                    0.90         0.82     1->2
pratique / expression                     0.89         0.78    2->10
structure / relecture                     0.89         0.79     3->7
documentation / relecture                 0.88         0.78     4->8
pratique / relecture                      0.88         0.74    5->12
documentation / expression                0.87         0.81     6->4
style / relecture                         0.85         0.71    7->13
style / expression                        0.84         0.54    8->15
structure / documentation                 0.84         0.80     9->6
structure / style                         0.83         0.84    10->1
style / documentation                     0.82         0.81    11->3
documentation / pratique                  0.82         0.80    12->5
style / pratique                          0.82         0.78    13->9
expression / relecture                    0.81         0.71   14->14
structure / pratique                      0.80         0.77   15->11

14/15 paires changent de rang entre les deux classements

Lecture

Regardez la ligne style / expression : distance entre prompts 0.84, distance entre réponses 0.54.

C’est la paire dont les prompts n’ont presque aucun mot commun — la paraphrase a été écrite pour cela — et c’est, de très loin, la paire dont les réponses se ressemblent le plus. Huitième au classement des prompts, dernière à celui des réponses.

14 des 15 paires changent de rang d’une colonne à l’autre. Le classement obtenu en comparant les prompts n’est pas une approximation dégradée du bon classement : c’en est simplement un autre.

Corollaire pratique : on n’audite pas un catalogue d’assistants en relisant leurs prompts, quelle que soit l’attention qu’on y met. Il faut les faire parler.

Lecture du résultat : la table des 15 paires et ses sauts de rang

La cellule de calcul précédente (cellule de code distance_prompts + distance_reponses) a imprimé la table des 15 paires triée par rang des prompts. Observations :

Paire d(prompts) d(réponses) Rang prompts → réponses
structure / expression 0.90 0.82 1 → 2
pratique / expression 0.89 0.78 2 → 10
structure / relecture 0.89 0.79 3 → 7
documentation / relecture 0.88 0.78 4 → 8
style / expression 0.84 0.54 8 → 15
style / relecture 0.85 0.71 7 → 13
expression / relecture 0.81 0.71 14 → 14 (seul invariant)

Les sauts de rang sont la mesure du découplage. La paire style / expression — qui passe du 8ᵉ rang des prompts au dernier (15ᵉ) rang des réponses — est le cas extrême : prompts disjoints (paraphrase), réponses identiques (confusion totale). À l’inverse, expression / relecture est la seule paire stable (14 → 14) : prompts proches ET réponses proches.

Commentaire statistique : sur les 15 paires, 14 changent de rang entre les deux classements. C’est le maximum théorique sauf coïncidence parfaite — et la coïncidence restante (expression/relecture) est instructive plutôt qu’accidentelle : ce sont précisément les deux assistants qui n’ont pas de signature forte (Tous les deux partent vers le même voisinage lexical que style). La mesure de corrélation de Spearman entre les deux classements serait probablement ρ < 0.3 — un découplage systématique, pas un artefact.

Conséquence opérationnelle : tout outil d’audit qui ne regarderait que les prompts (par exemple un diff texte automatisé) manque 93% de l’information (14/15 paires mal-classées). Le seul moyen de valider qu’un assistant n’est pas la paraphrase d’un autre est de regarder ses réponses.

Exercices

Les trois exercices se répondent avec ce qui précède : reponses, conf, X, cles, matrice_tfidf, discriminabilite.

Exercice 1 — la paire à fusionner

Le tableau de confusion dit où la mesure se trompe, mais il faut le lire à la main. Écrivez la fonction qui en extrait la paire la plus confondue, en comptant les confusions dans les deux sens — a pris pour b et b pris pour a.

C’est cette paire qu’on proposera de fusionner, ou de re-spécifier.

def paire_la_plus_confondue(conf, noms):
    """Renvoie (nom_a, nom_b, nombre_de_confusions) pour la paire la plus confondue.

    Les confusions se comptent dans les deux sens : conf[a][b] + conf[b][a].
    Renvoie None si aucune confusion n'a lieu.
    """
    # À vous.
    return None


print(paire_la_plus_confondue(conf, noms))
None

Exercice 2 — la sonde qui sépare

Toutes les sondes ne se valent pas. Certaines forcent la convergence, d’autres laissent chaque assistant révéler son angle.

Calculez l’exactitude sonde par sonde : pour chaque indice de sonde, la proportion des cinq réponses correspondantes qui sont correctement attribuées par predit. La meilleure sonde est celle qu’il faut garder si l’on doit réduire le protocole ; la pire est celle qu’il faut réécrire.

def exactitude_par_sonde(cles, vrai, predit, nb_sondes):
    """Renvoie une liste de longueur nb_sondes : l'exactitude de chaque sonde."""
    # À vous.
    pass


scores = exactitude_par_sonde(cles, vrai, predit, len(SONDES))
if scores:
    for i, s in enumerate(scores):
        print(f"  sonde {i} : {s:.2f}   {SONDES[i][:52]}")

Exercice 3 — réparer la redondance

Prenez l’assistant que la matrice de confusion désigne, et réécrivez son prompt pour qu’il cesse de recouvrir son voisin. Puis re-mesurez.

La contrainte qui rend l’exercice intéressant : ne le supprimez pas de l’atelier, et ne changez pas son domaine. Il ne s’agit pas de le renommer en autre chose, mais de trouver ce qu’il apporte que son voisin n’apporte pas — et de l’écrire. Si vous n’y arrivez pas, vous venez d’établir quelque chose d’utile : cet assistant n’avait pas de raison d’exister séparément.

Attendu : l’exactitude globale monte, et la case de confusion se vide. Si l’exactitude monte mais que la confusion demeure, c’est un autre assistant qui a bougé — relisez la matrice entière avant de conclure.

ASSISTANT_A_REECRIRE = ""   # À vous : le nom, tel qu'il figure dans PERSONAS.
PROMPT_V2 = ""              # À vous : son nouveau prompt système.


def remesurer(nom_assistant, nouveau_prompt):
    """Recollecte les réponses d'un assistant sous un nouveau prompt et renvoie
    (exactitude, tableau de confusion)."""
    if nom_assistant not in PERSONAS or not nouveau_prompt.strip():
        return None
    nouvelles = dict(reponses)
    for i, sonde in enumerate(SONDES):
        nouvelles[(nom_assistant, i)] = interroger(nouveau_prompt, sonde)
    Xb = matrice_tfidf([nouvelles[k] for k in cles])
    predit_b = discriminabilite(Xb, vrai)
    conf_b = {a: {b: 0 for b in noms} for a in noms}
    for v, p in zip(vrai, predit_b):
        conf_b[v][p] += 1
    return sum(v == p for v, p in zip(vrai, predit_b)) / len(vrai), conf_b


resultat = remesurer(ASSISTANT_A_REECRIRE, PROMPT_V2)
if resultat:
    print(f"exactitude : {exactitude:.2f} -> {resultat[0]:.2f}")
else:
    print("ASSISTANT_A_REECRIRE ou PROMPT_V2 n'est pas renseigné.")
ASSISTANT_A_REECRIRE ou PROMPT_V2 n'est pas renseigné.

Provenance des chiffres

Les sorties de ce notebook ont été produites le 11 août 2026, contre un serveur vLLM local servant qwen3.6-35b-a3b, avec temperature=0.3 et seed=7.

Ces valeurs ne se transportent pas. Un autre modèle, une autre version du même modèle, un autre régime de lots les déplaceront. C’est attendu : ce que le notebook transmet est le protocole et la forme des signaux — l’échange complet dans la matrice, l’écart entre les deux classements — pas les nombres.

Ce que ce notebook ne dit pas

Une exactitude élevée ne veut pas dire que les assistants sont bons. Elle dit qu’ils sont distinguables. Six assistants distincts et tous médiocres obtiendraient un excellent score. La distinctivité est une condition nécessaire, jamais suffisante.

La mesure est lexicale. TF-IDF voit des mots, pas du sens. Deux assistants qui diraient la même chose avec des vocabulaires disjoints passeraient pour distincts — c’est d’ailleurs le piège que expression tendait, et il a été attrapé seulement parce que les réponses convergent lexicalement même quand les prompts divergent. Sur un catalogue plus retors, on remplacerait TF-IDF par des embeddings ; le protocole, lui, ne bouge pas.

La mesure dépend du modèle. Un modèle qui suit mieux les instructions séparera davantage. Changer de modèle sous des prompts inchangés déplace les chiffres — information utile à relever avant une migration, pas après.

Six sondes, c’est peu. L’intervalle autour de l’exactitude est large, et une seule réponse mal attribuée déplace un assistant de 6/6 à 5/6. Le protocole est bon ; l’échantillon est une démonstration. En production, on monte le nombre de sondes bien au-delà du nombre d’assistants.

Un recouvrement n’est pas toujours un défaut. Deux assistants peuvent partager du terrain à dessein. Ce que la mesure fournit est un constat chiffré, pas un verdict : elle établit que la frontière n’existe pas dans les sorties, et laisse décider si elle devait exister.

Pour aller plus loin

  • cadrer-les-agents.md — l’autre moitié de la question : ce qu’un assistant a le droit de faire, et pourquoi la couche persona n’est pas une frontière de sécurité
  • comparatif-owui-vs-ai-engine.md — où se déclarent les assistants dans chacune des deux plateformes
Retour au sommet