Function Callingが無限ループする原因|ツール実行回数と終了条件の設計

Function Callingが無限ループする原因|ツール実行回数と終了条件の設計 AI開発

Function Callingを使ってAIへ外部APIやデータベース検索、メール送信などを任せると、同じツールが何度も呼び出されることがあります。

モデルが検索ツールを呼び、検索結果を返した直後に同じ条件で再検索するケースや、メール送信に成功したにもかかわらず、再び同じメールを送信しようとするケースがあります。

この状態を放置すると、OpenAI APIのトークン料金だけでなく、外部APIの料金も増加します。データ登録やメール送信などの副作用を伴うツールでは、同じ処理が複数回実行される危険もあります。

Function Callingは、一度関数を実行したら自動的に終了する仕組みではありません。モデルへツールの実行結果を返したあと、モデルが最終回答を生成する場合もあれば、追加のツール呼び出しを要求する場合もあります。OpenAIの公式ドキュメントでも、ツール呼び出しはモデルとアプリケーション間の複数ステップの処理であり、最終回答の代わりに新しいツール呼び出しが返ることがあると説明されています。

そのため、アプリケーション側にツールの最大実行回数、同一呼び出しの検出、全体タイムアウト、最終回答へ切り替える条件を実装する必要があります。

この記事では、OpenAIのResponses APIとTypeScriptを使い、Function Callingが無限ループする原因と安全な終了条件の設計を解説します。Function Callingそのものの基本は【TypeScript】OpenAI API入門、ツール引数の検証に使うZodの文法は【TypeScript】Zod 完全ガイドもあわせてご覧ください。

スポンサーリンク

Function Callingのループはアプリケーション側で動いている

Function Callingでは、モデル自身がJavaScriptや外部APIを直接実行するわけではありません。

アプリケーションが利用可能なツールをモデルへ渡すと、モデルは必要に応じてfunction_callを返します。アプリケーションはその引数を使って関数を実行し、結果をfunction_call_outputとしてモデルへ返します。

OpenAIのResponses APIでは、function_callfunction_call_outputが別のアイテムとして扱われ、同じcall_idによって関連付けられます。ツールの実行結果は通常、文字列またはJSON文字列としてモデルへ返します。

基本的な処理は、次のようなループになります。

基本のループ
while (true) {
  const response = await openai.responses.create({
    model,
    input,
    tools,
  });

  input.push(...response.output);

  const functionCalls = response.output.filter(
    (item) => item.type === "function_call",
  );

  if (functionCalls.length === 0) {
    return response.output_text;
  }

  for (const functionCall of functionCalls) {
    const result = await executeTool(
      functionCall.name,
      JSON.parse(functionCall.arguments),
    );

    input.push({
      type: "function_call_output",
      call_id: functionCall.call_id,
      output: JSON.stringify(result),
    });
  }
}

このコードには終了条件が一応あります。

モデルがツールを呼ばず、テキストだけを返したらループを終了します。

しかし、モデルが毎回何らかのツールを呼び続けた場合、while (true)は終了しません。APIエラーが発生しない限り、モデル呼び出しとツール実行が繰り返されます。

原因1:tool_choiceをrequiredのまま使い続けている

Function Callingの無限ループで最初に確認したいのがtool_choiceです。

OpenAI APIのtool_choiceには、モデルへツールの使用方法を指定する役割があります。標準のautoでは、モデルがツールを呼ばない、1個呼ぶ、複数呼ぶという判断を行います。

requiredを指定すると、モデルは1個以上のツールを呼ぶ必要があります。特定の関数を指定した場合は、その関数を必ず呼び出します。ツールを呼ばせたくないターンではnoneを指定できます。

次のコードでは、すべてのターンでツール呼び出しが必須です。

全ターンでrequiredの例
const response = await openai.responses.create({
  model,
  input,
  tools,
  tool_choice: "required",
});

ツールの実行結果を返した次のターンでもrequiredを指定すると、モデルは最終回答だけを返すことができません。

最初のターンだけ必ず検索させたい場合は、ツール実行後にautoまたはnoneへ切り替えます。

ターンごとにtool_choiceを切り替える例
let toolChoice: "required" | "auto" | "none" =
  "required";

while (true) {
  const response = await openai.responses.create({
    model,
    input,
    tools,
    tool_choice: toolChoice,
  });

  toolChoice = "auto";

  // 続きの処理
}

ツールを一度実行したら必ず最終回答へ進ませたい処理では、次のターンをnoneにします。

最終回答へ強制する例
toolChoice = "none";

ただし、複数段階の検索や確認が必要な処理では、最初のツール実行だけでnoneに切り替えるとタスクを完了できない場合があります。

requiredautononeを固定値として扱うのではなく、現在の処理段階に応じて切り替える必要があります。

原因2:最終回答を生成する条件が曖昧

モデルへ「必要なツールを使って回答してください」とだけ指示すると、どの時点で情報が十分なのかをモデルが判断しにくくなります。

たとえば、社内ドキュメントを検索するツールに次の説明だけを設定したとします。

