ChatGPT APIのFunction callingとNode.jsで処理をオートで振り分けて幸せになる

2023.12.28

はじめに

情報システム室 進地 です。

社内の営業向けにPoCで提供しているSlack Botツールがあります。 このツール(Sales Mediatorという)は、自然言語でBot宛にメンションしてもらうと、そのメンション内容の要約をChatGPT APIに依頼し、JSONで要約データを取得、その後は各要望に合わせた処理に振り分けるということをゴニョゴニョとやっておりました。

その際に結構面倒だったことが、

  • プロンプトエンジニアリングで各要望の問い合わせ方や返却値のフォーマットなどを事細かくChatGPT APIに伝えないといけない
  • ChatGPTから得られた返却値の中身を見て、後続の処理を自前であれこれ振り分けないといけない

ということで、特にChatGPT APIに必ずJSONで返してほしいのに、JSONで絶対に返して!絶対にJSONでお願い!というプロンプトを3回ぐらい書いておいても時折JSONで返してくれなかったりして難儀していたのでした。

それで、ChatGPT APIのFunction callingを遅まきながら試してみたところ、非常に便利でコードも減らせて幸せになれたので、その方法を簡単に共有します。

Lambdaコード解説

今回、LambdaはNode.jsで書いています。ライブラリのバージョンアップへの追随を考えるとPython実装の方が幸せになりやすいと思うのですが、文字列操作の柔軟性や処理を振る関数への動的委譲(リフレクション)の書きやすさはNode.jsの方が簡単だと私は思うので、痛し痒しです。

Function calling

Function callingを使ってChatGPT APIに問い合わせする箇所のコードは下記のようになります。

lambda/salesMediator.js(抜粋)

async function question(messages) {
  const response = await openai.createChatCompletion({
    model: "gpt-3.5-turbo-0613",
    messages: messages,
    functions: [
      {
        "name": "calcRevenuesTrends",
        "description": "指定顧客のレベニュー推移を返す",
        "parameters": {
          "type": "object",
          "properties": {
            "accountName": {
              "type": "string",
              "description": "顧客名"
            },
            "termStart": {
              "type": "string",
              "description": "集計期間の開始日。Date formatted in YYYY-mm-dd, e.g. 2023-06-13"
            },
            "termEnd": {
              "type": "string",
              "description": "集計期間の終了日。Date formatted in YYYY-mm-dd, e.g. 2023-06-13"
            },
            "outputFormat": {
              "type": "string",
              "enum": ["tsv", "csv", "json"],
              "description": "出力形式。ファイルの拡張子で指定する。 e.g. tsv"
            }
          },
          "required": ["accountName", "termStart", "termEnd"]
        }
      },
      {
        "name": "calcRevenuesTrendsOfAwsAccount",
        "description": "指定AWSアカウントIDのレベニュー推移を返す",
        "parameters": {
          "type": "object",
          "properties": {
            "awsAccountId": {
              "type": "string",
              "description": "AWSアカウントID(12桁)。 e,g. 123456789012"
            },
            "termStart": {
              "type": "string",
              "description": "集計期間の開始日。Date formatted in YYYY-mm-dd, e.g. 2023-06-13"
            },
            "termEnd": {
              "type": "string",
              "description": "集計期間の終了日。Date formatted in YYYY-mm-dd, e.g. 2023-06-13"
            },
            "outputFormat": {
              "type": "string",
              "enum": ["tsv", "csv", "json"],
              "description": "出力形式。ファイルの拡張子で指定する。 e.g. tsv"
            }
          },
          "required": ["awsAccountId", "termStart", "termEnd"]
        }
      }
    ],
    function_call: 'auto'
  });
  console.log(messages);
  return response.data;
}

顧客単位のレベニュー推移の問い合わせだった時はcalcRevenuesTrendsを、AWSアカウント単位のレベニュー推移の問い合わせがある時はcalcRevenuesTrendsOfAwsAccountを後続の処理として呼び出すように記載されたJSONが所定のフォーマットでAPIから返ってきます。

前者はプロパティにaccountNameがあるので、accountNameとして顧客名を詰めて返してくれますし、後者はプロパティにawsAccountIdがあるので、awsAccountIdとしてAWSアカウントIDを詰めて返してくれます。便利です。

後続処理関数への動的委譲(リフレクション)

ChatGPI APIからFunction callingを使って返り値を手に入れたら、後は、次のような簡単なコードで後続処理の振り分けを行うことができます。

lambda/salesMediator.js(抜粋2)

/* 動的委譲用マッピングテーブル */
const exec = {
  "calcRevenuesTrends": async (args) => { return await calcRevenuesTrends(args); },
  "calcRevenuesTrendsOfAwsAccount": async (args) => { return await calcRevenuesTrendsOfAwsAccount(args); }
}

/* SlackBotにメンションがあったら呼ばれる関数 */
app.event('app_mention', async ({ client, context, event, say, logger, body, ...rest }) => {
  // 中略 ...

  let messages = [];
  messages.push({ role: 'system', content: "あなたは売上分析家です。ユーザーが求める顧客のレベニュー推移を教えてください。"});
  messages.push({ role: 'user', content: text });
  // ChatGPT APIに問い合わせる
  const answer = await question(messages);

  if ( answer.choices[0].finish_reason === "function_call" ) {
    /* Function Callingが使われた場合はこちらに処理がくる */
    const functionCall = answer.choices[0].message.function_call;
    /* SlackBot固有のオブジェクト達も詰めておく */
    const slackArguments = {
      say,
      client,
      event
    };
    // functionCall.nameに後続処理の関数名が入っている
    const result = await exec[functionCall.name]({...JSON.parse(functionCall.arguments), ...slackArguments});
    return;
  } else {
    /* Function callingが使われなかった場合は素直に返却値を返す */
    await say({ thread_ts: threadTs, text: answer.choices[0].message.content});
    return;
  }
}

/* 顧客単位のレベニューの問い合わせならこちらで後続処理をする */
const calcRevenuesTrends = async (args) => {
  const { accountName, termStart, termEnd, outputFormat, say, client, event } = args;
  // 色々処理する ...
};

/* AWSアカウントID単位のレベニューの問い合わせならこちらで後続処理をする */
const calcRevenuesTrendsOfAwsAccount = async (args) => {
  const { awsAccountId, termStart, termEnd, outputFormat, say, client, event } = args;
  // 色々処理する ...
};

execという関数の処理結果を返す関数をマッピングテーブル化したオブジェクトを用意して、後は

    const result = await exec[functionCall.name]({...JSON.parse(functionCall.arguments), ...slackArguments});

の一行読んであげるだけで動的委譲ができてしまいます。今後、後続処理を増やしたいと思ったら、Function callingの箇所に定義を追加して、後続処理を担当する関数を追加してあげるだけ。振り分け処理には一切手を加えなくてOK。便利です。

後続処理する関数側では

  const { awsAccountId, termStart, termEnd, outputFormat, say, client, event } = args;

のように、argsから欲しい引数を柔軟に受け取れるのも良いです。 Slackのオブジェクトもスプレッド構文(...)を利用して、

<br />{...JSON.parse(functionCall.arguments), ...slackArguments}

という形でまとめて送ってあげています。便利です。

まとめ

ChatGPT APIのFunction callingを使って、ユーザーからの要求の解釈とその後続処理の振り分けをかなりシンプルに記述できることを示しました。ユーザの問い合わせ内容によって柔軟、かつ、堅牢に後続処理を振り分けて楽をしたい場合にかなりおすすめです。