OpenAIのFunction Callingを使って自然言語でツールの操作をする

先週、OpenAIから、APIでの返答に関数呼び出しのパラメータを返してくれるFunction Callingが発表されました。
試してみると結構たのしかったのでまとめてみます。

解説動画はこちら
youtu.be

とりあえず、こんな感じ。

用意したのは3つの関数。

set_position(id, left, top)
set_size(id, width, height)
set_color(id, color)

で、その割に、「中央に」だとか「隣に」だとか、コンテキストを踏まえて座標などを計算して関数を呼び出してくれています。
また、「四角も同じ」で直前に指示した「三角を緑に」と同様、四角を緑にしてくれています。

ほんとはちゃんとエディタ的にやろうと思ったけど、そこまでする必要ないなってなったので、表示のみで。

Function Callingとは

Function Callingは、手持ちの関数を教えておいてあげると、プロンプトの内容に従って適切な関数とパラメータを返してくれるというものです。
プラグインの利用時にChatGPTがやってることをAPIからも使えるようになった、という感じですね。

OpenAIの発表はこちら。
https://openai.com/blog/function-calling-and-other-api-updates

ドキュメントはここ。
https://platform.openai.com/docs/guides/gpt/function-calling

APIリファレンスはここ。
https://platform.openai.com/docs/api-reference/chat/create#chat/create-functions

リクエストの生成

では、OpenAIに投げるリクエストの、通常のチャットと考え方が違う部分について書いておきます。

関数定義

こんな感じの形式で、こちらの手持ちの関数を登録します。ここで、結局はパラメータのパース処理などを自分で行ったりするので、実際に実装された関数にこだわる必要はなく、あとでパースしやすい形式でパラメータを受け取るようにすればいいと思います。

"functions": [
{
  "name": "set_position",
  "description": "Set the position of an object",
  "parameters": {
    "type": "object",
    "properties": {
      "id": {
        "type": "string",
        "description": "The object ID to move"
      },
      "left": {
        "type": "integer",
        "description": "The left position in pixels"
      },
      "top": {
        "type": "integer",
        "description": "The top position in pixels"
      }
    },
    "required": ["id", "left", "top"]
  }
},

システムメッセージ

roleにsystemを設定したメッセージには、現状の状態を設定します。 こんな感じのプロンプトになっています。

You are object manipulator. 
field size is 800, 600. 
we have 3 objects below.
 id:rect, left:300, top:50, width:150, height:100, color:red
 id:image, left:250, top:240, width:240, height:160, color:black
 id:triangle, left:600, top:200, width:170, height:150, color:blue

全体サイズを指定することで「画像を中央に」などが通るようにしています。そうすると、オブジェクトのサイズも考慮した位置をset_positionで渡してくれます。 また、現在のオブジェクトの状態を渡しています。ChatGPTで対話するときに理解してくれるよなって思える程度の記述で十分です。

対話履歴

コンテキストを反映した指示ができるよう、履歴を送るようにします。

{"role": "user", "content": "三角を左に"},
{"role": "assistant", "content": "I moved the triangle from (600, 200) to (300, 200)"},
{"role": "user", "content": "少し戻して"},
{"role": "assistant", "content": "I moved the triangle from (300, 200) to (550, 200)"},
{"role": "user", "content": "三角を緑に"},
{"role": "assistant", "content": "I changed the triangle color from blue to green"},
{"role": "user", "content": "四角も"}

ここでuserの指示だけの履歴でいいかなと思ったけど、それを全部新しい指示だと思ってる感じだったので、システムが返してきた操作を保持してます。

※ 追記 21:01 コメントに、こういう場合のroleにはfunctionが用意されているという指摘がありますが、function callをそのまま記録すると直前の状態が保存されないため、「少し戻して」が失敗しやすくなります。
また、これはあくまで履歴なので、何が新たに生成されるかについては、そこまで大きな影響はないはずです。
当初rorleをsystemにしてたけど、assistantにしました。

レスポンスの処理

レスポンスのうち、メッセージ部分はこんな感じで返ってきます。ここにfunction_callが含まれる。

{
  "index" : 0,
  "message" : {
    "role" : "assistant",
    "content" : null,
    "function_call" : {
      "name" : "set_position",
      "arguments" : "{\n  \"id\": \"triangle\",\n  \"left\": 800,\n  \"top\": 200\n}"
    }
  },
  "finish_reason" : "function_call"
}

argumentsJSON文字列なので、改めてパースするのがめんどいという程度。 こんな感じで関数名で分岐してそれぞれの処理を書いていけばOKです。

switch(functionCall.at("/name").asText()) {
    case "set_position" -> {
        var oldLeft = obj.getLeft();
        var oldTop = obj.getTop();
        // オブジェクトを移動
        obj.setLeft((int) args.get("left"));
        obj.setTop((int) args.get("top"));
        history.addLast(new ChatLog("system", "I moved the %s from (%d, %d) to (%d, %d)"
                .formatted(obj.getId(), oldLeft, oldTop, obj.getLeft(), obj.getTop())));
    }

ここで、返答のときに変更前後の状態を含めたログを履歴に保持しています。そうすることで「元に戻して」などの処理の確度をあげています。

GPT-3.5とGPT-4の違い

モデルにはgpt-3.5-turbo-0613gpt-4-0613が選べます。
3.5のほうがかなり速いのだけど、GPT-4はコンテキストがある指示についてのハズレが少ないです。
直接的な処理は3.5でも大丈夫だけど、「少し戻して」のような履歴と位置関係が関係あるものは失敗率が高い感じ。
なので、できればGPT-4を使いたいけどレスポンス時間と利用価格がなぁというトレードオフになりそう。

まとめ

こうやって遊ぶ分には面白いです。プログラミングも難しくないです。
でも、実際に一般に使ってもらう機能と考えると、かなり難しいです。作る側は何ができるか把握しているけど、使う側はノーヒントなので、できることをやらない、できないことをやろうとするという感じになりそう。
ただ、たとえばカレンダーやタスク管理など機能が多いサービスではかなり便利に使えるはず。あと、ChatGPT使うからって一発逆転することはなくて、持ってるサービスがより便利になるという感じになりそう。
LLM自体がアプリケーション的にはナビゲーションとしての役割が多くなりそうだったけど、Function Callingはそれが作りやすくなった感じ。
けどなんか、対応する範囲が見えないとか何ができるか開発者自体にも把握できないとか、オープンワールドFPSを作るのと同じ種類の苦労がありそう。
あと、実際には操作対象はかなり多いのと言葉で対象指定は難しいので、ツール側で選択して操作を指定することになると思います。またそのとき状態報告のためのプロンプトはかなり長くなります。可能な操作も多くなるため、1つの関数をなるべく高機能化するほうがよさそう。今回だと、set positionとset sizeはset geometoryみたいにして1関数にまとめるとか。

今回のソースはここです。
https://gist.github.com/kishida/95e661db414bb7b05ec30b8d1b395d17