WWDC Quick Look 💓 By SwiftGGTeam
エージェント型 App の堅牢な評価を作る

エージェント型 App の堅牢な評価を作る

元の動画を見る

ハイライト

Apple は Xcode 27 の Evaluations フレームワークに、合成データ生成とツール呼び出し軌跡の検証機能を追加しました。開発者はコードで評価データセットを大量生成し、エージェント型 App の中でモデルがツールを呼ぶ順序、引数、意図が期待どおりかを精密に確認できます。

主要内容

手書きの評価データが抱える問題

たとえば、書評から自動でタグを付ける AI 機能を作ったとします。13 件のテストケースを書き、すべて通り、スコアもきれいに見える。ところがリリース後のユーザーフィードバックは散々です。

何が問題だったのでしょうか。13 件のサンプルでは現実世界の複雑さを覆えません。本の種類は無数にあり、書評の文体もばらばらで、短いもの、長いもの、曖昧なものがあります。小さなデータセットで出た高いスコアは、しばしば誤解を招きます。(00:47)

データセットを増やすには多くの手作業が必要で、時間もかかり、多様性も保証できません。Apple の解決策は、モデルにデータを生成させつつ、コードのルールで品質を守ることです。

合成データ生成: 13 件から 100 件へ

Evaluations フレームワークには makeSamples API があります。必要なのは、prompt、初期データセット、目標総数の 3 つだけです。フレームワークは種データをもとに、新しいサンプルのストリームを自動生成します。(03:31)

より複雑な場面では、SampleGenerator が細かな設定を提供します。どのモデルを使うか、たとえば大きなコンテキストが必要なら PrivateCloudComputeLanguageModel を使う、システム指示をカスタマイズする、ランダムサンプリングやスライディングウィンドウなどのサンプリング戦略を選ぶ、不合格サンプルを弾く検証ルールを定義するといったことができます。(05:53)

生成データを検証する

データを生成できることと、良いデータを生成できることは別です。モデルは短すぎる書評、数が合わない tag、大文字小文字が混在したタグを出すかもしれません。SampleGeneratorvalidator クロージャは、生成中にリアルタイムでフィルタできます。検証に通ったものは samples に入り、失敗したものは invalidSamples に入り、どちらもいつでも参照できます。(10:37)

拡張したデータセットで評価を再実行すると、スコアは下がることがよくあります。これはむしろ良い兆候です。以前のテストでは見えていなかった問題が露出したからです。Xcode 27 の Evaluations Report では、2 回の結果を直接比較して問題箇所を特定できます。(11:12)

ツール呼び出し評価: 「何を返したか」だけでなく「どう進めたか」を見る

エージェント型 App では、モデルが最終回答を直接返すとは限りません。検索、詳細取得、類似項目の探索といった一連のツールを使います。最終出力は正しく見えても、途中の経路が完全に間違っていることがあります。たとえば、モデルが検索ツールをまったく呼ばず、幻覚で答えを作ってしまう場合です。(13:44)

Apple はこの問題を解くために TrajectoryExpectation、つまり軌跡の期待値を導入しました。これはモデルセッション記録内のツール呼び出し順序、引数値、意図の一致を確認します。ツール呼び出しの順序を指定する ordered、順序を問わず出現を求める unordered、特定の呼び出しを明示的に禁止する disallowed を指定できます。(16:04)

引数マッチングには、完全一致 (.exact)、自然言語の意図マッチング (.naturalLanguage)、包含チェック (.contains)、範囲マッチ (.range) などがあります。これにより、コードとしての厳密さを保ちつつ、大規模モデル出力の曖昧さにも対応できます。(17:07)

詳細

makeSamples でデータセットを素早く拡張する

(05:16)

もっとも基本的な合成データ生成は数行で書けます。

let prompt = Prompt("""
    多様な書評と対応するタグを生成してください。
    ジャンル、時代、文化、読者ペルソナを幅広く含めてください。
    すでにデータセットに含まれる本は繰り返さないでください。
    """)

let dataset = Book.sampleBooks.map { book in
    ModelSample(prompt: book.review, expected: BookTags(tags: book.tags))
}

let targetCount = 100
var expandedDataset = dataset

for try await sample in dataset.makeSamples(prompt, targetCount: targetCount) {
    expandedDataset.append(sample)
    print("これまでに \(expandedDataset.count) 件のサンプルを生成しました。")
}

