LLMのためのツール利用API設計:エージェントのループとサイレント障害を防ぐ5つのパターン

Dev.to / 2026/5/5

💬 オピニオンDeveloper Stack & InfrastructureSignals & Early TrendsIdeas & Deep AnalysisTools & Practical Usage

要点

  • この記事は、LLMエージェントがクラッシュせずエラーも出さないまま再帰ループに入り、1.67Bトークンを消費し、推定で16,000〜50,000ドルのAPIコストが発生した実例を紹介しています。
  • エージェントのループは、ツール結果が曖昧である/サイレントに失敗する/矛盾する/長いコンテキストで誤読されるといった状況で起きやすく、その結果モデルが繰り返しリトライや「検証」のための追加呼び出しを行ってしまうと説明します。
  • 主要な主張は、根本原因がモデルやプロンプト品質ではなく、ツール/インターフェース設計にあることが多いという点です。
  • 著者は、1日あたり何千ものツール呼び出しを行うプロダクション環境で、エージェントのループとサイレント障害を防ぐことを目的とした複数のツール利用API設計パターンを提示します。
  • 提案されるパターンは、ツール結果を解釈可能で自己記述的にすることで、エージェントが成功・完了・ツール呼び出し停止の判断を確実にできるようにすることに焦点を当てています。

2025年7月、開発者のClaude Codeインスタンスが再帰ループに陥り、5時間で16.7億トークンを使い切り、誰も気づく前にAPI課金として推定16,000ドル〜50,000ドルが発生しました。エージェントはクラッシュしませんでした。エラーも出しませんでした。ツールを呼び続け、混乱し、さらにツールを呼び出し、黙ってコストを積み上げていくだけでした。古いソフトウェアのクラッシュ。LLMエージェントの出費。

これは、ほとんどのチームが“痛い目”を見て初めて知る失敗パターンです。あなたはクリーンなツールのインターフェースを設計し、エージェントはテスト環境では動き、プロダクションに出荷します。そして3週間後、あるエッジケースがループに落とし込みます。以下は、1日に何千回ものツール呼び出しを処理するプロダクションのLLMシステムで、エージェントのループとサイレント障害を防ぐために私たちが使ってきたパターンです。どれも“より良いプロンプト”の話ではありません。より良いツール設計の話です。

エージェントのループはなぜまず起きるのか

パターンの前に、失敗モードを正確に理解しておくと役に立ちます。

LLMエージェントはユーザーからの要求を受け取り、どのツールを呼ぶべきかを推論し、それを呼び、結果を受け取ります。次に、目的が達成されたかどうかを推論し、応答するか、別のツールを呼びます。このループは、目的が達成されたとき、またはモデルがこれ以上の行動は不要だと判断したときに終了するはずです。

しかし、次の場合には終了しません:

  1. ツール結果が曖昧。 モデルは呼び出しが成功したかどうか判断できないため、少し異なるパラメータで再度試します。
  2. ツールがサイレントに失敗する。 モデルは、必要なデータが実際には含まれていないのに、エラーではないレスポンスを受け取ります。そのため「リトライすべきだ」と解釈します。
  3. ツールが矛盾した情報を返す。 連続する2回の呼び出しで異なる結果が返り、モデルはどちらにも確信を持てず、さらにツールを呼び出して「確認」しようとします。
  4. モデルが自分の以前の出力を誤読する。 長いコンテキストウィンドウでは、モデルが前回のツール呼び出しの結果を見ても、それをすでに処理したことを忘れ、別の新しい情報として再処理してしまいます。

これらはすべて、ツール設計で防げます。モデルが問題なのではありません。インターフェースが問題なのです。

パターン1:すべてのツール結果を自己記述可能にする

エージェントのループの最も一般的な原因は、モデルが仮定を立てずには解釈できないツール結果です。

悪いツール結果:

{
  "results": [
    {"id": "h_1234", "name": "Hotel Granbell", "price": 128},
    {"id": "h_5678", "name": "Shibuya Stream", "price": 142}
  ]
}

この時点で、モデルはそれが何を意味するのかを仮定しなければなりません。これらは一致したもののすべてなのでしょうか? もっとあるのでしょうか? 検索は完了していますか? 何を探したのでしょう? モデルが混乱すると(そして規模が大きくなれば、いずれ必ず混乱します)、ツールをもう一度「確認するために」呼び出します。

自己記述可能なツール結果:

{
  "status": "success",
  "search_id": "srch_abc123",
  "query_summary": {
    "destination": "Shibuya, Tokyo",
    "check_in": "2026-07-12",
    "check_out": "2026-07-15",
    "guests": 1,
    "max_price": 150,
  },
  "results": [
    {"id": "h_1234", "name": "Hotel Granbell", "price": 128, "currency": "USD"},
    {"id": "h_5678", "name": "Shibuya Stream", "price": 142, "currency": "USD"}
  ],
  "total_matches": 2,
  "is_complete": true,
  "next_action_hint": "User has 2 valid options. Present both with prices. Do not search again unless user changes parameters."
}

is_complete: truenext_action_hint フィールドは、重要な追加です。これによりモデルはこの結果を読み取り、検索が完了したことを理解し、再クエリすることなく次に何をすべきかを把握できるようになります。query_summary のエコーによって、モデルは正しいパラメータでツールを呼び出したことを確認できます。

next_action_hint は型破りですが、非常に効果的です。これはツールのレスポンスに含まれる短い指示で、会話がどの状態にあるかをモデルに伝えます。ツールが、正しいループの終了へモデルを押しやるものだと考えてください。

# next_action_hint を注入するためにツールをラップする
def with_action_hint(tool_func):
    def wrapper(*args, **kwargs):
        result = tool_func(*args, **kwargs)
        result['next_action_hint'] = derive_hint(result)
        return result
    return wrapper

def derive_hint(result):
    if result['status'] == 'success' and result['total_matches'] == 0:
        return "No matches. Inform user and ask for relaxed criteria. Do not retry."
    if result['status'] == 'success' and result['total_matches'] > 0:
        return f"Found {result['total_matches']} matches. Present to user. Do not search again unless parameters change."
    if result['status'] == 'error':
        return f"Tool failed: {result['error']}. Inform user. Do not retry without user input."
    return "Process result and decide next step."

これをツール全体に実装した結果、プロダクション環境における「再試行に駆動されたループ」は約60%削減されました。

パターン 2: 「結果なし」と「ツール失敗」を区別する

エージェントのループが発生する原因として2番目に多いのは、「失敗状態が曖昧」なことです。

0件の一致が返ってくる検索は、成功したツール呼び出しです。一方、タイムアウトした検索は失敗したツール呼び出しです。ツールが空の結果配列を返すだけだと、LLMにとっては両者がまったく同じように見える可能性があります。

# 悪い例: 結果なしと区別がつかない
def search_hotels(query):
    try:
        results = supplier_api.search(query)
        return {"results": results}
    except Exception:
        return {"results": []}  # サイレントな失敗
# 良い例: リトライのガイダンス付きで状態を明示
def search_hotels(query):
    try:
        results = supplier_api.search(query, timeout=5)
        return {
            "status": "success",
            "results": results,
            "total_matches": len(results),
            "retryable": False,
        }
    except SupplierTimeout:
        return {
            "status": "error",
            "error_type": "timeout",
            "error_message": "Supplier API did not respond within 5 seconds.",
            "retryable": True,
            "retry_after_ms": 2000,
            "max_retries_remaining": get_retry_budget(query),
        }
    except SupplierAuthError:
        return {
            "status": "error",
            "error_type": "auth",
            "error_message": "API authentication failed.",
            "retryable": False,
            "user_facing_message": "We"e''re having trouble accessing hotel data. Please try again later.",
        }
    except RateLimitError as e:
        return {
            "status": "error",
            "error_type": "rate_limit",
            "error_message": f"Rate limit hit. Reset in {e.reset_seconds}s.",
            "retryable": True,
            "retry_after_ms": e.reset_seconds * 1000,
        }

retryable フラグは実際に重要な役割を果たしています。false のときは、LLM は「リトライしても意味がない」ことを理解し、代わりにユーザーへ通知します。true のときは、LLM は明確な制限付きの構造化されたリトライ手順を持っています。

このパターンがない場合、結果が空のように見える認証失敗が原因でモデルが「結果を見つける」ために、ますます創造的なパラメータの組み合わせを試し続けてしまい、トークンを消費して何も得られません。

パターン 3: オーケストレーターのレベルでハードな呼び出し予算を強制する

ツールがどれほどよく設計されていても、モデルは時々ループに入ります。オーケストレーターはハードな上限を強制しなければなりません。

class AgentOrchestrator:
    def __init__(self, max_tool_calls=15, max_total_cost_usd=0.50):
        self.max_tool_calls = max_tool_calls
        self.max_total_cost_usd = max_total_cost_usd
        self.calls_made = 0
        self.total_cost_usd = 0

    async def run_agent_turn(self, user_message, conversation_history):
        history = conversation_history + [{"role": "user", "content": user_message}]

        while self.calls_made < self.max_tool_calls:
            if self.total_cost_usd >= self.max_total_cost_usd:
                return self._cost_limit_response()
