[OpenAI] Function callingで遊んでみたら本質が見えてきたのでまとめてみた
ChatGPT、皆さん遊んで活用してますか!?
0613のモデルと並んで発表されたFunction callingの機能を使って遊んでみました。 「自然言語でリクエストを投げつければ内部で関数が実行されて正確な計算ができる!?」みたいな雑すぎる理解しかしていなかったので、 どんなことができるのかな?と思って試してみました。
試してみた結果、Function callingは関数を実行してくれる機能じゃない!(極論)ということがわかったので、 その辺について考察してみたいと思います。
前提として、OpenAI APIを用いたChatGPTの利用方法については基本を理解しているものとしています。 具体的には、以前私が書いたこのブログの内容が何となく理解できてれば十分だと思います。
また、こちらの記事にもざっくり目を通してあることが前提になっている箇所がありますので、 まずは下記で最新アップデートを確認して頂ければと思います!
やってみた
まずはいきなり実装例です。 今回は、「町田市が神奈川県だったら」という世界線で遊んでみます。
import json import os import sys import openai # APIキーの設定 openai.api_key = os.environ["OPENAI_API_KEY"] # 引数で質問を受ける question = sys.argv[1] # 関数の実装 def get_belonging_prefecture(cities): """市町村名の羅列を受け取って、それぞれと都道府県の対応情報を持ったdictを返す。 ただし特に定義のないものはモデルが学習した知識をそのまま使えるように「変更なし」を格納する""" def get_prefecture(city): return {"町田市": "神奈川県"}.get(city, "変更なし") prefecture_answer = [ {"市区町村": city, "都道府県": get_prefecture(city)} for city in cities.split(",") ] return json.dumps(prefecture_answer) # AIが使うことができる関数を羅列する functions = [ # AIが、質問に対してこの関数を使うかどうか、 # また使う時の引数は何にするかを判断するための情報を与える { "name": "get_belonging_prefecture", "description": "所属都道府県の変更情報を得る", "parameters": { "type": "object", "properties": { # cities引数の情報 "cities": { "type": "string", "description": "市区町村名入力。半角カンマ区切りで複数要素を入力可能。各要素は「xx市」「xx区」「xx町」「xx村」のいずれか。例: 世田谷区,大阪市,府中町,山中湖村", }, }, "required": ["cities"], }, } ] # 1段階目の処理 # AIが質問に対して使う関数と、その時に必要な引数を決める # 特に関数を使う必要がなければ普通に質問に回答する response = openai.ChatCompletion.create( model="gpt-4-0613", messages=[ {"role": "user", "content": question}, ], functions=functions, function_call="auto", ) print(json.dumps(response), file=sys.stderr) message = response["choices"][0]["message"] if message.get("function_call"): # 関数を使用すると判断された場合 # 使うと判断された関数名 function_name = message["function_call"]["name"] # その時の引数dict arguments = json.loads(message["function_call"]["arguments"]) # 2段階目の処理 # 関数の実行 function_response = get_belonging_prefecture( cities=arguments.get("cities"), ) print(function_response, file=sys.stderr) # 3段階目の処理 # 関数実行結果を使ってもう一度質問 second_response = openai.ChatCompletion.create( model="gpt-4-0613", messages=[ {"role": "user", "content": question}, message, { "role": "function", "name": function_name, "content": function_response, }, ], ) print(json.dumps(second_response), file=sys.stderr) print(second_response.choices[0]["message"]["content"].strip())
実行してみます。
$ export OPENAI_API_KEY="sk-HOGEHOGE" $ python cities.py "横浜市、町田市、相模原市、大磯町、これらの共通点は?" 2>/dev/null これらの共通点は、すべて神奈川県に位置していることです。横浜市、町田市、相模原市、大磯町は全て神奈川県内にある自治体です。
"正しい"回答が得られましたね。
標準エラーに出した、途中の経過はこんな感じです。
(#
のコメントは手で書いています)
# 1段階目の処理結果 # 使用する関数と引数が確定した { "id": "chatcmpl-7RgsfX2TuUMAZ72yI6X9GHYdxlnkM", "object": "chat.completion", "created": 1686834045, "model": "gpt-4-0613", "choices": [ { "index": 0, "message": { "role": "assistant", "content": null, "function_call": { # 使用する関数名と渡す引数 "name": "get_belonging_prefecture", "arguments": "{\n \"cities\": \"横浜市,町田市,相模原市,大磯町\"\n}" } }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 196, "completion_tokens": 32, "total_tokens": 228 } } # 2段階目の処理結果 # function_response [ { "市区町村": "横浜市", "都道府県": "変更なし" }, { "市区町村": "町田市", "都道府県": "神奈川県" }, { "市区町村": "相模原市", "都道府県": "変更なし" }, { "市区町村": "大磯町", "都道府県": "変更なし" } ] # 3段階目の処理結果 # 最終的な質問への回答 { "id": "chatcmpl-7RgshotshN5QgilN63akVEAQozfBm", "object": "chat.completion", "created": 1686834047, "model": "gpt-4-0613", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "これらの共通点は、すべて神奈川県に位置していることです。横浜市、町田市、相模原市、大磯町は全て神奈川県内にある自治体です。" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 339, "completion_tokens": 26, "total_tokens": 365 } }
中身を理解する
Function callingは、動作のイメージはなんとなくで想像できるのですが、 いざ実装してみると混乱しやすい印象です。 まず以下の点を意識すると理解しやすいと思います。
- Function callingは3段階の処理で実現される
- AIに質問する処理は2回登場する
3段階の処理とは
- (AIが)質問に必要な関数を選び、引数を作成する
- (プログラムが)関数を実行
- (AIが)関数結果も入力に入れて質問に回答する
です。 AIの処理が2回登場しています。
それぞれを少し細かく見ていきます。
(AIが)質問に必要な関数を選び、引数を作成する
まず質問に対して、あげられている関数から使用すべきものの抽出を行います。
実装例だと、市区町村名に対しての情報が提供されそうな関数get_belonging_prefecture
が選択されています。
どの関数が質問への回答に役立ちそうかを判定してくれるわけです。
例では候補となる関数は一つですが、複数の関数の中から選ばせることができます。
質問によっては必要な関数はナシとなる場合もあります。
なお、この挙動は "function_call": "auto"
(指定省略時のデフォルト挙動)の時のもので、
使用する関数を明示的に与えることもできます。
次に、その関数に対する引数を作成します。
ここがFunction callingの一番賢い所だと思うのですが、
引数として渡す値をしっかり絞ってあげることで、かなりの精度で適合する値を作ってくれます。
string
、number
などの型を指定することができ(int
みたいな細かい指定はできません)、
当然それに沿った入力値が作成されます。
実装例ではstring型の引数cities
を作っています(配列型とかはない)が、description
で引数の形式を細かく指定しています。
こうすることで、文字列というフリーフォーマットの枠に対して、期待したフォーマットの入力を得ることができます。
ただし、ここはAIが作成する部分ですので、100%期待通りのフォーマットになる訳ではないということはお忘れなく。
また例では使っていませんが、enum
というプロパティを使うことができ、引数を指定した値のいずれかに固定させることができます。
ただ実際に使ってみたところ、enum
に指定していない値が入力される事象も確認しています。
かなり強めに入力値を縛ることができますが、こちらもやはり絶対ではないことは覚えておく必要があります。
さて、ここで改めて注意すべきことは、ここではまだ関数の実行は行われないという点です。 ここで行われることはあくまでも、関数を選ぶことと引数を作成することだけです。
実装例でのFunction callingの役割
次の処理に行く前に、この部分のAIの思考をそれっぽくトレースしてみます。
- 質問は「横浜市、町田市、相模原市、大磯町、これらの共通点は?」だな。
- この質問に答えるに当たって、提示されている関数の中から有用そうな関数はあるかな?
get_belonging_prefecture
は、 関数名やdescriptionなどを見る限り市区町村について何か意味のある情報を得られそうだな。これを使おう。- この関数を使うためには
cities
っていう引数が必要だな。今回の質問だと4つの市区町村名を入れれば良さそうだ。 - 4つの市区町村をdescriptionの指示に従って一つのstringにして、これで引数も完成!
こんな感じでしょうか。
(プログラムが)関数を実行
実行する関数とその引数が決まりましたので、これを実行します。
このセクション名にも書いていますが、この関数実行は実行しているこのプログラム内で行われます。 言葉で書くとわかりにくいですが、OpenAIがどこかの知らない場所で実行するとかではなく、 あくまでもユーザが実行しているプログラムの中で普通に関数を呼ぶだけです。
なので、プログラムの実行環境からアクセスができる場所であれば、 外部のサービスにアクセスして情報を引っ張ってくることもできます。
(AIが)関数結果も入力に入れて質問に回答する
関数の結果を踏まえて、ユーザからの質問を改めてAIに投げます。
{ "role": "function", "name": function_name, "content": function_response, }
という入力形式で情報を与えること以外はただ普通にAIに質問をしているだけとなります。
関数名get_belonging_prefecture
とfunction_response
の内容を渡しているので、
横浜市 ---- 所属県 ----> 変更なし 町田市 ---- 所属県 ----> 神奈川県 相模原市 ---- 所属県 ----> 変更なし 大磯市 ---- 所属県 ----> 変更なし
という感じで情報が渡されていると期待できます。
function
というロールが登場して真新しいことをやっているような気もしますが、
情報量的にはsystem
やuser
のロールで自然言語で情報を渡すことと何も変わらないと推測できます。
内部処理的にはこのような形式の方が扱いやすく、 より適切に使われるようになるという可能性はありますが、 本質的に何か新しいことができるようになるという変化はないものと思います。
あとは最終結果を返してくれるのを待つだけです。
結局何ができるようになった?
処理の流れを一通り見てきましたが、Function callingで新たにできるようになったことは何なんでしょう?
元も子もない書き方をすれば、本質的に何か新しいことができるようになった訳ではないとも言えます。 Function callingを使わなくても、AIが返してきた自然言語の文章から頑張って正規表現などで必要な要素を拾ってあげて関数を実行する、 ということはこれまでもできました。 返答のフォーマットを厳しく指定することでかなりの精度でフォーマットに従った回答が得られるので、 そこそこ問題なく動くシステムを作ることもできたかと思います。
一方Function callingを使うと、関数に渡す引数という形で要素の抽出ができるようになりますので、
フォーマットに従わせるという工夫が不要になります。
これまで泣く泣く、AIを強い言葉で脅迫してフォーマットを強制するという行為を行っていた人は、
AIとまた仲良くやり直すチャンスになりそうです。
運用的にも、プロンプトに色々な注文をつけると制御が煩雑になるということも大いにあるので、
この辺をシンプルにできるのもメリットです。
もちろんAPIがサポートしている手法ということで、
プロンプトと正規表現による気合い実装とは、信頼性という点で大きなアドバンテージがあります。
また今回は深く触れていませんが、enum
があることで表記揺れなどにもかなり強くなっている所も見逃せません。
トータルすると、これまではユーザが頑張って積み木を積み上げて作っていたものに対して、 公式がしっかりとした制作キットを用意してくれたという感じでしょうか。 「積み木を積み上げれば元から作れたよ」と感じる人もいれば、 「制作キットが出たからやっと自分でも作れる!」と感じる人いて、印象は人によるとは思いますが、 趣味ではなくしっかりとしたシステムを作るのであれば、制作キットを使っておきたい所です。
引数作成だけを利用するのもアリ
Function callingの3段階の処理は、必ずしもすべてをセットで利用する必要はありません。 引数作成の部分は、
自然言語 => プログラムで使用できる整形済み要素
という変換処理に対応します。 この部分だけを利用して、全く別の関数を呼んだり、別プログラムに渡したりという利用も考えられます。
まとめ
Function callingの本質は、OpenAIが関数を「実行する」機能ではなく、関数を「呼び出す準備をする」機能であることがわかりました。 これまで気合いでの作り込みをしていた処理はFunction callingを使うことで精度が高まりそうです。
個人的には、引数の型としてtimestamp型やdate型が実装されて、 時刻情報を良い感じに処理してくれると嬉しいなぁ〜と思いましたが、 string型でフォーマットをガチガチに指定すれば結構いい感じに動いてくれそうな気がします。 自然言語で日付指定のジョブが動くような仕組みを作ってみてもいいかなと思いました。
以上、誰かの理解の助けになれば幸いです。