重要ポイント:

  • prompt は生成タスクを説明します。具体的なほど、出力データの品質は高くなります。
  • dataset は種データです。ModelSample で包み、入力 (prompt) と期待出力 (expected) を含めます。
  • targetCount は種データを含む最終データセットの総数です。この例では 87 件の新規データを生成します。
  • makeSamplesAsyncStream を返すため、各生成サンプルをリアルタイムに処理できます。

SampleGenerator の設定をカスタマイズする

(05:53)

より細かく制御したい場合は SampleGenerator を使います。

let generator = SampleGenerator<ModelSample<BookTags>>(
    prompt,
    samples: dataset,
    targetCount: targetCount,
    sessionProvider: {
        LanguageModelSession(
            model: PrivateCloudComputeLanguageModel(),
            instructions: """
                あなたは読書記録 App の評価スイート向けの合成データ生成器です。
                タグ付けシステムをストレステストできる、現実的で多様な本の
                エントリを作ることが仕事です。

                ルール:
                - 書評は少なくとも 100 文字以上にする。
                - 書評にはジャンル、気分やトーン、テーマを混ぜる。
                - 書評の長さにはばらつきを持たせる。
                - 3 個から 8 個のタグを作る。
                - タグはすべて小文字にする。
                """
        )
    }
)

重要ポイント:

  • sessionProviderLanguageModelSession を返すクロージャで、どのモデルを使うか、どのシステム指示を与えるかを制御します。
  • フレームワークは batch size を自動処理しますが、同じ session を再利用します。コンテキストウィンドウが尽きると、sessionProvider を再度呼んで新しい session を取得します。
  • システム指示は自己完結している必要があります。「前と同じように保つ」のような文脈依存の表現には頼らないでください。
  • デフォルトではオンデバイスモデルを使いますが、より大きなコンテキストが必要なら PrivateCloudComputeLanguageModel に切り替えられます。

生成サンプルを検証する

(10:37)

let generator = SampleGenerator<ModelSample<BookTags>>(
    prompt,
    samples: dataset,
    targetCount: targetCount,
    sessionProvider: { /* ... */ },
    validator: { sample in
        guard let book = sample.expected else { return false }

        // 書評は少なくとも 100 文字以上
        guard sample.promptDescription.count >= 100 else { return false }

        // タグは 3 個から 8 個
        guard (3...8).contains(book.tags.count) else { return false }

        // すべてのタグは小文字
        guard book.tags.allSatisfy({ $0 == $0.lowercased() }) else { return false }

        return true
    }
)

重要ポイント:

  • validator は各生成サンプルに対して独立に実行され、サンプルをまたいだ文脈は持ちません。
  • 長さチェック、個数範囲、フォーマット制約など、ハードコードしたルールの検証に向いています。
  • 「多様性」のように複数サンプルをまたぐ判断が必要なルールには向きません。その種のルールはシステム指示に入れて生成を誘導します。

検証結果にアクセスする

(10:58)

// 反復処理中
for try await sample in generator.run() {
    expandedDataset.append(sample)
}

// 反復処理後
let allSamples = await generator.samples
let invalidSamples = await generator.invalidSamples

print("新しいサンプルを \(allSamples.count) 件生成しました。合計: \(expandedDataset.count)")

重要ポイント:

  • generator.run() は生成を開始し、非同期ストリームを返します。
  • generator.samplesgenerator.invalidSamples はリアルタイムに更新され、いつでも参照できます。
  • 検証に失敗したデータも失われません。人が review してルールを調整し、再生成できます。

ツールの @Generable 引数を定義する

(15:30)

ツール引数には @Generable マクロを付け、モデルがどのように埋めればよいかを伝えます。

@Generable
struct SearchBooksArguments {
    @Guide(description: "タイトル、書評、タグに対して照合する自由形式の検索語")
    var query: String?

    @Guide(description: "この特定のタグを持つ本に結果を絞り込む")
    var tag: String?

    @Guide(description: "気分で結果を絞り込む")
    var mood: String?

    @Guide(description: "ジャンルで結果を絞り込む")
    var genre: String?

    @Guide(description: "返す結果の最大数。デフォルトは 5。")
    var limit: Int?
}

重要ポイント:

  • すべての引数を Optional にしておくと、モデルはユーザー意図に応じてどのフィールドを埋めるかを自律的に選べます。
  • @Guide は引数の説明を提供し、各引数の用途をモデルに理解させます。
  • 必須引数にすると、曖昧な指示のもとでモデルが無理に引数を作り、幻覚を起こすことがあります。

基本的な軌跡期待値: 引数の完全一致

(16:37)

TrajectoryExpectation(
    unordered: [
        ToolExpectation(
            "searchBooks",
            arguments: [
                .exact(argumentName: "tag", value: .string("gothic"))
            ]
        )
    ]
)

