Closed5

OpenAI Function Callingの変更点を試してみる

kun432kun432

そういえばずっとさわれていなかったので試してみる。

https://openai.com/blog/new-models-and-developer-products-announced-at-devday

Function Callingのアップデート

Function Callingでは、アプリの関数や外部APIをモデルに記述し、モデルがそれらの関数を呼び出すための引数を含むJSONオブジェクトを出力するようにインテリジェントに選択することができます。1つのメッセージで複数の関数を呼び出す機能を含む、いくつかの改良を本日リリースします。ユーザーは、「車の窓を開けてエアコンを切る」といった複数のアクションを要求するメッセージを1つ送ることができます。また、関数の呼び出し精度も向上しています: GPT-4 Turboは正しい関数パラメータを返す可能性が高くなりました。

以前にFunction Callingやった時の記録

https://zenn.dev/kun432/scraps/aa33415ced9f12

kun432kun432

公式ドキュメント

https://platform.openai.com/docs/guides/function-calling/parallel-function-calling

サンプルコードも書いてあるが、呼び出す関数が1つしか無いので、以下のExampleも踏まえて少し変えてみた。ちょっと元のコード、いろいろ気持ち悪いところがあって、その点は後述することとして、なるべく元のコードを維持する書き方にはしている。

https://github.com/openai/openai-cookbook/tree/main/examples/How_to_call_functions_with_chat_models.ipynb

from openai import OpenAI
import json
from pprint import pprint

client = OpenAI()


# 同じ天気を返すようにハードコードされたダミー関数の例
# 本番では、これはバックエンドAPIか外部APIとなる
def get_current_temperature(location, unit="fahrenheit"):
    """与えられた地域の現在の気温を取得する"""
    if "tokyo" in location.lower():
        return json.dumps({"location": "Tokyo", "temperature": "10", "unit": unit})
    elif "san francisco" in location.lower():
        return json.dumps({"location": "San Francisco", "temperature": "72", "unit": unit})
    elif "paris" in location.lower():
        return json.dumps({"location": "Paris", "temperature": "22", "unit": unit})
    else:
        return json.dumps({"location": location, "temperature": "unknown"})


def get_n_day_temperature_forecast(location, unit="fahrenheit"):
    """与えられた地域のn日間の気温予報を取得する"""
    if "tokyo" in location.lower():
        return json.dumps({"location": "Tokyo", "temperature": "13", "unit": unit})
    elif "san francisco" in location.lower():
        return json.dumps({"location": "San Francisco", "temperature": "65", "unit": unit})
    elif "paris" in location.lower():
        return json.dumps({"location": "Paris", "temperature": "20", "unit": unit})
    else:
        return json.dumps({"location": location, "temperature": "unknown"})


