AIエージェントにWeb検索、データベース検索、ファイル操作、Function Callingなどのツールを与えると、一度の質問から複数回のモデル呼び出しを自動実行できるようになります。
便利な一方で注意したいのが、AIエージェントの無限ループです。
検索結果が十分なのに何度も検索する、同じFunctionを同じ引数で繰り返す、エラーになったツールを延々と再試行する、別エージェント同士が処理を渡し続けるといった状態になることがあります。
この問題を、
十分な情報が集まったら終了してください
というプロンプトだけで防ぐのは不十分です。
AIモデルは終了判断そのものを間違える可能性があります。
本番環境では、モデルの判断とは別にアプリケーション側で最大ステップ数、ツール実行回数、トークン数、料金、実行時間などへハードリミットを設定する必要があります。
OpenAI Agents SDKもエージェントループにmaxTurnsを持ち、現在のTypeScript SDKでは標準10ターンで停止します。上限へ達するとMaxTurnsExceededErrorが発生します。
Claude Agent SDKにもmaxTurnsとmaxBudgetUsdがあり、最大エージェントターン数と推定料金の上限を設定できます。
GeminiのFunction Callingも、ツール結果をモデルへ返し、その結果からさらに別のFunction Callを生成する複数ターンの処理に対応しています。つまり、独自にエージェントループを実装する場合は、アプリケーション側で停止条件を持たせる必要があります。
この記事では、AIエージェントの無限ループが起こる原因と、TypeScriptで最大ステップ数・料金・トークン・ツール回数・実行時間を制限する方法を解説します。単一のFunction Callingループ自体の実装パターンはFunction Callingが無限ループする原因|ツール実行回数と終了条件の設計で詳しく解説しているため、この記事ではエージェント全体のガバナンス設計に焦点を当てます。
- AIエージェントは内部でループしている
- 無限ループは本当に無限でなくても問題になる
- 最低でも最大ステップ数を設定する
- OpenAI Agents SDKではmaxTurnsを使う
- 最大ステップ数は何回にするべきか
- ステップ数とツール回数は別に数える
- ツール実行回数にも上限を付ける
- ツールごとの上限も設定する
- 同じツール・同じ引数の繰り返しを検出する
- 同じツールでも引数が変われば別扱いにする
- ページネーションは特別扱いする
- エラーになったツールの再試行回数を制限する
- ステップ数だけでは料金事故を防げない
- Claude Agent SDKにはmaxBudgetUsdがある
- トークン予算も設定する
- モデルへ予算を知らせる方法とハードリミットは別
- 実行時間にもハードリミットを設定する
- AbortSignalで実際の処理も停止する
- ツール自体にもタイムアウトを設定する
- 「進捗がない状態」も検出する
- LLMの文章が変わっただけでは進捗にしない
- 明確な終了条件を定義する
- Structured Outputで終了状態を返す
- Tool Callがなくなったことを終了条件にする
- Geminiでtool_choice:anyを常用すると終了しにくくなる
- 最後の1ステップではツールを禁止する方法もある
- 上限到達時にエラーだけ返さない
- 料金上限に近づいたら終了フェーズへ移す
- ソフトリミットとハードリミットを分ける
- 副作用を持つツールは冪等化する
- 「確認」ツールと「実行」ツールを分離する
- サブエージェントにも予算を持たせる
- サブエージェント生成数も制限する
- Agent全体を制御するBudgetGuardを作る
- 独自エージェントループへ組み込む
- どの終了理由で止まったか保存する
- OpenAI Agents SDKではUsageもRun単位で確認できる
- 平均ステップ数だけでなくp95を見る
- 上限到達率も確認する
- Tool Resultを改善するとループが減る場合がある
- Tool Descriptionが曖昧だとループしやすい
- ツールを増やしすぎない
- 完了したツールを途中で無効化する
- ユーザー入力待ちはループ終了として扱う
- Blockedも正常な終了状態にする
- AIエージェントの安全な初期設定
- AIエージェントの無限ループに関するよくある質問
- まとめ
AIエージェントは内部でループしている
通常のLLM APIでは、ユーザーから入力を受け取り、モデルが回答を返せば処理は終了します。
ユーザー ↓ LLM ↓ 回答
Function Callingを利用したAIエージェントでは、モデルが最終回答ではなくツール呼び出しを返すことがあります。
ユーザー ↓ LLM ↓ search_web ↓ 検索結果 ↓ LLM ↓ read_page ↓ 取得結果 ↓ LLM ↓ 最終回答
ツールを一度実行したあと、結果をもう一度モデルへ送るため、モデル呼び出しが繰り返されます。
OpenAI Agents SDKも、モデル呼び出し後に最終出力なら終了、Handoffなら別エージェントへ移動、Tool Callならツールを実行して再びモデルを呼び出すループとして動作します。
GeminiのFunction Callingも、アプリケーションがFunctionを実行し、その結果をモデルへ返すことで次の判断を行い、この処理を複数ターン繰り返せます。
AIエージェントとは、基本的にこのループを管理する仕組みだと考えると分かりやすくなります。ReAct・Plan-and-Execute・Reflectionといった代表的なエージェント設計パターンはAIエージェント完全設計ガイドで解説しています。
無限ループは本当に無限でなくても問題になる
実際にはAPIのRate Limit、Context Window、サーバーのタイムアウトなどによって、永久に処理が続くとは限りません。
しかし、
検索 ↓ 検索 ↓ 検索 ↓ 検索 ↓ 検索
と数十回繰り返すだけでも問題です。
モデルAPIの料金が増え、検索APIや外部APIの料金も増えます。
ユーザーは回答を数分待つことになり、ContextへTool Resultが蓄積して入力トークンも増えていきます。
さらに、メール送信や注文登録のような副作用を持つFunctionを繰り返せば、単なる料金問題では済みません。
したがって「いつかContext Windowで止まる」ことを安全装置としてはいけません。
最低でも最大ステップ数を設定する
最も簡単な無限ループ対策は、エージェントが進められる最大ステップ数を決めることです。
const MAX_STEPS = 10;
for (
let step = 0;
step < MAX_STEPS;
step += 1
) {
const response =
await callModel();
if (
response.type === "final"
) {
return response;
}
await executeTools(
response.toolCalls,
);
}
throw new Error(
"最大ステップ数に達しました。",
);
これだけでも、プログラムが無制限にモデルを呼び続ける状態を防げます。
重要なのは、この上限をプロンプトではなくコード側へ置くことです。
最大10回までツールを使ってください
という指示は補助として利用できますが、モデルが守ることを前提にはしません。
OpenAI Agents SDKではmaxTurnsを使う
OpenAI Agents SDK for TypeScriptでは、run()にmaxTurnsを指定できます。
import {
Agent,
MaxTurnsExceededError,
run,
} from "@openai/agents";
const agent =
new Agent({
name: "Research Agent",
instructions: `
必要な情報だけを調査し、
十分な根拠が集まったら
最終回答を返してください。
`.trim(),
});
try {
const result =
await run(
agent,
"Node.jsの最新仕様を調べて",
{
maxTurns: 8,
},
);
console.log(
result.finalOutput,
);
} catch (error) {
if (
error instanceof
MaxTurnsExceededError
) {
console.error(
"最大ターン数に達しました。",
);
} else {
throw error;
}
}
OpenAI Agents SDKではmaxTurnsの標準値が10で、モデルが最終出力を生成する前に上限へ達するとMaxTurnsExceededErrorになります。nullを指定すると上限を無効化できます。
本番の自律エージェントで、
maxTurns: null
とする場合は、別の強制終了条件を必ず用意します。
最大ステップ数は何回にするべきか
すべてのAIエージェントに共通する正解はありません。
簡単な検索や問い合わせ処理なら4〜8ステップ程度から評価できます。
複数サイトを調査するResearch Agentなら8〜15程度、コード修正やテストまで行うCoding Agentなら10〜30程度必要になる場合があります。
これらは公式の推奨固定値ではなく、実運用を始めるための比較用の初期値です。
最初から100ステップを許可するより、
正常完了した処理のステップ数 p95のステップ数 上限到達率 1タスク当たり料金
を計測し、必要な範囲だけ広げます。
OpenAI Agents SDK自体が標準10ターンをSafety Limitとして設定していることからも、ターン上限を設けること自体が重要です。
ステップ数とツール回数は別に数える
最大10ステップとしても、一つのターンで複数のツールを並列呼び出しできる場合があります。
たとえば1ターンで、
search_google search_bing search_database search_internal_wiki
を同時に呼べば、1ステップで4回の外部処理が発生します。
OpenAI Agents SDKも、1ターンで複数のローカルFunction Toolを実行でき、toolExecution.maxFunctionToolConcurrencyで同時実行数を制限できます。
GeminiもParallel Function Callingに対応しているため、一つのモデルターンから複数Function Callが生成される場合があります。
したがって、
maxSteps
だけでなく、
maxToolCalls
も別に管理します。
ツール実行回数にも上限を付ける
たとえば最大ステップ10、最大ツール呼び出し20とします。
type AgentLimits = {
maxSteps: number;
maxToolCalls: number;
};
const limits:
AgentLimits = {
maxSteps: 10,
maxToolCalls: 20,
};
ツール実行前に確認します。
let toolCallCount = 0;
async function executeTool(
call: ToolCall,
) {
toolCallCount += 1;
if (
toolCallCount >
limits.maxToolCalls
) {
throw new Error(
"ツール実行回数の上限に達しました。",
);
}
return runTool(call);
}
これならモデルが一度に大量のTool Callを出しても、無制限に外部APIを実行することはありません。
ツールごとの上限も設定する
すべてのツールを同じ扱いにする必要はありません。
Web検索なら数回許可しても問題ありませんが、メール送信や注文作成を10回許可する理由はありません。
ツール単位の上限を持たせます。
const TOOL_LIMITS:
Record<string, number> = {
search_web: 8,
read_page: 12,
query_database: 5,
send_email: 1,
create_order: 1,
};
実行数を記録します。
const toolCounts =
new Map<string, number>();
function checkToolLimit(
toolName: string,
): void {
const current =
toolCounts.get(
toolName,
) ?? 0;
const next =
current + 1;
const limit =
TOOL_LIMITS[
toolName
] ?? 5;
if (next > limit) {
throw new Error(
`${toolName}の実行上限に達しました。`,
);
}
toolCounts.set(
toolName,
next,
);
}
特に副作用のあるツールは、検索ツールとは別の厳しい制限を設定します。
同じツール・同じ引数の繰り返しを検出する
AIエージェントで非常に多いのが、
search_web({
query: "Node.js latest version"
})
を実行したあと、まったく同じTool Callをもう一度生成するケースです。
同じ入力を何度実行しても新しい情報が得られないツールなら、ループと判断できます。
単純に引数のJSON文字列を比較すると、プロパティの順序が異なるだけで別の呼び出しとして扱われるため、キーを正規化してから比較する必要があります。
このFingerprint生成の実装(stableStringifyによるキー正規化とcreateToolFingerprint)はFunction Callingが無限ループする原因|ツール実行回数と終了条件の設計の「原因4:同じ引数のツール呼び出しを検出していない」で詳しく解説しているため、この記事では割愛します。
同じ呼び出しを完全に禁止すると、正当な再試行まで止める可能性があります。
そのため、通常は同じツールと同じ引数を1回または2回まで許可し、それを超えたらブロックする設計にします。以降のコード例では、このcreateToolFingerprint(name, args)が別モジュールから利用できる前提で進めます。
同じツールでも引数が変われば別扱いにする
次の処理はループとは限りません。
search_web({ query: "OpenAI Agents SDK" })
search_web({ query: "Claude Agent SDK" })
search_web({ query: "Gemini Function Calling" })
同じsearch_webでも検索Queryが違います。
そのため、
toolName
だけで重複判定すると、正常な調査を止めてしまいます。
toolName + normalized arguments
をFingerprintにするほうが扱いやすくなります。
ページネーションは特別扱いする
DB検索やAPI検索では、
page=1 page=2 page=3
と同じツールを何度も呼ぶことが正常です。
Fingerprintにはページ番号も含まれるため、別呼び出しとして扱えます。
ただし、
page=3 page=3 page=3
と繰り返せば検出できます。
AIエージェントのループ判定では、「同じツールを使ったか」より「同じ状態から同じアクションを繰り返したか」を見ることが重要です。
エラーになったツールの再試行回数を制限する
ツールがエラーを返すと、モデルが「もう一度試そう」と判断することがあります。
LLM ↓ get_customer ↓ 500 Error ↓ LLM ↓ get_customer ↓ 500 Error ↓ LLM ↓ get_customer
一時エラーなら再試行する意味があります。
しかし、入力不備や権限不足なら何回試しても成功しません。
ツール側でも再試行回数を管理します。
type ToolResult =
| {
ok: true;
data: unknown;
}
| {
ok: false;
retryable: boolean;
error: string;
};
モデルへ返すTool Resultにも再試行可否を含めます。
{
"ok": false,
"retryable": false,
"error": "customer_id is invalid"
}
システムプロンプトにも、
retryable=falseのツールエラーを 同じ引数で再試行してはいけません。
と指示します。
ただし最終的な制御はコード側で行います。
ステップ数だけでは料金事故を防げない
同じ10ステップでも、1ステップ当たりの料金は大きく違います。
短い分類を10回行う場合と、10万トークンのコンテキストを10回モデルへ渡す場合では料金が違います。
そのため、エージェントには金額予算も設定します。
type AgentBudget = {
maxCostUsd: number;
};
const budget:
AgentBudget = {
maxCostUsd: 0.50,
};
1回のモデル呼び出しが終わるたびに、リクエスト単位でトークン使用量をDBへ保存する方法で実装したUsageと料金計算を加算します。
let totalCostUsd = 0;
function recordCost(
costUsd: number,
): void {
totalCostUsd += costUsd;
if (
totalCostUsd >
budget.maxCostUsd
) {
throw new Error(
"AIエージェントの料金上限に達しました。",
);
}
}
これならステップ数が残っていても、想定料金を超えた時点で終了できます。
Claude Agent SDKにはmaxBudgetUsdがある
Claude Agent SDKには料金上限を指定するmaxBudgetUsdがあります。基本的なquery()の使い方やセッション管理はClaude Agent SDK完全ガイドで解説しています。
TypeScript SDKではmaxTurnsが最大エージェントターン数、maxBudgetUsdがクライアント側で計算した推定料金へ基づく停止条件として提供されています。
イメージとしては次のように設定できます。
import {
query,
} from "@anthropic-ai/claude-agent-sdk";
for await (
const message
of query({
prompt:
"リポジトリを調査して原因を特定してください。",
options: {
maxTurns: 12,
maxBudgetUsd: 0.50,
},
})
) {
console.log(
message,
);
}
Claude Agent SDKのResultにはnum_turnsやtotal_cost_usdなども含まれ、最大ターン到達時や最大料金到達時を区別できる結果形式があります。
ただし、金額の上限だけに依存するのではなく、ステップ数や時間上限も併用します。
トークン予算も設定する
料金はモデル変更によって変化します。
一方、トークン数を見れば、Contextが異常に膨らんでいる状態を直接検出できます。
OpenAI Agents SDKではRun全体のUsageとして、モデルAPIのリクエスト数、Input Tokens、Output Tokens、Total Tokensが集計されます。リクエストごとのUsageも取得できます。
独自ループなら次のように管理できます。
type TokenBudget = {
maxInputTokens: number;
maxOutputTokens: number;
maxTotalTokens: number;
};
const tokenBudget:
TokenBudget = {
maxInputTokens:
100_000,
maxOutputTokens:
30_000,
maxTotalTokens:
130_000,
};
使用量を加算します。
let inputTokens = 0;
let outputTokens = 0;
function recordUsage(
usage: {
inputTokens: number;
outputTokens: number;
},
): void {
inputTokens +=
usage.inputTokens;
outputTokens +=
usage.outputTokens;
const totalTokens =
inputTokens +
outputTokens;
if (
inputTokens >
tokenBudget
.maxInputTokens ||
outputTokens >
tokenBudget
.maxOutputTokens ||
totalTokens >
tokenBudget
.maxTotalTokens
) {
throw new Error(
"トークン予算を超えました。",
);
}
}
Contextがステップごとに増えるエージェントでは、後半になるほど1回のモデル呼び出しが高額になりやすいため、Total Tokensの監視が重要です。
モデルへ予算を知らせる方法とハードリミットは別
Claudeの一部モデルには、エージェントループ全体で利用できるトークン量をモデル自身へ知らせるtask_budgetがあります。
モデルは残り予算を認識し、予算が減るにつれて作業範囲を調整しながら完了を目指します。Anthropicはtask_budgetをモデルが認識するアドバイザリーな予算、max_tokensをリクエスト単位のハード上限として区別しています。
つまり、
残り予算が少ないので そろそろまとめてください
とモデル自身に判断させる仕組みと、
上限を超えたので アプリケーションが強制停止
する仕組みは別です。
本番では両方を使う考え方が安全です。
実行時間にもハードリミットを設定する
ステップと料金に余裕があっても、エージェントが10分、30分と動き続けることがあります。
ユーザー向け機能なら、最大実行時間も設定します。
const MAX_DURATION_MS =
90_000;
const startedAt =
Date.now();
function checkDuration():
void {
const elapsed =
Date.now() -
startedAt;
if (
elapsed >
MAX_DURATION_MS
) {
throw new Error(
"エージェントの最大実行時間に達しました。",
);
}
}
モデルを呼び出す前だけではなく、Tool Callの前にも確認します。
checkDuration(); const modelResponse = await callModel(); checkDuration(); const toolResult = await executeTool();
これにより、残り時間がないのに新しい高コスト処理を始めることを防げます。
AbortSignalで実際の処理も停止する
JavaScript側でErrorを投げるだけでは、既に開始したHTTPリクエストやツール処理が継続する場合があります。
OpenAI Agents SDKのrun()はAbortSignalによるキャンセルにも対応しています。
const signal =
AbortSignal.timeout(
90_000,
);
const result =
await run(
agent,
userInput,
{
maxTurns: 10,
signal,
},
);
独自Function Toolにも同じSignalを渡せる設計にします。
async function searchWeb(
query: string,
signal: AbortSignal,
) {
return fetch(
createSearchUrl(query),
{
signal,
},
);
}
上限到達時には「新しいループを開始しない」だけでなく、可能なら実行中の処理も中断します。
ツール自体にもタイムアウトを設定する
エージェント全体の最大時間が90秒でも、一つのツールが80秒停止すればほとんどの時間を消費します。
ツールごとにも短いタイムアウトを設定します。
async function executeToolWithTimeout(
call: ToolCall,
): Promise<unknown> {
const signal =
AbortSignal.timeout(
10_000,
);
return executeTool(
call,
signal,
);
}
Web検索なら10秒、DB検索なら5秒など、ツールの性質に合わせます。
OpenAI Agents SDKにもFunction Toolのタイムアウトを扱う仕組みがあり、タイムアウト時にはToolTimeoutErrorとして扱える構成があります。
「進捗がない状態」も検出する
ステップ数だけでなく、処理が前進しているかを見ます。
たとえば、
step 1: 検索結果Aを取得 step 2: 検索結果Aを取得 step 3: 検索結果Aを取得
なら、新しい情報は増えていません。
各ステップ終了時に「現在までに得られた成果」をSignature化できます。
import {
createHash,
} from "node:crypto";
function stableJson(
value: unknown,
): string {
if (
value === null ||
typeof value !== "object"
) {
return JSON.stringify(
value,
);
}
if (Array.isArray(value)) {
return (
"[" +
value
.map(stableJson)
.join(",") +
"]"
);
}
const object =
value as
Record<string, unknown>;
const keys =
Object.keys(object)
.sort();
return (
"{" +
keys
.map(
(key) =>
`${JSON.stringify(key)}:${stableJson(
object[key],
)}`,
)
.join(",") +
"}"
);
}
function createProgressSignature(
state: {
visitedUrls: string[];
foundDocumentIds:
string[];
completedTasks:
string[];
},
): string {
return createHash(
"sha256",
)
.update(
stableJson({
visitedUrls:
[...state.visitedUrls]
.sort(),
foundDocumentIds:
[
...state
.foundDocumentIds,
].sort(),
completedTasks:
[
...state
.completedTasks,
].sort(),
}),
)
.digest("hex");
}
前回と同じSignatureなら進捗なしと判断します。
let lastSignature:
string | null = null;
let noProgressSteps = 0;
function recordProgress(
signature: string,
): void {
if (
signature ===
lastSignature
) {
noProgressSteps += 1;
} else {
noProgressSteps = 0;
}
lastSignature =
signature;
if (
noProgressSteps >= 3
) {
throw new Error(
"3ステップ連続で進捗がありません。",
);
}
}
最大10ステップを待たず、3回連続で状態が変わらなければ早期停止できます。
LLMの文章が変わっただけでは進捗にしない
モデルが、
もう少し調べます
から、
追加情報を確認します
へ文章を変更しても、実際には進捗していません。
進捗判定には自然言語の説明ではなく、
新しいURLを取得した 新しいドキュメントを取得した テストが1件成功した TODOが1件完了した ファイルが変更された
といったアプリケーション側で観測できる状態を利用します。
AI自身に、
進捗しましたか?
と判定させるだけでは、ループ検出もAIの判断へ依存してしまいます。
明確な終了条件を定義する
エージェントが「十分調べた」と感じたら終了、という曖昧な条件だけではループしやすくなります。
タスクごとに完了条件を定義します。
たとえばFAQ検索エージェントなら、
回答本文がある 参照したdocument_idが1件以上ある 追加ツール呼び出しがない
という状態を完了とします。
コード修正エージェントなら、
対象ファイルを変更済み テストコマンドを実行済み テスト成功 未処理TODOがない
といった状態をアプリケーション側で確認できます。
Structured Outputで終了状態を返す
最終回答にStructured Outputを使うと、終了条件を扱いやすくなります。
import {
z,
} from "zod";
const AgentResultSchema =
z.object({
status:
z.enum([
"completed",
"blocked",
"needs_user_input",
]),
answer:
z.string(),
completedTasks:
z.array(
z.string(),
),
remainingTasks:
z.array(
z.string(),
),
});
モデルが、
{
"status": "completed",
"answer": "原因を特定しました。",
"completedTasks": ["ログ確認", "設定確認"],
"remainingTasks": []
}
を返した場合でも、アプリケーション側で検証します。
function canFinish(
result:
z.infer<
typeof AgentResultSchema
>,
): boolean {
return (
result.status ===
"completed" &&
result.remainingTasks
.length === 0
);
}
「completedという文字が返ってきたから終了」ではなく、必要な状態も検証します。
Tool Callがなくなったことを終了条件にする
一般的なエージェントループでは、モデルがTool Callを出さず最終テキストを返したときに終了します。
OpenAI Agents SDKも、求める型のテキスト出力があり、Tool Callがない状態をFinal Outputとして扱います。
独自実装なら次のようにできます。
if (
response.toolCalls.length ===
0
) {
const final =
validateFinalOutput(
response,
);
if (final.ok) {
return final.value;
}
}
ただし、Tool Callがないだけで内容が空だったり、不完全だったりする場合があります。
最終出力スキーマも検証します。
Geminiでtool_choice:anyを常用すると終了しにくくなる
Gemini Interactions APIではFunction Callingのモードとしてauto、any、none、validatedなどがあります。
autoではモデルがFunctionを呼ぶか直接回答するか判断します。
anyではモデルが必ずFunction Callを生成するよう制約されます。
エージェントループ全体で常に、
tool_choice = any
を使うと、最終回答を返したい段階でもFunction Callが要求されるため、終了設計と衝突する可能性があります。
調査中だけFunctionを許可し、最終回答フェーズでは、
auto
またはツールを無効化する設計が扱いやすくなります。
最後の1ステップではツールを禁止する方法もある
最大ステップ10なら、10ステップ目まで通常のツール選択を許可する必要はありません。
たとえば残り1ステップになったらモデルへ、
残りステップは1です。 これ以上ツールを使用せず、 これまでの結果から最終回答を作成してください。
と指示できます。
function getInstructions(
step: number,
maxSteps: number,
): string {
const remaining =
maxSteps - step;
if (remaining <= 1) {
return `
追加ツールは使用せず、
現在までの情報から
最終回答を作成してください。
`.trim();
}
return `
必要な場合だけツールを使い、
十分な情報が集まったら
終了してください。
`.trim();
}
これはハードリミットの代わりではありません。
最終ステップを有効活用するための補助です。
上限到達時にエラーだけ返さない
最大ステップ数に達したとき、
Internal Server Error
だけをユーザーへ返す必要はありません。
すでに得られた結果から部分回答を作れる場合があります。
OpenAI Agents SDKにはmaxTurns到達時を処理するerrorHandlers.maxTurnsがあり、最大ターンエラーをそのまま投げる代わりに最終出力へ変換する仕組みがあります。
独自実装でも、
if (
reason ===
"max_steps"
) {
return {
status:
"partial",
answer:
createPartialAnswer(
state,
),
};
}
のようにできます。
ただし、処理していない内容を「完了した」と表示しないよう、部分結果であることは区別します。
料金上限に近づいたら終了フェーズへ移す
0.50ドルを上限として、0.49ドルまで使ったあと突然強制停止するより、残り予算をモデルへ伝えられると最終回答を作りやすくなります。
function createBudgetMessage(
usedUsd: number,
maxUsd: number,
): string {
const remaining =
Math.max(
0,
maxUsd -
usedUsd,
);
return `
残り実行予算は
約$${remaining.toFixed(3)}です。
予算が少ない場合は
追加調査を止め、
現在までの結果をまとめてください。
`.trim();
}
ただしモデルへの通知はソフトリミットです。
if (
usedUsd >= maxUsd
) {
throw new BudgetExceeded();
}
というハードリミットも残します。
ソフトリミットとハードリミットを分ける
たとえば最大10ステップなら、8ステップをソフトリミット、10ステップをハードリミットにできます。
step 1~7: 通常実行 step 8: 終了を意識するよう通知 step 9: 新しい高コスト処理を制限 step 10: 強制停止
料金でも同じ考え方ができます。
予癰70%: 通常実行 予癰80%: 残り予算を通知 予癰90%: 新規調査を抑制 予算100%: 強制終了
これなら、上限直前まで検索して突然回答できなくなる問題を減らせます。
副作用を持つツールは冪等化する
無限ループ対策で特に重要なのがメール送信、注文作成、支払い、ファイル削除などです。
最大ステップ数を設定しても、
send_email ↓ send_email
と2回実行されれば問題になります。
Tool CallごとにIdempotency Keyを作ります。
type SendEmailInput = {
operationId: string;
to: string;
subject: string;
body: string;
};
実行前に確認します。
const existing =
await db.emailOperations
.findUnique({
where: {
operationId:
input.operationId,
},
});
if (existing) {
return {
status:
"already_sent",
messageId:
existing.messageId,
};
}
エージェントが同じ操作を再度要求しても、副作用は一度だけ発生します。
OpenAI Agents SDKのマルチエージェント関連ドキュメントでも、副作用を持つツールについて中断後の継続で重複実行しないよう、Call IDなどを利用して冪等化することが案内されています。
「確認」ツールと「実行」ツールを分離する
注文作成のような重要処理では、
create_order
をいきなり実行させるより、
prepare_order ↓ confirm_order ↓ execute_order
で内容を作り、ユーザー承認を取得し、確定する構成が安全です。
エージェントがループしても、ユーザー承認なしに確定処理を繰り返せません。
特に破壊的操作は、ループ制御とは別にHuman-in-the-loopの承認境界を持たせます。
サブエージェントにも予算を持たせる
マルチエージェント構成では、親エージェントのステップ数だけを見ても不十分です。
親 Agent ↓ Research Agent ↓ Search Agent ↓ Research Agent ↓ 親 Agent
という構成では、サブエージェント内部で大量のモデル呼び出しが行われる可能性があります。
グローバル予算をContextへ持たせます。
type SharedBudget = {
maxToolCalls: number;
usedToolCalls: number;
maxTokens: number;
usedTokens: number;
maxCostUsd: number;
usedCostUsd: number;
deadline: number;
};
すべてのAgentとToolが同じBudgetオブジェクトを参照します。
function consumeToolCall(
budget:
SharedBudget,
): void {
budget.usedToolCalls += 1;
if (
budget.usedToolCalls >
budget.maxToolCalls
) {
throw new Error(
"グローバルTool予算を超えました。",
);
}
}
親Agentが8ステップ、各Subagentが8ステップだから最大8ステップ、とは考えません。
全エージェントを含めた総予算を管理します。
サブエージェント生成数も制限する
AIが「別Agentへ任せたほうがよい」と判断するたびにSubagentを作れる構成では、Agent数そのものが膨らむ可能性があります。
const MAX_SUBAGENTS = 3;
let subagentCount = 0;
function beforeSpawnSubagent():
void {
subagentCount += 1;
if (
subagentCount >
MAX_SUBAGENTS
) {
throw new Error(
"Subagent数の上限に達しました。",
);
}
}
さらに同時実行数も制限します。
モデルAPI、検索API、DB接続を大量のSubagentが同時利用すると、無限ループでなくてもRate LimitやConnection Poolを使い切る可能性があります。
Agent全体を制御するBudgetGuardを作る
複数の制限を一か所へまとめると管理しやすくなります。
import {
createToolFingerprint,
} from "./tool-fingerprint";
type AgentLimits = {
maxSteps: number;
maxToolCalls: number;
maxTokens: number;
maxCostUsd: number;
maxDurationMs: number;
maxDuplicateCalls: number;
};
class AgentBudgetGuard {
private steps = 0;
private toolCalls = 0;
private tokens = 0;
private costUsd = 0;
private readonly startedAt =
Date.now();
private readonly duplicates =
new Map<
string,
number
>();
constructor(
private readonly limits:
AgentLimits,
) {}
beforeStep(): void {
this.steps += 1;
if (
this.steps >
this.limits.maxSteps
) {
throw new Error(
"max_steps",
);
}
this.checkTime();
}
beforeTool(
name: string,
args: unknown,
): void {
this.toolCalls += 1;
if (
this.toolCalls >
this.limits
.maxToolCalls
) {
throw new Error(
"max_tool_calls",
);
}
const fingerprint =
createToolFingerprint(
name,
args,
);
const duplicateCount =
(
this.duplicates.get(
fingerprint,
) ?? 0
) + 1;
this.duplicates.set(
fingerprint,
duplicateCount,
);
if (
duplicateCount >
this.limits
.maxDuplicateCalls
) {
throw new Error(
"duplicate_tool_loop",
);
}
this.checkTime();
}
recordUsage(
usage: {
totalTokens: number;
costUsd: number;
},
): void {
this.tokens +=
usage.totalTokens;
this.costUsd +=
usage.costUsd;
if (
this.tokens >
this.limits.maxTokens
) {
throw new Error(
"max_tokens",
);
}
if (
this.costUsd >
this.limits.maxCostUsd
) {
throw new Error(
"max_cost",
);
}
}
private checkTime():
void {
const elapsed =
Date.now() -
this.startedAt;
if (
elapsed >
this.limits
.maxDurationMs
) {
throw new Error(
"max_duration",
);
}
}
}
設定します。
const guard =
new AgentBudgetGuard({
maxSteps: 10,
maxToolCalls: 20,
maxTokens: 120_000,
maxCostUsd: 0.50,
maxDurationMs: 90_000,
maxDuplicateCalls: 2,
});
これで一つの制限だけに依存しないエージェントになります。
独自エージェントループへ組み込む
実際のループでは、モデル呼び出しとTool Callの両方を監視します。
async function runAgent(
initialInput: string,
): Promise<string> {
const history:
AgentMessage[] = [
{
role: "user",
content:
initialInput,
},
];
while (true) {
guard.beforeStep();
const response =
await callModel(
history,
);
guard.recordUsage({
totalTokens:
response.usage
.totalTokens,
costUsd:
response.usage
.estimatedCostUsd,
});
if (
response.toolCalls
.length === 0
) {
return validateFinalAnswer(
response.text,
);
}
for (
const call
of response.toolCalls
) {
guard.beforeTool(
call.name,
call.arguments,
);
const result =
await executeToolWithTimeout(
call,
);
history.push({
role: "tool",
toolCallId:
call.id,
content:
JSON.stringify(
result,
),
});
}
}
}
モデルが正常なら、Tool Callが不要になった時点で終了します。
モデルが異常動作しても、ステップ、Tool Call、Token、Cost、Time、Duplicateのどれかが上限へ到達して停止します。
どの終了理由で止まったか保存する
すべてを、
agent_failed
として保存すると改善できません。
終了理由を分けます。
type AgentStopReason = | "completed" | "max_steps" | "max_tool_calls" | "max_tokens" | "max_cost" | "max_duration" | "duplicate_tool_loop" | "no_progress" | "user_cancelled" | "tool_error";
DBへ保存します。
CREATE TABLE ai_agent_runs (
id BIGSERIAL PRIMARY KEY,
trace_id UUID NOT NULL,
agent_name
VARCHAR(100)
NOT NULL,
status
VARCHAR(32)
NOT NULL,
stop_reason
VARCHAR(64)
NOT NULL,
steps
INTEGER
NOT NULL,
tool_calls
INTEGER
NOT NULL,
input_tokens
BIGINT
NOT NULL,
output_tokens
BIGINT
NOT NULL,
estimated_cost_usd
NUMERIC(20, 12)
NOT NULL,
duration_ms
INTEGER
NOT NULL,
created_at
TIMESTAMPTZ
NOT NULL
DEFAULT NOW()
);
これにより、
max_stepsが多い duplicate_tool_loopが多い max_costが増えた 特定ツールだけ繰り返している
といった問題を分析できます。
OpenAI Agents SDKではUsageもRun単位で確認できる
OpenAI Agents SDKでは、Run全体のUsageがresult.state.usageに集約されます。
requests、inputTokens、outputTokens、totalTokensを確認でき、さらにrequestUsageEntriesからモデルリクエストごとのUsageも取得できます。
const result =
await run(
agent,
input,
{
maxTurns: 10,
},
);
const usage =
result.state.usage;
console.log({
requests:
usage.requests,
inputTokens:
usage.inputTokens,
outputTokens:
usage.outputTokens,
totalTokens:
usage.totalTokens,
});
この値をリクエスト単位でトークン使用量をDBへ保存する方法で作った料金計算処理へ渡せば、Agent Run単位の原価も記録できます。
平均ステップ数だけでなくp95を見る
通常は3ステップで終わるAgentでも、一部だけ25ステップ掛かっている可能性があります。
平均値だけでは異常値が見えません。
DBへRunを保存し、
SELECT
PERCENTILE_CONT(0.5)
WITHIN GROUP (
ORDER BY steps
)
AS p50_steps,
PERCENTILE_CONT(0.95)
WITHIN GROUP (
ORDER BY steps
)
AS p95_steps,
PERCENTILE_CONT(0.99)
WITHIN GROUP (
ORDER BY steps
)
AS p99_steps
FROM ai_agent_runs
WHERE
created_at >=
NOW()
- INTERVAL '30 days';
のように確認します。
たとえばp95が6ステップなら、最大30ステップを許可する必要が本当にあるのか検討できます。
上限到達率も確認する
最大ステップ数10を設定した場合、10へ達した割合を確認します。
SELECT
COUNT(*) FILTER (
WHERE
stop_reason =
'max_steps'
)::NUMERIC
/
NULLIF(
COUNT(*),
0
)
AS max_steps_rate
FROM ai_agent_runs
WHERE
created_at >=
NOW()
- INTERVAL '7 days';
上限到達率が高いからといって、すぐ20へ増やすのは避けます。
ログを確認して、
本当にあと1ステップで完了したのか 同じ検索を繰り返していたのか Tool Resultが分かりにくかったのか 終了条件が曖昧だったのか
を切り分けます。
無限ループを上限拡大で隠すと、料金だけが増える可能性があります。
Tool Resultを改善するとループが減る場合がある
AIが同じFunctionを繰り返す原因が、モデルではなくTool Resultにある場合があります。
悪い例は、
{
"status": "error"
}
だけを返すことです。
モデルには、なぜ失敗したのか、再試行すべきなのか分かりません。
{
"ok": false,
"code": "CUSTOMER_NOT_FOUND",
"retryable": false,
"message": "指定されたcustomer_idは存在しません。別のIDを取得してください。"
}
のように返せば、同じ入力で再試行する必要がないと判断しやすくなります。
GeminiのFunction Calling公式ガイドでも、FunctionやParameterの説明を明確にし、Function Callを実行前に検証し、堅牢なエラー処理を実装することがBest Practiceとして案内されています。
Tool Descriptionが曖昧だとループしやすい
次の説明では用途が分かりません。
{
"name": "search",
"description": "Searches things"
}
どの状況で使い、何を返し、いつ再実行すべきかを明確にします。
{
name:
"search_documents",
description: `
社内ドキュメントを
キーワード検索します。
同じqueryを繰り返しても
結果は変化しません。
必要な文書が見つからない場合は
別のqueryへ変更してください。
`.trim()
}
モデルへの説明もループ防止に利用できます。
ただし説明はソフトガードであり、重複実行検出はコード側にも残します。
ツールを増やしすぎない
似た役割のツールを大量に渡すと、Agentが選択を迷ったり、複数ツールで同じ情報を何度も確認したりする原因になります。
GeminiのFunction Callingガイドでは、アクティブなツール集合を10〜20個程度までに抑えることがBest Practiceとして案内されています。
たとえば、
search_web google_search internet_search search_internet web_lookup
のように似たツールを並べるより、一つの役割へ整理します。
Tool Routingを使い、タスクに必要なツールだけをそのターンへ公開する方法もあります。
完了したツールを途中で無効化する
一度だけ必要なツールを、最後までモデルへ見せ続ける必要はありません。
たとえばプロフィール取得が終了したら、
get_profile
を次のモデル呼び出しから外します。
最初: get_profile / search_orders / create_report プロフィール取得後: search_orders / create_report 注文取得後: create_report
選択肢を減らすことで、完了済み処理へ戻る可能性を下げられます。
モデルが何を呼ぶかだけでなく、アプリケーション側が「今どのツールを呼べるか」を管理します。
ユーザー入力待ちはループ終了として扱う
情報が足りない場合、AIが勝手に推測しながらツールを繰り返すより、ユーザーへ質問するほうがよいケースがあります。
Structured Outputへ、
needs_user_input
を用意します。
{
"status": "needs_user_input",
"answer": "対象の注文番号を入力してください。",
"completedTasks": [],
"remainingTasks": ["注文の確認"]
}
この状態になったらAgent Loopを終了します。
ユーザーの次の入力から新しいRunとして再開します。
「必ず自力で完了しなければならない」というPromptは、不必要な検索や推測ループを増やすことがあります。
Blockedも正常な終了状態にする
外部API障害や権限不足で処理できない場合もあります。
completed needs_user_input blocked
をすべて正式な終了状態として扱います。
if (
result.status ===
"completed" ||
result.status ===
"needs_user_input" ||
result.status ===
"blocked"
) {
return result;
}
「completedになるまで回す」という設計だと、達成不可能なタスクでループしやすくなります。
AIエージェントの安全な初期設定
Web検索やRAGを使う一般的なエージェントなら、最初の比較設定として最大10ステップ、最大20 Tool Call、同一Function+同一引数は2回まで、全体60〜120秒程度といった制限から評価できます。
料金はサービスの単価によって大きく変わるため、1Run当たり許容できる原価から逆算します。
たとえば無料ユーザーへ提供する機能と、有料ユーザー向けの長時間Researchでは同じ予算にする必要はありません。
重要なのは数値そのものではなく、
ステップ Tool Call 同一処理 Token Cost Time
を独立した制限として持つことです。
一つの上限だけでは防げない問題を、別の上限で止められます。
AIエージェントの無限ループに関するよくある質問
Q最大ステップ数は10で十分ですか
Aタスクによります。OpenAI Agents SDKは標準10ターンをSafety Limitとして設定していますが、10がすべてのAgentに最適という意味ではありません。自分の正常Runのp95を計測して決めます。
Qプロンプトに「最大5回」と書くだけではだめですか
Aハードリミットの代わりにはなりません。モデルが指示を守らなかった場合にも必ず終了できるよう、コード側でステップ数を数えます。プロンプトへの残り回数通知は、上限内でうまく作業をまとめてもらうための補助として使います。
QFunction Callingを何回まで許可すべきですか
A検索Agentなら複数回必要ですが、決済やメール送信など副作用のあるFunctionは別扱いします。Agent全体のTool Call上限に加え、Toolごとの上限と冪等性を設定します。
Q同じFunctionを2回呼んだらループですか
A必ずしもそうではありません。検索条件やページ番号が変わっていれば正常な場合があります。Function名だけでなく、正規化した引数を含むFingerprintで判定します。
Q料金上限だけ設定すれば十分ですか
A十分ではありません。安いモデルなら非常に長時間ループしても料金上限へ達しない可能性があります。ステップ、Tool Call、実行時間、重複処理などの上限も併用します。
Qトークン上限だけならどうですか
AContextの肥大化は検知できますが、外部APIの大量呼び出しや副作用までは防げません。Tool Call回数やToolごとの制限も必要です。
QmaxTurnsへ達したらどうすればよいですか
A取得済み情報で安全に部分回答できるなら、Partial Resultとして返せます。情報不足で回答すると危険な用途なら、完了扱いにせず「上限に達したため処理を完了できなかった」と終了します。OpenAI Agents SDKにも最大ターン到達時を最終出力へ変換するerrorHandlers.maxTurnsがあります。
QClaudeでは料金で自動停止できますか
AClaude Agent SDKにはmaxBudgetUsdがあり、クライアント側の推定料金が指定値へ達するとQueryを停止できます。Resultでも最大料金到達を区別できます。ただし、料金だけでなくmaxTurnsなども併用するほうが安全です。
まとめ
AIエージェントの無限ループを防ぐには、「必要な情報が集まったら終了してください」というPromptだけに依存してはいけません。
AIエージェントは、
モデル ↓ Tool Call ↓ Tool Result ↓ モデル
というループで動作するため、オーケストレーター側で必ず終了条件を持たせます。
最初に設定したいのが最大ステップ数です。
OpenAI Agents SDKではmaxTurnsが標準10ターンに設定されており、上限へ達するとMaxTurnsExceededErrorになります。
しかし、最大ステップ数だけでは十分ではありません。
1ターンから複数Tool Callが発生することがあるため、Tool Call総数も別に制限します。
同じFunctionと同じ引数が繰り返された場合はFingerprintで検出し、数回連続したらループとして停止します。
さらに、Token、料金、実行時間にもハードリミットを設定します。
副作用を持つメール送信、注文、決済、削除などのFunctionについては、回数制限だけでなくIdempotency Keyを使い、同じ処理が再実行されても結果が二重に発生しないようにします。
終了条件も、
completed
だけにしません。
needs_user_input blocked
のような「これ以上自律実行しない状態」も正常な終了として扱うことで、達成不可能なタスクを延々と続ける問題を防げます。
本番のAIエージェントでは、最終的に、
最大ステップ 最大Tool Call数 Toolごとの上限 同一Tool Callの重複上限 Token予算 料金予算 最大実行時間 進捗なし回数 明示的な完了条件 副作用の冪等性
を組み合わせます。
そしてRunごとに、ステップ数、Tool Call数、Token、料金、Duration、Stop ReasonをDBへ保存します。
無限ループ対策の目的は単純に「10回で止める」ことではありません。
正常なAgentには必要なだけ仕事をさせながら、異常なAgentだけを料金事故や副作用が起きる前に確実に停止させることが重要です。
