🏭

Azure OpenAI で Function Calling を使う

2024/07/30に公開

執筆日

2024/07/30

概要

Function Calling(関数呼び出し)を使うことでプレーンなテキスト生成以外にも、多様な機能を統合し対話型アプリケーションをより実用的で動的なものにすることが可能です。特に複雑な要求に対する出力フォーマットの安定化や、外部リソースとの連携の必要がある場合に有用だと思っています。単純にJSON形式で回答を出力したいというだけでも、Azure OpenAIではresponse_formatに対応しているモデルが少ないため、使い方を知っておいて損はありません。
自由に設定できる項目が多く、応用できる範囲も広大な機能なため一度自分で使ってみないと感覚が掴めないと思うので是非一度自分でコードを書いて動かしてみることをオススメします。

前提

  • Azure OpenAIのリソースでFunction Callingが利用可能なモデルがデプロイされている(対応状況が本家のAPIと違う場合があるので注意)
    • gpt-4oは最初対応していませんでしたがいつの間にか対応していました
    • (2024/07時点の対応モデル) gpt-35-turbo (1106), gpt-35-turbo (0125), gpt-4 (1106-Preview), gpt-4 (0125-Preview), gpt-4 (vision-preview), gpt-4 (2024-04-09), gpt-4o (2024-05-13)

依存ライブラリインストール

$ pip install openai

Note

  • 少し古い情報だとfunctions, function_callでFunction Callingを呼び出しているものが多いかと思いますが、現在は非推奨になっています。新しいAPI Versionでは廃止される可能性もあるので気を付けてください。
  • 本家のOpenAI APIにあるparallel_tool_callsによる並列関数呼び出しには対応していません。複数の関数を呼び出したい場合は別々に呼び出す必要があり、入力トークン数がかさんでしまいます。したがってまとめられる機能はできるだけまとめるのがいいでしょう。

GPTのレスポンス取得関数

関数定義
import os
from dotenv import load_dotenv
from openai import AzureOpenAI

load_dotenv(".env")
OPENAI_ENDPOINT = os.getenv("OPENAI_ENDPOINT")
OPENAI_KEY = os.getenv("OPENAI_KEY")
OPENAI_API_VERSION = os.getenv("OPENAI_API_VERSION")
OPENAI_DEPLOYMENT_NAME = os.getenv("OPENAI_DEPLOYMENT_NAME")

client = AzureOpenAI(
        azure_endpoint = OPENAI_ENDPOINT,
        api_key = OPENAI_KEY,
        api_version = OPENAI_API_VERSION
    )

def get_gpt_response(
        client: AzureOpenAI, 
        system_message: str = "", 
        user_message: str = "",
        tools: list = None,
        tool_choice: str = "none", # "auto" or "none" or {"type": "function", "function": {"name": "function_name"}}
        recall_chat: bool = False
        ):
    messages = []
    messages.append({
        "role": "system", 
        "content": [{"type": "text", "text": system_message}]
    })    
    messages.append({
        "role": "user", 
        "content": [{"type": "text", "text": user_message}]
    })

    try:
        response = client.chat.completions.create(
            model = OPENAI_DEPLOYMENT_NAME,
            messages = messages,
            tools = tools,
            tool_choice = tool_choice,
        )
    except Exception as e:
        return str(e), None
    
    res_message = response.choices[0].message
    # 関数が実行されるとres_messageにtool_callsが追加される
    if res_message.tool_calls:
        func_results = get_tools_result(res_message)
        if recall_chat:
            messages.extend(func_results)
            response = client.chat.completions.create(
                model = OPENAI_DEPLOYMENT_NAME,
                messages = messages,
            )
            content = response.choices[0].message.content
        else:
            content = "\n".join([func_result["content"] for func_result in func_results])
    else:
        print("No tool calls found")
        content = res_message.content

    tokens = {"input": response.usage.prompt_tokens, "output": response.usage.completion_tokens}

    return content, tokens

# ここの詳細は後述内容を確認
def get_tools_result(res_message):
    func_results = []
    for tool_call in res_message.tool_calls:
        tool_name = tool_call.function.name # 呼び出した関数名を取得
        tool_arguments = json.loads(tool_call.function.arguments) # "properties"に設定した出力JSONをdictに読み込み
        result = function_map[tool_name](**tool_arguments)
        result["tool_call_id"] = tool_call.id
        func_results.append(result)

    return func_results

使用例1 - 現在の時間を質問する(単一関数)

