Aller au contenu
mcpd
aucun daemon
Source

Tests de régression

L’exécution qui a cassé mardi devient le cas que toutes les versions suivantes devront passer.

La boucle qu’il s’agit de fermer

Un agent échoue sur une vraie question. Quelqu’un la reproduit à la main, change un prompt, et cela semble aller mieux. Trois semaines plus tard, il échoue de nouveau sur la même question, parce que rien ne l’avait retenue.

Un dataset la retient. Capturez l’exécution en échec : ce n’est plus une anecdote, c’est un cas, avec une entrée, une réponse attendue quand vous en avez une, et un renvoi vers la trace dont il vient. Toutes les versions suivantes s’y confrontent.

Capturer une exécution

Trouvez l’exécution dans l’interface ou par l’API, puis donnez son identifiant de trace au dataset.

capturer une exécution ratée comme cas

curl -X POST localhost:4317/v1/api/datasets/$DATASET/capture \ -H 'content-type: application/json' \ -d '{"trace_ids": ["b3d9e71a4c0f428eb6a5d2317f90c684"]}'

Le cas capturé conserve source_trace_id : des mois plus tard, vous pouvez ouvrir l’exécution qui l’a motivé et lire ce qui s’est vraiment passé au lieu de le deviner d’après l’entrée.

Trace b3d9e71a Fault
0ms 2.1s 4.2s 6.3s 8.4s
  1. agent support_agent fail
  2. model chat
  3. tool returns_policy
  4. model chat
  5. tool returns_policy
  6. model chat
  7. tool returns_policy

The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.

La réponse de l’exécution n’est pas la réponse attendue

use_output_as_expected est désactivé par défaut, et ce défaut est le point important. Les exécutions qui valent la peine d’être capturées sont en général celles qui ont mal tourné, et enregistrer une mauvaise réponse comme référence graverait le défaut dans le marbre : toutes les versions suivantes seraient alors mesurées sur leur aptitude à le reproduire.

Activez-le quand vous capturez des exécutions qui étaient justes, pour figer un comportement qui vous convient déjà. Laissez-le désactivé, et écrivez vous-même la réponse attendue, quand vous capturez un échec.

Les scorers intégrés

mcpd est livré avec treize scorers déterministes : du code ordinaire qui lit une exécution stockée et renvoie un nombre. Ils ne coûtent rien, ils n’appellent jamais de modèle, et ils donnent deux fois la même réponse, ce qui est la propriété dont un test de régression a besoin et qu’un modèle juge n’a pas.

ScorerRéussit quand
answeredL’exécution a produit une réponse non vide.
no_errorsAucun span de l’exécution n’a échoué.
exact_matchLa réponse est égale à celle attendue.
containsLa réponse contient une chaîne exigée.
json_validLa réponse s’analyse comme du JSON.
latency_budgetL’exécution a fini dans un budget exprimé en millisecondes.
cost_budgetL’exécution n’a pas coûté plus qu’un montant fixé.
no_tool_loopAucun outil n’a été appelé deux fois avec la même entrée.
tool_efficiencyL’exécution est restée dans un budget d’appels d’outils.
not_a_refusalLa réponse n’est ni un refus ni une excuse.
no_leaked_secretAucune clé, aucun token et aucun secret d’accès n’apparaît dans la réponse.
span_presentLe run a enregistré un span du type ou du nom exigé.
tool_succeededL’outil exigé s’est exécuté, et au moins un appel s’est terminé correctement.

GET /v1/api/scorers renvoie la même liste avec un indicateur needs_config. Un scorer qui a besoin d’une réponse attendue et n’en reçoit pas se récuse au lieu de deviner, et sa récusation est rapportée.

Quand c’est un modèle qui note

Certaines questions ne se règlent par aucun code déterministe. Savoir si une réponse a vraiment traité la question, si un résumé a gardé le sens, si le ton était juste : cela demande un lecteur, et un modèle est le lecteur bon marché.

La notation par juge est inactive tant que vous ne l’avez pas configurée, et un scorer par juge ne tourne jamais sans que vous le nommiez. Ces deux défauts existent pour la même raison : noter dépense de l’argent à chaque exécution lue, et une fonctionnalité qui le fait sans le dire est une fonctionnalité que vous découvrez sur une facture.

{
  "judge": {
    "endpoint": "http://localhost:11434/v1",
    "model": "qwen2.5:14b",
    "api_key": "",
    "timeout_seconds": 30,
    "criteria": {
      "answer_relevance": "Does the answer address the question that was asked?",
      "no_hedging": "Does the answer commit, rather than hedge?"
    }
  }
}

La requête a la forme des chat completions d’OpenAI, ce qui n’est pas une préférence pour un fournisseur : Ollama, vLLM, llama.cpp, LM Studio, OpenRouter et les fournisseurs hébergés l’acceptent tous, donc la même configuration atteint un modèle sur votre portable et une passerelle en production. Laissez api_key vide pour un modèle local qui n’en veut pas. La température est à zéro, parce qu’un juge qui répond différemment à chaque fois ne peut pas être comparé à lui-même, ce qui est la seule chose à quoi servent les scores.

Les critères se configurent au lieu d’être passés à chaque requête. Un identifiant de scorer est stocké sur chaque score qu’il produit et c’est sur lui qu’une comparaison ultérieure regroupe : laisser chaque appel inventer à la fois l’identifiant et la question construirait une colonne à laquelle personne ne peut se fier.

Les critères configurés n’apparaissent pas dans GET /v1/api/scorers. Cet endpoint liste les treize scorers intégrés et rien d’autre, donc un client qui construit son menu de scorers à partir de là ne proposera pas vos critères. Le seul endroit où le daemon vous les nomme est l’erreur renvoyée pour un identifiant de scorer inconnu, qui liste ensemble les scorers intégrés et les critères configurés.

