T-CREATOR

GPT-5 ツール呼び出しが暴走する時の診断フロー:関数設計/停止条件/リトライ制御

GPT-5 ツール呼び出しが暴走する時の診断フロー:関数設計/停止条件/リトライ制御

GPT-5 でツール呼び出しを実装していると、想定外の連続呼び出しやループが発生し、API コストが急激に増加したり、システムが応答しなくなる事態に直面することがあります。 こうした「暴走」は、関数設計の不備や停止条件の欠如、リトライ制御の誤りなど、複数の要因が絡み合って発生するケースが多いです。

本記事では、GPT-5 のツール呼び出しが暴走する際の診断フローと、関数設計・停止条件・リトライ制御の 3 つの観点から具体的な解決策を解説します。 初心者の方でも実践できるよう、段階的なチェックリストとサンプルコードを交えながら、確実に問題を特定し解決できる方法をご紹介しましょう。

背景

ツール呼び出しの基本メカニズム

GPT-5 では、Function Calling や Tool Calling と呼ばれる機能を通じて、外部 API やデータベース、ファイルシステムなどと連携できます。 基本的な流れは以下の通りです。

mermaidflowchart LR
  user["ユーザー"] -->|プロンプト| gpt["GPT-5"]
  gpt -->|ツール選択| tool["Tool API"]
  tool -->|実行結果| gpt
  gpt -->|次のアクション判断| decision{継続?}
  decision -->|Yes| tool
  decision -->|No| response["最終回答"]
  response --> user

この図から分かるように、GPT-5 は一度ツールを呼び出した後、その結果を受け取り「次にどうするか」を自律的に判断します。 適切に設計されていれば、必要な情報を取得した時点で停止し、ユーザーへ回答を返すのですが、設計に問題があると「継続」の判断が延々と繰り返されてしまうのです。

暴走が引き起こす問題

ツール呼び出しの暴走は、単なるバグではなく、ビジネスやユーザー体験に直接的な影響を与える重大な問題です。

#問題の種類影響度具体例
1API コストの急増★★★1 回のリクエストで数百回のツール呼び出しが発生し、課金額が 10 倍に
2レスポンス時間の遅延★★★ユーザーが数分待たされ、タイムアウトが発生
3レート制限への抵触★★☆短時間に大量リクエストが発生し、API 制限に到達
4リソースの枯渇★★☆サーバーのメモリや CPU が圧迫され、他の処理にも影響

これらの問題を未然に防ぐためには、暴走の原因を正確に診断し、適切な対策を講じることが不可欠です。

暴走の主な原因パターン

暴走が発生する原因は、大きく分けて 3 つのパターンに分類できます。

mermaidflowchart TD
  cause["暴走の原因"] --> design["関数設計の問題"]
  cause --> stop["停止条件の欠如"]
  cause --> retry["リトライ制御の誤り"]

  design --> design1["曖昧な関数定義"]
  design --> design2["パラメータ不足"]
  design --> design3["複数ツールの依存関係"]

  stop --> stop1["終了判定ロジックがない"]
  stop --> stop2["max_iterationsの未設定"]
  stop --> stop3["エラー時の処理不備"]

  retry --> retry1["無限リトライループ"]
  retry --> retry2["バックオフ戦略の欠如"]
  retry --> retry3["エラーハンドリング不足"]

次章からは、これらの原因を一つずつ診断し、解決していく手順を見ていきましょう。

課題

診断が難しい理由

GPT-5 のツール呼び出し暴走を診断する際、従来のバグとは異なる困難さがあります。 それは、AI の判断プロセスが確率的であり、同じ入力でも異なる挙動を示すことがある点です。

再現性が低いため、問題が発生しても「たまたま」と見過ごされがちですし、ログを見ても「なぜこのツールを選んだのか」が分かりにくいケースも少なくありません。

一般的な診断アプローチの限界

通常のソフトウェア開発では、エラーログやスタックトレースを見れば原因が特定できますが、AI のツール呼び出しではそうはいきません。

mermaidflowchart LR
  traditional["従来の診断"] --> log["エラーログ確認"]
  traditional --> stack["スタックトレース分析"]
  traditional --> fix["原因特定→修正"]

  ai_tool["AI診断"] --> log2["実行ログ確認"]
  ai_tool --> pattern["呼び出しパターン分析"]
  ai_tool --> prompt["プロンプト/関数設計見直し"]
  ai_tool --> control["制御ロジック追加"]

  style traditional fill:#e1f5ff
  style ai_tool fill:#fff4e1