昔の説明例
社内ドキュメントを検索します。

この説明では、一度検索した結果で回答してよいのか、別のキーワードでも検索するべきなのかが不明確です。

ツールの説明とシステム指示には、ツールを使用する条件だけでなく、使用しない条件と終了条件も記載します。

終了条件を明記した指示例
const instructions = `
あなたは社内ヘルプデスクです。

ユーザーの質問へ回答するために社内資料が
必要な場合だけ、search_docsを使用してください。

十分な根拠を取得したら追加検索を行わず、
取得した情報を使って最終回答を生成してください。

同じ検索語でsearch_docsを複数回実行してはいけません。

検索結果に情報がない場合は、
検索を繰り返さず、確認できなかったことを回答してください。
`.trim();

プロンプトによる終了指示は有効ですが、これだけに依存してはいけません。

モデルが指示に反して再びツールを呼ぶ可能性を考え、コード側にも強制的な上限を設定します。

原因3:ツールの戻り値が曖昧

ツールを実行したあと、単純な文字列だけを返すと、モデルが成功したのか失敗したのか判断できない場合があります。

次の戻り値では、検索結果が0件だったのか、検索処理自体が失敗したのかが分かりません。

曖昧な戻り値
結果はありませんでした

モデルは「別の検索が必要」と判断し、同じツールを呼び直す可能性があります。

ツールの戻り値には、処理状態、再試行の可否、データ、エラーコードを明示します。

0件だった場合
{
  "status": "not_found",
  "retryable": false,
  "query": "経費精算の期限",
  "data": [],
  "message": "該当する資料は見つかりませんでした"
}

一時的な通信障害の場合は、別の状態を返します。

一時エラーの場合
{
  "status": "temporary_error",
  "retryable": true,
  "errorCode": "UPSTREAM_TIMEOUT",
  "message": "検索サーバーが一時的に応答しませんでした"
}

入力内容が不正な場合は、再試行できないことを明示します。

入力不正の場合
{
  "status": "invalid_input",
  "retryable": false,
  "errorCode": "QUERY_TOO_LONG",
  "message": "検索語は200文字以内にしてください"
}

単にerrorという文字列を返すと、モデルは引数を少し変更して何度も再試行する可能性があります。

retryableを明示した構造化データにすることで、モデルとアプリケーションの両方が次の動作を判断しやすくなります。

原因4:同じ引数のツール呼び出しを検出していない

モデルが同じ関数と同じ引数を何度も返す場合、すでに取得した情報が会話へ正しく反映されていない可能性があります。

ツール名と正規化した引数から識別子を作り、実行回数を記録します。

単純に元のJSON文字列を比較すると、プロパティの順序が異なるだけで別の呼び出しとして扱われることがあります。次の2つは同じ内容ですが、文字列としては一致しません。

順序違いの例1
{"query":"経費精算","limit":5}
順序違いの例2
{"limit":5,"query":"経費精算"}

比較前にオブジェクトのキーを並べ替え、安定した文字列へ変換します。

src/fingerprint.ts
function stableStringify(value: unknown): string {
  if (value === null || typeof value !== "object") {
    return JSON.stringify(value);
  }

  if (Array.isArray(value)) {
    return `[${value
      .map((item) => stableStringify(item))
      .join(",")}]`;
  }

  const object = value as Record<string, unknown>;
  const keys = Object.keys(object).sort();

  const entries = keys.map((key) => {
    return `${JSON.stringify(key)}:${stableStringify(
      object[key],
    )}`;
  });

  return `{${entries.join(",")}}`;
}

function createToolFingerprint(
  name: string,
  argumentsValue: unknown,
): string {
  return `${name}:${stableStringify(argumentsValue)}`;
}

同じ呼び出しを完全に禁止すると、正当な再試行まで止める可能性があります。

そのため、通常は同じツールと同じ引数を1回または2回まで許可し、それを超えたらブロックする設計にします。

原因5:ツールのエラーをそのままモデルへ返している

外部APIがタイムアウトしたとき、例外メッセージをそのままモデルへ返す実装があります。

非推奨例
try {
  return await externalApi();
} catch (error) {
  return String(error);
}

この戻り値には、再試行してよいエラーなのか、永久に失敗するエラーなのかが含まれていません。

認証エラーや権限エラー、存在しないIDなどは、同じ処理を繰り返しても成功しません。

一方、ネットワークタイムアウトや一時的な503エラーは、少し時間を空ければ成功する可能性があります。

ツール側でエラーを分類し、再試行できる回数を決めます。

src/tool-result-type.ts
type ToolResult = {
  status:
    | "success"
    | "not_found"
    | "invalid_input"
    | "temporary_error"
    | "permanent_error"
    | "blocked";
  retryable: boolean;
  terminal: boolean;
  data?: unknown;
  errorCode?: string;
  message: string;
};

ツール内部のリトライと、モデルによるツール再呼び出しは別の処理です。