passer un scorer par juge sur l’exécution la plus récente

curl -X POST localhost:4317/v1/api/score \ -H 'content-type: application/json' \ -d '{"limit": 1, "scorers": ["answer_relevance"]}'

timeout_seconds est la durée qu’un appel de notation peut prendre, de la connexion au dernier octet de la réponse : 30 par défaut, de 1 à 300. Au-delà, le scorer se récuse en nommant la clé, et mcpd lit au plus 1 MiB d’une réponse.

Chaque scorer par juge est un appel au modèle par exécution, et la requête doit se terminer dans --request-timeout-seconds. Une requête note donc au plus autant d’exécutions qu’il en tient dans ce délai si chaque appel prend tout son timeout_seconds. Avec les valeurs par défaut, 30 et 30, et un seul scorer par juge, cela fait une exécution. Une requête qui en demande plus, dans trace_ids ou limit, est refusée par un 400 qui nomme le plafond, avant tout appel au modèle. Sans limit, la requête note autant d’exécutions récentes qu’il en tient. Augmentez --request-timeout-seconds pour noter davantage en une requête. Chaque score par juge est enregistré dès que le modèle répond, donc une requête interrompue garde les scores qu’elle a terminés.

Il se récuse au lieu de deviner

Un juge qui renvoie un nombre qu’il n’a pas dérivé est pire que pas de juge du tout, parce qu’il a l’air d’une preuve. Chacun des cas ci-dessous est donc rapporté dans skipped avec son motif, et aucun ne produit de score.

Ce qui s’est passéCe que fait mcpd
L’exécution n’a enregistré aucune réponse Se récuse. Une exécution sans réponse n’a pas mal noté en pertinence, elle n’a pas été mesurée.
Le modèle a répondu en prose Se récuse, en citant la réponse. Du JSON entouré d’un bloc de code ou d’une phrase reste lu.
Le modèle a répondu hors de l’échelle Se récuse. Un sept sur une échelle qui s’arrête à un ne vaut pas neuf dixièmes, et le ramener dans les bornes inventerait un nombre.
Le modèle n’a pas pu être joint Se récuse, avec le statut et le corps de la réponse. Une limite de débit est un motif, pas un zéro.
La réponse dépassait 1 MiB Se récuse sans lire la suite. Un verdict fait quelques centaines d’octets, donc une réponse de cette taille n’en est pas un.
Le délai de la requête ne laissait pas le temps de l’appel Refuse sans appeler le modèle. La passe répond avec les scores qu’elle a terminés, dans le délai, plutôt qu’avec un 503.

Chaque score par juge enregistre le modèle comme son scorer_version. Deux scores issus de deux juges différents ne sont pas comparables, et ce champ est ce qui le dit sur l’enregistrement.

La notation se fait après coup

POST /v1/api/score passe des scorers sur des exécutions déjà stockées. Rien n’est évalué en ligne. Une évaluation placée dans le chemin de la requête ajoute sa latence à ce qu’elle juge, et une évaluation qui lève une exception ne doit jamais faire échouer la requête d’un utilisateur.

noter les cent dernières exécutions

curl -X POST localhost:4317/v1/api/score \ -H 'content-type: application/json' \ -d '{"project": "support-desk", "limit": 100, "config": {"max_ms": 4000}}'

La réponse porte les scores qu’elle a écrits et, séparément, chaque scorer qui s’est récusé avec le motif de sa récusation. Un scorer qui n’a pas pu répondre est plus intéressant qu’un scorer qui a renvoyé un nombre, donc mcpd ne l’avale jamais. Le plafond d’ingestion du projet vaut aussi à cette porte : un score calculé par la passe qui ferait dépasser --quota-per-minute au projet n’est pas stocké, et il est listé dans skipped avec ce motif, plutôt qu’écrit au-delà du plafond ou jeté en silence.

Envoyez "dry_run": true pour voir ce qu’une passe produirait sans rien stocker.

Les exécutions qu’une passe lit sont décidées par trace_ids, from et limit. Nommez les exécutions dans trace_ids pour noter exactement celles-là. Laissez le champ vide et la passe prend les limit exécutions les plus récentes du projet, de la plus récente à la plus ancienne, et from borne cette page : une passe qui reçoit from ne lit que les exécutions démarrées à cet instant ou après, ce qui vous permet de tout noter depuis un déploiement sans lister d’identifiants.

Comparer deux versions

Enregistrez une expérience pour chaque version que vous voulez comparer, étiquetez ses scores avec l’identifiant de cette expérience, et demandez l’écart à mcpd.

le nouveau prompt a-t-il aidé

curl 'localhost:4317/v1/api/experiments/compare?baseline=v3&candidate=v4'

La réponse est un écart par scorer, avec l’effectif de chaque côté et un indicateur decidable. En dessous de vingt observations, mcpd dit que la comparaison ne peut pas être tranchée au lieu d’annoncer un mouvement.

C’est une règle volontairement grossière, pas un test de significativité. L’échec qu’elle évite est le plus courant : une moyenne qui a bougé sur quatre exécutions, publiée sans son effectif, lue comme un résultat et mise en production. Imprimer les effectifs à côté de l’écart est ce qui empêche de prendre du bruit pour un constat.

Où cela tourne

Tout ceci passe par l’API HTTP, donc la boucle a sa place dans la CI : capturez les échecs quand ils arrivent, notez les exécutions que produit votre suite de tests, enregistrez le build comme une expérience, et faites échouer le job quand regressed revient à vrai sur une comparaison tranchable.

Ensuite