Choisir le modèle derrière son chatbot — une mini-évaluation reproductible

← README AI-Engine-WordPress | Parcours livresagités | Comparatif OWUI vs AI-Engine

AI-Engine, comme Open WebUI, laisse choisir le modèle par chatbot. C’est la décision la plus structurante d’un déploiement, et c’est presque toujours celle qu’on prend au feeling : on ouvre le playground, on pose trois questions, la réponse est jolie, on garde le modèle.

Trois questions ne sont pas une mesure. Ce notebook remplace l’impression par un protocole minimal : un jeu de scénarios dont chacun porte une assertion vérifiable par code, exécuté en plusieurs répétitions pour faire apparaître la variance, puis un tableau de verdict.

L’objectif n’est pas de désigner un vainqueur universel — il n’y en a pas. C’est de rendre le choix argumentable et rejouable : quand le modèle change, quand le fournisseur modifie ses prix, quand une régression apparaît, on relance le même banc au lieu de rediscuter d’intuitions.

Ce que ce notebook n’est pas. Ni un benchmark académique, ni une mesure de performance (latence, coût, débit). C’est un banc de conformité fonctionnelle : le modèle fait-il ce qu’un chatbot de site exige de lui ?

Les quatre propriétés qu’on mesure, et pourquoi celles-là

Un chatbot de site de contenu échoue rarement parce qu’il écrit mal. Il échoue sur quelques points précis, tous invisibles en démonstration et tous vérifiables par code.

Propriété Question posée Pourquoi c’est critique
Ancrage / refus Face à une question dont la réponse n’est pas dans le contexte fourni, le modèle refuse-t-il ? C’est la propriété du RAG. Un modèle qui comble les trous produit des affirmations fausses au nom du site. Une hallucination polie est pire qu’un « je ne sais pas ».
Respect du format Rend-il un JSON strict aux clés demandées ? Dès que la sortie alimente un traitement (formulaire, champ, appel suivant), la prose libre casse la chaîne.
Stabilité de langue Répond-il en français à une question en français ? Les petits modèles dérivent vers l’anglais sous consigne système anglophone. Sur un site francophone, c’est rédhibitoire.
Discipline d’appel d’outil Face à un outil disponible, émet-il un appel structuré plutôt que d’en parler en prose ? C’est le prérequis de MCP. Un modèle qui répond « je vais appeler get_horaires » au lieu de l’appeler ne pilotera jamais un serveur d’outils.

Chaque scénario est rejoué plusieurs fois. Un modèle qui passe une fois sur trois n’a pas la propriété : il a eu de la chance, et la différence ne se voit qu’en répétant.

Le corpus utilisé est entièrement fictif (une médiathèque inventée). Aucun contenu réel, aucun site tiers, aucune donnée personnelle.

Configuration

Le banc parle à n’importe quel endpoint compatible OpenAI : vLLM, Ollama, LM Studio, llama.cpp, LocalAI, ou l’API OpenAI elle-même. Rien n’est codé en dur — ni adresse, ni clé. Copiez .env.example en .env et renseignez vos valeurs.

EVAL_BASE_URL=http://VOTRE-ENDPOINT:PORT/v1
EVAL_API_KEY=votre-cle
EVAL_MODEL=nom-du-modele        # optionnel : sinon, le premier modèle servi

La cellule suivante n’affiche jamais la clé ni l’adresse : seulement de quoi vérifier qu’elles sont chargées. Une sortie de notebook est commitée dans un dépôt public ; elle ne doit révéler ni secret ni topologie réseau.

import os
import re
import json
import time
import statistics
import unicodedata
from pathlib import Path

# Chargement optionnel d'un .env local (aucune dependance requise).
env_path = Path(".env")
if env_path.is_file():
    for line in env_path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if line and not line.startswith("#") and "=" in line:
            k, v = line.split("=", 1)
            os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))

BASE_URL = os.environ.get("EVAL_BASE_URL", "")
API_KEY = os.environ.get("EVAL_API_KEY", "")
MODEL = os.environ.get("EVAL_MODEL", "")


def redact_url(url: str) -> str:
    """Confirme la forme d'une URL sans reveler l'hote ni le port."""
    m = re.match(r"^(https?)://([^/:]+)(:\d+)?(/.*)?$", url or "")
    if not m:
        return "<non defini>"
    scheme, host, port, path = m.groups()
    kind = "adresse-IP-privee" if re.match(r"^\d+\.\d+\.\d+\.\d+$", host) else "nom-d-hote"
    return f"{scheme}://<{kind}>{'<:port>' if port else ''}{path or ''}"