response = await call_llm(history, tools=self.tools)
            self.total_cost_usd += response.cost_usd

            if not response.tool_calls:
                # モデルが最終レスポンスを生成したので、ループを抜ける
                return response.content

            for tool_call in response.tool_calls:
                self.calls_made += 1
                tool_result = await self.execute_tool(tool_call)
                history.append({"role": "tool", "content": tool_result})

        # 呼び出し回数の上限に到達。最終レスポンスを強制する。
        return await self._force_final_response(history)

    async def _force_final_response(self, history):
        # 明示的な指示を追加し、tools=NoneでLLMを呼び出す
        history.append({
            "role": "system",
            "content": "Tool call limit reached. Produce a final response to the user "
                       "based on information already gathered. Do not request more tools."
        })
        response = await call_llm(history, tools=None)
        return response.content

ここには2つのセーフガードがあります。まず、max_tool_calls が反復処理の上限を設けることで、無限ループを防ぎます。15は予約(booking)ワークフローにおけるデフォルトです。それ以上の値は、ほとんどの場合エージェントが混乱していて生産的ではないサインです。次に、max_total_cost_usd は財務面のブレーカ(遮断装置)です。たとえエージェントが工夫して大量のツール呼び出しを行う手段を見つけても、会話あたりの予算を超えて支出することはできません。

上限に到達したとき、オーケストレータは単にエラーを返すだけではありません。tools=None を指定してLLMをもう1回呼び出し、これまでに収集できた情報だけをもとに最終レスポンスを生成するよう強制します。これは「Sorry, agent failed.」よりはるかに良いUXです。

大量処理のシステムでは、テナントごとのレート制限も実装してください。Claude Code の単独開発者によるインシデントでは、アカウントあたりの上限がなかったため、$16〜50K が燃えました。プロダクションシステムには、会話ごとの制限とテナントごとの制限の両方が必要です。

パターン4: 繰り返し呼び出しを検出してショートサーキットする

呼び出し予算(バジェット)があったとしても、エージェントは微妙な違いをつけて同じ呼び出しを繰り返すことで予算を浪費します。解決策は、オーケストレータに重複排除(デデュープ)層を追加することです。

import hashlib
import json

class ToolCallDeduplicator:
    def __init__(self, window_size=5):
        self.recent_calls = []
        self.window_size = window_size

    def is_duplicate(self, tool_name, arguments):
        signature = self._signature(tool_name, arguments)
        is_dup = any(call == signature for call in self.recent_calls)
        self.recent_calls.append(signature)
        if len(self.recent_calls) > self.window_size:
            self.recent_calls.pop(0)
        return is_dupdef _signature(self, tool_name, arguments):
        # 比較用に引数を正規化する
        normalized = json.dumps(arguments, sort_keys=True, default=str)
        return f"{tool_name}:{hashlib.sha256(normalized.encode()).hexdigest()[:16]}"

# オーケストレータ内では
async def execute_tool(self, tool_call):
    if self.deduplicator.is_duplicate(tool_call.name, tool_call.arguments):
        return {
            "status": "duplicate_call_blocked",
            "message": (
                f"This exact {tool_call.name} call was made earlier in this conversation "
                f"with the same arguments. The previous result is already in your context. "
                f"Use it instead of calling again."
            ),
            "retryable": False,
        }

    return await self._actually_execute(tool_call)

モデルが5回のウィンドウ内で同じ引数で同じツールを2回呼び出した場合、オーケストレータは再実行する代わりに、構造化された「これは重複です」というメッセージを返します。モデルはそれを認識し、ほとんどの場合、先ほどの結果を参照することで回復します。

このパターンは本番システムにおいて、約8%の呼び出しを捕捉しました。ツール呼び出し全体の8%は不要なリピートでした。それらをブロックすることで、コストとレイテンシの両方を節約できました。

細かい点として、重複排除のシグネチャは、ニア・ダイプリケート(ほぼ同一)も検出できる程度に“情報落ち”した形であるべきです。私たちは引数の完全一致を使っていますが、いくつかのツールでは(単語の順序だけが違う検索クエリなど)、ハッシュ化の前に正規化ステップを行うことで、より多くを検出できるはずです。

Pattern 5: LLMの内部ではなく境界でのパラメータ検証

不正なツール呼び出しを検出するための最も遅い経路は、LLMにそれを作らせ、ツールを実行させ、失敗がそのまま伝播するのを待つことです。最も速い経路は、ツールが実行される前にパラメータを検証することです。

from pydantic import BaseModel, Field, validator
from datetime import date, timedelta