def run_conversation(query):
    # Step 1: 会話履歴と関数をモデルに送る。
    messages = [
        {
            "role": "system",
            "content": (
                "あなたは親切なアシスタントです。"
                "必要な場合は、事前に定義された関数を実行して、その結果を踏まえて回答を行います。"
                "ユーザーのリクエストが曖昧な場合は、説明を求めてください。"
            )
        },
        {
            "role": "user",
            "content": query
        },
    ]

    tools = [
        {
            "type": "function",
            "function": {
                "name": "get_current_temperature",
                "description": "Get the current temperature",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "The city and state, e.g. San Francisco, CA",
                        },
                        "unit": {
                            "type": "string",
                            "enum": ["celsius", "fahrenheit"],
                            "description": "The temperature unit to use. Infer this from the users location.",
                        },
                    },
                    "required": ["location", "unit"],
                },
            }
        },
        {
            "type": "function",
            "function": {
                "name": "get_n_day_temperature_forecast",
                "description": "Get an N-day temperature forecast",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "The city and state, e.g. San Francisco, CA",
                        },
                        "unit": {
                            "type": "string",
                            "enum": ["celsius", "fahrenheit"],
                            "description": "The temperature unit to use. Infer this from the users location.",
                        },
                        "num_days": {
                            "type": "integer",
                        "description": "The number of days to forecast",
                        }
                    },
                    "required": ["location", "format", "num_days"]
                },
            }
        },
    ]
    first_response = client.chat.completions.create(
        model="gpt-3.5-turbo-0125",
        messages=messages,
        tools=tools,
        tool_choice="auto",  # autoがデフォルト、ただし明示的に設定することもできる。
    )
    first_response_message = first_response.choices[0].message
    tool_calls = first_response_message.tool_calls
    # Step 2: 関数が呼ぶ必要があるかどうかの判断結果をチェックする
    if tool_calls:
        # Step 3: 実際に関数を呼ぶ
        # 注意: JSONレスポンスは常に正しいというわけではないので、エラーハンドリングを行うこと
        available_functions = {
            "get_current_temperature": get_current_temperature,
            "get_n_day_temperature_forecast": get_n_day_temperature_forecast,
        } 
        messages.append(first_response_message)  # アシスタントの応答を会話履歴に追加
        # Step 4: 各関数の実行結果をモデルに送信する
        for tool_call in tool_calls:
            function_name = tool_call.function.name
            function_to_call = available_functions[function_name]
            function_args = json.loads(tool_call.function.arguments)
            function_response = function_to_call(
                location=function_args.get("location"),
                unit=function_args.get("unit"),
            )
            messages.append(
                {
                    "tool_call_id": tool_call.id,
                    "role": "tool",
                    "name": function_name,
                    "content": function_response,
                }
            )  # 関数の結果を会話履歴に追加する
        second_response = client.chat.completions.create(
            model="gpt-3.5-turbo-0125",
            messages=messages,
        ) # 関数を実行した結果を見て、モデルが新しいレスポンスを返す
        return second_response, messages
    else:
        # 関数が実行されなかった場合は最初のレスポンスを返す
        return first_response, messages

では試してみる。

answer, conversation = run_conversation("東京の気温を教えて?")
print(f"回答: {answer}")
print(f"----- 会話履歴 -----")
for idx, c in enumerate(conversation, start=1):
    print(idx, ": ", c)
print(f"--------------------")
回答: ChatCompletion(id='chatcmpl-8rPwXTV1RtE4MtWpBLZr5vOIkPvUf', choices=[Choice(finish_reason='stop', index=0, logprobs=None, message=ChatCompletionMessage(content='東京の現在の気温は10度です。', role='assistant', function_call=None, tool_calls=None))], created=1707741801, model='gpt-3.5-turbo-0125', object='chat.completion', system_fingerprint='fp_69829325d0', usage=CompletionUsage(completion_tokens=16, prompt_tokens=160, total_tokens=176))
----- 会話履歴 -----
1 :  {'role': 'system', 'content': 'あなたは親切なアシスタントです。必要な場合は、事前に定義された関数を実行して、その結果を踏まえて回答を行います。ユーザーのリクエストが曖昧な場合は、説明を求めてください。'}
2 :  {'role': 'user', 'content': '東京の気温を教えて?'}
3 :  ChatCompletionMessage(content=None, role='assistant', function_call=None, tool_calls=[ChatCompletionMessageToolCall(id='call_hz0x3mjTwBHKOAHEpfpHWl2K', function=Function(arguments='{"location":"Tokyo","unit":"celsius"}', name='get_current_temperature'), type='function')])
4 :  {'tool_call_id': 'call_hz0x3mjTwBHKOAHEpfpHWl2K', 'role': 'tool', 'name': 'get_current_temperature', 'content': '{"location": "Tokyo", "temperature": "10", "unit": "celsius"}'}
--------------------
--------------------

1〜2行目が最初の会話履歴。

3行目が、モデルが返してきた、Functaion Callingでどの関数にどういう引数で使うべきか?の回答。ここではget_current_temperatureを使って、東京かつ気温単位を摂氏で指定するという回答になっている。

で、4行目が、実際に上記の関数を実行した結果。東京・摂氏・10度という結果を取得している。

質問を変えてみる。

answer, conversation = run_conversation("東京の今と明日の気温を教えて?")
print(f"回答: {answer}")
print(f"----- 会話履歴 -----")
for idx, c in enumerate(conversation, start=1):
    print(idx, ": ", c)