重要ポイント:

  • unordered はツール呼び出しの時系列を制限せず、出現していればよいことを表します。
  • .exact は引数値が指定した文字列と完全一致することを求めます。
  • 引数値が明確で、正確に予測できる場面に向いています。

自然言語の意図マッチング

(17:07)

TrajectoryExpectation(
    "searchBooks",
    arguments: [
        .naturalLanguage(
            argumentName: "mood",
            criteria: "高揚感、希望、前向きな感情に関係していること"
        )
    ]
)

重要ポイント:

  • .naturalLanguage は正確な値ではなく、期待する意図を自然言語で記述します。
  • 気分、文体、テーマのような曖昧な概念の評価に向いています。
  • 背後で追加のモデル推論を行うため、評価にはより時間がかかります。

順序付きツール呼び出しの期待値

(17:34)

TrajectoryExpectation(
    ordered: [
        ToolExpectation(
            "searchBooks",
            arguments: [
                .exact(argumentName: "tag", value: .string("gothic"))
            ]
        ),
        ToolExpectation(
            "getBookDetails",
            arguments: [
                .keyOnly(argumentName: "bookId")
            ]
        )
    ]
)

重要ポイント:

  • ordered はツールが指定順に呼ばれることを要求します。
  • 先に検索し、その後で詳細を取得するのは自然な順序です。逆なら bug です。
  • .keyOnly は引数が存在するかだけを確認し、具体的な値は制限しません。

特定のツール呼び出しを禁止する

(17:55)

TrajectoryExpectation(
    unordered: [
        ToolExpectation(
            "searchBooks",
            arguments: [
                .naturalLanguage(
                    argumentName: "genre",
                    criteria: "サイエンスフィクションを指していること")
            ]
        )
    ],
    disallowed: [
        ToolExpectation("findSimilarBooks")
    ]
)

重要ポイント:

  • disallowed は出現してはいけないツール呼び出しを列挙します。
  • ユーザーが「似た本は探さないで」と言っているのにモデルが findSimilarBooks を呼んだら失敗です。
  • 否定指示をモデルが守れるかをテストできます。

完全なツール呼び出し評価

(18:14)

let samples = SampleArrayLoader(samples: [
    ModelSample(
        prompt: "'gothic' タグが付いた本をすべて探してください。",
        instructions: "ユーザーが自分の蔵書を探索するのを手伝ってください。",
        expectations: TrajectoryExpectation(
            unordered: [
                ToolExpectation(
                    "searchBooks",
                    arguments: [
                        .exact(argumentName: "tag", value: .string("gothic"))
                    ]
                )
            ]
        )
    )
])

struct BookLibraryToolCallEval: Evaluation {
    var dataset = samples

    let pass = Metric("すべて合格")
    let percent = Metric("合格率")

    var evaluators: Evaluators {
        ToolCallEvaluator(allPass: pass, percentagePass: percent)
    }
}

重要ポイント:

  • ModelSample にはユーザー prompt、システム指示、軌跡期待値が含まれます。
  • ToolCallEvaluator はモデルセッションを自動実行し、ツール呼び出し記録を捕捉して期待値と比較します。
  • 評価結果は Xcode の Evaluations Report に直接表示されます。

ツール評価用の合成データを生成する

(19:20)

let prompt = Prompt("""
    個人の蔵書アシスタント向けに、多様なユーザークエリを生成してください。
    各サンプルには prompt (ユーザーが言うこと) と、どのツールを
    どの順序で呼ぶべきかを記述する trajectory expectation が必要です。
    """)

let instructions = """
    利用できるツール:
    - searchBooks(query?, tag?, mood?, genre?, limit?): 蔵書を検索する
    - getBookDetails(bookId): 1 冊の本の詳細を取得する
    - findSimilarBooks(bookId, maxResults?): タグが共通する本を探す
    順序要件:
    - searchBooks は getBookDetails または findSimilarBooks より前に呼ぶ必要がある
    - 順序が重要なら TrajectoryExpectation(ordered:) を使い、そうでなければ (unordered:) を使う
    使用する引数マッチャー:
    - 正確な値には .exact、曖昧なマッチングには .naturalLanguage
    - 任意の値でよい場合は .keyOnly、数値制約には .range
    - 部分文字列のマッチングには .contains/.hasPrefix/.hasSuffix
    """

重要ポイント:

  • TrajectoryExpectationGenerable プロトコルに準拠しているため、自動生成できます。
  • システム指示には、利用可能なツール、呼び出し順序のルール、引数マッチャーの使い方を明確に列挙する必要があります。
  • モデルはあなたが定義したツールを知りません。必要な文脈はすべて instructions に書きます。

