マガジン

LLM APIを呼ぶコードのテスト:モックと実API確認を分ける三層の進め方

AIを使いこなす

Claudeで執筆し、GPTで自動校閲・修正しています。

三つのトレーに木片を置き、段階ごとに確認する手元
記事の内容をもとに制作したAI生成イメージです。実際の製品画面や検証結果の写真ではありません。

結論から言うと、テストは「偽クライアント」「固定レスポンスの再生」「少数の実API確認」の三層に分けると、課金をほぼ増やさずに壊れやすい部分を守れます。実APIに触れるのは最後の層だけにして、そこは手動か夜間実行に限り、回数と出力量に上限を付けます。 理由は、LLM APIを呼ぶコードで壊れるのが主に「モデルの賢さ」ではなく、自分のコードの周辺だからです。プロンプトの組み立て、返ってきた文字列のパース、エラー時の分岐、リトライの重複といった部分は、毎回課金される呼び出しがなくても検証できます。API利用料は入力・出力トークンの量で決まります(Claude APIの料金ページ)。CIで何度も実呼び出しをすると、そのまま費用になります。 この記事は、その分け方と、層ごとのテスト対象、呼び出しの差し替え方を示します。回答の品質評価(evals)の設計は扱いません。

この記事の目次
  1. 三層の対象一覧
  2. 呼び出しを差し替える擬似コード
  3. 層2の保存物を作る手順
  4. 層3を安全に回す条件
  5. どの層に書くか迷ったときの判断表

三層の対象一覧

下の表が、層ごとに何を守るかの一覧です。層の分け方は編集上の提案で、公式仕様ではありません。

  • 層1 偽クライアント、呼び出し先:自作の台本付きフェイク、実行タイミング:毎コミット、守る対象:リトライ可否の分岐、課金・上限エラーでの停止、タイムアウト時の扱い、リクエスト組み立て(入力の切り詰め、max tokensの指定)、呼び出し回数
  • 層2 固定レスポンス再生、呼び出し先:保存済みのレスポンスJSON、実行タイミング:毎コミット、守る対象:レスポンスのパース、JSONスキーマ検証、空応答・途中切れ・想定外フィールドへの耐性、ストリーミング途中のエラー処理
  • 層3 実API確認、呼び出し先:本物のAPI、実行タイミング:手動またはリリース前・夜間、守る対象:認証・モデル名・パラメータが今も通るか、レスポンス形が層2の保存物から変わっていないか

層1で特に見る分岐

エラーの扱いは、各社の公式ドキュメントが「再試行してよいもの」と「再試行しても直らないもの」を分けています。

  • OpenAIは、429に「リクエストが速すぎる」場合と、組織・プロジェクトの支出上限や組織の利用上限に達した場合があると説明しています。後者は単なる再試行では解消せず、上限の変更や、月次上限のリセットを待つ対応が必要です(OpenAI Error codes。確認日2026-10-30)。
  • Anthropicは、設定した支出上限に達したときに400を返すと記載しています。使用量ティアの月間上限に達した429にはretry-afterヘッダーが付かず、アクセスが再開するまで失敗し続けます。500や529は一時的な障害として再試行の対象です(Claude API errors。確認日2026-10-28)。
  • ストリーミングでは、200が返った後にエラーが起きる場合があり、通常のHTTPエラー処理には乗りません。同ページに記載があります。

つまり、層1には「一時的な失敗は再試行して成功する」と「上限系の失敗は1回で止まる」の2本が最低限必要です。後者を書いておかないと、障害時に再試行が課金と待ち時間を積み上げるコードを見逃します。

もう一点、Anthropicの公式SDKは一時的な失敗を既定で2回まで自動再試行します(同ページ)。自前のリトライも書くと二重になり、テストで数えた回数と実際の呼び出し回数がずれます。リトライを置く場所をSDK側か自作側のどちらか一つに決め、もう一方は無効化するかテストで明示してください。

呼び出しを差し替える擬似コード

差し替えのコツは、SDKを直接呼ばずに、自分で定義した薄い入り口を一つ挟むことです。以下は説明用の未実行の擬似コードで、特定のSDKの実際のAPIとは対応していません。

python
# 説明用の未実行擬似コード
class LlmPort:                      # アプリはこの入り口だけを使う
    def complete(self, req): ...