print(f"--------------------")
回答: ChatCompletion(id='chatcmpl-8rQA1k4orM4NeU7h6lboCGmFc15jb', choices=[Choice(finish_reason='stop', index=0, logprobs=None, message=ChatCompletionMessage(content='現在の東京の気温は10°Cであり、明日の気温予報は13°Cです。', role='assistant', function_call=None, tool_calls=None))], created=1707742637, model='gpt-3.5-turbo-0125', object='chat.completion', system_fingerprint='fp_69829325d0', usage=CompletionUsage(completion_tokens=31, prompt_tokens=232, total_tokens=263))
----- 会話履歴 -----
1 :  {'role': 'system', 'content': 'あなたは親切なアシスタントです。必要な場合は、事前に定義された関数を実行して、その結果を踏まえて回答を行います。ユーザーのリクエストが曖昧な場合は、説明を求めてください。'}
2 :  {'role': 'user', 'content': '東京の今と明日の気温を教えて?'}
3 :  ChatCompletionMessage(content=None, role='assistant', function_call=None, tool_calls=[ChatCompletionMessageToolCall(id='call_0k6aciXnZ6wlnZQFXVYLhd84', function=Function(arguments='{"location": "Tokyo", "unit": "celsius"}', name='get_current_temperature'), type='function'), ChatCompletionMessageToolCall(id='call_nUj5WmIL7ZuYtXtuGMXuXlY1', function=Function(arguments='{"location": "Tokyo", "unit": "celsius", "num_days": 1}', name='get_n_day_temperature_forecast'), type='function')])
4 :  {'tool_call_id': 'call_0k6aciXnZ6wlnZQFXVYLhd84', 'role': 'tool', 'name': 'get_current_temperature', 'content': '{"location": "Tokyo", "temperature": "10", "unit": "celsius"}'}
5 :  {'tool_call_id': 'call_nUj5WmIL7ZuYtXtuGMXuXlY1', 'role': 'tool', 'name': 'get_n_day_temperature_forecast', 'content': '{"location": "Tokyo", "temperature": "13", "unit": "celsius"}'}
--------------------

複数の関数が実行されてその両方の結果が追加されているのがわかる。最終回答もそれを踏まえたものになっている。

kun432kun432

コードの書きっぷりやレスポンス的には、以前はfunction_*と指定していたものがtool_*になったような感じでそれほど違いはない。

個人的に気持ち悪いなぁと思うのは、3行目。ChatCompletionMessageオブジェクトがそのまま会話履歴に入ってるんだよね。。。公式のサンプルコードがこうなっていて、APIのやり取りとしても問題なさそうなんだけども。

ただ、この会話履歴をファイルなりDBなりに保存して永続化することを、Pythonオブジェクトのままで保持するのは微妙。

以前は3と4のところはレスポンスを紐解いてこういうメッセージを追加していたと思う。

{
    "role": "assistant",
    "function_call": {
      "name": "get_current_temperature",
      "arguments": "{\"location\":\"Tokyo\",\"unit\":\"celsius\"}"
    }
},
{
    "role": "function",
    "name": "get_current_temperature",
    "content": "{\"location\": \"Tokyo\", \"temperature\": \"10\", \"unit\": \"celsius\"}",
}

messageオブジェクトについてAPIリファレンスを見てみる。

https://platform.openai.com/docs/api-reference/chat/create

こういう感じで渡せば良いのだと思う。

{
    "role": "assistant",
    "tool_calls": [
        {
            "id": "call_XXXXXXXXXXXXXXXXX",
            "type": "function",
            "function": {
                "name": "get_current_temperature",
                "arguments": "{\"location\":\"Tokyo\",\"unit\":\"celsius\"}"
            } 
        }
    ]
},
{
    "role": "tool",
    "tool_call_id": "call_XXXXXXXXXXXXXXXXX",
    "content": "{\"location\": \"Tokyo\", \"temperature\": \"10\", \"unit\": \"celsius\"}"
}

複数の場合はこう。