print("BASE_URL :", redact_url(BASE_URL))
print("API_KEY  :", f"chargee ({len(API_KEY)} caracteres)" if API_KEY else "ABSENTE")
print("MODEL    :", MODEL or "<sera deduit de /v1/models>")
assert BASE_URL and API_KEY, "Renseignez EVAL_BASE_URL et EVAL_API_KEY (voir .env.example)."
BASE_URL : http://<nom-d-hote><:port>/v1
API_KEY  : chargee (32 caracteres)
MODEL    : qwen3.6-35b-a3b
from openai import OpenAI

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

if not MODEL:
    MODEL = client.models.list().data[0].id

t0 = time.perf_counter()
probe = client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "user", "content": "Combien font 2+2 ? Reponds en une phrase."}],
    max_tokens=512,
    temperature=0.0,
)
elapsed = time.perf_counter() - t0
msg = probe.choices[0].message

# Un modele "a raisonnement" emet des tokens de reflexion AVANT sa reponse.
# Ils sont factures sur le meme budget max_tokens, et exposes a part.
raisonnement = getattr(msg, "reasoning", None) or getattr(msg, "reasoning_content", None)
RAISONNEUR = bool(raisonnement)

print(f"modele        : {MODEL}")
print(f"contenu       : {(msg.content or '').strip()[:60]!r}")
print(f"finish_reason : {probe.choices[0].finish_reason}")
print(f"tokens generes: {probe.usage.completion_tokens}")
print(f"raisonnement  : {'OUI (' + str(len(raisonnement)) + ' caracteres caches)' if RAISONNEUR else 'non'}")
print(f"aller-retour  : {elapsed:.2f} s")
modele        : qwen3.6-35b-a3b
contenu       : '2+2 font 4.'
finish_reason : stop
tokens generes: 435
raisonnement  : OUI (1369 caracteres caches)
aller-retour  : 4.99 s

Le corpus fictif et les scénarios

Le contexte ci-dessous joue le rôle du passage que le RAG aurait retrouvé. Il est volontairement court et lacunaire : c’est précisément ce qu’il ne dit pas qui permet de tester le refus.

Notez le scénario ancrage_absent : le tarif d’abonnement n’apparaît nulle part dans le contexte. Un modèle honnête le signale ; un modèle complaisant invente un montant plausible. C’est le test le plus révélateur du lot, et le seul qu’une démonstration ne fait jamais — en démonstration, on pose des questions dont on connaît la réponse.

CONTEXTE = """Mediatheque de Valmont -- informations pratiques.
La mediatheque est ouverte du mardi au samedi, de 10h a 18h30.
Elle est fermee les jours feries et la premiere semaine d'aout.
Le fonds compte 42 000 ouvrages, dont 3 500 bandes dessinees.
L'inscription est gratuite pour les habitants de la commune.
Le pret est limite a 8 documents pour une duree de trois semaines."""

MOTS_FR = {"le", "la", "les", "des", "est", "sont", "pas", "dans", "pour", "une",
           "vous", "nous", "ce", "cette", "aux", "avec", "sur", "il", "elle", "que"}
MOTS_EN = {"the", "is", "are", "not", "in", "for", "you", "we", "this", "with",
           "and", "of", "to", "it", "that", "there", "here", "please"}


def sans_accents(txt: str) -> str:
    """Compare a l'identique 'mentionne' et 'mentionne' accentue."""
    decompose = unicodedata.normalize("NFD", txt.lower())
    return "".join(c for c in decompose if unicodedata.category(c) != "Mn")


# Une negation et un marqueur d'absence, dans un sens ou dans l'autre, a moins de
# 60 caracteres l'un de l'autre. La proximite remplace la liste de tournures
# figees : "il ne mentionne aucun tarif" et "il n'est donc pas mentionne" sont
# deux refus corrects que toute liste finit par manquer.
NEGATION = r"(?:\bpas\b|\baucun\w*\b|\bnulle part\b|\brien\b)"
ABSENCE = (r"(?:mentionn\w*|indiqu\w*|precis\w*|figur\w*|information\w*|"
           r"apparai\w*|contient|disponible|donnee\w*|element\w*)")