従来の診断では「コードのバグ」を探しますが、AI ツール呼び出しでは「AI の判断を誘導する設計」を見直す必要があるのです。

具体的な課題の分類

ツール呼び出し暴走の診断で直面する課題を、3 つの観点から整理しましょう。

関数設計における課題

関数の定義が曖昧だと、GPT-5 はどのツールを使うべきか迷い、複数のツールを試行錯誤してしまいます。

#課題症状影響
1関数名が抽象的似た名前のツールを連続呼び出し無駄な実行が増加
2パラメータの説明不足不正な値で何度もリトライエラー率が上昇
3戻り値の形式が不明確結果を正しく解釈できず再実行ループが発生

停止条件における課題

明確な終了条件がないと、GPT-5 は「まだ情報が足りない」と判断し、延々とツールを呼び続けます。

最も危険なのは、成功条件も失敗条件も定義されていない状態で、この場合、GPT-5 は自分で「十分」と判断するまで実行を続けてしまいます。

リトライ制御における課題

エラーが発生した際のリトライロジックが適切でないと、同じツールを無限に呼び出してしまうことがあります。

typescript// 危険な例:リトライ制御がない
async function callToolWithoutControl(
  toolName: string,
  params: any
) {
  try {
    return await executeTool(toolName, params);
  } catch (error) {
    // エラーが発生したら、もう一度同じツールを呼ぶ(無限ループの危険)
    return callToolWithoutControl(toolName, params);
  }
}

このコードには、リトライ回数の上限も、待機時間(バックオフ)もありません。 ネットワークエラーや一時的なサーバー障害が発生すると、無限に再実行されてしまうのです。

解決策

診断フローの全体像

暴走問題を解決するためには、体系的な診断フローに従って、原因を特定し対策を講じることが重要です。

mermaidflowchart TD
  start["暴走検出"] --> collect["ログ収集"]
  collect --> analyze["実行パターン分析"]
  analyze --> identify["原因特定"]

  identify --> check1{関数設計?}
  identify --> check2{停止条件?}
  identify --> check3{リトライ制御?}

  check1 -->|Yes| fix1["関数定義の改善"]
  check2 -->|Yes| fix2["停止条件の追加"]
  check3 -->|Yes| fix3["リトライ制御の実装"]

  fix1 --> test["テスト実行"]
  fix2 --> test
  fix3 --> test

  test --> verify{解決?}
  verify -->|No| analyze
  verify -->|Yes| done["完了"]

以下、3 つの観点から具体的な解決策を見ていきます。

関数設計の改善

明確な関数定義の作成

ツール呼び出しの暴走を防ぐ最初のステップは、GPT-5 が迷わないような明確な関数定義を作ることです。

typescript// 改善前:曖昧な関数定義
const vagueFunction = {
  name: 'get_data',
  description: 'データを取得する',
  parameters: {
    type: 'object',
    properties: {
      id: { type: 'string' },
    },
  },
};

この定義では、「どんなデータを取得するのか」「id は何を指すのか」が不明確です。 GPT-5 は文脈から推測しようとしますが、推測が外れると何度も呼び直してしまいます。

typescript// 改善後:明確で具体的な関数定義
const clearFunction = {
  name: 'get_user_profile',
  description:
    'ユーザーIDを指定して、ユーザープロフィール情報(名前、メールアドレス、登録日)を取得します。ユーザーが存在しない場合はnullを返します。',
  parameters: {
    type: 'object',
    properties: {
      user_id: {
        type: 'string',
        description:
          '取得対象のユーザーID。UUIDv4形式の文字列(例:550e8400-e29b-41d4-a716-446655440000)',
      },
    },
    required: ['user_id'],
  },
};

この定義では、関数名が具体的で、取得できるデータの種類、パラメータの形式、戻り値の条件まで明記されています。 GPT-5 は迷うことなく、適切なタイミングでこのツールを呼び出せるようになります。

パラメータの詳細指定

パラメータの説明が不足していると、GPT-5 は不正な値を渡してエラーを繰り返すことがあります。

#パラメータ要素記載すべき内容効果
1型情報string、number、boolean 等型エラーを防止
2形式UUID、ISO8601、正規表現等フォーマットエラーを防止
3制約条件最小値、最大値、enum 等範囲外エラーを防止
4例示具体的な使用例正しい使い方を明示

戻り値の明確化

ツールの実行結果がどのような形式で返ってくるのかを明確にすることも重要です。