{
    "role": "assistant",
    "tool_calls": [
        {
            "id": "call_XXXXXXXXXXXXXXXXX",
            "type": "function",
            "function": {
                "name": "get_current_temperature",
                "arguments": "{\"location\":\"Tokyo\",\"unit\":\"celsius\"}"
            } 
        },
        {
            "id": "call_YYYYYYYYYYYYYYYYYY",
            "type": "function",
            "function": {
                "name": "get_n_day_temperature_forecast",
                "arguments": "{\"location\": \"Tokyo\", \"num_days\": 1, \"unit\": \"celsius\"}"
            } 
        }
    ]
},
{
    "role": "tool",
    "tool_call_id": "call_XXXXXXXXXXXXXXXXX",
    "content": "{\"location\": \"Tokyo\", \"temperature\": \"10\", \"unit\": \"celsius\"}",
},
{
    "role": "tool",
    "tool_call_id": "call_YYYYYYYYYYYYYYYYYY",
    "content": "{\"location\": \"Tokyo\", \"num_days\": 1, \"unit\": \"celsius\"}"
}

方法は色々ある。アドホックに該当箇所だけゴニョゴニョするとかメッセージを自分で組み立てる(どのみちツールの結果は自分で作っているのだし)というのもあるけど、model_dump()でレスポンスを辞書に変換するのが筋が良さそうに思える。ただし書き方が全部変わるので、深い階層にアクセスする場合とかはタイプ数増えて、ちょっと面倒ではある。

(snip)
    first_response = client.chat.completions.create(
        model="gpt-3.5-turbo-0125",
        messages=messages,
        tools=tools,
        tool_choice="auto",  # autoがデフォルト、ただし明示的に設定することもできる。
    )
    first_response = first_response.model_dump()     # ここ
    first_response_message = first_response["choices"][0]["message"]     # 指定の仕方が全部変わる
    tool_calls = first_response_message["tool_calls"]
(snip)

あと"role": "assistant"のところなんだけども、メッセージを普通に取り出すと、こういうオブジェクトになっている。

{
    'content': None,
    'role': 'assistant',
    'function_call': None,
    'tool_calls': [
        {
            'id': 'call_XXXXXXXXXXXXXXXXX',
            'function': {
                'arguments': '{"location":"Tokyo","num_days":3}',
                'name': 'get_n_day_temperature_forecast'
            },
            'type': 'function'
        }
    ]
}

これをそのままメッセージとして追加するとこうなる。

BadRequestError: Error code: 400 - {'error': {'message': "None is not of type 'object' - 'messages.2.function_call'", 'type': 'invalid_request_error', 'param': None, 'code': None}}

これがダメ。tool_callfunction_callはどうやら排他っぽい。

    'function_call': None,

なのでこれを取り除いてからメッセージに追加してやる必要がある。

なので、この部分をアドホックに取り除いてやる、とか、メッセージを自分で組み立てる方法もあるけど、mdoel_dump()exclude_unset=Trueをつけると、明示的に指定してない箇所は含まれなくなるらしい。

    first_response = first_response.model_dump(exclude_unset=True)  

https://github.com/openai/openai-python/issues/777#issuecomment-1907789179

kun432kun432

最終的なコード。最終レスポンスは何もしてないけど、当然会話履歴に追加するとは思うので、model_dump()することになると思う。

from openai import OpenAI
import json
from pprint import pprint

client = OpenAI()


# 同じ天気を返すようにハードコードされたダミー関数の例
# 本番では、これはバックエンドAPIか外部APIとなる
def get_current_temperature(location, unit="fahrenheit"):
    """与えられた地域の現在の気温を取得する"""
    if "tokyo" in location.lower():
        return json.dumps({"location": "Tokyo", "temperature": "10", "unit": unit})
    elif "san francisco" in location.lower():
        return json.dumps({"location": "San Francisco", "temperature": "72", "unit": unit})
    elif "paris" in location.lower():
        return json.dumps({"location": "Paris", "temperature": "22", "unit": unit})
    else:
        return json.dumps({"location": location, "temperature": "unknown"})


