月曜日にはテストが通ったのに、火曜日には失敗した。入力もコードも同じで、temperature=0 も指定している。それでもモデルが「同じ意味だが少し違う表現」の回答を返したため、厳密な文字列アサーションが落ちる。結果として、製品ではなくテストスイートのデバッグに時間を使うことになります。
これは、言語モデルを呼び出すシステムのテストで避けられない課題です。temperature=0 は出力の揺れを減らしますが、実行ごとのバイト単位の一致を保証しません。本記事では、変動する自然言語ではなく、変動しない契約に対してアサートする方法を解説します。これは、AIエージェントが本番環境で動作しなくなる理由で紹介した障害モード3の実装面に焦点を当てた内容です。
temperature=0 が決定論的ではない理由
温度は次のトークンのサンプリング方法を制御します。temperature=0 では最も確率の高いトークンを選ぶため、再現性があるように見えます。しかし、サービス全体としては決定論的ではありません。
主な理由は次のとおりです。
- GPU 上の浮動小数点演算では、加算順序により丸め誤差が変わる
- リクエストのバッチ処理や実行ハードウェアが変わる
- 推論カーネル、推論ライブラリ、量子化方式が更新される
- プロバイダーが別リージョンや別インフラへルーティングする
わずかな数値差で最上位トークンが入れ替わると、その後の生成結果全体が変わる可能性があります。vLLM の議論でも、固定シードと temperature=0 だけではビット単位の再現性に十分でないことが説明されています。
つまり、決定論は API リクエストのフラグではなく、推論スタック全体の特性です。テストでは「まったく同じ文章」を期待するのではなく、「満たすべき契約」を検証してください。
厳密な文字列アサーションが不安定な理由
次のテストは、一見正しく見えます。
assert(response === "Your order total is $42.00.");
しかし、モデルが次のように返した場合も意味は正しいままです。
Your total comes to $42.00.
このときテストは失敗しますが、製品上の障害ではありません。
正しい結果で失敗するテストが増えると、チームは失敗を信頼しなくなります。再実行して通るまで待つ運用になれば、本当のデグレードもノイズに埋もれます。不安定なテストの原因で扱ったように、非決定論的な出力は flaky test を生む典型的な要因です。
テキストではなく構造と意味をアサートする
出力の表現は変わっても、契約は変わるべきではありません。
たとえば返金エージェントの回答なら、表現ではなく次の事実を検証します。
-
order_idが存在する -
statusが許可された状態である -
amountが数値である - 顧客向けレスポンスに内部情報が含まれない
テストで確認すべきことは、次のように変わります。
悪い問い: モデルはこの文章を正確に返したか?
良い問い: 応答は正しい構造、型、値域、禁止事項を満たすか?
以下のアサーションを組み合わせると、言い回しの変化を許容しながら、本当の障害を検出できます。
1. JSON スキーマでレスポンスを検証する
エージェントが構造化データを返せる場合、最初に JSON スキーマを定義してください。
{
"type": "object",
"required": ["order_id", "status", "amount"],
"additionalProperties": false,
"properties": {
"order_id": {
"type": "string",
"pattern": "^ORD-[A-Z0-9]+$"
},
"status": {
"type": "string",
"enum": ["refunded", "pending", "denied"]
},
"amount": {
"type": "number",
"minimum": 0
}
}
}
このスキーマで検出できる問題は明確です。
- 必須フィールドがない
-
amountが"42.00"のような文字列になっている - 未定義の
statusが返る - JSON ではなく自由テキストが返る
- オブジェクトのネスト構造が崩れる
レスポンススキーマを Apidog に登録し、実際のエージェント応答を検証すると、文字列全体の差分ではなく、壊れたフィールドを特定できます。
2. ツール呼び出しの名前・ターゲット・ペイロードを検証する
エージェントがツールを呼び出す場合、推論文ではなくツール呼び出し自体をテストします。
確認対象は3つです。
- 正しいツールを選んだか
- 正しいエンドポイントや操作を選んだか
- 引数がツールスキーマに一致するか
予約エージェントなら、自然言語の説明がどう変わっても、ツール呼び出しは次の契約を満たす必要があります。
{
"tool": "createReservation",
"method": "POST",
"path": "/reservations",
"body": {
"date": "2025-06-20",
"guests": 2
}
}
検証例です。
assert(toolCall.path === "/reservations");
assert(toolCall.method === "POST");
assert(Number.isInteger(toolCall.body.guests));
assert(toolCall.body.guests > 0);
assert(isValidDate(toolCall.body.date));
AIエージェントのAPI呼び出しをテストするエンドツーエンドの方法では、ツールスキーマをキャプチャしてテストへ組み込む方法を詳しく扱っています。
3. 厳密な数値ではなく、値域を検証する
モデルが計算・推定・抽出する数値は、可能な限り値域でテストします。
たとえばカート合計について、すべての税ルールを固定しない限り、常に同じ金額を期待するのは危険です。一方で、次の不変条件はテストできます。
assert(response.total >= 0);
assert(response.total <= cartSubtotal + maxShipping + maxTax);
このような値域チェックは、以下を検出できます。
- 負の合計金額
- 桁を誤った異常な金額
- 商品があるのに合計がゼロ
- レイテンシ予算超過
- 異常なトークン使用量
ポイントは、通常の変動を許容しつつ、バグでは必ず失敗する範囲を選ぶことです。
4. 必須キーと禁止キーを検証する
もっとも低コストで効果的なアサーションは、キーの存在・不在チェックです。
assert(response.resolution != null);
assert(!("internal_notes" in response));
assert(!("raw_prompt" in response));
サポートエージェントなら、顧客向けレスポンスに resolution は必要です。一方で、internal_notes や raw_prompt は絶対に返してはいけません。
このチェックは文言に依存しないため、自然言語の揺れで flaky になりません。また、意図しない内部情報の露出を検出する基本的な防御策にもなります。
5. 自由テキストはプロパティと閾値で検証する
自由テキストを返す API でも、完全一致以外の方法でテストできます。
まずは、決定的に確認できるプロパティを検証します。
assert(response.message.includes(orderId));
assert(response.message.length <= 500);
assert(!response.message.includes("internal use only"));
assert(!response.message.includes("system prompt"));
意味的な妥当性が必要な場合は、参照回答との埋め込み類似度を利用し、閾値を超えることを確認します。
assert(similarity(response.message, expectedAnswer) >= 0.8);
ただし、意味的類似度は完全な正解判定ではありません。話題から外れた回答の検出には役立ちますが、細かな事実誤認を見逃す可能性があります。スキーマ、必須キー、値域などの構造的アサーションと併用してください。
6. 厳密なスナップショットではなく、契約をスナップショットする
自然言語レスポンス全体のスナップショットは壊れやすいですが、安定した部分ならスナップショットできます。
固定する対象は次のようなものです。
- キーの集合
- フィールドの型
- enum 値
- 数値の最小値・最大値
- ネスト構造
- 許可されるツール呼び出し
たとえば、次のような契約を記録します。
{
"required": ["order_id", "status", "amount"],
"status": ["refunded", "pending", "denied"],
"amount": {
"type": "number",
"minimum": 0
}
}
これならスナップショットが壊れたとき、原因は同義語への言い換えではなく、確認すべき構造変更になります。
状態とメモリを持つエージェントでは不変条件をテストする
ここまでの例は、単一リクエストのテストを前提にしています。しかし実際のエージェントは会話をまたいで状態やメモリを保持します。
出力は次の要素にも影響されます。
- 過去ターンで保存した情報
- 検索結果の順位
- 要約の内容
- 会話の実行順序
- 開始時点のセッション状態
同じ会話を2回実行しても、検索結果の並びや以前の要約が変われば、最終回答が異なる場合があります。AIエージェントのメモリがどのように機能するかも参照してください。
ステートフルなテストでは、次の2つを実践します。
テスト前に状態を初期化する
各テストで、メモリやセッションを既知の状態に戻します。
beforeEach(async () => {
await resetAgentMemory(testSessionId);
await seedAgentMemory(testSessionId, {
customer_id: "CUST-001",
cart_items: []
});
});
一度に複数の変数を変えず、検証したい条件だけを変化させることが重要です。
会話パスに依存しない不変条件を検証する
会話の表現や途中経路ではなく、最後まで成立すべき条件をテストします。
assert(finalBalance >= 0);
assert(reservations.length === 1);
assert(reservations[0].status === "confirmed");
たとえば、何ターンかかっても、1つのフライト予約を完了した会話は最終的に予約を1件だけ作成すべきです。このような不変条件は、状態を持つ非決定論的なエージェントでも有効です。
依存 API をモックして、変動要因を減らす
ライブのサードパーティ API を使うと、モデル以外の変動要因が増えます。
- レート制限
- 外部データの更新
- 一時的な障害
- レスポンス遅延
- 検索結果や在庫情報の変化
テストでは、対象外の依存関係を固定してください。
mockPaymentApi.reply({
receipt_id: "R-001",
status: "paid",
amount: 42.0
});
mockSearchApi.reply({
results: [
{ id: "doc-1", title: "返品ポリシー" },
{ id: "doc-2", title: "返金処理" },
{ id: "doc-3", title: "注文状況" }
]
});
依存 API をモックすると、支払い API は常に同じ領収書を返し、検索 API は常に同じ結果を返します。残る変動要因はエージェントの推論だけになるため、テスト対象を明確にできます。
Apidogで依存 API のモックを用意し、安定したレスポンスを返すように設定したうえで、スキーマや値域のアサーションを実行してください。この組み合わせは、エージェントAIテストの重要な実装パターンです。
Apidog を使う場所、使わない場所
Apidog は API 設計、テスト、モックのためのプラットフォームです。エージェントフレームワーク、モデルホスト、エージェントランタイム、評価プラットフォームではありません。
Apidog を使うべき場所は、エージェントと API の境界です。
- レスポンススキーマの検証
- 必須・禁止フィールドの検証
- 数値範囲の検証
- ツール呼び出しペイロードの検証
- 外部 API のモック
- 再現可能な API テストの実行
つまり、対象はモデルがどのように文章を生成したかではなく、API 要求と応答がどの契約を満たすかです。
言葉遣いではなく契約をテストする
非決定論は、temperature=0 で完全に消せるバグではありません。言語モデルを使うシステムの性質として扱う必要があります。
不安定な厳密文字列アサーションを、次のいずれかへ置き換えてください。
- JSON スキーマ
- ツール呼び出しのスキーマ
- 必須キー・禁止キー
- 数値の値域
- 会話をまたぐ不変条件
- 自由テキストのプロパティチェック
- 意味的類似度の閾値チェック
今週は、テストスイート内の flaky な文字列アサーションを1つ選び、スキーマまたは値域チェックへ書き換えてみてください。テキストが少し変わってもテストは通り、本当に契約が壊れたときだけ失敗するようになります。
Top comments (0)