typescript// ツール実装例:戻り値の形式を統一
interface ToolResponse<T> {
  success: boolean; // 成功したかどうか
  data?: T; // 成功時のデータ
  error?: {
    // エラー時の情報
    code: string;
    message: string;
  };
  metadata?: {
    // メタデータ
    execution_time_ms: number;
    timestamp: string;
  };
}
typescript// getUserProfile関数の実装
async function getUserProfile(
  user_id: string
): Promise<ToolResponse<UserProfile>> {
  try {
    const profile = await database.users.findUnique({
      where: { id: user_id },
    });

    if (!profile) {
      return {
        success: false,
        error: {
          code: 'USER_NOT_FOUND',
          message: `ユーザーID ${user_id} が見つかりませんでした`,
        },
      };
    }

    return {
      success: true,
      data: profile,
      metadata: {
        execution_time_ms: 45,
        timestamp: new Date().toISOString(),
      },
    };
  } catch (error) {
    return {
      success: false,
      error: {
        code: 'DATABASE_ERROR',
        message: error.message,
      },
    };
  }
}

この実装では、成功・失敗が明確に区別でき、エラーコードによって原因も特定できます。 GPT-5 はこの構造化された結果を受け取ることで、次のアクションを適切に判断できるようになるのです。

停止条件の実装

最大実行回数の設定

最もシンプルで効果的な暴走対策は、ツール呼び出しの最大回数を設定することです。

typescript// 最大実行回数を管理するクラス
class ToolExecutionManager {
  private executionCount: Map<string, number> = new Map();
  private maxExecutions: number;

  constructor(maxExecutions: number = 10) {
    this.maxExecutions = maxExecutions;
  }

  // ツール実行前のチェック
  canExecute(sessionId: string): boolean {
    const count = this.executionCount.get(sessionId) || 0;
    return count < this.maxExecutions;
  }

  // 実行回数をインクリメント
  incrementCount(sessionId: string): void {
    const count = this.executionCount.get(sessionId) || 0;
    this.executionCount.set(sessionId, count + 1);
  }

  // 実行回数を取得
  getCount(sessionId: string): number {
    return this.executionCount.get(sessionId) || 0;
  }

  // セッション終了時にクリア
  clearSession(sessionId: string): void {
    this.executionCount.delete(sessionId);
  }
}

このマネージャーを使うことで、セッションごとにツール呼び出し回数を追跡し、上限を超えたら強制的に停止できます。

typescript// 実際の使用例
const manager = new ToolExecutionManager(15); // 最大15回

async function executeToolWithLimit(
  sessionId: string,
  toolName: string,
  params: any
) {
  // 実行可能かチェック
  if (!manager.canExecute(sessionId)) {
    throw new Error(
      `ツール呼び出しが上限(${manager.maxExecutions}回)に達しました。` +
        `現在の実行回数: ${manager.getCount(sessionId)}`
    );
  }

  // 実行回数をインクリメント
  manager.incrementCount(sessionId);

  // ツールを実行
  return await executeTool(toolName, params);
}

この仕組みにより、どんな状況でも一定回数以上のツール呼び出しは発生しなくなります。

成功条件の明確化

単に回数制限をするだけでなく、「どうなったら成功なのか」を明確に定義することも重要です。

typescript// 成功条件を定義するインターフェース
interface SuccessCriteria {
  // 必須データが揃っているか
  hasRequiredData: (context: any) => boolean;

  // 目標が達成されているか
  isGoalAchieved: (context: any) => boolean;

  // これ以上の実行が無意味か
  isRedundant: (context: any) => boolean;
}
typescript// ユーザー情報取得タスクの成功条件
const userInfoCriteria: SuccessCriteria = {
  hasRequiredData: (context) => {
    // 名前、メール、プロフィールの3つが揃っているか
    return context.name && context.email && context.profile;
  },

  isGoalAchieved: (context) => {
    // 全ての必須項目が取得済みか
    const required = [
      'name',
      'email',
      'profile',
      'preferences',
    ];
    return required.every(
      (key) => context[key] !== undefined
    );
  },

  isRedundant: (context) => {
    // 同じツールを3回以上呼んでいたら冗長
    const toolHistory = context.toolHistory || [];
    const lastThree = toolHistory.slice(-3);
    return lastThree.every(
      (tool: string) => tool === lastThree[0]
    );
  },
};

これらの条件を各ツール実行後にチェックすることで、必要な情報が揃った時点で即座に停止できます。

タイムアウトの設定

回数制限に加えて、時間制限も設定することで、より確実に暴走を防げます。

typescript// タイムアウト機能付きツール実行
class TimeoutToolExecutor {
  private startTime: Map<string, number> = new Map();
  private timeoutMs: number;

