生成型評価

Goa design で評価 scenario を定義し、型付き hook と信頼できる report を生成します。

エージェントを変更した後も、回答の品質は保たれていますか?

evaluation(eval)は、実際のエージェントを実行し、結果が引き続き正しいことを確認する反復可能なテストです。unit test とは異なり、実サービスとモデルを呼ぶため数分かかることがあり、「正しいか」の一部は厳密な値比較ではなくモデルが書いた回答を読む必要があります。

多くのチームは、test case の YAML、専用 runner、モデル回答を照合する正規表現の集まりを手作業で構築します。Goa-AI はエージェントを定義する同じ design からこの harness を生成し、回答照合を、calibration 済みの model judge が評価する claim に置き換えます。

evaluation suite の各要素には、明確な所有者が 1 つだけあります。

  • design は各 scenario の名前、検証対象、input の形、tag、制限時間を記述します。
  • 生成コード はその記述から Go 型と scenario ごとの interface method を作ります。design と application がずれると build が失敗するため、test が黙って消えることはありません。
  • application code はそれらの method を実装し、エージェントを呼び、evidence を収集し、成立すべき条件を宣言します。
  • runnergoa.design/goa-ai/eval)は scenario を選択し、同時実行数を制限し、回答を評価して JSON report を作ります。

機能全体は次の 6 用語で説明できます。

用語意味
Scenario「chat agent にすべての alarm を列挙させる」など、1 つの test case
Hook1 scenario に対して実装する Go method。エージェントを実行し、起きたことを返す
Check「すべての result page を取得した」など、コードが厳密に確認する pass/fail の事実
Claimモデルの回答について真でなければならない短い文
Judge回答に照らして各 claim を分類する model-backed grader
Report実行対象、成否、理由、所要時間をまとめた JSON

回答の表現は実行ごとに変わるため、完全一致では判定できません。そのため claim を使います。一方、コードで厳密に確認できる事実をモデルへ委ねないために check を使います。

design で scenario を宣言する

design は各 scenario input の「形」、つまり存在する field と有効条件を宣言します。実際の値は含めません。具体的な user ID、facility、prompt は application code に置くため、同じ design をどの環境でも使えます。

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.")
            })
        })
    })
})

規則は次のとおりです。

  • suite、scenario、tag の名前には lower_snake_case を使います。report と command-line flag の安定した ID になるため、名前を変えると test の識別子も変わります。
  • すべての suite と scenario に Description が必要です。suite には正の Timeout が必要で、scenario の Timeout はその scenario に限り suite の値を置き換えます。
  • Input は任意です。Input のない scenario は context.Context だけを受け取る hook を生成します。Input は tool の Args と同じ形式(名前付き Goa 型、primitive、array、map、attribute を列挙する inline function)を受け取ります。evaluation input では OneOf を使えません。
  • suite は design の top level または Agent 内に宣言できます。agent 内に置くと、生成 package からその agent の tool contract も利用できます。

Go コードを生成する

design で goa.design/goa-ai/eval/dsl を import すると evaluation generator が登録されます。通常どおり goa gen を実行すると、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 には scenario ごとに 1 method があるため、design に scenario を追加すると application が実装するまで build が失敗します。Inputs には Input を宣言した scenario ごとに field があり、application が実値を設定します。New は supplied value を design の規則(required field、format、length)に照らして検証し、scenario を開始する前にエラーを返します。

agent suite の tool contract

agent の tool も design に宣言されるため、generator は agent が呼べる tool(利用する別 agent の tool を含む)を正確に把握しています。suite を Agent 内に宣言すると、生成 package に次が含まれます。

func MustToolContract(name tools.Ident) *tools.ToolSpec

tool 名を渡すと、schema と argument/result codec を含む生成 contract を返します。hook で記録済み tool call を decode し、手書き JSON 処理なしで argument を厳密に確認できます。build 時に agent から到達できる全 tool が対象です。runtime に discovery される tool は generator が contract を知らないため対象外です。agent が使えない tool を指定すると panic します。これは evaluation 自体の bug だからです。

実行可能 command を作る

goa gen の後に goa example を実行します。

goa example example.com/product/design

これにより cmd/<suite>-evals/main.go が一度だけ作られ、上書きはされません。その後は application が所有するファイルです。以降の design 変更でも gen/evals は更新されます。hook signature が変われば compile に失敗し、input value が不足すれば New が失敗するため、command が黙って古くなることはありません。