class FakeLlm(LlmPort):             # 層1: 台本どおりに返す/例外を投げる
    def __init__(self, script):
        self.script, self.calls = list(script), []
    def complete(self, req):
        self.calls.append(req)
        step = self.script.pop(0)
        if isinstance(step, Exception): raise step
        return step

class ReplayLlm(LlmPort):           # 層2: 保存済みレスポンスを返す
    def __init__(self, path): self.saved = load_json(path)
    def complete(self, req): return self.saved

class LiveLlm(LlmPort):             # 層3: 環境変数と回数上限つき
    MAX_CALLS = 10
    def __init__(self, sdk_client):
        require_env("RUN_LIVE_LLM")         # 未設定なら即スキップ
        self.sdk, self.count = sdk_client, 0
    def complete(self, req):
        self.count += 1
        if self.count > self.MAX_CALLS: raise StopTest("上限")
        req = req.with_max_output_tokens(200)   # 出力を小さく固定
        return self.sdk.call(req)

# テスト例(層1)
def test_overload_is_retried():
    llm = FakeLlm([OverloadedError(), ok('{"label":"billing"}')])
    r = classify(llm, "説明用の問い合わせ文", sleep=no_sleep)
    assert r.label == "billing" and len(llm.calls) == 2

def test_quota_error_stops_at_once():
    llm = FakeLlm([QuotaExceededError()])
    assert raises(StopService, classify, llm, "説明用の問い合わせ文")
    assert len(llm.calls) == 1

sleep=no_sleepのように待機処理も差し替え可能にしておくと、リトライのテストが一瞬で終わります。

層2の保存物を作る手順

固定レスポンスは、層3で一度だけ実際に呼んで得た応答から作ります。手順は次の通りです。

  1. 層3の確認用スクリプトで、入力を短い説明用の文に絞って1回呼ぶ。
  2. 返ってきたJSON全体を保存する前に、個人情報・社内情報・APIキーが含まれていないか人が確認する。入力に実データを使わないのが最も確実です。
  3. 保存したファイルをfixtures/に置き、層2のテストから読み込む。
  4. 正常系のほかに、手で編集した異常系(本文が空、JSONが途中で切れる、期待しないキーが増える)を別ファイルとして作る。
  5. モデルやSDKを更新したら層3を再実行し、保存物と形が違わないかを見る。違えば保存物を更新します。

公式ドキュメントによると、エラーレスポンスのtypeの値は今後増える可能性があります(Claude API errors)。そのため、知らない値が来たときの既定動作(ログを残して失敗扱いにする、など)も層2の異常系に入れておくと安全です。

層3を安全に回す条件

実API確認は、次の条件を満たすときだけ動くようにします。

  • 専用のキーを使い、本番とは別のプロジェクトやワークスペースに置く。
  • プロバイダー側の支出上限を設定する。Anthropicには組織・ワークスペース単位で自分で設定できる上限があります(上記エラーページ参照)。上限到達はテスト失敗ではなく「停止」として扱います。
  • 呼び出しは10回前後に絞り、出力トークン数も小さく固定する。
  • 環境変数が立っていないときは必ずスキップする。通常のCIには入れない。
  • 失敗時の調査用に、レスポンスのリクエストIDをログに残す。Anthropicは全レスポンスにrequest-idヘッダーを付けると説明しています。

回答の良し悪しを測る評価は、この層3とは別物です。Anthropicの評価設計のページは、実際の利用に近い入力とエッジケースを用意し、可能なら自動採点にすること、分類のように正解が決まるものは完全一致で採点できることを挙げています(Create strong empirical evaluations)。評価用のケースは、層3の「通るか」の確認に混ぜず、別のジョブとして回数と費用を管理してください。

どの層に書くか迷ったときの判断表

  • 429のとき再試行するか、止まるか、置く層:層1
  • プロンプトに入力が正しく埋め込まれるか、置く層:層1
  • 返ってきたJSONの必須キーが欠けたとき落ちないか、置く層:層2
  • 空の応答や途中切れで画面が壊れないか、置く層:層2
  • モデル名を変えたら通らなくなっていないか、置く層:層3
  • 回答の内容が良いか、置く層:層3ではなく別の評価ジョブ

この表で迷うものは、まず層1か層2に置けないかを考えてください。層3は「自分のコードの外側が変わっていないか」を見る最後の砦に絞ると、費用も実行時間も抑えられます。

出典・参考資料