  constructor(timeoutMs: number = 30000) {
    // デフォルト30秒
    this.timeoutMs = timeoutMs;
  }

  async execute(
    sessionId: string,
    toolName: string,
    params: any
  ): Promise<any> {
    // セッション開始時刻を記録
    if (!this.startTime.has(sessionId)) {
      this.startTime.set(sessionId, Date.now());
    }

    // 経過時間をチェック
    const elapsed =
      Date.now() - this.startTime.get(sessionId)!;
    if (elapsed > this.timeoutMs) {
      throw new Error(
        `ツール実行がタイムアウトしました。` +
          `制限時間: ${this.timeoutMs}ms, 経過時間: ${elapsed}ms`
      );
    }

    // ツールを実行
    return await executeTool(toolName, params);
  }

  clearSession(sessionId: string): void {
    this.startTime.delete(sessionId);
  }
}

回数制限とタイムアウトを組み合わせることで、「10 回まで、または 30 秒以内」という二重の安全装置を実現できますね。

リトライ制御の実装

エクスポネンシャルバックオフの導入

一時的なエラーに対しては適切にリトライすべきですが、待機時間を徐々に伸ばすことで、無駄な再実行を減らせます。

typescript// エクスポネンシャルバックオフの実装
class ExponentialBackoff {
  private baseDelayMs: number;
  private maxDelayMs: number;
  private maxRetries: number;

  constructor(
    baseDelayMs: number = 1000, // 初回待機時間: 1秒
    maxDelayMs: number = 32000, // 最大待機時間: 32秒
    maxRetries: number = 5 // 最大リトライ回数: 5回
  ) {
    this.baseDelayMs = baseDelayMs;
    this.maxDelayMs = maxDelayMs;
    this.maxRetries = maxRetries;
  }

  // リトライ回数に応じた待機時間を計算
  calculateDelay(retryCount: number): number {
    const delay =
      this.baseDelayMs * Math.pow(2, retryCount);
    return Math.min(delay, this.maxDelayMs);
  }

  // ジッターを追加(同時リトライの衝突を防ぐ)
  calculateDelayWithJitter(retryCount: number): number {
    const delay = this.calculateDelay(retryCount);
    const jitter = Math.random() * 1000; // 0-1秒のランダムな遅延
    return delay + jitter;
  }
}

この実装では、リトライごとに待機時間が倍増していきます(1 秒 →2 秒 →4 秒 →8 秒...)。 ランダムなジッターを加えることで、複数のリクエストが同時にリトライして再度衝突するのも防げるのです。

typescript// バックオフを使ったリトライ実装
async function executeWithRetry<T>(
  operation: () => Promise<T>,
  backoff: ExponentialBackoff = new ExponentialBackoff()
): Promise<T> {
  let lastError: Error;

  for (
    let attempt = 0;
    attempt <= backoff.maxRetries;
    attempt++
  ) {
    try {
      // 操作を実行
      return await operation();
    } catch (error) {
      lastError = error as Error;

      // 最後のリトライなら例外を投げる
      if (attempt === backoff.maxRetries) {
        throw new Error(
          `${backoff.maxRetries}回のリトライ後も失敗しました: ${lastError.message}`
        );
      }

      // 待機時間を計算
      const delay =
        backoff.calculateDelayWithJitter(attempt);
      console.log(
        `リトライ ${attempt + 1}/${backoff.maxRetries}: ` +
          `${delay}ms後に再試行します`
      );

      // 待機
      await new Promise((resolve) =>
        setTimeout(resolve, delay)
      );
    }
  }

  throw lastError!;
}

リトライ可能なエラーの判定

全てのエラーをリトライすべきではありません。 リトライしても意味がないエラーは即座に失敗させることが重要です。