外部APIへの一時的な再試行はツール内部で完結させ、最終的な結果だけをモデルへ返すほうが制御しやすくなります。

原因6:ツールの結果を正しいcall_idで返していない

Responses APIでは、モデルが返したfunction_callと、その実行結果をcall_idで関連付けます。モデルが複数のツールを呼んだ場合も、各結果を元の呼び出しへ対応させる必要があります。

次のように、新しいIDを生成してはいけません。

非推奨:新しいIDを生成
input.push({
  type: "function_call_output",
  call_id: crypto.randomUUID(),
  output: JSON.stringify(result),
});

モデルが返した値をそのまま使用します。

推奨:元のcall_idを使用
input.push({
  type: "function_call_output",
  call_id: functionCall.call_id,
  output: JSON.stringify(result),
});

また、次のモデル呼び出しへ進む前に、モデルが返したresponse.outputも履歴へ追加します。

履歴への追加
input.push(...response.output);

ツールの結果だけを送り、元のfunction_callを履歴から欠落させると、会話の状態が正しくつながらない可能性があります。

原因7:成功済みの副作用ツールを再び公開している

検索ツールは同じ処理を繰り返しても、主な問題は料金や待ち時間です。

一方、メール送信、注文確定、返金、ファイル削除、データ更新などのツールは、同じ呼び出しが繰り返されると実際の被害につながります。

たとえば、send_emailが成功したあとも同じツールをモデルへ公開し続けると、モデルが確認のために再度呼び出す可能性があります。

副作用を伴う処理が成功したら、次のターンではtool_choice: "none"を指定して最終回答を生成させる方法があります。

次ターンで最終回答へ
let forceFinalAnswer = false;

const response = await openai.responses.create({
  model,
  input,
  tools,
  tool_choice: forceFinalAnswer
    ? "none"
    : "auto",
});

ツールの実行結果にterminalを含め、成功時に切り替えます。

terminalで切り替える例
const result: ToolResult = {
  status: "success",
  retryable: false,
  terminal: true,
  data: {
    messageId: "msg_123",
  },
  message: "メールを送信しました",
};

if (result.terminal) {
  forceFinalAnswer = true;
}

さらに、外部処理側にも冪等性キーを実装します。

冪等性キーの例
await sendEmail({
  idempotencyKey: `${conversationId}:${operationId}`,
  to,
  subject,
  body,
});

モデル側のループ防止だけでは、ネットワーク再送やサーバーの再起動による重複実行を防げません。

重要な副作用ツールでは、同じ冪等性キーによる処理を一度だけ受け付ける仕組みが必要です。

原因8:並列ツール呼び出しの依存関係を考慮していない

対応モデルでは、モデルが1ターンに複数の関数を呼び出す場合があります。

parallel_tool_callsfalseにすると、1回のモデル応答で呼び出される関数を0個または1個に制限できます。

並列呼び出しを制限する例
const response = await openai.responses.create({
  model,
  input,
  tools,
  parallel_tool_calls: false,
});

独立した天気情報を複数都市について取得するような処理は、並列実行に向いています。

一方、顧客を検索してから、その顧客IDを使って注文を取得する処理には順序があります。

依存関係のあるツールを並列実行すると、必要な引数がまだ取得できていない状態で次のツールが呼ばれ、エラーと再試行を繰り返す可能性があります。

副作用のあるツールや、順序が重要なワークフローでは、まずparallel_tool_calls: falseから始めるほうが安全です。

strictモードだけでは無限ループを防げない

Function Callingでは、ツールの引数をJSON Schemaで定義できます。

strict: trueを指定すると、モデルが関数スキーマに従った引数を生成しやすくなります。OpenAIはstrictモードを常に有効にすることを推奨しており、各オブジェクトのadditionalPropertiesfalseにし、すべてのプロパティをrequiredへ含める必要があります。省略可能な値はnullを許可して表現します。

src/tools.ts
const tools = [
  {
    type: "function",
    name: "search_docs",
    description:
      "社内資料を検索します。回答に必要な場合だけ使用してください。",
    parameters: {
      type: "object",
      properties: {
        query: {
          type: "string",
          description:
            "検索に使用する簡潔なキーワード",
        },
        limit: {
          type: "number",
          description:
            "取得する最大件数",
        },
      },
      required: ["query", "limit"],
      additionalProperties: false,
    },
    strict: true,
  },
] satisfies OpenAI.Responses.Tool[];

strictモードで防げるのは、主に引数の形式崩れです。

同じ正しい引数を何度も生成する問題や、ツールを終了すべきタイミングの判断、ツール結果の事実性までは保証しません。

引数のスキーマと、ループの終了条件は別々に設計する必要があります。

終了条件は複数設定する

安全なFunction Callingでは、一つの終了条件だけに依存しません。

モデルがツールを呼ばなければ終了するという条件に加えて、モデルを呼び出す最大ターン数、ツールの合計実行回数、同一ツール呼び出しの回数、処理全体の時間、ツール単体の時間を制限します。

また、メール送信やデータ更新などの最終処理が成功した場合は、残りのツール呼び出しを禁止して最終回答へ切り替えます。

