AIがローカル関数を呼び出す方法(OpenAIプロトコルに基づく)
本稿では、OpenAI互換のFunction Calling(ツール呼び出し)機能を基盤として、汎用的かつ実用的な実装方法を解説します。
レンダリング中...
大模型が「実際に何かをする」必要がある場合――ライブラリの検索、ライブラリの作成、内部APIの呼び出し、通知の送信など――は、あなたのサービス内で実際のコードを実行することになります。「モデルの決定」と「ローカル実行」を繋ぐのが、「AIによるローカル関数の呼び出し」が解決しようとしていることです。
## 適用シナリオと目標
典型的なシナリオとしては、対話型ビジネスオペレーション(例:「記帳を手伝って」「先月の合計を調べて」)、チケット/タスク作成、データクエリと統計、内部システムコマンド解析などがあります。共通点は、ユーザーが自然言語で意図を表現し、モデルがそれを解析して呼び出すべき「ツール」とパラメータを選択し、あなたのバックエンドが具体的なロジックを実行し、その結果をモデルに渡して最終的な応答を生成することです。
目標は3点に集約できます。
- **ツールが記述可能であること**(モデルがどの関数があり、パラメータがどのような形式であるかを知っていること)
- **ローカルで実行されること**(機密性の高いロジックやデータを第三者に渡さないこと)
- **結果がフィードバック可能であること**(ツールの戻り値が対話に再度参加し、自然言語応答の生成に利用できること)。
## 全体アーキテクチャの考え方
「記述 + 実行」を分離します。OpenAIの`tools`(Function Calling)を使用して、ローカルの機能を「名前 + パラメータスキーマ」として記述します。モデルは対話中に`tool_calls`を返します。あなたのサービスは`tool_calls`を解析し、ローカルで名前とパラメータに基づいて実際の関数を呼び出します。戻り値を`role: "tool"`の形式でメッセージリストに挿入し、再度モデルにリクエストして、モデルがツールの結果に基づいてユーザー向けの応答を生成するようにします。これにより、機密性の高いロジックとデータは常にあなたのプロセス内に留まり、モデルは「ツールの選択、パラメータの入力、応答の作成」のみを行います。
現在使用しているモデルまたはゲートウェイが`tool_calls`をサポートしていない場合は、ダウングレード層を追加できます。モデルに構造化されたJSON(例:`{ "action": { "name", "args" }, "reply" }`)を出力させ、それを解析してローカル実行と二次リクエストを行うことで、「解析 → 実行 → 要約」のクローズドループを「tool_calls」がない環境でも維持できます。
## ツールの定義:OpenAIフォーマット
モデルは「どの関数があり、各関数にはどのようなパラメータが必要か」を知る必要があります。OpenAIは`tools`配列を使用して記述することを規定しており、各項目は`type: "function"`で、`function.name`、`function.description`、`function.parameters`(JSON Schema)を含みます。記述が明確であるほど、モデルがツールを間違えたり、パラメータの入力を漏らしたりする可能性は低くなります。
以下は、機密性を解除したツールの定義例です(名前とビジネス上の意味は一般化されています)。
```ts
import type { ChatCompletionTool } from "openai/resources/chat/completions";
export const CHAT_TOOLS: ChatCompletionTool[] = [
{
type: "function",
function: {
name: "create_item",
description: "ビジネスレコードを作成します。「記帳」「追加」「登録」などの意図をユーザーが表現した場合に使用します。",
parameters: {
type: "object",
properties: {
category: { type: "string", description: "カテゴリ、例:タイプA、タイプB" },
amount: { type: "number", description: "金額または数量" },
title: { type: "string", description: "タイトル/概要" },
date: { type: "string", description: "日付 YYYY-MM-DD、指定しない場合は当日" },
remark: { type: "string", description: "備考" },
},
required: ["category", "amount", "title"],
},
},
},
{
type: "function",
function: {
name: "list_items",
description: "条件に基づいてビジネスレコードをクエリします。「調べる」「一覧表示」「フィルタリング」などをユーザーが求めた場合に使用します。",
parameters: {
type: "object",
properties: {
category: { type: "string", description: "カテゴリでフィルタリング" },
startDate: { type: "string", description: "開始日 YYYY-MM-DD" },
endDate: { type: "string", description: "終了日 YYYY-MM-DD" },
pageNum: { type: "number", description: "ページ番号、デフォルトは1" },
pageSize: { type: "number", description: "1ページあたりのアイテム数、デフォルトは15" },
},
},
},
},
{
type: "function",
function: {
name: "get_summary",
description: "集計統計を取得します。「合計いくら」「統計」「集計」などをユーザーが尋ねた場合に使用します。",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "開始日 YYYY-MM-DD" },
endDate: { type: "string", description: "終了日 YYYY-MM-DD" },
month: { type: "string", description: "月 YYYY-MM、日付範囲とどちらか一方を指定" },
},
},
},
},
];
```
実際のプロジェクトでは、ビジネスに応じてツールを追加・削除したり、`description`や`properties`を詳細化したり、必要に応じて`enum`で値の制約を設けたりすることで、モデルの誤った入力や漏れを減らすことができます。
## ローカル実行ハンドラ:名前から関数へのマッピング
ツール実行レイヤーは2つのことだけを行います。`name`に基づいて対応する実装を見つけ、`args`と実行コンテキスト(現在のユーザーID、テナントIDなど)を渡します。そして、文字列形式の結果(通常はJSON)を返し、`role: "tool"`のメッセージに挿入できるようにします。
機密性を解除した実行ハンドラの例です。
```ts
export interface ToolExecutionContext {
userId: number;
// 拡張可能:tenantId, requestId, locale など
}
export async function executeTool(
name: string,
args: Record<string, unknown>,
ctx: ToolExecutionContext,
): Promise<string> {
switch (name) {
case "create_item":
return execCreateItem(args, ctx);
case "list_items":
return execListItems(args, ctx);
case "get_summary":
return execGetSummary(args, ctx);
default:
return JSON.stringify({ success: false, message: `未知のツール: ${name}` });
}
}
async function execCreateItem(
args: Record<string, unknown>,
ctx: ToolExecutionContext,
): Promise<string> {
const category = String(args.category ?? "");
const amount = Number(args.amount ?? 0);
const title = String(args.title ?? "");
const date = args.date ? new Date(String(args.date)) : new Date();
const remark = args.remark ? String(args.remark) : null;
if (!title || Number.isNaN(amount)) {
return JSON.stringify({ success: false, message: "タイトルと数値は必須です" });
}
// ここで実際の永続化(データベース、APIなど)を接続します
const created = await yourCreateItemService({
userId: ctx.userId,
category,
amount,
title,
date,
remark,
});
return JSON.stringify({
success: true,
item: { id: created.id, title: created.title, amount: created.amount },
});
}
```
`list_items`、`get_summary`も同様です。`args`から安全にパラメータを取得し(型変換、デフォルト値)、ビジネスレイヤーを呼び出し、`JSON.stringify`で返します。これにより、エージェント層は具体的なテーブル構造やRPCを気にする必要がなく、「ツール名 + パラメータ辞書 → 文字列結果」という規約に依存するだけになります。
## 対話プロキシ:tool_calls を伴う複数ラウンドのループ
エージェント層は「メッセージチェーン」を維持します。システムプロンプト + ユーザー履歴 + 現在のモデル応答です。モデルが`tool_calls`を返した場合、各呼び出しをループ処理し、`function.name`と`function.arguments`(JSON)を解析します。オプションでパラメータ補完(例:「今日」「今月」を実際の日付に変換)を行い、`executeTool`を呼び出します。結果を`role: "tool"`、`tool_call_id`をモデルのIDと対応させてメッセージチェーンに追加し、再度モデルにリクエストします。`tool_calls`がない場合や最大ラウンド数に達した場合は、現在の`message.content`を最終応答とし、現在のラウンドでツールが実行された場合は、通常、モデルに再度リクエストしてツールの結果に基づいて自然言語の要約を生成させます。
コアループの機密性を解除した例です。
```ts
async function runWithToolCalls(opts: {
userId: number;
messages: ChatCompletionMessageParam[];
maxToolRounds: number;
client: OpenAI;
config: { model: string; temperature?: number; maxTokens?: number };
}): Promise<{ content: string }> {
const { userId, messages, maxToolRounds, client, config } = opts;
const fullMessages: ChatCompletionMessageParam[] = [
{ role: "system", content: SYSTEM_PROMPT },
...messages,
];
let round = 0;
let lastContent = "";
while (round < maxToolRounds) {
const response = await client.chat.completions.create({
model: config.model,
temperature: config.temperature ?? 0.5,
max_tokens: config.maxTokens ?? 3000,
messages: fullMessages,
tools: CHAT_TOOLS,
});
const msg = response.choices[0]?.message;
if (!msg) return { content: "有効な応答が返されませんでした" };
fullMessages.push(msg);
const toolCalls = msg.tool_calls;
if (!toolCalls?.length) {
lastContent = msg.content ?? "操作が完了しました。";
break;
}
for (const tc of toolCalls) {
if (tc.type !== "function") continue;
const name = tc.function.name;
let args: Record<string, unknown> = {};
try {
args = JSON.parse(tc.function.arguments || "{}");
} catch {
args = {};
}
const output = await executeTool(name, args, { userId });
fullMessages.push({
role: "tool",
tool_call_id: tc.id!,
content: output,
});
}
round++;
}
// 現在のラウンドでツールが実行された場合、モデルに結果に基づいて自然言語を生成させるために再度リクエストします
if (fullMessages.some((m) => m.role === "tool")) {
const finalResponse = await client.chat.completions.create({
model: config.model,
temperature: config.temperature ?? 0.5,
max_tokens: config.maxTokens ?? 3000,
messages: fullMessages,
});
const finalMsg = finalResponse.choices[0]?.message;
lastContent = finalMsg?.content ?? lastContent ?? "操作が完了しました。";
}
return { content: lastContent };
}
```
ポイント:`tools`は「モデルが呼び出すかどうかを決定する必要がある」リクエストでのみ渡します。最後の「応答のみ生成」ラウンドでは、モデルが再度tool_callsを生成しないように、`tools`を渡さないようにします。`maxToolRounds`は、異常な状況での無限ループを防ぎます。
## ダウングレードソリューション:tool_calls がない場合の JSON コマンド
一部のモデルまたはプロキシは`tool_calls`をサポートしていないか、異常な形式を返すことがあります。この場合、「JSONコマンド」パスを追加できます。システムプロンプトを使用して、モデルに固定構造のJSONのみを出力するように要求します。例:`{ "action": { "name": "create_item" | "list_items" | "get_summary" | "none", "args": { ... } }, "reply": "オプションのプリセット応答" }`。コードはJSONを解析し、`action.name`が`"none"`でない場合は、`action.args`を使用して`executeTool`を呼び出します。次に、ツールの戻り値と`reply`を組み合わせて「要約」リクエストを行い、モデルにツールの結果に基づいて最終応答を生成させます。`action.name === "none"`の場合は、`reply`を直接応答として使用します。これにより、tool_callsをサポートしていない環境でも、「意図の解析 → ローカル実行 → 自然言語要約」のプロセスを維持できます。
```ts
const JSON_PLAN_SYSTEM = `あなたはコマンド解析アシスタントです。Markdownや追加の説明なしで、JSONのみを出力してください。
フォーマット: { "action": { "name": "create_item"|"list_items"|"get_summary"|"none", "args": {...} }, "reply": "ユーザーへの応答(nameがnoneの場合は必須)" }
ツールを呼び出せる場合は、actionを優先して入力してください。noneは使用しないでください。`;
async function runWithJsonPlan(opts: {
userId: number;
userText: string;
client: OpenAI;
config: { model: string; maxTokens?: number };
}): Promise<{ content: string }> {
const { userId, userText, client, config } = opts;
const res = await client.chat.completions.create({
model: config.model,
temperature: 0.1,
max_tokens: config.maxTokens ?? 3000,
messages: [{ role: "user", content: `${JSON_PLAN_SYSTEM}\n\nユーザーリクエスト:${userText}` }],
});
const raw = res.choices[0]?.message?.content?.trim();
if (!raw) throw new Error("コンテンツが返されませんでした");
const parsed = parseJsonPlan(raw); // rawからJSONを抽出し、parseします
const name = parsed.action?.name;
const args = parsed.action?.args ?? {};
if (name && name !== "none") {
const toolOutput = await executeTool(name, args, { userId });
// オプション:再度モデルを呼び出し、toolOutput + parsed.reply を使用して最終的なcontentを生成します
return { content: await summarizeWithModel(client, config, userText, name, toolOutput, parsed.reply) };
}
return { content: parsed.reply?.trim() ?? "リクエストを解析できませんでした。" };
}
```
メインエントリポイントでは、まず`runWithToolCalls`を試み、失敗または空の応答が返された場合に`runWithJsonPlan`にフォールバックすることで、互換性を保証できます。
## パラメータ補完とコンテキスト
**例:時間概念**
ユーザーはしばしば「今日」「今月」「先月」と言いますが、モデルが必ずしも具体的な日付を入力するとは限りません。`executeTool`を呼び出す前に、現在のサーバー時間とユーザーの元のテキストに基づいてパラメータ補完を行うことができます。例えば、「今日」「本日」を検出したら、`date`または`startDate/endDay`を当日に書き込みます。「今月」は現在のYYYY-MMを`month`に書き込みます。「昨日」は前日を使用します。これにより、モデルの負担を軽減し、曖昧さを回避できます。実装としては、エージェント内で`args`に対して`applyTemporalHints(toolName, args, userText, new Date())`を実行し、時間に関連するフィールドのみを変更し、その他はモデルの元の値を維持します。
## まとめ
「OpenAI tools の記述 + ローカル executeTool のマッピング」を使用することで、AI の決定とローカルの機能を連携させることができます。複数ラウンドのメッセージ(`role: "tool"`を含む)と、最後のツールなしの要約リクエストを通じて、ユーザー向けの自然言語応答を得られます。tool_calls をサポートしていない環境では、「固定 JSON コマンド + ローカル解析実行 + 二次要約」をダウングレードソリューションとして使用することで、同じ実行レイヤーとビジネスロジックを維持できます。ツールの記述を明確にし、パラメータを安全に変換し、時間などのコンテキスト補完を行うことで、ユーザビリティと安定性を大幅に向上させることができます。END
コメント
ログインしてコメントを閲覧・投稿してください
ログインへ