#エラー種類リトライ理由
1ネットワークタイムアウトする一時的な障害の可能性
2500 番台サーバーエラーするサーバー側の一時的問題
3429 レート制限する時間を置けば回復
4400 番台クライアントエラーしないリクエスト自体が不正
5バリデーションエラーしないパラメータの修正が必要
6認証エラーしない認証情報の更新が必要
typescript// リトライ可能なエラーかを判定
function isRetryableError(error: any): boolean {
  // HTTPステータスコードによる判定
  if (error.status) {
    // 500番台:サーバーエラー(リトライ可能)
    if (error.status >= 500 && error.status < 600) {
      return true;
    }

    // 429:レート制限(リトライ可能)
    if (error.status === 429) {
      return true;
    }

    // 408:リクエストタイムアウト(リトライ可能)
    if (error.status === 408) {
      return true;
    }

    // 400番台:クライアントエラー(リトライ不可)
    if (error.status >= 400 && error.status < 500) {
      return false;
    }
  }

  // ネットワークエラー(リトライ可能)
  const retryableErrorCodes = [
    'ETIMEDOUT',
    'ECONNRESET',
    'ECONNREFUSED',
    'ENETUNREACH',
    'EHOSTUNREACH',
  ];

  if (
    error.code &&
    retryableErrorCodes.includes(error.code)
  ) {
    return true;
  }

  // その他のエラーはリトライしない
  return false;
}
typescript// リトライ判定を組み込んだ実行関数
async function executeWithSmartRetry<T>(
  operation: () => Promise<T>,
  backoff: ExponentialBackoff = new ExponentialBackoff()
): Promise<T> {
  let lastError: Error;

  for (
    let attempt = 0;
    attempt <= backoff.maxRetries;
    attempt++
  ) {
    try {
      return await operation();
    } catch (error) {
      lastError = error as Error;

      // リトライ不可能なエラーの場合は即座に失敗
      if (!isRetryableError(error)) {
        throw new Error(
          `リトライ不可能なエラーが発生しました: ${lastError.message}`
        );
      }

      // 最後のリトライなら例外を投げる
      if (attempt === backoff.maxRetries) {
        throw new Error(
          `${backoff.maxRetries}回のリトライ後も失敗しました: ${lastError.message}`
        );
      }

      // 待機してリトライ
      const delay =
        backoff.calculateDelayWithJitter(attempt);
      await new Promise((resolve) =>
        setTimeout(resolve, delay)
      );
    }
  }

  throw lastError!;
}

この実装により、無駄なリトライを減らし、本当に解決可能なエラーだけを再試行できますね。

サーキットブレーカーパターン

連続してエラーが発生する場合、一定期間ツール呼び出しを遮断するサーキットブレーカーパターンも有効です。

typescript// サーキットブレーカーの状態
enum CircuitState {
  CLOSED, // 正常(呼び出し可能)
  OPEN, // 遮断中(呼び出し不可)
  HALF_OPEN, // 回復試行中(限定的に呼び出し可能)
}
typescript// サーキットブレーカーの実装
class CircuitBreaker {
  private state: CircuitState = CircuitState.CLOSED;
  private failureCount: number = 0;
  private lastFailureTime: number = 0;
  private successCount: number = 0;

  constructor(
    private failureThreshold: number = 5, // 5回連続失敗でOPEN
    private timeout: number = 60000, // 60秒後にHALF_OPENへ
    private halfOpenSuccessThreshold: number = 2 // 2回成功でCLOSEDへ
  ) {}

  // ツール実行前のチェック
  async execute<T>(
    operation: () => Promise<T>
  ): Promise<T> {
    // OPEN状態かチェック
    if (this.state === CircuitState.OPEN) {
      // タイムアウト経過していたらHALF_OPENへ
      if (
        Date.now() - this.lastFailureTime >=
        this.timeout
      ) {
        this.state = CircuitState.HALF_OPEN;
        this.successCount = 0;
      } else {
        throw new Error(
          'サーキットブレーカーがOPEN状態です。' +
            `${this.timeout}ms後に再試行できます。`
        );
      }
    }

    try {
      // 操作を実行
      const result = await operation();

      // 成功時の処理
      this.onSuccess();
      return result;
    } catch (error) {
      // 失敗時の処理
      this.onFailure();
      throw error;
    }
  }

  private onSuccess(): void {
    this.failureCount = 0;

    if (this.state === CircuitState.HALF_OPEN) {
      this.successCount++;

      // 必要回数成功したらCLOSEDへ
      if (
        this.successCount >= this.halfOpenSuccessThreshold
      ) {
        this.state = CircuitState.CLOSED;
      }
    }
  }

  private onFailure(): void {
    this.failureCount++;
    this.lastFailureTime = Date.now();

    // 閾値を超えたらOPENへ
    if (this.failureCount >= this.failureThreshold) {
      this.state = CircuitState.OPEN;
    }
  }

  getState(): CircuitState {
    return this.state;
  }
}

サーキットブレーカーを使うことで、障害が発生しているツールへの呼び出しを一時的に止め、システム全体への影響を最小限に抑えられます。

具体例

実践的な統合実装

ここまで解説した全ての要素を組み合わせた、実践的なツール実行システムを構築してみましょう。

統合マネージャーの設計

typescript// 全ての制御機能を統合したマネージャー
class ToolExecutionController {
  private executionManager: ToolExecutionManager;
  private timeoutExecutor: TimeoutToolExecutor;
  private backoff: ExponentialBackoff;
  private circuitBreakers: Map<string, CircuitBreaker>;
  private criteria: SuccessCriteria;