上限へ達したときは、無言で処理を終了するのではなく、途中まで取得できた情報と終了理由をユーザーへ返せる設計にします。

ツール単体へタイムアウトを設定する

モデルの最大ターン数を制限しても、1回のツール処理が終了しなければ全体が止まります。

AbortSignal.timeout()を利用できる処理では、外部APIへ制限時間を渡します。

src/search-documents.ts
async function searchDocuments(
  query: string,
  signal: AbortSignal,
): Promise<unknown> {
  const response = await fetch(
    "https://example.internal/search",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        query,
      }),
      signal,
    },
  );

  if (!response.ok) {
    throw new Error(
      `検索APIがHTTP ${response.status}を返しました。`,
    );
  }

  return response.json();
}

呼び出し側では、10秒などの上限を設定します。

呼び出し例
const data = await searchDocuments(
  query,
  AbortSignal.timeout(10_000),
);

利用しているライブラリがAbortSignalに対応していない場合は、Promise.race()でタイムアウトを追加できます。

src/with-timeout.ts
async function withTimeout<T>(
  promise: Promise<T>,
  milliseconds: number,
): Promise<T> {
  let timeoutId: ReturnType<
    typeof setTimeout
  > | undefined;

  const timeoutPromise = new Promise<never>(
    (_, reject) => {
      timeoutId = setTimeout(() => {
        reject(
          new Error(
            `処理が${milliseconds}msを超えました。`,
          ),
        );
      }, milliseconds);
    },
  );

  try {
    return await Promise.race([
      promise,
      timeoutPromise,
    ]);
  } finally {
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
    }
  }
}

Promise.race()は呼び出し元の待機を終了させますが、内部処理そのものを停止できるとは限りません。

可能であれば、外部処理にもAbortSignalを渡して実際の通信を中止します。

Zodでツール引数を実行前に検証する

strictモードを使用していても、外部から届く値を実行時に検証する設計は有効です。Zodの基本文法は【TypeScript】Zod 完全ガイドで解説しています。

Zodを使うと、ツールごとに引数の型と範囲を確認できます。

src/argument-schemas.ts
import { z } from "zod";

const SearchDocsArgumentsSchema = z
  .object({
    query: z
      .string()
      .trim()
      .min(1)
      .max(200),
    limit: z
      .number()
      .int()
      .min(1)
      .max(10),
  })
  .strict();

const CreateTicketArgumentsSchema = z
  .object({
    title: z
      .string()
      .trim()
      .min(1)
      .max(100),
    description: z
      .string()
      .trim()
      .min(1)
      .max(2_000),
    priority: z.enum([
      "low",
      "normal",
      "high",
    ]),
  })
  .strict();

モデルから届いたargumentsをJSONとして解析したあと、safeParse()へ渡します。

パースの例
function parseJson(
  value: string,
): unknown {
  try {
    return JSON.parse(value);
  } catch {
    throw new Error(
      "ツール引数をJSONとして解析できません。",
    );
  }
}

スキーマ検証に失敗した場合は、ツールを実行しません。

検証失敗時の例
const parsed =
  SearchDocsArgumentsSchema.safeParse(
    rawArguments,
  );

if (!parsed.success) {
  return {
    status: "invalid_input",
    retryable: false,
    terminal: false,
    errorCode: "INVALID_TOOL_ARGUMENTS",
    message:
      "search_docsの引数が不正です。",
  } satisfies ToolResult;
}

引数エラーをモデルへ返して修正を許可する場合も、同じエラーを繰り返せる回数を制限します。

安全なFunction CallingループをTypeScriptで実装する

次の実装では、モデルの最大ターン数、ツールの合計実行数、同一呼び出し回数、ツール単体のタイムアウト、処理全体のタイムアウトを制限します。

副作用のあるツールが成功した場合は、次のターンでtool_choice: "none"を指定し、強制的に最終回答を生成させます。

src/run-tool-loop.ts
import OpenAI from "openai";
import { z } from "zod";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  timeout: 120_000,
  maxRetries: 1,
});

const MAX_MODEL_TURNS = 6;
const MAX_TOOL_CALLS = 8;
const MAX_IDENTICAL_CALLS = 2;
const TOOL_TIMEOUT_MS = 10_000;
const TOTAL_TIMEOUT_MS = 60_000;

type ToolResult = {
  status:
    | "success"
    | "not_found"
    | "invalid_input"
    | "temporary_error"
    | "permanent_error"
    | "blocked";
  retryable: boolean;
  terminal: boolean;
  data?: unknown;
  errorCode?: string;
  message: string;
};

const SearchDocsArgumentsSchema = z
  .object({
    query: z.string().trim().min(1).max(200),
    limit: z.number().int().min(1).max(10),
  })
  .strict();

const CreateTicketArgumentsSchema = z
  .object({
    title: z.string().trim().min(1).max(100),
    description: z.string().trim().min(1).max(2_000),
    priority: z.enum(["low", "normal", "high"]),
    operationId: z.string().trim().min(1).max(100),
  })
  .strict();

