Évaluations générées
Votre agent a changé. Ses réponses se sont-elles dégradées ?
Une évaluation est un test reproductible qui exécute l’agent réel et vérifie que son résultat reste correct. Ce n’est pas un test unitaire : elle appelle des systèmes et des modèles réels, peut durer plusieurs minutes, et une partie de la vérification exige d’interpréter une réponse rédigée par un modèle plutôt que de comparer des valeurs exactes.
La plupart des équipes construisent elles-mêmes ce dispositif avec des cas en YAML, un exécuteur spécifique et des expressions régulières appliquées aux réponses. Goa-AI génère ce dispositif à partir du design qui définit déjà l’agent et remplace la comparaison de texte par des affirmations évaluées par un modèle juge préalablement calibré.
Chaque partie d’une suite d’évaluation a un propriétaire unique :
- Le design décrit chaque scénario : son nom, son objectif, la forme de son entrée, ses tags et son délai.
- Le code généré transforme cette description en types Go et en une méthode d’interface par scénario. Si le design et l’application divergent, la compilation échoue ; aucun test ne peut disparaître silencieusement.
- Le code de l’application implémente ces méthodes : il appelle l’agent, recueille les preuves et énonce les résultats attendus.
- Le runner de
goa.design/goa-ai/evalsélectionne les scénarios, limite leur concurrence, évalue les réponses et produit un rapport JSON.
Six termes couvrent l’ensemble du mécanisme :
| Terme | Signification |
|---|---|
| Scénario | Un cas de test, par exemple « demander à l’agent de chat de lister toutes les alarmes » |
| Hook | La méthode Go écrite pour un scénario ; elle exécute l’agent et renvoie ce qui s’est produit |
| Vérification | Un fait vrai ou faux vérifié exactement par le code, par exemple « toutes les pages ont été récupérées » |
| Affirmation | Une courte phrase anglaise qui doit être vraie au regard de la réponse du modèle |
| Juge | Un évaluateur fondé sur un modèle qui attribue un résultat à chaque affirmation |
| Rapport | Le résumé JSON d’une exécution : ce qui a été exécuté, ce qui a réussi, pourquoi et en combien de temps |
Les formulations des réponses varient, ce qui empêche une comparaison exacte des chaînes : c’est le rôle des affirmations. À l’inverse, tout fait que le code peut vérifier exactement doit rester une vérification déterministe et ne jamais être délégué à un modèle.
Déclarer les scénarios dans le design
Le design déclare la forme de l’entrée de chaque scénario : les champs disponibles et leurs règles de validité. Il ne contient jamais les valeurs réelles. Les IDs d’utilisateurs, les installations et les messages concrets restent dans l’application, ce qui permet d’utiliser le même design dans tous les environnements.
package design
import (
. "goa.design/goa-ai/dsl"
. "goa.design/goa-ai/eval/dsl"
. "goa.design/goa/v3/dsl"
)
var ChatEvalInput = Type("ChatEvalInput", func() {
Attribute("user_id", String, "User running the evaluation.", func() {
Format(FormatUUID)
})
Attribute("prompt", String, "User message.", func() {
MinLength(1)
})
Required("user_id", "prompt")
})
var _ = Service("chat_agent", func() {
Agent("chat", "Answers product questions.", func() {
Suite("chat", func() {
Description("Exercises production Chat outcomes.")
Timeout("2m")
Scenario("alarm_inventory", func() {
Description("Retrieves every alarm in a fixed window.")
Input(ChatEvalInput)
Tags("production", "alarm")
Timeout("3m")
})
Scenario("health_check", func() {
Description("Verifies application-owned setup.")
})
})
})
})
Règles applicables :
- Les noms des suites, scénarios et tags utilisent
lower_snake_case. Ils deviennent des identifiants stables dans les rapports et les options de ligne de commande. - Chaque suite et scénario doit avoir une
Description. Chaque suite doit définir unTimeoutpositif ; leTimeoutd’un scénario remplace celui de la suite pour ce seul scénario. Inputest facultatif. SansInput, le hook ne reçoit qu’uncontext.Context.Inputaccepte les mêmes formes que lesArgsd’un outil : type Goa nommé, primitive, tableau, map ou fonction inline qui déclare des attributs. Les entrées d’évaluation ne prennent pas en chargeOneOf.- Une suite peut être déclarée au niveau supérieur du design ou dans un
Agent. Dans un agent, le package généré accède également aux contrats d’outils de cet agent.
Générer le code Go
L’import de goa.design/goa-ai/eval/dsl enregistre le générateur
d’évaluations. La commande habituelle goa gen écrit alors
gen/evals/<suite>/suite.go :
type ChatEvalInput struct {
UserID string
Prompt string
}
type Hooks interface {
AlarmInventory(context.Context, *ChatEvalInput) (eval.Result, error)
HealthCheck(context.Context) (eval.Result, error)
}
type Inputs struct {
AlarmInventory *ChatEvalInput
}
func New(hooks Hooks, inputs Inputs) (eval.Suite, error)
Hooks possède une méthode par scénario : l’ajout d’un scénario casse la
compilation tant que l’application ne l’implémente pas. Inputs possède un
champ pour chaque scénario qui déclare une entrée. L’application y place les
valeurs réelles. New vérifie ces valeurs selon les règles du design (champs
obligatoires, formats et longueurs) et renvoie une erreur avant le démarrage
d’un scénario.
Contrats d’outils des suites d’agents
Le générateur connaît tous les outils déclarés d’un agent, y compris ceux des
autres agents qu’il utilise. Une suite déclarée dans un Agent expose :
func MustToolContract(name tools.Ident) *tools.ToolSpec
Cette fonction renvoie le contrat généré de l’outil : son schéma et les codecs qui décodent ses arguments et résultats. Les hooks peuvent ainsi décoder et vérifier exactement les appels enregistrés sans écrire de traitement JSON. Elle couvre tous les outils accessibles à la compilation, mais pas ceux découverts dynamiquement dans un registre. Demander un outil inaccessible provoque une panique, car cela révèle une erreur dans l’évaluation elle-même.
Créer la commande exécutable
Après goa gen, exécutez :
goa example example.com/product/design
La commande crée une fois cmd/<suite>-evals/main.go et ne le remplace jamais.
Le fichier appartient ensuite à l’application. Les changements ultérieurs du
design continuent à mettre gen/evals à jour : une signature de hook modifiée
fait échouer la compilation et une entrée absente fait échouer New.
Le fichier initial contient un TODO pour chaque hook, chaque valeur d’entrée
et le juge. Sa ligne de commande est déjà fonctionnelle :
--scenario <id>exécute un scénario ; l’option peut être répétée ;--tag <tag>exécute les scénarios qui portent ce tag ; cette option peut aussi être répétée, mais ne peut pas être combinée à--scenario;--max-concurrency <n>limite le nombre de scénarios simultanés (5 par défaut).
Chaque exécution écrit le rapport JSON sur la sortie standard et renvoie un
code non nul en cas d’échec. L’application peut aussi ignorer cette commande et
appeler New depuis un test Go.
Écrire les hooks
Implémentez l’interface générée sur un type ordinaire :
type hooks struct {
client *Client
}
func (h *hooks) AlarmInventory(
ctx context.Context,
input *genevals.ChatEvalInput,
) (eval.Result, error) {
answer, evidence, err := h.client.Run(ctx, input.UserID, input.Prompt)
if err != nil {
return eval.Result{}, err
}
return eval.Result{
Checks: []eval.Check{{
Name: "all_pages_retrieved",
Passed: evidence.Exhausted,
}},
Claims: []eval.Claim{{
ID: "complete_answer",
Text: "The answer reports every alarm in the window.",
}},
Output: answer,
Artifacts: []eval.Artifact{{
Name: "protocol",
URI: evidence.ArtifactURI,
}},
}, nil
}
Un hook renvoie trois catégories d’informations :
- Les vérifications portent sur les faits exacts : noms d’outils, IDs, nombres et états. Une vérification en échec doit fournir un diagnostic.
- Les affirmations portent sur la réponse du modèle. Écrivez une
affirmation par fait, plutôt qu’une longue phrase composée. N’utilisez pas
d’expressions régulières ou de listes de mots-clés pour approximer le sens.
Les affirmations sont évaluées par rapport à
Output. SiOutputest vide, chacune reçoitnot_addressedet le scénario échoue sans appeler le juge. - Les artefacts sont des liens facultatifs vers des preuves enregistrées, par exemple des journaux, transcriptions ou traces de protocole.
L’erreur renvoyée est réservée aux problèmes d’infrastructure : agent injoignable, délai dépassé ou environnement défaillant. Une réponse obtenue mais incorrecte doit produire une vérification en échec ou une affirmation non étayée.
Le runner refuse les résultats mal formés avant leur évaluation : il faut au moins une vérification ou une affirmation, les noms et IDs doivent être uniques et les artefacts doivent posséder un nom et une URI.
Recueillir les preuves et déclarer les attentes
Le package eval/evidence fournit une implémentation commune pour observer les
événements d’une exécution et comparer les preuves aux attentes du scénario.
Un evidence.Collector consomme les événements du runtime (début et fin
d’outils, réponses de l’assistant, cycle du workflow et confirmations). Il
construit une evidence.Evidence contenant les appels d’outils et leur JSON
canonique, corrélés par ID et ordonnés selon leur causalité, la réponse
accumulée, une éventuelle confirmation en attente et la phase terminale.
collector := evidence.NewCollector()
for !collector.Done() {
event, err := stream.Recv()
if err != nil {
return eval.Result{}, err
}
if err := collector.Consume(event); err != nil {
return eval.Result{}, err
}
}
ev, err := collector.Finish()
Une evidence.Expect déclare les attentes déterministes et les transforme en
vérifications. Chaque package d’ensemble d’outils généré expose un descripteur
typé par outil, par exemple helpers.AnswerTool, qui associe l’identifiant aux
codecs de charge utile et de résultat. evidence.ExpectCall construit une
attente à partir de ce descripteur ; une modification de nom ou de type dans le
design casse alors la compilation de la suite.
expect := evidence.Expect{
Tools: []evidence.Tool{
evidence.ExpectCall(helpers.AnswerTool,
func(p *helpers.AnswerPayload) error {
if p.Question == "" {
return errors.New("question must not be empty")
}
return nil
},
nil, // result unconstrained
),
},
ForbidTools: []tools.Ident{admin.DeleteRecords},
}
return eval.Result{
Checks: expect.Checks(ev),
Claims: claims,
Output: ev.Answer,
}, nil
Par défaut, Expect associe les outils déclarés à une sous-séquence ordonnée
des appels observés ; les appels non déclarés restent libres. Avec
Exact: true, toute la trajectoire causale est comparée appel par appel.
evidence.ExpectFailure exige une classification d’échec précise,
ForbidFailureKinds interdit certaines classes pour toutes les tentatives et
RequireAllAttemptsSuccessful refuse tout résultat absent ou en échec.
evidence.ExpectConfirmation vérifie que l’exécution s’est arrêtée sur une
confirmation en attente. Pour un outil découvert par registre sans descripteur
généré, utilisez un evidence.Tool et des assertions evidence.Decoded.
Les limites sont transportées à côté du résultat typé et non dans son type de
domaine. Définissez Tool.Bounds pour vérifier le nombre renvoyé, le total,
l’état de troncature, le conseil d’affinement ou le curseur :
alarms := evidence.ExpectCall(ada.ListAlarmsTool, nil, nil)
alarms.Bounds = func(bounds *agent.Bounds) error {
if bounds == nil || bounds.Truncated {
return errors.New("expected a complete alarm inventory")
}
return nil
}
Exécuter la suite
suite, err := genevals.New(&hooks{client: client}, genevals.Inputs{
AlarmInventory: &genevals.ChatEvalInput{
UserID: userID,
Prompt: "List every alarm in the requested window.",
},
})
if err != nil {
return err
}
runner, err := eval.NewRunner(judge.New(modelClient), eval.RunnerConfig{
MaxConcurrency: 5,
Reporter: reporter,
})
if err != nil {
return err
}
report, err := runner.Run(ctx, suite)
MaxConcurrency est obligatoire et positif. L’échec d’un scénario n’arrête
pas les autres, et le rapport conserve toujours l’ordre du design. Les hooks
et le juge doivent donc accepter les appels concurrents.
Le Reporter facultatif reçoit un rappel au début et à la fin de chaque
scénario. Chaque scénario sélectionné reçoit exactement un rappel de fin, y
compris s’il n’a pas démarré à cause d’une annulation.
L’annulation du contexte empêche le démarrage de nouveaux scénarios et annule
ceux en cours. Run renvoie alors l’erreur du contexte avec le rapport partiel.
Les hooks doivent respecter cette annulation.
Un juge nil n’est permis que si aucun hook ne renvoie d’affirmation :
runner, err := eval.NewRunner(nil, eval.RunnerConfig{MaxConcurrency: 2})
Sélectionner des scénarios
Le runner valide toute sélection avant d’appeler l’agent ou un modèle :
report, err := runner.Run(ctx, suite)
report, err := runner.RunScenarios(ctx, suite, "alarm_inventory", "solar_analysis")
report, err := runner.RunTags(ctx, suite, "smoke", "alarm")
RunScenarios sélectionne des noms exacts. RunTags sélectionne chaque
scénario portant au moins un tag demandé. Les deux refusent les sélections et
valeurs vides, les doublons et les noms ou tags inconnus.
Fonctionnement du jugement
eval/judge construit un juge à partir du même model.Client validé et opaque
que le reste de Goa-AI. Les tests qui ont besoin d’un client déterministe
implémentent model.Provider puis appellent model.NewClient ; le code
d’application ne peut pas implémenter directement model.Client.
Le juge reçoit la réponse et les affirmations, puis attribue exactement un résultat et une justification courte à chacune :
entailed: la réponse établit l’affirmation ;contradicted: elle établit son contraire ;not_addressed: elle traite d’autre chose ;indeterminate: elle est trop ambiguë ou contradictoire.
Seul entailed réussit. Avant tout scénario, le runner calibre le juge avec un
exemple fixe de chaque résultat. Un juge incapable de les distinguer arrête la
suite avant tout appel à l’application. Cette calibration a un délai de deux
minutes géré par le runner.
Le protocole du juge est strict : IDs absents ou dupliqués, résultats inconnus, champs supplémentaires et réponses mal formées sont des erreurs. Il ne réessaie ni ne répare une réponse incorrecte.
Lire le rapport
Les noms des champs JSON du rapport sont stables. La durée d’un scénario comprend le hook, la validation du résultat et le jugement.
- Les échecs de suite (sélection invalide, calibration ou annulation) sont
renvoyés par
Runet enregistrés dans le champerrordu rapport. - Les échecs de scénario (hook, résultat, délai ou jugement) sont enregistrés sur ce scénario, afin que les autres puissent se terminer.
Après une exécution sans erreur de suite, report.Passed n’est vrai que si
toutes les vérifications et affirmations de tous les scénarios sélectionnés ont
réussi. Une valeur false doit faire échouer la commande ou le travail CI.
Migrer depuis les suites à entrée texte
Les entrées typées remplacent l’ancienne chaîne unique :
- remplacez
Input("some literal")par un type Goa et déplacez la valeur dans leInputsgénéré ; - utilisez
DescriptionetTimeoutde Goa v3, ainsi queTagsde Goa-AI ;eval/dslne déclare désormais queSuite,ScenarioetInput; - remplacez les hooks
(context.Context, string)par leurs signatures typées ; - remplacez
New(hooks)parNew(hooks, inputs)et traitez son erreur de validation.
Régénérez avant de compiler. Le package de suite et les hooks résident dans le même binaire Go : une incompatibilité produit une erreur de compilation, pas une surprise à l’exécution.
Modèles juges distants
Les suites utilisent le même model.Client opaque et validé que les
planificateurs. Si le modèle juge s’exécute dans un autre processus, exposez le
fournisseur avec gateway.NewServer et connectez-vous avec
gateway.NewRemoteClient ou gateway.NewCountingRemoteClient. Le client de
comptage est obligatoire lorsqu’une politique d’évaluation exige le nombre
exact de jetons d’entrée. Consultez
Passerelles de modèles distants pour le
contrat de transport et de validation.