  constructor(config: {
    maxExecutions?: number;
    timeoutMs?: number;
    maxRetries?: number;
    criteria: SuccessCriteria;
  }) {
    this.executionManager = new ToolExecutionManager(
      config.maxExecutions
    );
    this.timeoutExecutor = new TimeoutToolExecutor(
      config.timeoutMs
    );
    this.backoff = new ExponentialBackoff(
      1000,
      32000,
      config.maxRetries || 5
    );
    this.circuitBreakers = new Map();
    this.criteria = config.criteria;
  }

  // ツールごとのサーキットブレーカーを取得
  private getCircuitBreaker(
    toolName: string
  ): CircuitBreaker {
    if (!this.circuitBreakers.has(toolName)) {
      this.circuitBreakers.set(
        toolName,
        new CircuitBreaker()
      );
    }
    return this.circuitBreakers.get(toolName)!;
  }
}

実行フローの実装

typescript// 統合マネージャーにツール実行メソッドを追加
class ToolExecutionController {
  // ... 前述のコンストラクタ ...

  async executeTool(
    sessionId: string,
    toolName: string,
    params: any,
    context: any
  ): Promise<any> {
    // ステップ1: 成功条件のチェック
    if (this.criteria.isGoalAchieved(context)) {
      throw new Error(
        '既に目標が達成されています。これ以上の実行は不要です。'
      );
    }

    if (this.criteria.isRedundant(context)) {
      throw new Error('冗長な実行が検出されました。');
    }

    // ステップ2: 実行回数のチェック
    if (!this.executionManager.canExecute(sessionId)) {
      throw new Error(
        `実行回数が上限に達しました: ${this.executionManager.getCount(
          sessionId
        )}回`
      );
    }

    // ステップ3: サーキットブレーカーとリトライを使って実行
    const circuitBreaker = this.getCircuitBreaker(toolName);

    const result = await executeWithSmartRetry(async () => {
      return await circuitBreaker.execute(async () => {
        return await this.timeoutExecutor.execute(
          sessionId,
          toolName,
          params
        );
      });
    }, this.backoff);

    // ステップ4: 実行回数をインクリメント
    this.executionManager.incrementCount(sessionId);

    return result;
  }

  // セッション終了時のクリーンアップ
  endSession(sessionId: string): void {
    this.executionManager.clearSession(sessionId);
    this.timeoutExecutor.clearSession(sessionId);
  }
}

この統合実装により、関数設計・停止条件・リトライ制御の全てが連携して動作します。

実際の使用例:ユーザー情報収集タスク

それでは、実際のユースケースで統合マネージャーを使ってみましょう。

typescript// ユーザー情報収集タスクの成功条件
const userInfoCriteria: SuccessCriteria = {
  hasRequiredData: (context) => {
    return !!(
      context.name &&
      context.email &&
      context.profile
    );
  },

  isGoalAchieved: (context) => {
    const required = [
      'name',
      'email',
      'profile',
      'preferences',
      'history',
    ];
    return required.every((key) => context.data[key]);
  },

  isRedundant: (context) => {
    const history = context.toolHistory || [];
    if (history.length < 3) return false;

    const lastThree = history.slice(-3);
    return lastThree.every(
      (tool: string) => tool === lastThree[0]
    );
  },
};
typescript// コントローラーのセットアップ
const controller = new ToolExecutionController({
  maxExecutions: 15, // 最大15回
  timeoutMs: 30000, // 30秒でタイムアウト
  maxRetries: 3, // 各ツールは最大3回リトライ
  criteria: userInfoCriteria,
});
typescript// GPT-5のツール定義
const tools = [
  {
    name: 'get_user_basic_info',
    description:
      'ユーザーの基本情報(名前、メールアドレス)を取得します。',
    parameters: {
      type: 'object',
      properties: {
        user_id: {
          type: 'string',
          description: 'ユーザーID(UUID形式)',
        },
      },
      required: ['user_id'],
    },
  },
  {
    name: 'get_user_profile',
    description:
      'ユーザーのプロフィール情報(自己紹介、アバター画像URL)を取得します。',
    parameters: {
      type: 'object',
      properties: {
        user_id: {
          type: 'string',
          description: 'ユーザーID(UUID形式)',
        },
      },
      required: ['user_id'],
    },
  },
  {
    name: 'get_user_preferences',
    description:
      'ユーザーの設定情報(通知設定、言語設定、テーマ)を取得します。',
    parameters: {
      type: 'object',
      properties: {
        user_id: {
          type: 'string',
          description: 'ユーザーID(UUID形式)',
        },
      },
      required: ['user_id'],
    },
  },
];
typescript// タスクの実行
async function collectUserInfo(userId: string) {
  const sessionId = `session_${Date.now()}`;
  const context = {
    data: {},
    toolHistory: [],
  };

  try {
    // GPT-5にツール呼び出しを依頼
    const response = await gpt5.chat({
      messages: [
        {
          role: 'system',
          content:
            'ユーザーIDを指定して、利用可能なツールで全ての情報を収集してください。',
        },
        {
          role: 'user',
          content: `ユーザーID ${userId} の情報を全て取得してください。`,
        },
      ],
      tools: tools,
      tool_choice: 'auto',
    });

    // ツール呼び出しを処理
    for (const toolCall of response.tool_calls || []) {
      const { name, arguments: params } = toolCall;

      // コントローラーを通じて実行
      const result = await controller.executeTool(
        sessionId,
        name,
        JSON.parse(params),
        context
      );

      // 結果をコンテキストに保存
      if (result.success) {
        Object.assign(context.data, result.data);
      }

      // 実行履歴を記録
      context.toolHistory.push(name);

      // 目標達成チェック
      if (userInfoCriteria.isGoalAchieved(context)) {
        console.log('全ての情報を取得しました!');
        break;
      }
    }

    return context.data;
  } finally {
    // セッションをクリーンアップ
    controller.endSession(sessionId);
  }
}