const tools = [
  {
    type: "function",
    name: "search_docs",
    description: `
社内資料を検索します。
ユーザーの質問へ回答するために資料が必要な場合だけ使用してください。
同じ検索語で繰り返し検索してはいけません。
検索結果が0件の場合は、別の根拠がない限り最終回答へ進んでください。
    `.trim(),
    parameters: {
      type: "object",
      properties: {
        query: {
          type: "string",
          description: "検索に使う200文字以内のキーワード",
        },
        limit: {
          type: "number",
          description: "取得件数。1以上10以下",
        },
      },
      required: ["query", "limit"],
      additionalProperties: false,
    },
    strict: true,
  },
  {
    type: "function",
    name: "create_ticket",
    description: `
ユーザーが明確に依頼した場合だけサポートチケットを作成します。
同じoperationIdのチケットを複数作成してはいけません。
作成成功後は追加のツールを呼ばず、結果をユーザーへ回答してください。
    `.trim(),
    parameters: {
      type: "object",
      properties: {
        title: { type: "string" },
        description: { type: "string" },
        priority: {
          type: "string",
          enum: ["low", "normal", "high"],
        },
        operationId: {
          type: "string",
          description: "重複登録を防ぐ一意な操作ID",
        },
      },
      required: [
        "title",
        "description",
        "priority",
        "operationId",
      ],
      additionalProperties: false,
    },
    strict: true,
  },
] satisfies OpenAI.Responses.Tool[];

function stableStringify(value: unknown): string {
  if (value === null || typeof value !== "object") {
    return JSON.stringify(value);
  }

  if (Array.isArray(value)) {
    return `[${value
      .map((item) => stableStringify(item))
      .join(",")}]`;
  }

  const object = value as Record<string, unknown>;

  const entries = Object.keys(object)
    .sort()
    .map((key) => {
      return `${JSON.stringify(key)}:${stableStringify(
        object[key],
      )}`;
    });

  return `{${entries.join(",")}}`;
}

function createFingerprint(
  name: string,
  argumentsValue: unknown,
): string {
  return `${name}:${stableStringify(argumentsValue)}`;
}

function parseArguments(value: string): unknown {
  try {
    return JSON.parse(value);
  } catch {
    return null;
  }
}

async function searchDocuments(
  query: string,
  limit: number,
  signal: AbortSignal,
): Promise<unknown[]> {
  const response = await fetch(
    "https://example.internal/search",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query, limit }),
      signal,
    },
  );

  if (!response.ok) {
    throw new Error(
      `検索APIがHTTP ${response.status}を返しました。`,
    );
  }

  const value: unknown = await response.json();

  return Array.isArray(value) ? value : [];
}

async function createTicket(
  argumentsValue: z.infer<
    typeof CreateTicketArgumentsSchema
  >,
  signal: AbortSignal,
): Promise<{ ticketId: string }> {
  const response = await fetch(
    "https://example.internal/tickets",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": argumentsValue.operationId,
      },
      body: JSON.stringify(argumentsValue),
      signal,
    },
  );

  if (!response.ok) {
    throw new Error(
      `チケットAPIがHTTP ${response.status}を返しました。`,
    );
  }

  const value = (await response.json()) as {
    ticketId?: unknown;
  };

  if (typeof value.ticketId !== "string") {
    throw new Error(
      "チケットIDを取得できませんでした。",
    );
  }

  return { ticketId: value.ticketId };
}

async function executeTool(
  name: string,
  rawArguments: unknown,
): Promise<ToolResult> {
  const signal = AbortSignal.timeout(TOOL_TIMEOUT_MS);

  try {
    if (name === "search_docs") {
      const parsed =
        SearchDocsArgumentsSchema.safeParse(rawArguments);

      if (!parsed.success) {
        return {
          status: "invalid_input",
          retryable: false,
          terminal: false,
          errorCode: "INVALID_SEARCH_ARGUMENTS",
          message: "検索ツールの引数が不正です。",
        };
      }

      const documents = await searchDocuments(
        parsed.data.query,
        parsed.data.limit,
        signal,
      );

      if (documents.length === 0) {
        return {
          status: "not_found",
          retryable: false,
          terminal: false,
          data: [],
          message: "該当する資料は見つかりませんでした。",
        };
      }

      return {
        status: "success",
        retryable: false,
        terminal: false,
        data: documents,
        message: `${documents.length}件の資料を取得しました。`,
      };
    }

    if (name === "create_ticket") {
      const parsed =
        CreateTicketArgumentsSchema.safeParse(rawArguments);

      if (!parsed.success) {
        return {
          status: "invalid_input",
          retryable: false,
          terminal: false,
          errorCode: "INVALID_TICKET_ARGUMENTS",
          message: "チケット作成の引数が不正です。",
        };
      }

      const ticket = await createTicket(parsed.data, signal);

      return {
        status: "success",
        retryable: false,
        terminal: true,
        data: ticket,
        message: "サポートチケットを作成しました。",
      };
    }

    return {
      status: "permanent_error",
      retryable: false,
      terminal: false,
      errorCode: "UNKNOWN_TOOL",
      message: `未対応のツールです: ${name}`,
    };
  } catch (error) {
    if (
      error instanceof DOMException &&
      error.name === "TimeoutError"
    ) {
      return {
        status: "temporary_error",
        retryable: true,
        terminal: false,
        errorCode: "TOOL_TIMEOUT",
        message: "ツールの処理がタイムアウトしました。",
      };
    }

    return {
      status: "temporary_error",
      retryable: true,
      terminal: false,
      errorCode: "TOOL_EXECUTION_FAILED",
      message:
        error instanceof Error
          ? error.message
          : "ツールの実行に失敗しました。",
    };
  }
}

