Évaluations générées

Markdown
Définissez des scénarios dans le design Goa, générez des hooks typés et produisez des rapports fiables.

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/eval sélectionne les scénarios, limite leur concurrence, évalue les réponses et produit un rapport JSON.

Six termes couvrent l’ensemble du mécanisme :

TermeSignification
ScénarioUn cas de test, par exemple « demander à l’agent de chat de lister toutes les alarmes »
HookLa méthode Go écrite pour un scénario ; elle exécute l’agent et renvoie ce qui s’est produit
VérificationUn fait vrai ou faux vérifié exactement par le code, par exemple « toutes les pages ont été récupérées »
AffirmationUne courte phrase anglaise qui doit être vraie au regard de la réponse du modèle
JugeUn évaluateur fondé sur un modèle qui attribue un résultat à chaque affirmation
RapportLe 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 un Timeout positif ; le Timeout d’un scénario remplace celui de la suite pour ce seul scénario.
  • Input est facultatif. Sans Input, le hook ne reçoit qu’un context.Context. Input accepte les mêmes formes que les Args d’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 charge OneOf.
  • 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. Si Output est vide, chacune reçoit not_addressed et 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
}

grader, err := judge.New(modelClient, maxOutputTokens)
if err != nil {
    return err
}
runner, err := eval.NewRunner(grader, 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.

L’application doit fournir un maxOutputTokens strictement positif à judge.New et traiter son erreur. Lisez cette valeur dans la configuration de l’application avant d’exécuter la suite. Zéro et les valeurs négatives font échouer la construction sans appeler le modèle ; il n’existe aucune valeur par défaut.

Cette valeur est un plafond inclusif de jetons de sortie pour une réponse complète du modèle, comprenant tous les jugements et leur structure JSON. Ce n’est ni un quota par affirmation, ni un budget total par scénario ou suite. Goa-AI transmet la même valeur dans la requête initiale et chaque requête de correction autorisée, quel que soit le nombre d’affirmations. Choisissez une valeur prise en charge par le fournisseur et le modèle configurés ; une valeur non prise en charge reste une erreur, sans réduction silencieuse du plafond. Un plafond fini ne garantit pas que la réponse pourra se terminer.

Référence partagée et migration du juge

Placez le contexte factuel partagé par plusieurs affirmations dans la chaîne facultative Result.Reference, au lieu de le répéter dans chaque affirmation. Conservez dans Output la réponse à évaluer :

result := eval.Result{
    Output:    answer,
    Reference: "Supported export formats: CSV and JSON.",
    Claims: []eval.Claim{{
        ID:   "export_formats",
        Text: "The answer lists the supported export formats.",
    }},
}

Le runner transmet la référence séparément, sans modifier la réponse. Le juge fondé sur un modèle l’inclut une seule fois dans chaque requête, y compris les requêtes de correction existantes. Les faits de la référence aident à vérifier l’exactitude de la réponse ; ils ne fournissent jamais le contenu qu’elle omet. Dans cet exemple, énumérer les formats uniquement dans la référence ne suffit pas à satisfaire l’affirmation. Un Output vide attribue toujours not_addressed à chaque affirmation sans appeler le juge, même si la référence contient la réponse.

Les juges personnalisés implémentent la nouvelle interface à quatre arguments :

Judge(ctx context.Context, output string, claims []eval.Claim, reference string) ([]eval.Judgment, error)

Modifiez les appels directs en grader.Judge(ctx, output, claims, reference). Passez "" lorsqu’aucun contexte supplémentaire n’est nécessaire ; la calibration utilise aussi une référence vide. Le runner conserve une référence non vide dans le champ reference du rapport JSON et omet ce champ lorsqu’elle est vide. Les anciens rapports sans ce champ indiquent toujours l’absence de contexte supplémentaire. Les lecteurs externes à validation stricte doivent accepter le nouveau champ avant de lire des rapports qui l’incluent. Aucune migration des rapports enregistrés ni modification des suites générées ou des contrats des services du produit n’est nécessaire. Ce changement n’ajoute aucun appel au modèle et ne modifie ni le choix du modèle, ni les limites de jetons, ni les résultats possibles, ni le nombre de corrections.

Résultats et validation des réponses

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.

Appliquez les conditions de chaque affirmation telles qu’elles sont écrites. « Indiquer le prix » exige un prix. « Tout prix cité doit correspondre à la référence ; ne citer aucun prix satisfait cette contrainte » permet l’omission : une réponse non vide qui ne cite aucun prix satisfait cette contrainte (entailed, et non not_addressed) si ses autres exigences sont remplies. L’omission ne fournit pas le contenu obligatoire, n’étaye pas une déclaration incluse sans preuve et ne résout pas une condition inconnue sur le monde.

Le modèle interprète ces conditions ; le framework ne classe pas les affirmations par du code et ne réécrit ni les résultats ni leurs justifications. Un Output entièrement vide attribue toujours not_addressed à chaque affirmation et fait échouer le scénario sans appeler le juge.

Le prompt demande d’appeler exactement une fois l’outil d’évaluation fourni, sans indiquer un nom propre au fournisseur. Son schéma exige une propriété portant l’ID de chaque affirmation, contenant un résultat et une justification non vide. Le juge renvoie les jugements dans l’ordre des affirmations en les recherchant par nom, et non selon leur position dans la réponse. Les noms manquants, inconnus ou dupliqués, les champs supplémentaires et les résultats invalides sont refusés.

Les métadonnées structurelles permettent au validateur d’expliquer les erreurs indépendantes des champs, par exemple une affirmation encodée comme chaîne alors qu’un objet est requis. Le texte complet des affirmations reste dans le schéma et les preuves de référence dans la requête ; ni l’un ni les autres ne sont copiés dans ces métadonnées de correction. Une affirmation nommée requests est valide si le schéma l’exige. Le juge ne fournit pas de verdicts d’exemple.

Le mécanisme de correction existant et borné peut demander une réponse de remplacement, mais ne répare jamais une sortie invalide. Les résultats possibles, le choix du modèle, les limites de tokens et le nombre de corrections restent inchangés ; des indications plus précises ne garantissent pas leur respect. Une fois les corrections épuisées, l’appelant reçoit une erreur, pas des jugements inventés. Voir le contrat du juge du framework.

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 Run et enregistrés dans le champ error du 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 la construction du juge

judge.New(client, opts...) *Judge est remplacé par judge.New(client, maxOutputTokens, opts...) (*Judge, error). Modifiez chaque appelant pour fournir son plafond de réponse configuré, strictement positif, et traiter l’erreur avant de créer le runner. Les options existantes comme WithModelClass suivent le plafond obligatoire et conservent leur sens.

L’ancien calcul 256 × nombre d'affirmations est supprimé. Ce changement du code source Go exige de modifier les appelants pour compiler avec la nouvelle version. Il ne change ni le choix du modèle, ni le prompt, ni les résultats possibles, ni la validation stricte, ni le nombre de corrections. Aucune migration des rapports enregistrés n’est nécessaire.

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 le Inputs généré ;
  • utilisez Description et Timeout de Goa v3, ainsi que Tags de Goa-AI ; eval/dsl ne déclare désormais que Suite, Scenario et Input ;
  • remplacez les hooks (context.Context, string) par leurs signatures typées ;
  • remplacez New(hooks) par New(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 (en anglais) pour le contrat de transport et de validation.