この実装では、以下の保護機能が全て働いています。

#保護機能動作内容
1実行回数制限15 回を超えたら強制停止
2タイムアウト30 秒を超えたら強制停止
3成功条件判定必要な情報が揃ったら即座に終了
4冗長性検出同じツールを 3 回連続で呼んだら警告
5リトライ制御一時的エラーは最大 3 回まで再試行
6サーキットブレーカー特定ツールが連続失敗したら一時遮断

ログとモニタリングの実装

暴走を早期に検出するためには、適切なログとモニタリングが不可欠です。

typescript// ログ記録用のインターフェース
interface ToolExecutionLog {
  session_id: string;
  tool_name: string;
  attempt: number;
  timestamp: string;
  duration_ms: number;
  success: boolean;
  error?: string;
  circuit_state?: CircuitState;
}
typescript// ロギング機能を追加したコントローラー
class ToolExecutionController {
  private logs: ToolExecutionLog[] = [];

  // ... 既存のプロパティ ...

  async executeTool(
    sessionId: string,
    toolName: string,
    params: any,
    context: any
  ): Promise<any> {
    const startTime = Date.now();
    let success = false;
    let error: string | undefined;

    try {
      // ツール実行(前述のロジック)
      const result = await this.executeToolInternal(
        sessionId,
        toolName,
        params,
        context
      );

      success = true;
      return result;
    } catch (err) {
      error = (err as Error).message;
      throw err;
    } finally {
      // ログを記録
      const duration = Date.now() - startTime;
      const circuitBreaker =
        this.getCircuitBreaker(toolName);

      this.logs.push({
        session_id: sessionId,
        tool_name: toolName,
        attempt: this.executionManager.getCount(sessionId),
        timestamp: new Date().toISOString(),
        duration_ms: duration,
        success,
        error,
        circuit_state: circuitBreaker.getState(),
      });

      // 暴走の兆候を検出
      this.detectRunawayPattern(sessionId);
    }
  }

  // 暴走パターンの検出
  private detectRunawayPattern(sessionId: string): void {
    const sessionLogs = this.logs.filter(
      (log) => log.session_id === sessionId
    );
    const recentLogs = sessionLogs.slice(-5); // 直近5件

    // 同じツールが5回連続で呼ばれている
    if (recentLogs.length === 5) {
      const toolNames = recentLogs.map(
        (log) => log.tool_name
      );
      const allSame = toolNames.every(
        (name) => name === toolNames[0]
      );

      if (allSame) {
        console.warn(
          `⚠️ 暴走の可能性: ${toolNames[0]} が5回連続で呼び出されています`
        );
      }
    }

    // 短時間に大量の呼び出し
    if (sessionLogs.length >= 10) {
      const firstLog = sessionLogs[0];
      const lastLog = sessionLogs[sessionLogs.length - 1];
      const elapsedMs =
        new Date(lastLog.timestamp).getTime() -
        new Date(firstLog.timestamp).getTime();

      if (elapsedMs < 5000) {
        // 5秒以内に10回
        console.warn(
          `⚠️ 暴走の可能性: 5秒以内に${sessionLogs.length}回のツール呼び出し`
        );
      }
    }
  }

