OpenAI の Realtime API で音声を使って任意の関数を実行する(Function calling編)

| 8 min read
Author: noboru-kudo noboru-kudoの画像

つい先日OpenAIのRealtime APIを利用した以下の記事を書きました。

ここではCLIベースの超簡単会話ツールを作成しました。

Realtime APIはChat Completion API同様にFunction callingにも対応しています。これを利用すると音声でも任意のAPIを実行できるようになります。
今回はこれを試してみます。

基本的な構成は前回記事と同じですので、セットアップや音声の入力や出力についての記述は省略します。必要に応じて前回記事をご参照ください。

本記事のソースコードは以下より確認可能です。

Information

本サイトでもChat Completion APIの方のFunction callingは以下の記事でご紹介してます。
興味があればご参照ください。

Function callingで実行する関数を用意する

#

Realtime APIから実行する関数を用意します。ここでは最新の情報をWeb検索するAPIを作成します。
今回はGoogleが提供する以下のカスタム検索APIを使います。

Googleカスタム検索API自体は本題ではありませんので、詳細なセットアップ手順は省略します(手順通りやればできます)[1]

NPMプロジェクトに必要なAPIを依存関係に追加しておきます。

npm install @googleapis/customsearch

ここでは以下の関数を用意しました。

import { customsearch } from '@googleapis/customsearch';

const API_KEY = process.env.CSE_API_KEY ?? '';
const ENGINE_ID = process.env.CSE_ENGINE_ID ?? '';

export async function webSearch({ query }: { query: string }) {
  console.log('Web Search:', query);
  const api = customsearch({
    auth: API_KEY,
    version: 'v1'
  });
  // https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list
  const result = await api.cse.list({
    q: query,
    cx: ENGINE_ID
  });
  return (result.data.items ?? []).map(item => ({
    title: item.title,
    link: item.link,
    snippet: item.snippet
  }));
}

検索クエリを受け取って、Google検索した結果を返すシンプルなものです。

Realtime APIに実行可能な関数を設定する

#

作成した関数をRealtime APIに指定します。これはRealtime APIのsession.updateイベントを使います。

import WebSocket from 'ws';
import { spawn } from 'child_process';
import { webSearch } from './google-search.js';

const url = 'wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01';
const ws = new WebSocket(url, {
  headers: {
    'Authorization': 'Bearer ' + process.env.OPENAI_API_KEY,
    'OpenAI-Beta': 'realtime=v1'
  }
});

const instructions = `あなたは物知りなAIアシスタントです。
ユーザーの質問には、webSearch関数をこっそり使って、驚きや笑いを交えた答えを返してください。
技術的な裏側は内緒にして、親しみやすくユニークな会話を楽しみましょう。
ユーザーを楽しませつつ、役立つ情報も忘れずに!`;

// セッション作成
ws.on('open', () => {
  // Realtime APIの基本設定
  ws.send(JSON.stringify({
    type: 'session.update',
    session: {
      voice: 'shimmer',
      instructions: instructions,
      input_audio_transcription: { model: 'whisper-1' },
      turn_detection: { type: 'server_vad' }
    }
  }));

  // Function callingで実行する関数を指定
  ws.send(JSON.stringify({
    type: 'session.update',
    session: {
      tools: [{
        type: 'function',
        // 関数名
        name: 'webSearch',
        // 関数説明(AIの実効判断材料)
        description: 'Performs an internet search using a search engine with the given query.',
        // JSONスキーマでパラメータを指定
        parameters: {
          type: 'object',
          properties: {
            query: {
              type: 'string',
              description: 'The search query'
            }
          },
          required: ['query']
        }
      }],
      tool_choice: 'auto' 
    }
  }));
})

ここでは音声種類や会話検知モード等の基本的な設定に続いて、Function callingの指定をしています。
session.tools配下にChat Completion APIと同じように、関数名やパラメータ(JSONスキーマ)を指定します(複数可)。

session.tool_choiceにはautoを指定しています。これは関数実行可否をAIが自動判定するモードです。
関数実行を必須とする場合はrequiredを指定します。現段階ではChat Completion APIのように実行する関数自体は指定できないようです。

Function callingは他の設定と一緒に1つのsession.updateイベントで指定してもいいですし、後から別途指定しても構いません(同一セッション内で関数の変更や削除もできるようです)。

関数を実行して結果を連携する

#

