01はじめに
この記事の対象読者
- OpenAI APIや互換APIのFunction Calling機能を使い始めたエンジニア
- ツールを定義したのにモデルが呼んでくれない、または意図しない引数で呼ばれる問題に直面している方
- Function Callingを実運用に載せるための設計指針を探している方
TypeScript(Node.js v20以上)の基本的な読み書きができることを前提にしています。 OpenAI APIのChat Completions APIを直接呼び出した経験があると、より理解しやすいと思います。
実行環境
- Node.js: v20.18.1
- TypeScript: v5.4.5
- openai: v4.52.7(OpenAI APIクライアント)
- zod: v3.23.8
サンプルコードはOpenAI API互換のインターフェースを使いますが、ツール定義の設計思想はAnthropic Claude APIやGoogle Gemini APIにも同様に適用できます。
この記事で得られること
- ツール定義の粒度に関する設計判断の基準
- 引数スキーマをLLMが正しく解釈しやすい形で書くための具体的な方法
- ツールの実行結果をモデルに返すときの設計パターン
- 「呼んでくれない」「間違った引数で呼ぶ」「呼びすぎる」という3種類のトラブルへの対処法
(関連記事: 構造化出力で挙動を安定させる:JSONスキーマ活用の実践)
02Function Callingとは何か
Function Calling(ツール呼び出し)は、LLMがテキストを生成する代わりに「この関数をこの引数で呼んでほしい」という要求を返す仕組みです。
通常の対話では、モデルはテキストを返します。 Function Callingを有効にした場合、モデルはテキストの代わりに「どのツールを、どういう引数で呼ぶか」という構造化データを返すことができます。
flowchart LR
subgraph A["通常の応答"]
U1["ユーザー: 東京の天気は?"] --> M1["モデル: 「東京の現在の天気情報を調べるには...」\n(テキスト)"]
end
subgraph B["Function Calling あり"]
U2["ユーザー: 東京の天気は?"] --> M2["モデル: { tool: \"get_weather\", args: { city: \"Tokyo\" } }\n(構造化データ)"]
end
アプリケーション側でツールを実際に実行し、その結果をモデルに渡すと、モデルは結果をもとに最終的な回答を生成します。
この一連の流れを「ツール呼び出しサイクル」と呼びます。
flowchart TD
A["ユーザー入力"]
B["LLMにメッセージを送る(tools付き)"]
C["モデルがtool_callsを返す"]
D["アプリがツールを実行する"]
E["実行結果をtoolメッセージとしてLLMに渡す"]
F["モデルが最終回答を生成する"]
A --> B --> C --> D --> E --> F
シンプルに見えますが、実運用では「ツールを呼んでくれない」「引数が間違っている」「呼びすぎる」といった問題が頻繁に起きます。 これらの問題の多くは、ツール定義の設計や引数スキーマの書き方に起因しています。
03ツール定義の基本構造
まず、OpenAI APIでのツール定義の基本構造を確認します。
// tools/weather.ts
import OpenAI from "openai";
export const weatherTool: OpenAI.Chat.ChatCompletionTool = {
type: "function",
function: {
name: "get_current_weather",
description:
"指定した都市の現在の天気情報を取得します。" +
"気温・天気の状態(晴れ・曇り・雨など)・湿度を返します。",
parameters: {
type: "object",
properties: {
city: {
type: "string",
description:
"天気を取得したい都市名。英語表記で入力してください(例: Tokyo, Osaka, New York)。",
},
unit: {
type: "string",
enum: ["celsius", "fahrenheit"],
description:
"気温の単位。指定しない場合は celsius が使われます。",
},
},
required: ["city"],
},
},
};
このツール定義を使ったAPIコールの全体像を示します。
// lib/basic-tool-call.ts
import OpenAI from "openai";
import { weatherTool } from "../tools/weather";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// ツールの実装(実際のAPIコールの代わりにモックを使用)
async function getCurrentWeather(city: string, unit: string = "celsius") {
// 実際の実装では外部の天気APIを呼び出す
return {
city,
temperature: unit === "celsius" ? 22 : 72,
unit,
condition: "晴れ",
humidity: 65,
};
}
export async function askWithTools(userMessage: string): Promise<string> {
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "user", content: userMessage },
];
// 1回目のAPI呼び出し(ツール定義を渡す)
const firstResponse = await client.chat.completions.create({
model: "gpt-5.4-mini",
messages,
tools: [weatherTool],
tool_choice: "auto",
});
const firstMessage = firstResponse.choices[0].message;
// モデルがツールを呼ばなかった場合はそのままテキストを返す
if (!firstMessage.tool_calls || firstMessage.tool_calls.length === 0) {
return firstMessage.content ?? "";
}
// ツール呼び出しを処理する
const toolResults: OpenAI.Chat.ChatCompletionMessageParam[] = [];
for (const toolCall of firstMessage.tool_calls) {
if (toolCall.function.name === "get_current_weather") {
const args = JSON.parse(toolCall.function.arguments) as {
city: string;
unit?: string;
};
const result = await getCurrentWeather(args.city, args.unit);
toolResults.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify(result),
});
}
}
// 2回目のAPI呼び出し(ツール結果を含める)
const secondResponse = await client.chat.completions.create({
model: "gpt-5.4-mini",
messages: [
...messages,
firstMessage, // アシスタントのtool_callsメッセージ
...toolResults, // ツール実行結果
],
tools: [weatherTool],
});
return secondResponse.choices[0].message.content ?? "";
}
実行例を示します。
// 実行例
const answer = await askWithTools("東京の今の天気を教えてください");
console.log(answer);
// 出力例(LLMの実際の応答によって変わります)
東京の現在の天気は晴れで、気温は22°C、湿度は65%です。
基本構造はシンプルです。 しかし実際に複数のツールを定義して複雑な操作をさせようとすると、「呼んでくれない」「引数が間違っている」「同じツールを何度も呼ぶ」といった問題が起き始めます。 ここからは、これらの問題を設計で防ぐ方法を順番に解説します。
04ツール定義の粒度設計
Function Callingで最初に直面する設計判断は「ツールをどの粒度で定義するか」です。
粒度が粗すぎる問題
粒度が粗いとは、1つのツールが複数の責務を持つ状態です。
// 粒度が粗すぎる例:何でもできる汎用ツール
const databaseTool: OpenAI.Chat.ChatCompletionTool = {
type: "function",
function: {
name: "manage_database",
description: "データベースの操作を行います。",
parameters: {
type: "object",
properties: {
operation: {
type: "string",
// 操作の種類が多すぎる
enum: ["select", "insert", "update", "delete", "create_table", "drop_table"],
description: "実行する操作の種類",
},
table: { type: "string", description: "テーブル名" },
data: { type: "object", description: "操作に使うデータ" },
condition: { type: "string", description: "WHERE句の条件" },
columns: {
type: "array",
items: { type: "string" },
description: "SELECT対象の列名",
},
},
required: ["operation"],
},
},
};
このツール定義の問題は2つあります。
1つ目は、引数の意味がoperationによって変わる点です。
selectではcolumnsが意味を持ちますが、insertでは不要です。
LLMは「どのoperationでどの引数が必要か」を正確に推論しにくくなります。
2つ目は、セキュリティリスクです。
drop_tableのような危険な操作を、些細な操作と同じツールに混在させると、LLMが誤って実行するリスクが上がります。
粒度が細かすぎる問題
逆に、操作ごとに別ツールを定義しすぎると「ツールの選択に迷う」問題が起きます。
// 粒度が細かすぎる例:類似ツールが多すぎる
const tools = [
{ name: "get_user_by_id" },
{ name: "get_user_by_email" },
{ name: "get_user_by_phone" },
{ name: "get_user_by_username" },
{ name: "find_user" }, // 上記と何が違うのか曖昧
{ name: "search_user" }, // find_userと何が違うのか曖昧
{ name: "lookup_user" }, // さらに曖昧
];
類似した名前のツールが多いと、LLMは「どれを選べばよいか」の判断精度が下がります。 後述する「呼んでくれない」問題や「間違ったツールを呼ぶ」問題の原因になります。
適切な粒度の考え方
筆者たちが実装を通じて整理した粒度設計の指針を示します。
1. 1ツール1副作用の原則
ツールは「1つの副作用(または1種類の問い合わせ)」に絞ります。 読み取り(副作用なし)と書き込み(副作用あり)を同じツールに混在させません。
// 良い例:読み取りと書き込みを分離する
const getUserTool: OpenAI.Chat.ChatCompletionTool = {
type: "function",
function: {
name: "get_user",
description:
"ユーザーIDを指定してユーザー情報を取得します。読み取り専用の操作です。",
parameters: {
type: "object",
properties: {
user_id: {
type: "string",
description: "取得するユーザーのID(例: usr_abc123)",
},
},
required: ["user_id"],
},
},
};
const updateUserEmailTool: OpenAI.Chat.ChatCompletionTool = {
type: "function",
function: {
name: "update_user_email",
description:
"ユーザーのメールアドレスを更新します。更新後は確認メールが送信されます。",
parameters: {
type: "object",
properties: {
user_id: {
type: "string",
description: "更新対象ユーザーのID",
},
new_email: {
type: "string",
description: "新しいメールアドレス(RFC 5321形式)",
},
},
required: ["user_id", "new_email"],
},
},
};
2. 「引数が文脈によって変わる」ツールは分割する
operationパラメータのように「他のパラメータの意味が変わる」ような設計は、ツールを分割するサインです。
3. 類似ツールは統合か削除を検討する
find_userとsearch_userが同じ機能なら、どちらかを削除します。
違う機能なら、説明文でその違いを明確にします。
4. ツール数の目安
ツールの数が増えるほど選択精度は下がる傾向があります。 実運用の感触として、10個を超えると明らかに選択ミスが増えることが多いです(ただしこれは目安であり、モデルの性能やツールの類似度によって変わります)。 10個を超える場合は、用途に応じてツールをサブセットに分けて渡すことを検討してください。
// コンテキストに応じてツールをサブセットに分けて渡す例
function getToolsForContext(
context: "read_only" | "admin" | "user_management"
): OpenAI.Chat.ChatCompletionTool[] {
switch (context) {
case "read_only":
return [getUserTool, searchOrdersTool, getProductTool];
case "admin":
return [getUserTool, updateUserEmailTool, deleteUserTool, createProductTool];
case "user_management":
return [getUserTool, updateUserEmailTool, resetPasswordTool];
}
}
05引数スキーマの設計
ツール定義のうち、LLMの挙動に最も影響するのが引数スキーマです。
特にdescriptionフィールドの書き方が、正しい引数を渡してもらえるかどうかを大きく左右します。
descriptionに書くべき3要素
引数のdescriptionには、次の3要素を含めると効果的です。
- 何を渡すか(フィールドの意味)
- どの形式で渡すか(フォーマット・型の詳細)
- 例(ひとつ以上の具体例)
// descriptionが薄い例(LLMが迷いやすい)
{
start_date: {
type: "string",
description: "開始日"
}
}
// descriptionが充実した例(LLMが迷いにくい)
{
start_date: {
type: "string",
description:
"検索の開始日。ISO 8601形式(YYYY-MM-DD)で指定してください。" +
"例: 2026-01-01。タイムゾーンは不要です。" +
"省略した場合は30日前の日付が使われます。"
}
}
enumを活用する
取りうる値が固定されている場合はenumを使います。
LLMに自由なテキスト生成をさせると、想定外の値が来ることがあります。
// enumなし(LLMが自由に文字列を生成してしまう)
{
status: {
type: "string",
description: "ステータス(active, inactive, suspended のいずれか)"
}
}
// enumあり(LLMが選択肢から選ぶ)
{
status: {
type: "string",
enum: ["active", "inactive", "suspended"],
description:
"ユーザーアカウントのステータス。" +
"active: 通常利用可能、inactive: 無効化済み、suspended: 一時停止中"
}
}
ネストの深さに注意する
引数スキーマを深くネストさせると、LLMの引数生成精度が下がることがあります。 特に3階層を超えるネストは、なるべく平坦化を検討してください。
// ネストが深すぎる例
{
type: "object",
properties: {
order: {
type: "object",
properties: {
customer: {
type: "object",
properties: {
address: {
type: "object",
properties: {
city: { type: "string" },
zip: { type: "string" }
}
}
}
}
}
}
}
}
// 平坦化した例
{
type: "object",
properties: {
order_id: { type: "string", description: "注文ID" },
customer_city: { type: "string", description: "配送先の都市名" },
customer_zip: {
type: "string",
description: "配送先の郵便番号(ハイフンなし7桁、例: 1000001)"
}
}
}
Zodでスキーマを定義してJSONスキーマに変換する
TypeScriptで開発する場合、Zodでスキーマを定義してJSON Schemaに変換すると、型安全性とAPI定義の一貫性を両立できます。
// tools/schemas.ts
import OpenAI from "openai";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
// 引数の型定義
export const SearchOrdersSchema = z.object({
customer_id: z
.string()
.describe("検索対象の顧客ID(例: cus_abc123)"),
status: z
.enum(["pending", "processing", "shipped", "delivered", "cancelled"])
.optional()
.describe(
"絞り込むステータス。省略した場合は全ステータスを返します。" +
"pending: 支払い待ち、processing: 処理中、" +
"shipped: 発送済み、delivered: 配達完了、cancelled: キャンセル済み"
),
start_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe(
"検索開始日。YYYY-MM-DD形式(例: 2026-01-01)。省略した場合は30日前から。"
),
end_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe(
"検索終了日。YYYY-MM-DD形式(例: 2026-12-31)。省略した場合は今日まで。"
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(20)
.describe("返す件数の上限(1〜100の整数)。デフォルトは20件。"),
});
export type SearchOrdersArgs = z.infer<typeof SearchOrdersSchema>;
// Zodスキーマ → JSON Schema(OpenAI API形式)に変換
export function buildToolFromZod(
name: string,
description: string,
schema: z.ZodObject<z.ZodRawShape>
): OpenAI.Chat.ChatCompletionTool {
const jsonSchema = zodToJsonSchema(schema, { target: "openApi3" });
return {
type: "function",
function: {
name,
description,
// zodToJsonSchemaの出力からpropertiesとrequiredを取り出す
parameters: {
type: "object",
properties: (jsonSchema as Record<string, unknown>).properties as Record<string, unknown>,
required: (jsonSchema as Record<string, unknown>).required as string[] | undefined,
},
},
};
}
// tools/orders.ts
import OpenAI from "openai";
import { SearchOrdersSchema, buildToolFromZod } from "./schemas";
export const searchOrdersTool = buildToolFromZod(
"search_orders",
"顧客IDを指定して注文履歴を検索します。" +
"ステータスや日付で絞り込むことができます。",
SearchOrdersSchema
);
ZodスキーマはAPIの引数バリデーションにも再利用できます。 後述するツール実行時の引数検証と組み合わせると、より堅牢な実装になります。
06ツール実行結果の返し方
ツールを実行した結果をモデルに返す方法も、最終的な応答品質に影響します。
結果メッセージの構造
ツール実行結果はrole: "tool"のメッセージとして渡します。
contentは文字列である必要があります(オブジェクトをそのまま渡すことはできません)。
// ツール実行と結果の返し方
async function executeToolCall(
toolCall: OpenAI.Chat.ChatCompletionMessageToolCall
): Promise<OpenAI.Chat.ChatCompletionToolMessageParam> {
let result: unknown;
try {
if (toolCall.function.name === "search_orders") {
const rawArgs = JSON.parse(toolCall.function.arguments);
// Zodでバリデーション
const args = SearchOrdersSchema.parse(rawArgs);
result = await searchOrders(args);
} else {
result = { error: `未知のツール: ${toolCall.function.name}` };
}
} catch (error) {
// バリデーションエラーや実行エラーをモデルに伝える
result = {
error: error instanceof Error ? error.message : "不明なエラーが発生しました",
};
}
return {
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify(result),
};
}
結果の情報量を適切に保つ
モデルに返す結果が多すぎるとコンテキストを圧迫し、少なすぎると正確な回答が生成できません。
避けた方がよいパターン:生のDBレスポンスをそのまま返す
// 良くない例:不要な情報が多い
const dbResult = await db.query("SELECT * FROM orders WHERE ...");
return {
role: "tool",
tool_call_id: toolCall.id,
// 内部フィールドやLLMに不要な情報が大量に含まれる
content: JSON.stringify(dbResult.rows),
};
推奨パターン:LLMに必要な情報だけに絞る
// 良い例:LLMが使う情報に絞って返す
type OrderSummary = {
order_id: string;
status: string;
amount: number;
currency: string;
created_at: string;
item_count: number;
};
function toOrderSummary(row: DatabaseRow): OrderSummary {
return {
order_id: row.id,
status: row.status,
amount: row.total_amount,
currency: row.currency_code,
created_at: row.created_at.toISOString().split("T")[0],
item_count: row.line_items.length,
};
}
const results = dbResult.rows.map(toOrderSummary);
return {
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify({
total_count: results.length,
orders: results,
}),
};
エラーを意味のある形で返す
ツールの実行が失敗した場合も、モデルが次のアクションを判断できる形で情報を返します。
// エラーレスポンスの例
type ToolErrorResult = {
success: false;
error_code: string;
message: string;
// モデルに次の行動を示唆する
suggestion?: string;
};
// 例: ユーザーが見つからなかった場合
const notFoundResult: ToolErrorResult = {
success: false,
error_code: "USER_NOT_FOUND",
message: "指定されたIDのユーザーが見つかりませんでした。",
suggestion: "別のIDで検索するか、search_usersツールでユーザーを検索してください。",
};
suggestionフィールドで次のアクションを示すと、モデルが自律的に問題を解決しやすくなります。
BizPlan(事業計画エージェント)の実装でも、ツールエラー時にこのような示唆を返すことで、不要な中断を減らせることを確認しています。
07トラブルシューティング:呼んでくれない
「ツールを定義したのにモデルが呼んでくれない」問題は、最もよく遭遇するトラブルです。
原因1:ツール名や説明文が曖昧
モデルは「いつこのツールを使うべきか」をdescriptionから判断します。 descriptionが「何をするツールか」だけで「いつ使うか」を示していない場合、モデルが呼ぶ判断をしにくくなります。
// 呼ばれにくい例:何をするかしか書いていない
{
name: "get_product_info",
description: "商品情報を返します。",
}
// 呼ばれやすい例:いつ使うかも明示する
{
name: "get_product_info",
description:
"商品IDを指定して、商品名・価格・在庫数・説明文を取得します。" +
"ユーザーが特定の商品について質問したとき、" +
"または注文内容に商品の詳細が必要なときに使用してください。",
}
原因2:tool_choiceの設定
tool_choiceパラメータを"none"に設定するとツールは一切呼ばれません。
また"auto"(デフォルト)でも、モデルがツールを使わずに回答できると判断した場合は呼ばれません。
必ず特定のツールを呼ばせたい場面ではtool_choiceを明示します。
// ツールを必ず呼ばせる設定
const response = await client.chat.completions.create({
model: "gpt-5.4-mini",
messages,
tools: [weatherTool],
// 特定のツールを必ず呼ばせる
tool_choice: {
type: "function",
function: { name: "get_current_weather" },
},
});
// 何らかのツールを必ず呼ばせる
const responseRequired = await client.chat.completions.create({
model: "gpt-5.4-mini",
messages,
tools: [weatherTool, searchOrdersTool],
tool_choice: "required", // gpt-5.4-mini以降で対応
});
原因3:システムプロンプトとツールの乖離
システムプロンプトで「データベースを検索して回答してください」と指示しているのに、 ツール定義の説明文が弱いと、モデルがテキストだけで回答しようとすることがあります。
システムプロンプトにツールを使う条件を明記するアプローチが有効です。
const systemPrompt = `あなたは注文管理システムのアシスタントです。
ユーザーから注文に関する質問を受けたときは、必ずsearch_ordersツールを使って
最新の情報を取得してから回答してください。
ツールを使わずに記憶から回答することは禁止です。
ツールを使う場面:
- 特定の顧客の注文状況を確認する
- 注文のステータスを調べる
- 特定期間の注文を一覧する`;
診断コードを書く
問題の原因を特定するために、モデルの応答を詳しくログに出すデバッグコードを用意すると便利です。
// lib/debug-tool-call.ts
export async function debugToolCall(
messages: OpenAI.Chat.ChatCompletionMessageParam[],
tools: OpenAI.Chat.ChatCompletionTool[]
): Promise<void> {
const response = await client.chat.completions.create({
model: "gpt-5.4-mini",
messages,
tools,
tool_choice: "auto",
});
const message = response.choices[0].message;
console.log("=== ツール呼び出しデバッグ ===");
console.log("finish_reason:", response.choices[0].finish_reason);
// "stop" → テキストで終了(ツールを呼ばなかった)
// "tool_calls" → ツールを呼ぼうとした
// "length" → トークン上限に達した
if (message.tool_calls) {
console.log("呼び出されたツール:");
for (const tc of message.tool_calls) {
console.log(` - ${tc.function.name}`);
console.log(` 引数: ${tc.function.arguments}`);
}
} else {
console.log("ツールは呼ばれませんでした。テキスト応答:");
console.log(message.content);
}
}
// 出力例(ツールが呼ばれなかった場合)
=== ツール呼び出しデバッグ ===
finish_reason: stop
ツールは呼ばれませんでした。テキスト応答:
申し訳ありませんが、その情報はわかりかねます。
finish_reasonがstopのままなら、ツール定義の説明文かシステムプロンプトを見直します。
08トラブルシューティング:間違った引数で呼ぶ
ツールは呼ばれるのに、引数の値が間違っているというケースです。
パターン1:日付・時刻のフォーマット不一致
最もよく起きる引数ミスが日付のフォーマット問題です。
// ツール定義で期待するフォーマット
{
start_date: {
type: "string",
description: "開始日" // フォーマットが未指定
}
}
// モデルが返してくることがある値のバリエーション
// "2026-06-11" ✓ ISO 8601形式
// "June 11, 2026" ✗ 英語表記
// "2026/06/11" ✗ スラッシュ区切り
// "06-11-2026" ✗ 月日年の順
// "今日" ✗ 相対表現
対処法はdescriptionでフォーマットを明記し、サーバー側でも正規化することです。
// tools/date-helpers.ts
/**
* LLMから受け取った日付文字列を YYYY-MM-DD 形式に正規化する。
* 完全な正規化はできないが、よくある形式を吸収する。
*/
export function normalizeDateString(input: string): string {
// すでに YYYY-MM-DD 形式の場合はそのまま返す
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
return input;
}
// YYYY/MM/DD → YYYY-MM-DD
if (/^\d{4}\/\d{2}\/\d{2}$/.test(input)) {
return input.replace(/\//g, "-");
}
// それ以外は Date() でパースを試みる
const parsed = new Date(input);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString().split("T")[0];
}
// パース失敗時はエラーとして扱う
throw new Error(
`日付のフォーマットを認識できません: "${input}". YYYY-MM-DD形式で指定してください。`
);
}
パターン2:IDの混在
user_idに商品IDや注文IDが入ってくるケースです。
// IDプレフィックスをdescriptionで明示する
{
user_id: {
type: "string",
description:
"操作対象のユーザーID。'usr_' で始まる文字列です(例: usr_abc123)。" +
"注文ID(ord_xxx)や商品ID(prd_xxx)とは異なります。"
}
}
またはZodのバリデーションで弾いて、エラーメッセージに正しい形式を含めます。
const UserIdSchema = z
.string()
.regex(/^usr_/, "ユーザーIDは 'usr_' で始まる必要があります(例: usr_abc123)");
パターン3:数値の範囲外
// 範囲をdescriptionに明記する
{
limit: {
type: "number",
minimum: 1,
maximum: 100,
description:
"取得件数の上限。1〜100の整数を指定してください。デフォルトは20です。" +
"大量取得が必要な場合はページネーション(page引数)を使用してください。"
}
}
minimum・maximumはJSON Schemaで標準的なフィールドですが、OpenAI APIが引数バリデーションにこれらを利用する保証はありません(2026年6月時点)。
あくまでモデルへのヒントとして機能させつつ、アプリ側でも検証することを推奨します。
引数バリデーションを関数として切り出す
すべてのツール呼び出しに対して一貫したバリデーションを行うため、ツール実行の前にバリデーション層を挟む設計が有効です。
// lib/tool-executor.ts
import { z } from "zod";
import OpenAI from "openai";
import { SearchOrdersSchema, SearchOrdersArgs } from "../tools/schemas";
type ToolHandler<TArgs, TResult> = (args: TArgs) => Promise<TResult>;
type ToolDefinition<TArgs, TResult> = {
schema: z.ZodType<TArgs>;
handler: ToolHandler<TArgs, TResult>;
};
// ツール名 → (スキーマ, ハンドラ) のマッピング
const toolRegistry: Record<string, ToolDefinition<unknown, unknown>> = {
search_orders: {
schema: SearchOrdersSchema,
handler: async (args) => {
// 実際の実装では DB やAPIを呼ぶ
return searchOrdersFromDB(args as SearchOrdersArgs);
},
},
// 他のツールを追加していく
};
/**
* tool_callsを受け取り、バリデーションとハンドラ実行を行い、
* tool メッセージの配列を返す。
*/
export async function executeToolCalls(
toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[]
): Promise<OpenAI.Chat.ChatCompletionToolMessageParam[]> {
const results: OpenAI.Chat.ChatCompletionToolMessageParam[] = [];
for (const toolCall of toolCalls) {
const toolDef = toolRegistry[toolCall.function.name];
if (!toolDef) {
results.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify({
success: false,
error_code: "UNKNOWN_TOOL",
message: `ツール "${toolCall.function.name}" は登録されていません。`,
}),
});
continue;
}
let rawArgs: unknown;
try {
rawArgs = JSON.parse(toolCall.function.arguments);
} catch {
results.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify({
success: false,
error_code: "INVALID_JSON",
message: "引数のJSONパースに失敗しました。",
}),
});
continue;
}
// Zodでバリデーション
const parseResult = toolDef.schema.safeParse(rawArgs);
if (!parseResult.success) {
results.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify({
success: false,
error_code: "VALIDATION_ERROR",
message: "引数のバリデーションに失敗しました。",
// Zodのエラーをモデルにフィードバックするとリトライ精度が上がることがある
details: parseResult.error.issues.map((i) => ({
field: i.path.join("."),
problem: i.message,
})),
}),
});
continue;
}
// ハンドラを実行
try {
const result = await toolDef.handler(parseResult.data);
results.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify({ success: true, data: result }),
});
} catch (error) {
results.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify({
success: false,
error_code: "EXECUTION_ERROR",
message:
error instanceof Error
? error.message
: "ツールの実行中にエラーが発生しました。",
}),
});
}
}
return results;
}
このパターンでは、バリデーションエラーの詳細(どのフィールドの何が問題か)をモデルにフィードバックしています。 モデルがエラー内容を受け取ると、引数を修正して再度ツールを呼び直す動作につながることがあります。 ただし、常にリトライされるわけではなく、モデルがエラー内容を解釈してユーザーに聞き返す動作になる場合もあります。
09トラブルシューティング:呼びすぎる
ツールの呼びすぎは「不必要なAPIコスト」「レスポンス遅延」「外部サービスへの過剰リクエスト」を引き起こします。
パターン1:同じツールを複数回呼ぶ
// 問題のある呼び出しパターン
1. get_user(user_id: "usr_001") ← 1回目
2. get_user(user_id: "usr_001") ← 2回目(同じ引数)
3. get_user(user_id: "usr_001") ← 3回目(同じ引数)
原因の多くは「前の結果を覚えていない」ことです。 ツールの実行結果をモデルに明示的に渡しているにもかかわらず再度呼ばれる場合は、 会話履歴の設計に問題があることがほとんどです。
// 同一引数の重複呼び出しを検出してキャッシュする
class ToolCallCache {
private cache = new Map<string, unknown>();
private makeKey(toolName: string, args: unknown): string {
return `${toolName}:${JSON.stringify(args)}`;
}
get(toolName: string, args: unknown): unknown | undefined {
return this.cache.get(this.makeKey(toolName, args));
}
set(toolName: string, args: unknown, result: unknown): void {
this.cache.set(this.makeKey(toolName, args), result);
}
}
// 1リクエストのライフサイクル内でキャッシュを使う
const cache = new ToolCallCache();
async function executeWithCache(
toolCall: OpenAI.Chat.ChatCompletionMessageToolCall
): Promise<OpenAI.Chat.ChatCompletionToolMessageParam> {
const args = JSON.parse(toolCall.function.arguments);
const cached = cache.get(toolCall.function.name, args);
if (cached !== undefined) {
console.log(`[Cache HIT] ${toolCall.function.name}`);
return {
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify(cached),
};
}
const result = await executeRealTool(toolCall);
cache.set(toolCall.function.name, args, JSON.parse(result.content as string));
return result;
}
パターン2:ループ回数の上限を設けない
モデルが「ツールを呼ぶ→結果を受け取る→またツールを呼ぶ」を繰り返す設計では、無限ループのリスクがあります。 必ず最大反復回数を設定してください。
// lib/agent-loop.ts
import OpenAI from "openai";
import { executeToolCalls } from "./tool-executor"; // 前述の executeToolCalls を使用
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function runAgentLoop(
initialMessages: OpenAI.Chat.ChatCompletionMessageParam[],
tools: OpenAI.Chat.ChatCompletionTool[],
options: {
maxIterations?: number;
onToolCall?: (name: string, args: unknown) => void;
} = {}
): Promise<string> {
const { maxIterations = 10, onToolCall } = options;
const messages = [...initialMessages];
let iterations = 0;
while (iterations < maxIterations) {
iterations++;
const response = await client.chat.completions.create({
model: "gpt-5.4-mini",
messages,
tools,
tool_choice: "auto",
});
const message = response.choices[0].message;
messages.push(message);
// ツールを呼ばなければ終了
if (
response.choices[0].finish_reason === "stop" ||
!message.tool_calls ||
message.tool_calls.length === 0
) {
return message.content ?? "";
}
// ツール呼び出しを実行
for (const toolCall of message.tool_calls) {
if (onToolCall) {
onToolCall(
toolCall.function.name,
JSON.parse(toolCall.function.arguments)
);
}
}
const toolResults = await executeToolCalls(message.tool_calls);
messages.push(...toolResults);
}
// 上限に達した場合
console.warn(`[AgentLoop] 最大反復回数(${maxIterations})に達しました。`);
return "申し訳ありませんが、処理が完了できませんでした。もう一度お試しください。";
}
パターン3:不要なツールが多い
使う可能性の低いツールを大量に渡すと、モデルが「このツールを試してみよう」という動作に入りやすくなります。 前述の「コンテキストに応じてツールをサブセットに分けて渡す」アプローチを組み合わせてください。
パターン4:並列呼び出しの制御
現行モデル(gpt-5.4-mini 以降)は、複数のツールを並列で呼ぶことができます(parallel_tool_calls)。
これは効率的な反面、順序依存のあるツール呼び出しで問題になることがあります。
// 並列呼び出しを無効にする場合
const response = await client.chat.completions.create({
model: "gpt-5.4-mini",
messages,
tools,
parallel_tool_calls: false, // 1回につき1ツールずつ呼ばせる
});
順序に依存しないツール(複数の天気情報を取得するなど)は並列のままでよいですが、 「先にユーザー情報を取得してからその情報を使って注文を検索する」のような依存関係がある場合は無効化を検討してください。
10複数ツールを組み合わせる実装例
ここまでの設計指針を組み合わせた、実用的な実装例を示します。 顧客サポートエージェントの例として、「ユーザー情報取得」「注文検索」「返品申請」の3ツールを組み合わせます。
// tools/support-tools.ts
import OpenAI from "openai";
import { z } from "zod";
import { buildToolFromZod } from "./schemas";
// ユーザー情報取得
export const GetUserSchema = z.object({
user_id: z
.string()
.regex(/^usr_/, "ユーザーIDは 'usr_' で始まります(例: usr_abc123)")
.describe("取得するユーザーのID(例: usr_abc123)"),
});
// 注文検索
export const SearchOrdersSchema = z.object({
user_id: z.string().describe("検索対象の顧客ID"),
status: z
.enum(["pending", "processing", "shipped", "delivered", "cancelled"])
.optional()
.describe("絞り込むステータス(省略すると全件)"),
});
// 返品申請
export const CreateReturnSchema = z.object({
order_id: z
.string()
.regex(/^ord_/, "注文IDは 'ord_' で始まります(例: ord_xyz789)")
.describe("返品対象の注文ID(例: ord_xyz789)"),
reason: z
.enum(["damaged", "wrong_item", "not_needed", "other"])
.describe(
"返品理由。damaged: 破損、wrong_item: 間違い、not_needed: 不要、other: その他"
),
note: z
.string()
.max(500)
.optional()
.describe("補足メモ(500文字以内)"),
});
export const supportTools: OpenAI.Chat.ChatCompletionTool[] = [
buildToolFromZod(
"get_user",
"ユーザーIDを指定してアカウント情報(名前・メール・プラン)を取得します。" +
"ユーザーが誰かを確認するときや、アカウント情報が必要なときに使ってください。",
GetUserSchema
),
buildToolFromZod(
"search_orders",
"顧客IDを指定して注文履歴を取得します。" +
"特定の注文について問い合わせがあったとき、または注文状況を確認するときに使ってください。",
SearchOrdersSchema
),
buildToolFromZod(
"create_return",
"注文IDと返品理由を指定して返品申請を作成します。" +
"ユーザーが返品・交換を希望し、注文IDが確認できた場合にのみ使ってください。" +
"注文IDが不明な場合は先にsearch_ordersで確認してください。",
CreateReturnSchema
),
];
// lib/support-agent.ts
import OpenAI from "openai";
import { supportTools } from "../tools/support-tools";
import { executeToolCalls } from "./tool-executor";
import { runAgentLoop } from "./agent-loop";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const SYSTEM_PROMPT = `あなたは顧客サポートエージェントです。
利用可能なツール:
- get_user: ユーザー情報を取得します
- search_orders: 注文履歴を検索します
- create_return: 返品申請を作成します
対応方針:
1. ユーザーの問い合わせ内容を確認します
2. 必要な情報が不足している場合はツールで取得します
3. 返品申請は、ユーザーが明示的に希望した場合のみ行います
4. 不確かな情報は推測せず、ツールで確認してから回答します`;
export async function handleSupportRequest(
userId: string,
userMessage: string
): Promise<string> {
const initialMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: SYSTEM_PROMPT },
{
role: "user",
content: `ユーザーID: ${userId}\n\nメッセージ: ${userMessage}`,
},
];
return runAgentLoop(initialMessages, supportTools, {
maxIterations: 5,
onToolCall: (name, args) => {
// 実運用では監査ログに記録する
console.log(`[Tool] ${name}`, args);
},
});
}
実行例を示します。
// 実行例
const reply = await handleSupportRequest(
"usr_abc123",
"先週届いた商品が壊れていたので返品したいです"
);
console.log(reply);
// 処理の流れ(ログ)
[Tool] get_user { user_id: 'usr_abc123' }
[Tool] search_orders { user_id: 'usr_abc123', status: 'delivered' }
[Tool] create_return { order_id: 'ord_xyz789', reason: 'damaged', note: '商品が破損した状態で届いた' }
// 最終出力(LLMの実際の応答によって変わります)
返品申請を承りました。注文ID ord_xyz789 の返品手続きが完了しました。
返品ラベルを登録のメールアドレスに送付いたします。商品を受け取り次第、
3〜5営業日以内に返金処理を行います。
ご不便をおかけして申し訳ありません。
11ツール定義をコードと分離して管理する
ツールの数が増えると、ツール定義のメンテナンスが煩雑になります。 定義をJSONまたはYAMLファイルに分離し、コードから参照するパターンが有効です。
// tools/registry.ts
import OpenAI from "openai";
type ToolEntry = {
definition: OpenAI.Chat.ChatCompletionTool;
contexts: string[]; // どのコンテキストで使うか
};
const toolRegistry: Record<string, ToolEntry> = {};
export function registerTool(
name: string,
definition: OpenAI.Chat.ChatCompletionTool,
contexts: string[]
): void {
toolRegistry[name] = { definition, contexts };
}
export function getToolsForContext(
context: string
): OpenAI.Chat.ChatCompletionTool[] {
return Object.values(toolRegistry)
.filter((entry) => entry.contexts.includes(context))
.map((entry) => entry.definition);
}
export function getAllTools(): OpenAI.Chat.ChatCompletionTool[] {
return Object.values(toolRegistry).map((entry) => entry.definition);
}
// tools/index.ts(ツールを登録する)
import { registerTool } from "./registry";
import { supportTools } from "./support-tools";
registerTool("get_user", supportTools[0], ["support", "admin"]);
registerTool("search_orders", supportTools[1], ["support", "admin", "analytics"]);
registerTool("create_return", supportTools[2], ["support"]);
// 使用例
const supportContext = getToolsForContext("support");
// → [get_user, search_orders, create_return]
const analyticsContext = getToolsForContext("analytics");
// → [search_orders]
12まとめ
Function Callingを安定させるための設計ポイントを整理します。
ツール定義の粒度
- 1ツール1副作用の原則を守る
- 引数の意味が操作によって変わる場合はツールを分割する
- 類似ツールは統合または削除して、モデルの選択精度を保つ
引数スキーマの設計
descriptionに「何を渡すか」「どの形式か」「例」の3要素を含める- 値が固定の場合は
enumを使う - ネストは浅く保つ(2階層以内を目安に)
- Zodでスキーマを定義してAPIとバリデーションを一元管理する
結果の返し方
- LLMが必要とする情報に絞って返す(生のDBレスポンスをそのまま渡さない)
- エラー時は
error_codeとsuggestion(次のアクションの示唆)を含める
トラブルシューティング
- 呼んでくれない → descriptionに「いつ使うか」を明記し、システムプロンプトも合わせる
- 間違った引数 → フォーマットと制約をdescriptionに書き、サーバー側でも検証する
- 呼びすぎる → 最大反復回数を設定し、コンテキストに応じてツールをサブセット化する
これらの問題の多くは、ツール定義の書き方一つで改善できます。 「LLMはdescriptionを手がかりにしてツールを選び、引数を生成している」という視点で定義を見直すと、問題の原因を特定しやすくなります。
(関連記事: 構造化出力で挙動を安定させる:JSONスキーマ活用の実践) (関連記事: ハーネス設計入門:対話を成果物に収束させる仕組みの作り方)
13参考文献
- OpenAI Platform — Function Calling(2026年6月時点)
- OpenAI Platform — Structured Outputs(2026年6月時点)
- OpenAI Node.js SDK — v4.52.7
- Zod 公式ドキュメント(v3.23.8)
- zod-to-json-schema — ZodスキーマをJSON Schemaに変換するライブラリ
- JSON Schema Specification —
minimum/maximum等のバリデーションキーワードの仕様