  // ログの取得
  getLogs(sessionId?: string): ToolExecutionLog[] {
    if (sessionId) {
      return this.logs.filter(
        (log) => log.session_id === sessionId
      );
    }
    return this.logs;
  }
}

このロギング機能により、問題が発生した際に詳細な実行履歴を確認できますし、暴走の兆候を早期に検出することも可能になります。

テストケースの作成

暴走対策が正しく機能しているかを確認するため、テストケースを作成しましょう。

typescript// テストケース1: 最大実行回数の制限
describe('ToolExecutionController - 実行回数制限', () => {
  it('最大実行回数を超えたらエラーを投げる', async () => {
    const controller = new ToolExecutionController({
      maxExecutions: 3,
      criteria: mockCriteria,
    });

    const sessionId = 'test_session_1';

    // 3回は成功する
    await controller.executeTool(
      sessionId,
      'tool1',
      {},
      {}
    );
    await controller.executeTool(
      sessionId,
      'tool2',
      {},
      {}
    );
    await controller.executeTool(
      sessionId,
      'tool3',
      {},
      {}
    );

    // 4回目はエラー
    await expect(
      controller.executeTool(sessionId, 'tool4', {}, {})
    ).rejects.toThrow('実行回数が上限に達しました');
  });
});
typescript// テストケース2: タイムアウト
describe('ToolExecutionController - タイムアウト', () => {
  it('制限時間を超えたらエラーを投げる', async () => {
    const controller = new ToolExecutionController({
      timeoutMs: 1000, // 1秒でタイムアウト
      criteria: mockCriteria,
    });

    const sessionId = 'test_session_2';

    // 最初の実行
    await controller.executeTool(
      sessionId,
      'tool1',
      {},
      {}
    );

    // 1.5秒待機
    await new Promise((resolve) =>
      setTimeout(resolve, 1500)
    );

    // タイムアウトエラー
    await expect(
      controller.executeTool(sessionId, 'tool2', {}, {})
    ).rejects.toThrow('タイムアウトしました');
  });
});
typescript// テストケース3: サーキットブレーカー
describe('ToolExecutionController - サーキットブレーカー', () => {
  it('連続失敗後は呼び出しを遮断する', async () => {
    const controller = new ToolExecutionController({
      maxRetries: 0, // リトライなし
      criteria: mockCriteria,
    });

    const sessionId = 'test_session_3';
    const failingTool = 'failing_tool';

    // モックツールを5回連続で失敗させる
    mockExecuteTool.mockRejectedValue(
      new Error('API Error')
    );

    for (let i = 0; i < 5; i++) {
      try {
        await controller.executeTool(
          sessionId,
          failingTool,
          {},
          {}
        );
      } catch (error) {
        // エラーは想定内
      }
    }

    // 6回目は即座にサーキットブレーカーエラー
    await expect(
      controller.executeTool(sessionId, failingTool, {}, {})
    ).rejects.toThrow('サーキットブレーカーがOPEN状態です');
  });
});

これらのテストを定期的に実行することで、暴走対策が確実に機能していることを保証できます。

まとめ

GPT-5 のツール呼び出しが暴走する問題は、関数設計・停止条件・リトライ制御の 3 つの観点から体系的に診断し対策することで、確実に防ぐことができます。

本記事で解説した主要なポイントを振り返りましょう。

#対策カテゴリキーポイント期待効果
1関数設計明確な関数定義とパラメータ説明GPT-5 の判断ミスを削減
2停止条件最大実行回数とタイムアウトの設定暴走の強制停止
3成功条件目標達成時の即座な終了無駄な実行の削減
4リトライ制御エクスポネンシャルバックオフ一時的エラーへの適切な対応
5エラー判定リトライ可能/不可能の分類無駄なリトライの排除
6サーキットブレーカー連続失敗時の一時遮断システム全体への影響軽減

これらの対策を組み合わせた統合コントローラーを実装することで、安全で効率的なツール呼び出しシステムを構築できます。

また、適切なログとモニタリングを導入することで、問題の早期発見と迅速な対応が可能になるでしょう。 GPT-5 のツール呼び出しは非常に強力な機能ですが、適切な制御なしでは暴走のリスクがあります。

本記事で紹介した診断フローと実装パターンを活用し、安心して AI ツール呼び出しを活用していただければ幸いです。 あなたのプロジェクトでも、これらの手法を取り入れて、より堅牢なシステムを構築してみてください。

関連リンク