export async function runToolLoop(
  userInput: string,
): Promise<string> {
  const model = process.env.OPENAI_MODEL;

  if (!model) {
    throw new Error("OPENAI_MODELが設定されていません。");
  }

  const input: OpenAI.Responses.ResponseInput = [
    { role: "user", content: userInput },
  ];

  const identicalCallCounts = new Map<string, number>();

  const startedAt = Date.now();

  let totalToolCalls = 0;
  let forceFinalAnswer = false;

  for (let turn = 1; turn <= MAX_MODEL_TURNS; turn += 1) {
    const elapsed = Date.now() - startedAt;

    if (elapsed >= TOTAL_TIMEOUT_MS) {
      throw new Error(
        "Function Callingの全体タイムアウトに達しました。",
      );
    }

    const isLastTurn = turn === MAX_MODEL_TURNS;

    const response = await openai.responses.create({
      model,
      instructions: `
あなたは社内ヘルプデスクです。
必要な場合だけツールを使用してください。
十分な情報を得たらツールを追加実行せず、最終回答を生成してください。
同じツールを同じ引数で繰り返し呼び出してはいけません。
ツール結果のretryableがfalseの場合、同じ処理を再試行してはいけません。
ツール結果のterminalがtrueの場合、直ちに最終回答を生成してください。
      `.trim(),
      input,
      tools,
      tool_choice:
        forceFinalAnswer || isLastTurn ? "none" : "auto",
      parallel_tool_calls: false,
    });

    input.push(...response.output);

    const functionCalls = response.output.filter(
      (item) => item.type === "function_call",
    );

    if (functionCalls.length === 0) {
      const finalText = response.output_text.trim();

      if (!finalText) {
        throw new Error(
          "モデルが最終回答を生成しませんでした。",
        );
      }

      return finalText;
    }

    if (totalToolCalls + functionCalls.length > MAX_TOOL_CALLS) {
      forceFinalAnswer = true;

      for (const functionCall of functionCalls) {
        input.push({
          type: "function_call_output",
          call_id: functionCall.call_id,
          output: JSON.stringify({
            status: "blocked",
            retryable: false,
            terminal: true,
            errorCode: "MAX_TOOL_CALLS_EXCEEDED",
            message:
              "ツールの最大実行回数に達したため、追加実行を停止しました。",
          } satisfies ToolResult),
        });
      }

      continue;
    }

    for (const functionCall of functionCalls) {
      totalToolCalls += 1;

      const rawArguments = parseArguments(
        functionCall.arguments,
      );

      if (rawArguments === null) {
        input.push({
          type: "function_call_output",
          call_id: functionCall.call_id,
          output: JSON.stringify({
            status: "invalid_input",
            retryable: false,
            terminal: false,
            errorCode: "INVALID_JSON_ARGUMENTS",
            message:
              "ツール引数をJSONとして解析できませんでした。",
          } satisfies ToolResult),
        });

        continue;
      }

      const fingerprint = createFingerprint(
        functionCall.name,
        rawArguments,
      );

      const identicalCount =
        (identicalCallCounts.get(fingerprint) ?? 0) + 1;

      identicalCallCounts.set(fingerprint, identicalCount);

      let result: ToolResult;

      if (identicalCount > MAX_IDENTICAL_CALLS) {
        result = {
          status: "blocked",
          retryable: false,
          terminal: true,
          errorCode: "DUPLICATE_TOOL_CALL",
          message:
            "同じツールと引数の繰り返しを検出したため、実行を停止しました。",
        };
      } else {
        result = await executeTool(
          functionCall.name,
          rawArguments,
        );
      }

      input.push({
        type: "function_call_output",
        call_id: functionCall.call_id,
        output: JSON.stringify(result),
      });

      if (result.terminal) {
        forceFinalAnswer = true;
      }

      console.info({
        turn,
        toolName: functionCall.name,
        callId: functionCall.call_id,
        identicalCount,
        totalToolCalls,
        status: result.status,
        retryable: result.retryable,
        terminal: result.terminal,
      });
    }
  }

  throw new Error(
    "Function Callingの最大ターン数に達しました。",
  );
}

この実装では、最後のモデルターンで必ずtool_choice: "none"を指定しています。