関数実行が必要と判定された場合に、Realtime APIは以下のイベントで関数の引数を送信してきます。
ざっと調べた感じだと、関数の引数は以下のイベントから取得できそうです。

いろいろなイベントがあって悩まされますね。。。
公開されているリファレンス実装を確認すると、response.function_call_arguments.deltaイベントでストリーミングされてくる引数を溜めて、response.output_item.doneイベントで関数の実行をしていました。

ここでは、わざわざストリーミングされてくる引数を組み立てる理由もないので、response.output_item.doneイベントで引数取得と関数実行の両方をやってしまおうと思います。
イベントのペイロードは以下の通りです。

{
  "type": "response.output_item.done",
  "event_id": "event_AGIfARbBn3ieWo305yKxR",
  "response_id": "resp_AGIfAftaxFnAnGbovPFbW",
  "output_index": 0,
  "item": {
    "id": "item_AGIfAEWj1dZyY3EbCt4re",
    "object": "realtime.item",
    "type": "function_call",
    "status": "completed",
    "name": "webSearch",
    "call_id": "call_swWIenO6JtScDTOw",
    "arguments": "{\"query\":\"2024 Nobel Prize winners\"}"
  }
}

このイベントはFunction calling以外でも使われているので、item.typefunction_callのものを見るのが良さそうです。
以下のように実装してみました。

ws.on('message', (message) => {
  const event = JSON.parse(message.toString());
  console.log(event.type);
  switch (event.type) {
    case 'response.audio.delta':
      // Realtime APIからの音声出力はスピーカーから再生
      audioStream.write(Buffer.from(event.delta, 'base64'));
      break;
    case 'response.output_item.done':
      const { item } = event;
      // 1. 関数実行依頼判定(function_call)
      if (item.type === 'function_call') {
        if (item.name === 'webSearch') {
          // 2. 関数実行
          webSearch(JSON.parse(item.arguments)).then(output => {
            // 3. 実行結果連携
            ws.send(JSON.stringify({
              type: 'conversation.item.create',
              item: {
                type: 'function_call_output',
                call_id: item.call_id,
                output: JSON.stringify(output)
              }
            }));
            // 4. レスポンス生成要求
            ws.send(JSON.stringify({ type: 'response.create', }));
          });
        }
      }
      break;
    case 'response.audio_transcript.done':
    case 'conversation.item.input_audio_transcription.completed':
      console.log(event.type, event.transcript);
      break;
    case 'error':
      console.error('ERROR', event.error);
      break;
  }
});

item.namesession.updateで指定した関数名、item.argumentsに実行する引数(JSON文字列)が設定されてきますので、これをもとに関数を実行(Google検索)します。
関数実行結果はconversation.item.createイベントでRealtime APIに連携します。

注意点として、実行結果の連携後にレスポンス生成(response.createイベント)を要求する必要があります(これをしないと何も応答してくれません)。
こうすると、関数実行結果をもとに回答が生成されて音声としてレスポンスが送信されてきます。このイベント(response.audio.delta)は音声ストリーミングとしてサブスクライブしてますので、そのままスピーカーから再生されることになります(前回記事参照)。

ここでの実行の流れを整理すると以下の通りです(送信・購読しているイベントのみ表示)。

sequenceDiagram
  Actor User as ユーザー
  participant App as アプリ
  participant API as Realtime API
  participant CSE as Googleカスタム検索

  App ->> API: session.updated: 関数設定
  loop
      User ->> App: 音声入力(マイク)
      App ->> API: input_audio_buffer.append: chunk N
  end
  
  API ->> API: 関数利用判定
  
  API ->> App: response.function_call_arguments.done: 実行関数・引数
  App ->> CSE: Web検索(関数実行)
  CSE -->> App: 検索結果
  App ->> API: conversation.item.create: 検索結果連携
  App ->> API: response.create: レスポンス要求
  API ->> API: レスポンス生成

  loop
      API ->> App: response.audio.delta: 音声出力 chunk N
      App ->> User: 音声再生(スピーカー)
  end

まとめ

#

今回はRealtime APIのFunction callingを使った外部API実行にチャレンジしてみました。
Chat Completion APIのFunction callingの経験があれば、それほど難しいものではありませんね。

AIが会話ベースでいろんな外部タスクをお願いできる頼もしい存在になってくれる日も近いのかもしれませんね。


  1. もちろんもっと高機能なSerpAPIでもいいですし、Function callingを使うだけであればダミー関数でも構いません。 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。