「注文をキャンセルして」「先月の売上を確認して」のような自然言語を、業務APIの呼び出しにつなげると、利用者は複雑な画面操作やAPI仕様を覚えずに済みます。
一方で、LLMの出力をそのまま業務APIへ渡す設計は危険です。引数の取り違え、権限のないデータ参照、意図しない更新、重複実行などが起こり得ます。
この記事では、Function Callingを「LLMに実行権限を与える機能」ではなく、「呼び出したい処理の候補を構造化して提案させる機能」として扱います。実際の実装では、アプリケーション側で入力検証・認可・確認・監査を行うことが重要です。
Function Callingの責任範囲を分ける
処理全体の流れを図にすると次の通りです。
Function Callingの基本的な流れは次のとおりです。
- ユーザーの自然言語をLLMへ渡す
- LLMが呼び出す関数名と引数を返す
- アプリケーションが引数を検証する
- ユーザー権限を確認する
- 必要ならユーザーに確認する
- 業務APIを実行する
- 実行結果をLLMへ渡し、自然言語で応答する
ここで重要なのは、2の結果を信頼しすぎないことです。LLMはデータベースの権限管理者ではなく、業務ルールの最終判断者でもありません。
特に、参照系APIと更新系APIは分けて設計します。注文状況の取得は比較的安全ですが、注文キャンセルや返金は副作用を伴います。関数の説明にも、その違いを明示しておくと意図しない呼び出しを減らせます。
関数スキーマを小さく厳密に定義する
まず、Function Callingへ渡す関数定義を作ります。例として、注文状況の取得と注文キャンセルを定義します。
tools = [
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "指定された注文の現在の状態を取得する。データを変更しない。",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "注文番号。例: ORD-12345"
}
},
"required": ["order_id"],
"additionalProperties": False
}
}
},
{
"type": "function",
"function": {
"name": "cancel_order",
"description": "注文をキャンセルする。実行前に必ずユーザー確認を要求する。",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "注文番号。例: ORD-12345"
},
"reason": {
"type": "string",
"enum": ["customer_request", "duplicate", "other"]
}
},
"required": ["order_id", "reason"],
"additionalProperties": False
}
}
}
]
additionalProperties: Falseを設定すると、想定していない引数を受け取りにくくなります。また、自由入力の文字列を減らし、可能なものはenumで限定します。
ただし、スキーマだけでは安全性は不十分です。たとえば、モデルがorder_idとして別ユーザーの注文番号を返す可能性はあります。必ず業務API側でも、ログインユーザーと注文の所有者を照合します。
アプリケーション側で引数を検証する
私が実装するときは、LLMの出力を直接APIクライアントへ渡さず、Pydanticなどで一度検証します。
python -m venv .venv
.venv\Scripts\activate
pip install pydantic pytest
次の例では、関数名の許可リスト、引数の形式、実行ユーザーを確認しています。
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class GetOrderStatusArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
order_id: str = Field(pattern=r"^ORD-[0-9]{5,}$")
class CancelOrderArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
order_id: str = Field(pattern=r"^ORD-[0-9]{5,}$")
reason: Literal["customer_request", "duplicate", "other"]
READ_TOOLS = {"get_order_status"}
WRITE_TOOLS = {"cancel_order"}
def execute_tool(tool_name: str, raw_args: dict, user: dict, order_api):
if tool_name not in READ_TOOLS | WRITE_TOOLS:
raise ValueError("許可されていない関数です")
if tool_name == "get_order_status":
args = GetOrderStatusArgs.model_validate(raw_args)
# tenant_idやuser_idはLLMの引数ではなく認証情報から取得する
return order_api.get_status(
tenant_id=user["tenant_id"],
user_id=user["user_id"],
order_id=args.order_id,
)
if tool_name == "cancel_order":
args = CancelOrderArgs.model_validate(raw_args)
if "orders:write" not in user["scopes"]:
raise PermissionError("注文変更権限がありません")
# 更新系処理は、ここで即時実行せず確認待ちにする
return {
"requires_confirmation": True,
"action": "cancel_order",
"order_id": args.order_id,
"reason": args.reason,
}
tenant_idやuser_idをFunction Callingの引数に含めない点がポイントです。これらをモデルに指定させると、プロンプトインジェクションなどで別ユーザーになりすます余地が生まれます。
認証情報から取得した値を、サーバー側で必ず注入します。
更新系APIには確認と冪等性を入れる
参照系の呼び出しは自動実行しても、更新系は確認を挟む設計が安全です。
たとえば、ユーザーが「ORD-12345をキャンセルして」と入力した場合、次のような確認画面やメッセージを返します。
注文 ORD-12345 をキャンセルします。
理由: customer_request
この操作は取り消せません。実行してよろしいですか?
ユーザーが明示的に承認した後だけ、確定用の処理を呼び出します。
def confirm_cancel_order(
order_id: str,
reason: str,
user: dict,
order_api,
request_id: str,
):
if "orders:write" not in user["scopes"]:
raise PermissionError("注文変更権限がありません")
return order_api.cancel(
tenant_id=user["tenant_id"],
user_id=user["user_id"],
order_id=order_id,
reason=reason,
idempotency_key=request_id,
)
idempotency_keyは、通信の再試行や画面の二重送信に備えるためのキーです。同じキーで複数回リクエストされても、キャンセル処理が一度だけ実行されるように業務API側で制御します。
また、残高変更、返金、権限変更、削除などは、Function Callingだけで完結させない方がよいでしょう。金額や対象件数に上限を設け、必要に応じて人の承認ワークフローへ接続します。
監査ログとテストで確認する
実行した関数だけでなく、提案された関数、検証結果、認可結果、確認の有無も記録します。
def write_audit_log(event_store, *, user, tool_name, args, result, request_id):
event_store.append({
"request_id": request_id,
"user_id": user["user_id"],
"tenant_id": user["tenant_id"],
"tool_name": tool_name,
"args": args,
"result_type": type(result).__name__,
})
ログにはパスワードやアクセストークンなどの秘密情報を含めません。注文番号やユーザーIDも、運用上必要な範囲に絞ります。
手元で確認したテストケースは、正常系だけでは不十分でした。最低限、次のようなケースを自動テストにします。
def test_unknown_tool_is_rejected():
with pytest.raises(ValueError):
execute_tool("delete_all_orders", {}, user, order_api)
def test_extra_argument_is_rejected():
with pytest.raises(ValidationError):
execute_tool(
"get_order_status",
{"order_id": "ORD-12345", "user_id": "admin"},
user,
order_api,
)
def test_write_tool_requires_scope():
no_write_user = {
"user_id": "u-1",
"tenant_id": "t-1",
"scopes": ["orders:read"],
}
with pytest.raises(PermissionError):
execute_tool(
"cancel_order",
{"order_id": "ORD-12345", "reason": "duplicate"},
no_write_user,
order_api,
)
あわせて、「別テナントの注文番号を指定する」「キャンセル済み注文を再度キャンセルする」「同じリクエストを再送する」「曖昧な注文番号を入力する」といった業務上の境界値も確認します。
まとめ
Function Callingは、自然言語と業務APIをつなぐ便利な仕組みです。しかし、安全性を担保するのはLLMではなく、周辺のアプリケーション実装です。
実装時は、次の点を守ると設計しやすくなります。
- 関数名と引数を許可リストで管理する
- スキーマとサーバー側の両方で入力検証する
- ユーザーIDやテナントIDは認証情報から取得する
- 更新系処理には確認、権限チェック、冪等性を入れる
- 実行内容を監査ログへ残し、境界値をテストする
「LLMがAPIを呼ぶ」のではなく、「LLMが候補を提案し、アプリケーションが安全性を確認してからAPIを呼ぶ」と考えるのが、実運用での基本パターンです。