ツール評価の合成サンプルを検証する

(19:51)

validator: { sample in
    // expectations が定義されている必要がある
    guard sample.output.expectations != nil else { return false }

    let expectations = sample.output.expectations!

    // 少なくとも 1 つのツールを参照している必要がある
    let totalExpectations = expectations.ordered.count + expectations.unordered.count
    guard totalExpectations > 0 else { return false }

    // すべてのツール名は有効な集合に含まれている必要がある
    let validTools: Set<String> = ["searchBooks", "getBookDetails", "findSimilarBooks"]
    let allExpectations = expectations.ordered + expectations.unordered + expectations.disallowed
    for expectation in allExpectations {
        guard validTools.contains(expectation.name) else { return false }
    }

    return true
}

重要ポイント:

  • 合成データが軌跡期待値を含んでいるか検証します。
  • 少なくとも 1 つのツールを参照していることを確認します。
  • すべてのツール名が事前定義した集合に含まれているかを確認し、存在しないツールをモデルが作り出すのを防ぎます。

重要な示唆

1. 既存の AI 機能にデータ駆動の評価を追加する

何をするか: 既存の AI 機能、たとえばスマート返信、コンテンツ分類、推薦システムを Evaluations フレームワークに接続し、SampleGenerator で数十件の種サンプルから数百件へ拡張します。

なぜ価値があるか: 多くの開発者は AI 機能の良し悪しを「感覚」で判断しています。定量評価があれば、prompt を変えたりモデルを差し替えたりするたびにスコア変化が見え、「直したつもりで悪化した」を避けられます。

どう始めるか: 既存のテストケースを ModelSample に変換し、生成タスクを説明する prompt を書き、targetCount を 100 に設定します。validator で明らかな不良データを弾き、Xcode で評価を走らせてスコアを見ます。

2. 多段階 AI ワークフローに軌跡チェックを加える

何をするか: App の中でモデルが複数のツールを呼び、検索 + 絞り込み + 並べ替え + 表示のような複雑なタスクを行うなら、TrajectoryExpectation で各ステップを検証します。

なぜ価値があるか: モデルは業務ロジックを迂回して直接答えを返すことがあります。見た目は正しくても、実際には正しい流れを通っていません。軌跡チェックは、この「結果は合っているが経路が違う」隠れた bug を見つけます。

どう始めるか: ツール一覧と合理的な呼び出し順序を列挙し、各ユーザー意図に TrajectoryExpectation を書きます。ToolCallEvaluator で実行します。まず .exact マッチから始め、曖昧な引数には .naturalLanguage を使います。

3. 合成データでストレステストする

何をするか: SampleGenerator で大量のエッジケースを作ります。超長文入力、空入力、多言語混在、特殊文字などです。

なぜ価値があるか: エッジケースを手書きするのは退屈で、抜け漏れも起きやすいです。モデルに生成させれば、想定していなかった場面を覆い、prompt や機能の脆さを露出できます。

どう始めるか: prompt の中で「極端なケース」を明示的に求めます。たとえば「5000 文字の書評を 1 件生成する」「絵文字だけの入力を生成する」などです。validator で形式を保証し、評価を走らせて機能が壊れないかを確認します。

4. 評価を CI パイプラインに組み込む

何をするか: Evaluations テストを Swift Testing のテストケースとして書き、コードの commit やモデル更新のたびに自動実行します。

なぜ価値があるか: AI 機能の回帰は、従来のコードの回帰より気づきにくいものです。モデルバージョンの更新や prompt の微調整だけでも振る舞いが変わります。自動評価なら、本番に入る前に問題を止められます。

どう始めるか: Swift Testing で Evaluation 構造体を定義し、swift test で実行します。評価結果をファイルに出力し、CI で基準スコアと比較して、低下が閾値を超えたら merge を止めます。

5. model-as-judge の採点体系を作る

何をするか: 創作文章や対話品質のように、ルールで定量化しにくい出力には、別のモデルを審査員として使います。

なぜ価値があるか: 「書評 tag が適切か」のような評価軸は、コードで判断するのが難しい場合があります。モデルにモデルを評価させることで、この種の主観的な品質次元も覆えます。

どう始めるか: Resources の “Scoring with model-as-judge evaluators” ドキュメントを参考にします。採点軸と基準を定義し、ハードコードした validator の代わりに Evaluations フレームワークの model-as-judge evaluator を使います。

関連セッション

コメント

GitHub Issues · utterances