def get_n_day_temperature_forecast(location, unit="fahrenheit"):
    """与えられた地域のn日間の気温予報を取得する"""
    if "tokyo" in location.lower():
        return json.dumps({"location": "Tokyo", "temperature": "13", "unit": unit})
    elif "san francisco" in location.lower():
        return json.dumps({"location": "San Francisco", "temperature": "65", "unit": unit})
    elif "paris" in location.lower():
        return json.dumps({"location": "Paris", "temperature": "20", "unit": unit})
    else:
        return json.dumps({"location": location, "temperature": "unknown"})


def run_conversation(query):
    # Step 1: 会話履歴と関数をモデルに送る。
    messages = [
        {
            "role": "system",
            "content": (
                "あなたは親切なアシスタントです。"
                "必要な場合は、事前に定義された関数を実行して、その結果を踏まえて回答を行います。"
                "ユーザーのリクエストが曖昧な場合は、説明を求めてください。"
            )
        },
        {
            "role": "user",
            "content": query
        },
    ]

    tools = [
        {
            "type": "function",
            "function": {
                "name": "get_current_temperature",
                "description": "Get the current temperature",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "The city and state, e.g. San Francisco, CA",
                        },
                        "unit": {
                            "type": "string",
                            "enum": ["celsius", "fahrenheit"],
                            "description": "The temperature unit to use. Infer this from the users location.",
                        },
                    },
                    "required": ["location", "unit"],
                },
            }
        },
        {
            "type": "function",
            "function": {
                "name": "get_n_day_temperature_forecast",
                "description": "Get an N-day temperature forecast",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "The city and state, e.g. San Francisco, CA",
                        },
                        "unit": {
                            "type": "string",
                            "enum": ["celsius", "fahrenheit"],
                            "description": "The temperature unit to use. Infer this from the users location.",
                        },
                        "num_days": {
                            "type": "integer",
                        "description": "The number of days to forecast",
                        }
                    },
                    "required": ["location", "format", "num_days"]
                },
            }
        },
    ]
    first_response = client.chat.completions.create(
        model="gpt-3.5-turbo-0125",
        messages=messages,
        tools=tools,
        tool_choice="auto"  # autoがデフォルト、ただし明示的に設定することもできる。
        
    )
    first_response = first_response.model_dump(exclude_unset=True)     # 辞書に変換しつつ、余計なものを取り除く
    first_response_message = first_response["choices"][0]["message"]
    tool_calls = first_response_message["tool_calls"]
    # Step 2: 関数が呼ぶ必要があるかどうかの判断結果をチェックする
    if tool_calls:
        # Step 3: 実際に関数を呼ぶ
        # 注意: JSONレスポンスは常に正しいというわけではないので、エラーハンドリングを行うこと
        available_functions = {
            "get_current_temperature": get_current_temperature,
            "get_n_day_temperature_forecast": get_n_day_temperature_forecast,
        } 
        messages.append(first_response_message)  # アシスタントの応答を会話履歴に追加
        # Step 4: 各関数の実行結果をモデルに送信する
        for tool_call in tool_calls:
            function_name = tool_call["function"]["name"]
            function_to_call = available_functions[function_name]
            function_args = json.loads(tool_call["function"]["arguments"])
            function_response = function_to_call(
                location=function_args.get("location"),
                unit=function_args.get("unit"),
            )
            messages.append(
                {
                    "tool_call_id": tool_call["id"],
                    "role": "tool",
                    "name": function_name,
                    "content": function_response,
                }
            )  # 関数の結果を会話履歴に追加する
        second_response = client.chat.completions.create(
            model="gpt-3.5-turbo-0125",
            messages=messages,
        ) # 関数を実行した結果を見て、モデルが新しいレスポンスを返す
        return second_response, messages
    else:
        # 関数が実行されなかった場合は最初のレスポンスを返す
        return first_response, messages
kun432kun432

exclude_unset以外にもexclude_noneexclude_defaultsというのがある。

exclude_unset: Whether to exclude fields that are unset or None from the output.
exclude_defaults: Whether to exclude fields that are set to their default value from the output.
exclude_none: Whether to exclude fields that have a value of None from the output.

これか

https://docs.pydantic.dev/1.10/usage/exporting_models/

今回のケースだとexclude_noneでも良さそうな気はする。一応それに変更しても動いた。

このスクラップは2024/02/12にクローズされました