MONTANT = r"\d+(?:[.,]\d+)?\s*(?:eur|euro|\u20ac|\$)"


def dit_ne_sait_pas(txt: str) -> bool:
    """Detecte un refus explicite plutot qu'une reponse inventee."""
    t = sans_accents(txt)
    return bool(re.search(NEGATION + r".{0,60}?" + ABSENCE, t)
                or re.search(ABSENCE + r".{0,60}?" + NEGATION, t))


def est_francais(txt: str) -> bool:
    mots = re.findall(r"[^\W\d_]+", txt.lower(), re.UNICODE)
    if len(mots) < 5:
        return False
    fr = sum(1 for m in mots if m in MOTS_FR)
    en = sum(1 for m in mots if m in MOTS_EN)
    return fr > en


OUTILS = [{
    "type": "function",
    "function": {
        "name": "get_horaires",
        "description": "Retourne les horaires d'ouverture de la mediatheque pour un jour donne.",
        "parameters": {
            "type": "object",
            "properties": {"jour": {"type": "string",
                                    "description": "Jour de la semaine en minuscules, ex: mardi"}},
            "required": ["jour"],
        },
    },
}]

print("contexte :", len(CONTEXTE), "caracteres |", len(CONTEXTE.split()), "mots")
print("outils   :", [o["function"]["name"] for o in OUTILS])
contexte : 366 caracteres | 62 mots
outils   : ['get_horaires']
def v_ancrage_present(rep, _tc):
    return "42000" in rep.replace(" ", "").replace("\u202f", "").replace("\u00a0", "")


def v_ancrage_absent(rep, _tc):
    # Deux conditions : signaler l'absence ET ne pas avancer de montant.
    return dit_ne_sait_pas(rep) and not re.search(MONTANT, sans_accents(rep))


def v_format_json(rep, _tc):
    m = re.search(r"\{.*\}", rep, re.S)
    if not m:
        return False
    try:
        d = json.loads(m.group(0))
    except json.JSONDecodeError:
        return False
    return isinstance(d, dict) and set(d.keys()) == {"jours_ouverture", "nombre_ouvrages"}


def v_langue(rep, _tc):
    return est_francais(rep)


def v_outil(_rep, tool_calls):
    if not tool_calls:
        return False
    tc = tool_calls[0]
    if tc.function.name != "get_horaires":
        return False
    try:
        args = json.loads(tc.function.arguments)
    except (json.JSONDecodeError, TypeError):
        return False
    return "jour" in args


SCENARIOS = [
    dict(cle="ancrage_present", propriete="Ancrage",
         systeme="Tu reponds uniquement a partir du CONTEXTE fourni.",
         user=f"CONTEXTE:\n{CONTEXTE}\n\nQUESTION: Combien d'ouvrages compte le fonds ?",
         verif=v_ancrage_present, outils=None,
         attendu="cite 42 000 (l'information EST dans le contexte)"),
    dict(cle="ancrage_absent", propriete="Refus hors-contexte",
         systeme=("Tu reponds uniquement a partir du CONTEXTE fourni. "
                  "Si l'information n'y figure pas, dis-le explicitement et n'invente rien."),
         user=f"CONTEXTE:\n{CONTEXTE}\n\nQUESTION: Quel est le tarif annuel de l'abonnement ?",
         verif=v_ancrage_absent, outils=None,
         attendu="signale l'absence, sans inventer de montant"),
    dict(cle="format_json", propriete="Respect du format",
         systeme="Tu reponds par un objet JSON valide et rien d'autre. Aucun texte hors du JSON.",
         user=(f"CONTEXTE:\n{CONTEXTE}\n\nRends un JSON avec exactement deux cles : "
               '"jours_ouverture" (chaine) et "nombre_ouvrages" (entier).'),
         verif=v_format_json, outils=None,
         attendu="JSON parsable, aux deux cles exactes"),
    dict(cle="langue_fr", propriete="Stabilite de langue",
         systeme="You are a helpful assistant. Always answer in the language of the user's question.",
         user="En deux phrases, quels sont les jours d'ouverture de la mediatheque de Valmont ?",
         verif=v_langue, outils=None,
         attendu="repond en francais malgre une consigne systeme anglophone"),
    dict(cle="appel_outil", propriete="Discipline d'appel d'outil",
         systeme="Tu disposes d'outils. Utilise-les quand la question l'exige.",
         user="Quels sont les horaires du mardi ?",
         verif=v_outil, outils=OUTILS,
         attendu="emet un tool_call get_horaires(jour) valide, pas de la prose"),
]