生成ファイルは直ちに compile でき、application code が必要な各箇所(scenario ごとの空 hook、Input を宣言した scenario ごとの input value、judge)に TODO があります。利用可能な command line も含まれます。

  • --scenario <id> は 1 scenario を実行します。複数回指定できます。
  • --tag <tag> はその tag を持つ全 scenario を実行します。複数回指定できます。scenario flag と tag flag は併用できません。
  • --max-concurrency <n> は同時実行数を制限します(default 5)。

各実行は JSON report を standard output へ書き、suite が失敗すると non-zero で終了します。普通の Go program なので、local、CI、schedule のいずれでも実行できます。go test を使いたい application は command を省き、test から生成 New を呼べます。

hook を実装する

生成 interface を通常の型で実装します。

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
}

hook は 3 種類の情報を返します。

  • Checks は tool 名、ID、件数、state など、コードが厳密に確認できる事実です。失敗した check には、問題を説明する診断が必要です。
  • Claims はモデル回答についての文で、後から judge が評価します。複合した長い claim ではなく、事実ごとに 1 つ(「回答が alarm 名を示す」「回答が activation time を示す」)を書きます。そうすれば、失敗時に欠けた事実が明確です。正規表現や keyword list で回答の意味を近似しないでください。その役割を claim と judge が担います。claim は評価対象の回答 Output に照らして判定されます。Output が空、つまり run が回答を生成しなかった場合は、judge を呼ばずに全 claim を not_addressed とし、scenario を失敗させます。
  • Artifacts は log、transcript、protocol dump など、失敗調査に使う保存済み evidence への任意の link です。

返り値の error は、エージェントに接続できない、timeout、test environment の故障など infrastructure 問題だけに使います。「回答は返ったが内容が違う」は error ではなく、failed check または unsupported claim です。

runner は採点前に不正な result を拒否します。result には check または claim が 1 つ以上必要で、name と ID は一意、artifact には name と URI の両方が必要です。

evidence を収集し、expectation を宣言する

多くの hook は同じ 2 つの処理を行います。run の stream event を監視して agent の行動を記録し、その記録を scenario の expectation と比較します。eval/evidence package が両方を所有するため、すべての suite が同じ実装を共有します。

evidence.Collector は runtime の stream event(tool start/end、assistant reply、workflow lifecycle、confirmation boundary)を受け取り、evidence.Evidence を構築します。そこには、tool call ID で相関され因果順に並んだ全 tool call(親 tool の直後に、その child run が行った nested call が並ぶ)と正規 JSON argument/result、累積 assistant answer、pending confirmation、terminal workflow phase が含まれます。goa-ai stream を直接公開する application は event をそのまま渡します。独自 transport へ再 encoding する application は、wire type を stream event へ戻す小さな adapter を書きます。

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()

evidence.Expect は deterministic expectation を宣言し、evidence を check へ変換します。生成される各 toolset package は、tool identifier と型付き payload/result codec を組にした descriptor(例: helpers.AnswerTool)を tool ごとに 1 つ公開します。evidence.ExpectCall で descriptor から expectation を構築します。対応付けは生成時に固定され、assertion は型付き Go predicate なので手作業の JSON traversal は不要です。field の rename や型変更は、黙って一致しなくなる代わりに suite を compile error にします。

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

Expect は 2 つの trajectory mode を備えます。default は、宣言した tool を観測 call の順序付き subsequence として対応付け、未宣言 call は制約しません。run は拒否された call を再試行したり、1 tool の処理を複数 call に分けたりできます。Exact: true は完全な因果 trajectory を call ごとに比較するため、隠れた retry や余分な tool は失敗します。tool ごとの policy は failure semantics を扱います。evidence.ExpectFailure は正確に 1 つの classification で失敗すべき call を宣言し、ForbidFailureKinds は全 attempt にわたり保護対象の failure class を拒否し、RequireAllAttemptsSuccessful は失敗または result 欠落を拒否します。evidence.ExpectConfirmation は、run が完了せず operator confirmation 待ちで停止したことを確認します。生成 descriptor がない registry-discovered toolset では、tool identifier と evidence.Decoded assertion を持つ素の evidence.Tool を宣言します。