よくある簡単なチュートリアルです。
関数はtoolsに辞書形式で以下のキーを設定していきます。今回はコードでべた書きしていきますが、関数ごとにJSONやYAMLのファイルで定義するのがきれいだと思います。

  • "type": Azure OpenAIでは現在"function"のみがサポートされています。コード実行機能である"code_interpreter"などが今後追加されていくと思われます。
  • "function": 関数の詳細を設定します。
    • "name": 関数の名前です。
    • "description": 関数の説明です。これをプロンプトにしてGPTが関数を実行するかどうかの判断をするため簡潔にわかりやすく書きます。
    • "parameters": 出力の詳細・型などを定義します
      • "type": ここは"object"のみ対応しています。出力がJSON形式になります。
      • "properties": JSONの中身を定義します。ここでは好きな型を出力にできます。
        • {"<dictのキー名>": {"type": "<型>", "description": "<キーの詳細>", ...}
        • "required": 回答必須のキーをリストで指定
時間を尋ねるtoolsの例
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_current_time",
            "description": "指定された場所の現在時刻を取得します",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "取得したい都市名 (使用可能な都市名: Tokyo, New York, London)",
                    },
                },
                "required": ["location"],
            },
        }
    }
]

このtoolでは、どの都市の時刻を取得するかを出力しています。もちろんそれだけでは時間はわからないので、その出力を使って時刻を取得する関数を定義します。

function
import json
def get_current_time(location: str):
    locations = {
        "Tokyo": "Asia/Tokyo",
        "New York": "America/New_York",
        "London": "Europe/London"
    }

    if location not in locations:
        return "指定された都市名が不正です"
    
    tz = pytz.timezone(locations[location])
    now = datetime.datetime.now(tz)
    func_result = {
                    "tool_call_id": tool_call.id,
                    "role": "function",
                    "name": "get_current_time",
                    "content": f"{location}の現在時刻は{now}です"
                }
    return func_result

function_map = {
    "get_current_time": get_current_time,
}

実行してみます。

実行
system_message = "テンサイクンという名前の汎用的なアシスタントとしてふるまってください"
get_gpt_response(
        client, 
        system_message = system_message, 
        user_message = "東京の現在時刻を教えてください",
        tools = tools,
        tool_choice = "auto"
        )
# 出力
>>> Tokyoの現在時刻は2024-07-29 20:18:03 JSTです

get_gpt_response(
        client, 
        system_message = system_message, 
    user_message = "東京とアメリカの現在時刻を教えてください",
        tools = tools,
        tool_choice = "auto"
        )
# 出力
>>> Tokyoの現在時刻は2024-07-29 20:18:05 JSTです
>>> New Yorkの現在時刻は2024-07-29 11:18:05 EDTです

get_gpt_response(
        client, 
        system_message = system_message, 
        user_message = "あなたのお名前は何ですか?",
        tools = tools,
        tool_choice = "auto"
        )
# 出力
>>> No tool calls found
>>> 私の名前はテンサイクンです。あなたのお手伝いをするためのアシスタントです。どんなことでもお気軽にお聞きください!

注目すべき点として、同じ関数で対応できる複数の質問がある場合自動で複数回の呼び出しをしてくれます。なのでfor tool_call in res_message.tool_callsとして呼び出された回数だけ後処理が必要になります。
また、関数と関係ない質問をした場合は関数呼び出しが実行されずに通常の応答を返してくれます。

使用例2 - toolsを複数設定する(並列関数)

toolsの変数名やリストで定義されていたことからお察しの通りですが、複数の関数を定義できます。
また、tool_choiceの変数を設定することでどの関数を実行するか指定することもできます。

  • tool_choice="auto": 複数の関数の中からGPTが自動で判断してどの関数を実行するか決定してくれます。関係ないメッセージを送ると関数を実行しない通常の応答を返します。自動テキスト分類すごい。
  • tool_choice={"type": "function", "function": {"name": "<function名>"}}: 関数名を選択して実行できます。関係ないメッセージでも強制的に実行するため気を付けましょう。
    • 時間を聞く関数を使ってみたところ質問文の言語によってその国の時間を返してくれました
    • 関数と関係ない質問文の場合の返り値を指定しておけば予期せぬ出力を避けられます
  • tool_choice="none": 関数を実行しない通常の応答を強制します。複雑な処理フローを作る場合に重宝します。

Noteにも書いていますが、複数の関数を定義した場合でも一度のAPIコールで1つの関数しか実行してくれません。parallel_tool_calls変数はAzure OpenAIでは定義されていません。

具体例