print(f"{len(SCENARIOS)} scenarios definis :")
for s in SCENARIOS:
    print(f"  - {s['cle']:<16} [{s['propriete']}] -> {s['attendu']}")
5 scenarios definis :
  - ancrage_present  [Ancrage] -> cite 42 000 (l'information EST dans le contexte)
  - ancrage_absent   [Refus hors-contexte] -> signale l'absence, sans inventer de montant
  - format_json      [Respect du format] -> JSON parsable, aux deux cles exactes
  - langue_fr        [Stabilite de langue] -> repond en francais malgre une consigne systeme anglophone
  - appel_outil      [Discipline d'appel d'outil] -> emet un tool_call get_horaires(jour) valide, pas de la prose

Exercice 1 — Écrire un validateur pour une propriété non couverte

Le banc mesure quatre propriétés au moyen de cinq validateurs ayant tous la même signature, (rep, _tc) -> bool (où _tc est la liste des tool calls, ignorée par les validateurs qui n’en ont pas besoin). C’est ce squelette uniforme qui rend le banc extensible : ajouter une propriété, c’est ajouter un validateur de cette forme, puis un scénario qui l’utilise.

Une propriété utile et absente du banc ci-dessus est la concision : un chatbot de site qui délaye sa réponse dilue l’information et lasse le visiteur qui cherchait un fait. Complétez v_concision, qui doit renvoyer True quand la réponse fait au plus 80 mots.

  • Objectif — renvoyer True si rep contient ≤ 80 mots, False sinon.
  • Indice — découpez rep sur les espaces (str.split() ignore les blancs surnuméraires) et comparez la longueur au seuil.
  • Étape 1 — compter les mots de rep.
  • Étape 2 — comparer au seuil de 80 et renvoyer le booléen.

Une fois votre validateur écrit, ajoutez-lui un scénario dans SCENARIOS (une question simple, par exemple « résume les horaires ») et relancez le banc : la nouvelle ligne apparaît dans le tableau de synthèse.

def v_concision(rep, _tc):
    # Exercice 1 -- un validateur pour la concision.
    #
    # Renvoie True si la reponse fait au plus 80 mots, False sinon.
    # La signature (rep, _tc) est celle des autres validateurs ; _tc (les
    # tool_calls) est ignore ici.
    #
    # Indice : rep.split() decoupe sur les espaces en ignorant les blancs
    # surnumeraires. Comparez len(...) au seuil 80.
    return None  # TODO etudiant

Deux pièges rencontrés en écrivant ce notebook

Ce banc a rendu deux verdicts faux avant d’en rendre un vrai. Les deux accusaient le modèle ; les deux venaient du harnais. Ils sont racontés ici parce qu’ils sont plus instructifs que le tableau final : personne ne se méfie d’un résultat qui va dans le sens attendu.

Piège 1 — le budget de tokens partagé avec le raisonnement

La première version donnait un verdict accablant : trois propriétés sur cinq en ECHEC, avec des réponses vides. Le modèle paraissait incapable de répondre.

Il n’y avait aucun problème de modèle. Le modèle évalué raisonne avant de répondre : il émet d’abord une longue trace de réflexion, exposée séparément du contenu, mais facturée sur le même budget max_tokens. Avec un budget de 400 tokens, la réflexion consommait tout, la réponse était tronquée avant d’exister, et content revenait vide. Mes assertions voyaient une chaîne vide et concluaient sagement à l’échec.

Un banc mal calibré ne renvoie pas « je ne sais pas » : il renvoie un verdict faux, précis et crédible. C’est le principal risque de ce genre d’outil, et il est plus dangereux que l’absence de mesure — un tableau chiffré inspire une confiance qu’une intuition n’obtiendrait pas.

Trois corrections en découlent, appliquées ci-dessous :

  1. Budget de tokens généreux (3000), pour que la réflexion ne mange pas la réponse.
  2. Distinguer l’échec de la mesure invalide. Une réponse coupée par la limite (finish_reason == "length") n’est pas une propriété manquée : c’est une mesure inutilisable. Elle est comptée INVALIDE et exclue du verdict, au lieu d’être maquillée en FAIL.
  3. Ne pas conclure sur une mesure unique. Si moins de deux répétitions sont valides, le verdict est NON MESURE. Un 100 % calculé sur un seul appel n’est pas un taux, c’est une anecdote avec un signe de pourcentage.

Piège 2 — l’assertion qui ne reconnaît pas une bonne réponse

Corrigé le premier piège, le scénario ancrage_absent restait en ECHEC sur ses trois répétitions. Verdict sévère, et sur la propriété la plus sensible du lot : le modèle inventerait des informations absentes du contexte.

Les extraits disaient l’inverse. Le modèle répondait :

« Le contexte indique que l’inscription est gratuite pour les habitants de la commune. Il ne mentionne aucun tarif annuel d’abonnement. »

C’est exactement le comportement recherché. La faute était dans l’assertion : elle cherchait une liste de tournures figées ("n'est pas mentionné", "ne figure pas"…), et le modèle en avait employé une autre. Une liste de formulations est toujours incomplète, et le français permet d’insérer un adverbe au milieu ("il n'est **donc** pas mentionné") — ce qui suffit à faire échouer la correspondance littérale.

La version corrigée cherche une négation et un marqueur d’absence à proximité l’un de l’autre, dans un sens ou dans l’autre, insensible aux accents. Elle ajoute surtout la seconde moitié de la propriété, absente de la première version : ne pas avancer de montant. Signaler l’absence et inventer un prix dans la même phrase, c’est échouer.

Vérifiez toujours qu’un échec est un échec du modèle, et non un échec de votre harnais. Le doute doit porter d’abord sur l’instrument.

Concrètement : lisez les extraits avant de croire la colonne verdict. C’est la raison d’être de la colonne extrait dans les résultats — un banc qui ne conserve pas les réponses brutes ne se débogue pas.


Exécution

Trois répétitions par scénario, à temperature=0.7. Le choix d’une température non nulle est délibéré : c’est le régime dans lequel tourne un chatbot de production, et c’est celui où la variance se manifeste. À temperature=0, on mesurerait un cas particulier flatteur.

Un scénario n’est acquis que s’il passe toutes ses répétitions valides. Deux sur trois signifie qu’un visiteur sur trois voit le défaut.

REPETITIONS = 3
MAX_TOKENS = 3000          # large : la trace de raisonnement partage ce budget
resultats = []

for s in SCENARIOS:
    for rep in range(1, REPETITIONS + 1):
        kwargs = dict(
            model=MODEL,
            messages=[{"role": "system", "content": s["systeme"]},
                      {"role": "user", "content": s["user"]}],
            max_tokens=MAX_TOKENS,
            temperature=0.7,
        )
        if s["outils"]:
            kwargs["tools"] = s["outils"]

        t0 = time.perf_counter()
        try:
            r = client.chat.completions.create(**kwargs)
            choix = r.choices[0]
            msg = choix.message
            texte = (msg.content or "").strip()
            tool_calls = getattr(msg, "tool_calls", None) or []
            fin = choix.finish_reason or ""
            gen = r.usage.completion_tokens if r.usage else None

            # Mesure INVALIDE : coupee par la limite sans rien produire d'evaluable.
            if fin == "length" and not texte and not tool_calls:
                statut = "INVALIDE"
            else:
                statut = "PASS" if s["verif"](texte, tool_calls) else "FAIL"
            err = ""
        except Exception as exc:                 # panne reseau, modele indisponible
            texte, fin, gen, statut = "", "", None, "INVALIDE"
            err = type(exc).__name__
        dt = time.perf_counter() - t0

        resultats.append(dict(scenario=s["cle"], propriete=s["propriete"], rep=rep,
                              statut=statut, secondes=round(dt, 2), tokens=gen,
                              fin=fin, erreur=err,
                              extrait=" ".join(texte[:160].split())))
        print(f"  {s['cle']:<16} rep {rep}/{REPETITIONS}  {statut:<8} "
              f"{dt:5.2f}s  {str(gen or '-'):>4} tok  fin={fin or '-'}"
              f"{'  ' + err if err else ''}")

print(f"\n{len(resultats)} appels effectues.")
  ancrage_present  rep 1/3  PASS      2.89s   319 tok  fin=stop
  ancrage_present  rep 2/3  PASS      3.30s   364 tok  fin=stop
  ancrage_present  rep 3/3  PASS      7.75s   879 tok  fin=stop
  ancrage_absent   rep 1/3  PASS      9.49s  1088 tok  fin=stop
  ancrage_absent   rep 2/3  PASS      8.27s   944 tok  fin=stop
  ancrage_absent   rep 3/3  PASS      4.51s   505 tok  fin=stop
  format_json      rep 1/3  PASS      8.41s   949 tok  fin=stop
  format_json      rep 2/3  PASS     10.83s  1226 tok  fin=stop
  format_json      rep 3/3  PASS     10.70s  1209 tok  fin=stop
  langue_fr        rep 1/3  PASS      9.35s  1067 tok  fin=stop
  langue_fr        rep 2/3  PASS      4.73s   537 tok  fin=stop
  langue_fr        rep 3/3  PASS     13.82s  1600 tok  fin=stop
  appel_outil      rep 1/3  PASS      1.42s   158 tok  fin=tool_calls
  appel_outil      rep 2/3  PASS      2.20s   250 tok  fin=tool_calls
  appel_outil      rep 3/3  PASS      0.56s    56 tok  fin=tool_calls

15 appels effectues.
import pandas as pd

df = pd.DataFrame(resultats)


MIN_VALIDES = 2            # en deca, on ne conclut pas : une mesure n'est pas un taux


def verdict(g):
    valides = int((g.statut != "INVALIDE").sum())
    reussites = int((g.statut == "PASS").sum())
    if valides < MIN_VALIDES:
        v = "NON MESURE"
    elif reussites == valides:
        v = "ACQUIS"
    elif reussites:
        v = "INSTABLE"
    else:
        v = "ECHEC"
    return pd.Series(dict(reussites=reussites, valides=valides,
                          invalides=int((g.statut == "INVALIDE").sum()),
                          sec_med=g.secondes.median(),
                          tok_med=g.tokens.median(), verdict=v))


synthese = (df.groupby(["scenario", "propriete"]).apply(verdict, include_groups=False)
              .reset_index())
synthese["taux_pct"] = synthese.apply(
    lambda r: round(r.reussites / r.valides * 100) if r.valides else None, axis=1)
synthese[["scenario", "propriete", "reussites", "valides", "invalides",
          "taux_pct", "sec_med", "tok_med", "verdict"]]
scenario propriete reussites valides invalides taux_pct sec_med tok_med verdict
0 ancrage_absent Refus hors-contexte 3 3 0 100 8.27 944.0 ACQUIS
1 ancrage_present Ancrage 3 3 0 100 3.30 364.0 ACQUIS
2 appel_outil Discipline d'appel d'outil 3 3 0 100 1.42 158.0 ACQUIS
3 format_json Respect du format 3 3 0 100 10.70 1209.0 ACQUIS
4 langue_fr Stabilite de langue 3 3 0 100 9.35 1067.0 ACQUIS
compte = synthese.verdict.value_counts().to_dict()
invalides_total = int((df.statut == "INVALIDE").sum())

print(f"Modele evalue : {MODEL}")
for v in ("ACQUIS", "INSTABLE", "ECHEC", "NON MESURE"):
    print(f"  {v:<11}: {compte.get(v, 0)}/{len(synthese)}")
print(f"  latence mediane  : {statistics.median(df.secondes):.2f} s")
print(f"  mesures invalides: {invalides_total}/{len(df)}"
      f"{'  (budget de tokens a revoir)' if invalides_total else ''}\n")

for _, r in synthese[synthese.verdict != "ACQUIS"].iterrows():
    print(f"A surveiller -- {r.scenario} ({r.propriete}) : "
          f"{r.reussites}/{r.valides} valides, {r.invalides} invalide(s)")
    for _, l in df[(df.scenario == r.scenario) & (df.statut == "FAIL")].head(2).iterrows():
        print(f"    rep {l.rep} rendait : {l.extrait[:130]!r}")

if compte.get("ACQUIS", 0) == len(synthese):
    print("Les cinq proprietes sont acquises sur ce banc.")
Modele evalue : qwen3.6-35b-a3b
  ACQUIS     : 5/5
  INSTABLE   : 0/5
  ECHEC      : 0/5
  NON MESURE : 0/5
  latence mediane  : 7.75 s
  mesures invalides: 0/15

Les cinq proprietes sont acquises sur ce banc.

Lire le tableau

D’abord : que faire d’un tableau tout vert ?

Sur l’exécution enregistrée ci-dessus, les cinq propriétés ressortent ACQUIS. La conclusion à en tirer est modeste, et il faut résister à la formuler autrement : ce modèle ne présente aucun des cinq défauts testés. Ce n’est pas « ce modèle est bon ».

Un banc qui ne fait jamais échouer personne ne discrimine plus rien — il rassure, ce qui est exactement son défaut. Trois réactions saines devant du tout-vert :

  1. Durcir les cas avant de conclure. Ici, ancrage_absent pourrait poser une question dont le contexte contient une réponse voisine mais fausse : c’est le piège auquel les modèles cèdent réellement, bien plus qu’à une question franchement hors-sujet.
  2. Ajouter les cas venus de la production — les vraies questions des vrais visiteurs, celles qu’on n’aurait pas imaginées.
  3. Comparer, plutôt que valider. Le tableau prend son sens en faisant tourner le même banc sur deux ou trois modèles candidats : c’est l’écart qui décide, pas le score absolu.

Les quatre verdicts

ACQUIS (3/3) — la propriété tient sur ce banc. Ce n’est pas une garantie absolue : c’est l’absence de défaut sur cinq cas choisis pour être discriminants.

INSTABLE (1/3 ou 2/3) — le cas le plus important du tableau, et celui qu’une démonstration ne révèle jamais. Un modèle qui passe deux fois sur trois donnera raison à qui l’essaie deux fois, et tort à qui l’essaie une troisième. Sur une propriété de sûreté comme le refus hors-contexte, INSTABLE doit se lire comme un échec : la garantie recherchée est justement de ne jamais inventer.

ECHEC (0 sur les répétitions valides) — la propriété est absente. Selon laquelle, la conséquence diffère : un échec sur le format se rattrape par un parseur tolérant ; un échec sur l’ancrage ne se rattrape pas — il se paie en affirmations fausses publiées au nom du site.

NON MESURE, et la colonne invalides — aucune répétition exploitable, ou seulement une partie. Ce n’est pas un résultat sur le modèle, c’est un résultat sur le banc : budget de tokens trop court, endpoint indisponible, requête rejetée. La bonne réaction est de corriger l’instrument et de relancer, jamais d’interpréter. Cette colonne existe précisément pour que le tableau ne puisse plus déguiser une panne de mesure en verdict.

Ce qu’on en fait

Un scénario qui échoue ne condamne pas le modèle : il oriente le correctif. Refus faible → durcir la consigne système et relever le seuil de similarité du RAG. Format instable → passer en sortie structurée native plutôt que d’espérer la discipline du modèle. Dérive de langue → rédiger la consigne système dans la langue cible. Appel d’outil absent → le modèle n’est pas éligible à un usage agentique, quelle que soit sa qualité rédactionnelle.

C’est la différence entre « ce modèle est bon » et « ce modèle convient à cet usage, et voici ce qu’il faut compenser ».

Exercice 2 — Durcir un cas : une réponse voisine mais fausse

La section « Lire le tableau » recommande, devant un tableau tout vert, de durdir les cas avant de conclure. Le scénario ancrage_absent teste un refus franc : la réponse n’est nulle part dans le contexte. Mais c’est le piège auquel les modèles cèdent réellement qui manque ici : un contexte contenant une réponse voisine mais fausse, que le modèle peut confondre avec la vraie.

Construisez un tel scénario, au format des dicts de SCENARIOS.

  • Objectif — définir scenario_voisin, un dict avec les clés cle, propriete, systeme, user, verif, outils, attendu.
  • Indice — ajoutez au CONTEXTE un montant plausible mais inexact (par exemple « frais de retard : 1,50 € par semaine »), puis posez une question dont la vraie réponse (le tarif d’abonnement) reste absente. Un modèle complaisant confondra le leurre avec la réponse.
  • Étape 1 — définir un CONTEXTE_DURCI incluant le leurre.
  • Étape 2 — remplir scenario_voisin en réutilisant v_ancrage_absent comme verif (le validateur exige à la fois le signalement d’absence et l’absence de montant — le leurre est un montant, donc il doit aussi être refusé).
# Exercice 2 -- un scenario "voisin mais faux".
#
# Objectif : un dict au format de SCENARIOS ou le contexte contient un montant
# proche mais inexact, pour piéger un modele complaisant.
#
# Indice : ajoutez un leurre au contexte (ex. des frais de retard), puis posez
# la question du tarif d'abonnement (toujours absent). Reutilisez
# v_ancrage_absent comme verif : il exige le signalement d'absence ET l'absence
# de tout montant -- le leurre compris.

CONTEXTE_DURCI = None  # TODO etudiant : CONTEXTE + un leurre (montant inexact)

scenario_voisin = None  # TODO etudiant : dict(cle=..., propriete=..., ...)

Transposition à AI-Engine (et à Open WebUI)

Les deux plateformes exposent les mêmes leviers, sous des noms différents. Le banc ci-dessus teste le modèle ; les correctifs, eux, se posent dans la plateforme.

Ce que le banc mesure Levier côté AI-Engine Levier côté Open WebUI
Ancrage / refus Consigne système du chatbot, seuil et mode de recherche (Simple / Context-Aware / Smart) Consigne système, paramètres de récupération Knowledge
Respect du format Sortie structurée du provider, quand il la supporte Idem, selon le connecteur
Stabilité de langue Consigne système rédigée dans la langue cible Idem
Appel d’outil Fonctions attachées au chatbot, outils MCP exposés Tools / MCP

Un point pratique : AI-Engine permet de fixer un modèle différent par chatbot. Un banc comme celui-ci prend alors tout son sens — un modèle modeste mais discipliné suffit à un agent qui n’a qu’à appeler des outils, tandis qu’une surface conversationnelle publique exige un ancrage irréprochable. Ce sont deux décisions distinctes, qui se justifient par deux tableaux.

Limites, dites franchement

  • Cinq scénarios ne sont pas une évaluation. C’est un garde-fou minimal. Un banc utile en production compte quelques dizaines de cas, écrits à partir des questions réellement posées au chatbot.
  • Les vérificateurs sont heuristiques, et c’est le maillon faible. Le détecteur de refus repose sur une proximité négation / marqueur d’absence ; il ratera une formulation inattendue — c’est exactement le piège 2 raconté plus haut, et rien ne garantit qu’il ne se reproduira pas sur une tournure que je n’ai pas prévue. Un banc mature remplace ces heuristiques par un juge LLM — lui-même à valider, avec les mêmes précautions.
  • Rien ici ne mesure la qualité rédactionnelle. C’est volontaire : elle se juge à la lecture, pas par assertion.
  • Trois répétitions détectent l’instabilité grossière, pas une dérive de quelques pour cent. Augmenter REPETITIONS pour resserrer.

Le point n’est pas d’avoir le banc parfait, mais de ne plus choisir un modèle sur trois questions bien choisies.

Exercice 3 — Du verdict au levier de plateforme

Le tableau de transposition ci-dessus mappe « ce que le banc mesure » au levier à actionner côté plateforme (AI-Engine ou Open WebUI). Mais c’est le verdict (ACQUIS / INSTABLE / ÉCHEC) qui déclenche l’action : un ACQUIS ne demande rien, un ÉCHEC sur l’ancrage appelle un durcissement immédiat.

Écrivez une fonction qui, étant donné une propriété et son verdict, renvoie le levier recommandé et l’action correctrice.

  • Objectif — levier_pour(propriete, verdict) renvoie une chaîne décrivant le levier + l’action (ou None si ACQUIS).
  • Indice — appuyez-vous sur le tableau de transposition. Pour l’ancrage : consigne système + seuil/mode de recherche. Pour le format : sortie structurée native. Pour la langue : consigne système dans la langue cible. Pour l’outil : fonctions/MCP attachés au chatbot.
  • Étape 1 — choisir le levier selon la propriete.
  • Étape 2 — formuler l’action selon le verdict (rien si ACQUIS, surveillance si INSTABLE, correctif si ÉCHEC).
def levier_pour(propriete, verdict):
    # Exercice 3 -- du verdict au levier de plateforme.
    #
    # Renvoie le levier + l'action correctrice selon la propriete et son
    # verdict (ACQUIS / INSTABLE / ECHEC), ou None si rien a faire.
    #
    # Indice : table de transposition ci-dessus.
    #   Ancrage      -> consigne systeme + seuil/mode de recherche
    #   Format       -> sortie structuree native
    #   Langue       -> consigne systeme redigee dans la langue cible
    #   Appel d'outil-> fonctions/MCP attaches au chatbot
    # Et le verdict : ACQUIS = rien, INSTABLE = surveiller, ECHEC = corriger.
    return None  # TODO etudiant

Voir aussi

Retour au sommet