そのため、最大ターンへ到達した時点で新しいツール呼び出しを許可せず、それまでの結果を使って最終回答を生成させます。

同じ関数と同じ引数が規定回数を超えた場合も、ツール結果へterminal: trueを設定し、次のターンで最終回答へ移行します。

ツールの合計回数とモデルターン数は分けて数える

1回のモデル応答で複数のツールが返る場合、モデルターン数とツール実行数は一致しません。

モデルを3回しか呼んでいなくても、各ターンで3個の関数を実行すれば、ツール実行数は9回になります。

そのため、MAX_MODEL_TURNSだけでなくMAX_TOOL_CALLSも個別に管理します。

外部APIの料金が高い場合は、ツールごとに別の上限を持たせる方法もあります。

ツール別の上限例
const toolLimits = {
  search_docs: 5,
  create_ticket: 1,
} as const;

検索は複数回許可しても、チケット作成は1回だけにするなど、ツールの性質に応じて上限を変えます。

回数だけでなくコストの上限も考える

同じ1回のツール実行でも、処理コストは異なります。

小さなデータベース検索と、大量のWebページを取得する処理を同じ1回として扱うと、予想以上に費用が増える可能性があります。

ツールごとに仮想的なコストを設定し、1回の実行で消費した予算を加算する設計も有効です。

ツール別のコスト例
const toolCosts: Record<string, number> = {
  search_docs: 1,
  create_ticket: 3,
  deep_web_research: 10,
};

const MAX_TOOL_BUDGET = 15;

ツール実行前に残り予算を確認します。

予算を確認する例
const cost =
  toolCosts[functionCall.name] ?? 1;

if (usedToolBudget + cost > MAX_TOOL_BUDGET) {
  // 実行せず、予算上限の結果を返す
}

モデルターン数、ツール回数、時間、予算を組み合わせることで、処理内容の異なるツールを安全に管理できます。

永続的な状態をツール結果へ含める

モデルが同じツールを再実行する理由の一つは、処理済みであることが明確に伝わっていないためです。

チケット作成後は、単に「成功」と返すのではなく、作成されたIDと実行済みの操作IDを返します。

新規作成時
{
  "status": "success",
  "retryable": false,
  "terminal": true,
  "data": {
    "ticketId": "TICKET-1234",
    "operationId": "operation-abc",
    "alreadyExisted": false
  },
  "message": "チケットを作成しました"
}

同じ冪等性キーが再送された場合は、新規作成せず、既存の結果を返します。

重複送信時
{
  "status": "success",
  "retryable": false,
  "terminal": true,
  "data": {
    "ticketId": "TICKET-1234",
    "operationId": "operation-abc",
    "alreadyExisted": true
  },
  "message": "同じ操作はすでに完了しています"
}

モデルの挙動に関係なく、外部システム側で重複処理を防げる設計が重要です。

ユーザー確認が必要なツールは自動実行しない

削除、返金、購入、メール送信など、取り消しにくい処理は、モデルが呼び出した直後に実行しない設計も検討します。

モデルからツール呼び出しを受け取ったら一度処理を停止し、ユーザーへ確認画面を表示します。

OpenAI Agents SDKにはHuman-in-the-loopの承認フローがあり、承認が必要なツールでは実行前にランを一時停止できます。承認後は保存したRunStateから処理を再開できます。

手動のResponses APIループでも、同様の状態を作れます。

承認待ち状態の例
if (functionCall.name === "cancel_subscription") {
  return {
    type: "approval_required",
    pendingCall: {
      callId: functionCall.call_id,
      name: functionCall.name,
      arguments: rawArguments,
    },
  };
}

ユーザーが承認したあとだけ関数を実行し、その結果を同じcall_idへ返します。

無限ループ対策だけでなく、モデルが意図しない操作を提案した場合の安全装置にもなります。

Agents SDKではmaxTurnsを設定する

自前でFunction Callingループを実装せず、OpenAI Agents SDKを利用する方法もあります。

Agents SDKのRunnerは、モデルが最終出力を返した場合に終了し、ツール呼び出しを返した場合はツール結果を追加して次のターンへ進みます。

TypeScript版Agents SDKでは、maxTurnsの標準値が10に設定されており、上限へ到達するとMaxTurnsExceededErrorが発生します。nullを指定すると制限を無効化できますが、本番環境では無制限にしないほうが安全です。

src/agent.ts
import {
  Agent,
  MaxTurnsExceededError,
  run,
} from "@openai/agents";

const agent = new Agent({
  name: "Support agent",
  instructions: `
必要な場合だけツールを使ってください。
十分な情報を得たら最終回答を生成してください。
同じツールを同じ引数で繰り返してはいけません。
  `.trim(),
  tools: [
    // 定義したツール
  ],
});

async function main(): Promise<void> {
  try {
    const result = await run(
      agent,
      "経費精算の期限を調べてください。",
      {
        maxTurns: 6,
        signal: AbortSignal.timeout(60_000),
      },
    );

    console.log(result.finalOutput);
  } catch (error) {
    if (error instanceof MaxTurnsExceededError) {
      console.error(
        "エージェントが最大ターン数に達しました。",
      );

      return;
    }

    throw error;
  }
}