時間を聞く関数に質問の意図を推測する関数を追加しました。(並列実行できないため、もう少し排他的な関数にした方が良かったかも……。)
質問意図推測では複数の返り値に"integer"を設定しました。"integer"では最大最小を決められる"minimum", "maximum"のキーも設定できるので、0-100の確率で回答するように指示してみました。

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_current_time",
            "description": "指定された場所の現在時刻を取得します",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "取得したい都市名 (使用可能な都市名: Tokyo, New York, London)",
                    },
                },
                "required": ["location"],
            },
        }
    },
    {
        "type": "function",
        "function": {
            "name": "predict_question_intent",
            "description": "質問の意図を各項目の確率で予想します",
            "parameters": {
                "type": "object",
                "properties": {
                    "science": {
                        "type": "integer",
                        "minimum": 0, "maximum": 100,
                        "description": "科学に関する質問である確率",
                    },
                    "sports": {
                        "type": "integer",
                        "minimum": 0, "maximum": 100,
                        "description": "スポーツに関する質問である確率",
                    },
                    "business": {
                        "type": "integer",
                        "minimum": 0, "maximum": 100,
                        "description": "ビジネスに関する質問である確率",
                    },
                    "IT": {
                        "type": "integer",
                        "minimum": 0, "maximum": 100,
                        "description": "ITに関する質問である確率",
                    },
                    "others": {
                        "type": "integer",
                        "minimum": 0, "maximum": 100,
                        "description": "上記以外の質問である確率",
                    },
                },
                "required": ["science", "sports", "business", "IT", "others"],
            },
        }
    },
]

def predict_question_intent(science: int, sports: int, business: int, IT: int, others: int):
    intents = {
        "science": science,
        "sports": sports,
        "business": business,
        "IT": IT,
        "others": others
    }
    print(json.dumps(intents, indent=4, ensure_ascii=False))
    
    max_intent = max(intents, key=intents.get)

    result = {
        "role": "function",
        "name": "predict_question_intent",
        "content": f"質問の意図は{max_intent}です"
    }
    return result

# 関数のマッピングを定義
function_map = {
    "get_current_time": get_current_time,
    "predict_question_intent": predict_question_intent
}
実行
system_message = "テンサイクンという名前の汎用的なアシスタントとしてふるまってください"
get_gpt_response(
        client, 
        system_message = system_message, 
        user_message = "昨日のJリーグはどこのチームが勝ちましたか?",
        tools = tools,
        tool_choice = "auto"
        )
# 出力
>>> {
    "science": 0,
    "sports": 100,
    "business": 0,
    "IT": 0,
    "others": 0
}
>>> 質問の意図はsportsです

get_gpt_response(
        client, 
        system_message = system_message, 
        user_message = "今日の天気は?",
        tools = tools,
        tool_choice = {"type": "function", "function": {"name": "predict_question_intent"}}
        )
# 出力
>>> {
    "science": 70,
    "sports": 10,
    "business": 10,
    "IT": 5,
    "others": 5
}
>>> 質問の意図はscienceです

また特に合計を100にするように指示していませんが、%だと判断して合計が100になるように出力してくれました。かしこい。
ここでは質問の意図を分類しただけですが、この分類結果を使ってさらに特定のサイトを検索させてみるのも面白いと思います。

propertiesに関する深堀り

なかなか公式の一覧に辿り着けず、複数LLMに聞きながら調査した内容なので過不足あると思います。ご了承ください。

指定可能なtype

  • string: 文字列
  • number: 数値
  • integer: 整数
  • boolean: 真偽値
  • array: 配列("items"で要素の型を指定)
  • object: オブジェクト(もう一層設定もできる)

条件指定

minimum / maximum: 数値の最小値/最大値
minLength / maxLength: 文字列の最小/最大長
minItems / maxItems: 配列要素数の最小/最大数
enum: 回答の選択肢をリストで列挙(descriptionに書くより確実に出力を絞れる)
pattern: 正規表現で回答形式を指定できます。電話番号や住所のような形式が決まった文字列を出力したいときに使えます(具体例はLLMに聞いた方が正確そうです……)
default: デフォルト値を設定できますが、実行関数側で指定すればいいのでは?という気がします。

参考

https://learn.microsoft.com/ja-jp/azure/ai-services/openai/how-to/function-calling
https://learn.microsoft.com/ja-jp/azure/ai-services/openai/reference

あとがき

Function Callingは質問に応じた関数を呼び出す仕組みですが、その実態はLLMによるテキスト分類と言っていいと思います。昔はテキスト分類はそれ単体で学習が必要なものでしたが、現代ではLLMがテキスト生成の片手間でやるものになってしまったんだなという気持ちです。
今回は応用を見据えつつも簡単な2つの例を紹介しただけだったので、関数の出力をそのままprintしていますが、"role": "function"で関数の出力をメッセージに追加してさらにGPTに応答させられます。関数を使って決まったフォーマットのコンテキストを追加し、回答生成という使用方法ですね。

ヘッドウォータース

Discussion