class SearchHotelsArgs(BaseModel):
    destination: str = Field(min_length=2, max_length=100)
    check_in: date
    check_out: date
    guests: int = Field(ge=1, le=20)
    max_price: float = Field(gt=0, le=10000)

    @validator('check_in')
    def check_in_not_in_past(cls, v):
        if v < date.today():
            raise ValueError(f"check_in date {v} is in the past")
        return v

返却形式: {"translated": "翻訳されたHTML"}@validator('check_out')
    def check_out_after_check_in(cls, v, values):
        if 'check_in' in values and v <= values['check_in']:
            raise ValueError("check_out は check_in の後でなければなりません")
        if 'check_in' in values and (v - values['check_in']) > timedelta(days=90):
            raise ValueError("滞在期間は 90 日を超えることはできません")
        return v

async def execute_tool(self, tool_call):
    if tool_call.name == "search_hotels":
        try:
            args = SearchHotelsArgs(**tool_call.arguments)
        except ValidationError as e:
            return {
                "status": "validation_error",
                "errors": e.errors(),
                "user_facing_hint": (
                    "いくつかの検索パラメータが無効でした。再試行する前にユーザーに確認してください。"
                ),
                "retryable_after_correction": True,
            }
        return await self._search_hotels(args)

これは、次の 3 種類の不適切な呼び出しを検出します:

  1. 型エラー: LLM が、ツールが期待する整数ではなく文字列を渡してくる。
  2. 範囲エラー: LLM が 1 部屋で 50 人のゲストを検索しようとする。
  3. 論理エラー: チェックアウトがチェックインより前、過去の日付。

これらを境界(boundary)で構造化されたエラーレスポンスとして拒否することで、サプライヤー API に不正なデータが渡され、暗号のように解読しづらいエラーが返り、LLM がそのエラーを解釈できず、ループが始まってしまうという、より深い失敗モードを防ぎます。

Pydantic ベースのアプローチでは、JSON スキーマの生成も無料で得られます。これは、LLM に送るツール定義へそのまま反映できます。両端におけるスキーマ整合性のあるバリデーション。

おすすめしないこと

試してみたが、途中でやめたいくつかのアプローチ:

  • ループ途中で LLM に「もう終わった?」と聞く。 すべてを遅くし、しかも一貫してうまくいきません。オーケストレータ(司令塔)レベルのコール予算(call budget)のほうが信頼できます。
  • すべての反復で、LLM に呼び出し履歴を丸ごと見せる。 コンテキストコストが劇的に増え、得られる利点はほとんどありません。パターン 4(構造化されたフィードバックによる重複排除)のほうが効率的です。
  • 部分結果付きでツール実行をストリーミングする。 魅力的に見えますが、LLM が不完全なデータに基づいて行動してしまうという新たな失敗モードを生みます。完了するか、きれいに失敗するかのいずれかになる原子的(atomic)なツール呼び出しに固執してください。
  • API スペックからツール定義を自動生成する。 DRY(重複を減らす)に見えて魅力的ですが、自動生成された説明は通常、LLM に必要なものとは一致しません。ツールを使うべきとき/使わないべきときについて明示的なガイダンスを含めた手書きのツール説明のほうがうまく機能します。

本番での成果

LLM を活用した予約システムに対して、これら 5 つのパターンを実装した結果:

  • エージェントループのインシデント: 週あたり 3〜5 件から、月あたり 1 件未満へ減少。
  • 1 会話あたりの平均ツール呼び出し数: 22% 減少。主に重複や不要なリトライを排除したことによる。
  • 最終レスポンスまでの時間: 18% 改善。主に不正なパラメータ呼び出しをより早い段階でショートサーキットしたことが理由。
  • 1 会話あたりのコスト: 31% 減少。ツール呼び出し回数が減り、予算の厳格な適用によって抑えられたことの組み合わせ。

これらのパターンは華やかではありません。ほとんどがディフェンシブ(防御的)なエンジニアリングです。ですが、代わりに起こりうるのが Claude Code のインシデントです。 「クラッシュはしなかった」ものの、エージェントが使い続けてしまい、16,000 ドルから 50,000 ドルの損失につながりました。本番の LLM システムでは、コストが安定するかどうかの差は、まさにこのような“地味な”インフラの違いそのものです。

LLM エージェント向けにツールを設計するなら、ツールインターフェースを、モデルが混乱しているときでも守らなければならない契約(contract)として扱ってください。モデルは混乱します。契約はテストされます。パターン 1 から 5 は、その契約が生き残る方法です。

これらのパターンは Adamo Software の本番ビルドで開発されました。AI travel assistant のデプロイや、ツール利用の信頼性が交渉不可である agentic AI systems にも含まれています。