void main();

Agents SDKのmaxTurnsは有効な安全装置ですが、同じ副作用ツールの重複実行を自動的にすべて防ぐものではありません。

各ツールの冪等性、ユーザー承認、ツール別の回数制限は、アプリケーション側でも設計する必要があります。

最大ターン到達時に単純なエラーだけを返さない

最大ターン数へ到達したとき、ユーザーへ「エラーが発生しました」だけを返すと、途中まで取得した情報が無駄になります。

安全に利用できるツール結果が残っている場合は、それを使って限定的な回答を返します。

制限付き回答の例
追加検索の上限に達したため、確認できた範囲で回答します。
現在取得できた資料では、申請期限は翌月5営業日までと記載されています。
ただし、部門別の例外規定は確認できませんでした。

ただし、ツールが失敗して情報を取得できていない場合に、モデルへ推測させてはいけません。

「確認できなかった」「処理を完了できなかった」という状態を明確に返します。

ログへ残す情報

Function Callingのループを調査するには、モデルの最終エラーだけでなく、各ターンの状態を記録します。

最低限、モデルターン数、ツール名、call_id、引数のフィンガープリント、同一呼び出し回数、ツール実行時間、結果のstatusretryableterminalを記録します。

OpenAIへ送信したリクエストIDも残しておくと、API通信単位で問題を追跡しやすくなります。

一方、ツール引数にはメールアドレス、顧客情報、検索語などが含まれる可能性があります。

引数全文をログへ保存するのではなく、ハッシュ化したフィンガープリントや、問題調査に必要な項目だけを記録する設計が安全です。

Function Callingのテスト方法

無限ループ対策は、正常な質問だけでは確認できません。

検索結果が0件になる入力、外部APIがタイムアウトする入力、不正な引数が返る状況、同じ関数が連続して要求される状況をテストします。

副作用ツールでは、同じoperationIdを複数回送信し、実際のデータが一つしか作成されないことを確認します。

最大ターンへ到達したとき、処理が強制終了するだけでなく、ユーザー向けの回答または明確なエラーへ変換されることも確認します。

モデルを変更するとツール選択の傾向が変わる可能性があるため、モデル更新時にも回帰テストを実行する設計が望まれます。

Function Callingの無限ループに関するよくある質問

Qtool_choice: requiredを使い続けると何が問題?

Aすべてのターンでrequiredを指定すると、モデルは必ず何らかのツールを呼ぶ必要があり、最終回答だけを返すことができなくなります。最初のターンだけrequiredにし、ツール実行後はautoまたはnoneへ切り替えてください。

Qstrictモードを有効にすれば無限ループは防げる?

Aいいえ。strict: trueが防ぐのは主に引数の形式崩れです。同じ正しい引数を何度も生成する問題や、ツールを終了すべきタイミングの判断は別問題として残ります。ループの終了条件は、最大ターン数・合計実行回数・同一呼び出し検出などをコード側で別途設計する必要があります。

Q同じツール呼び出しを完全に禁止すべき?

A完全に禁止すると、正当な再試行まで止めてしまう可能性があります。通常は同じツールと同じ引数を1〜2回まで許可し、それを超えたらブロックする設計が扱いやすくなります。

QOpenAI Agents SDKのmaxTurnsだけで十分?

AmaxTurnsはモデルのターン数を制限する有効な安全装置ですが、同じ副作用ツールの重複実行をすべて自動的に防ぐものではありません。各ツールの冪等性キー、ユーザー承認フロー、ツールごとの回数制限は、アプリケーション側で別途設計する必要があります。

まとめ

Function Callingは、ツールを一度実行したら自動的に終了する機能ではありません。

モデルはツールの結果を受け取ったあと、最終回答を返すこともあれば、追加のツール呼び出しを返すこともあります。そのため、アプリケーション側でループを管理する必要があります。

無限ループが発生した場合は、まずtool_choice: "required"や特定関数の強制指定をすべてのターンで使っていないか確認します。

次に、ツールの戻り値へstatusretryableterminalを含め、成功、情報なし、一時エラー、永久エラーを区別します。

コード側では、モデルの最大ターン数、ツールの合計実行数、同一ツールと同一引数の実行回数、ツール単体のタイムアウト、処理全体のタイムアウトを制限します。

副作用を伴うツールが成功した場合は、次のターンでtool_choice: "none"を指定して最終回答へ切り替えます。ただし、モデル側の制御だけに頼らず、外部システムにも冪等性キーを実装することが重要です。

OpenAI Agents SDKを利用する場合も、maxTurnsを無制限にせず、各ツールの重複実行防止と承認フローを別に設計する必要があります。基本的なFunction Callingの使い方は【TypeScript】OpenAI API入門、引数検証に使うZodの文法は【TypeScript】Zod 完全ガイドもあわせてご覧ください。