bounded-result metadata は生成 domain result 型の内部ではなく、その横に保持されます。scenario が返却件数、全件数、truncation、refinement hint、continuation cursor を検査する必要がある場合は Tool.Bounds を設定します。

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
}

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 は必須で正の値でなければなりません。同時に実行される scenario は最大でその数です。1 scenario の失敗は他を停止しません。完了順にかかわらず、report は design の宣言順です。scenario は並列実行されるため、hook と judge は concurrent call に対応する必要があります。

任意の Reporter は各 scenario の開始時と終了時に callback を受け取るため、application は scheduling を実装せずに進捗を表示できます。選択された scenario には必ず 1 回の finished callback があります。run の cancel により開始しなかった scenario も対象ですが、その場合は start time が zero で started callback はありません。

context を cancel すると新しい scenario は開始されず、実行中の scenario は各 context を通じて cancel されます。Run は context error と partial report を返します。hook は cancellation を尊重しなければなりません。

どの hook も claim を返さない場合に限り、nil judge を渡せます。

runner, err := eval.NewRunner(nil, eval.RunnerConfig{MaxConcurrency: 2})

hook が claim を返したのに judge が nil なら、その scenario は judge が必要だという error で失敗します。

scenario を選択する

runner は agent や model を呼ぶ前にすべての選択を検証します。

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 は指定した scenario 名を実行します。RunTags は指定 tag の少なくとも 1 つを持つ全 scenario を実行します。どちらも空の選択、空値、重複、存在しない名前や tag を拒否するため、typo によって何も実行されず成功することはありません。

judge の仕組み

eval/judge は、Goa-AI の他の箇所と同じ不透明な検証済み model.Client から judge を作るため、設定済みのどの provider でも利用できます。deterministic な judge client が必要な test は model.Provider を実装し、model.NewClient を通します。application code は model.Client を直接実装できません。

各 scenario について、judge は回答と claim を受け取り、claim ごとに正確に 1 label と短い理由を返します。

  • entailed: 回答が claim を真だと裏付ける。
  • contradicted: 回答が claim を偽だと裏付ける。
  • not_addressed: 回答が別の話題だけを扱う。
  • indeterminate: 回答が曖昧または矛盾しており判断できない。

成功とみなすのは entailed だけです。

scenario を開始する前に、runner は label ごとに 1 つ、固定の 4 example で judge を検査します。この手順を calibration と呼びます。たとえば常に entailed を返して全 evaluation を成功させるような、label を区別できない judge は calibration に失敗し、application へ触れる前に suite を停止します。calibration は runner 所有の 2 分 deadline 内で行うため、接続不能または停止した model endpoint は suite を永久に block せず、明確な error になります。

judge は自身の protocol を厳格に検証します。claim ID の欠落や重複、未知の label、余分な field、不正な response は error です。不正出力を retry または修復しません。自分の出力を編集する judge は、信頼できる evidence ではないからです。

report を読む

report とその全要素は安定した JSON field 名を使うため、tooling が依存できます。scenario duration は hook call、result validation、judging を含みます。

失敗には 2 つの level があります。

  • suite-level: 不正な選択、calibration failure、cancellation は Run の error として返り、report の error field に記録されます。
  • scenario-level: hook error、不正な result、timeout、judging failure は該当 scenario の report に記録され、残りの scenario は完了まで続きます。

suite-level error なしで実行を終えたら report.Passed を確認します。全 selected scenario がすべての check と claim に成功した場合だけ true です。false なら呼び出し元 command または CI job を失敗させてください。

string-input suite からの upgrade

以前の version は、各 hook に 1 つの string を渡していました。型付き input は次のように置き換えます。

  • Input("some literal") を Goa input 型へ置き換え、literal は生成 Inputs value へ移す。
  • Goa v3 の DescriptionTimeout、Goa-AI の Tags を使う。eval/dsl が宣言するのは SuiteScenarioInput だけ。
  • hook を (context.Context, string) から生成された型付き signature へ更新する。
  • New(hooks)New(hooks, inputs) に更新し、validation error を処理する。

application code を compile する前に再生成してください。生成 suite package と application hook は同じ Go binary に入るため、network 越しの version 混在はありません。不一致は runtime の surprise ではなく compile error になります。

Remote Judge Model

evaluation suite は planner と同じ不透明な検証済み model.Client を使います。judge model が別 process で動く場合は gateway.NewServer で provider を公開し、gateway.NewRemoteClient または gateway.NewCountingRemoteClient で接続します。evaluation policy が正確な input-token count を必要とする場合は counting client が必要です。transport と validation contract は Remote Model Gateway を参照してください。