VercelのAI SDKを使った生成AIの関数呼び出しについて説明します。説明するアプリケーションMulai3の概要は前回の記事をご覧ください。
生成AIの関数呼び出しと用途
生の生成AIでは処理できないある種のタスクを、生成AIからカスタムの関数を呼び出せるようにして実現可能にする仕組みが関数呼び出しです。
AI SDK 3.0を紹介する公式の記事では画像の生成やタスク処理、天気と言った例が挙げられていますが、実用可能な例が少しイメージしづらいかも知れません。
こちらのサンプルアプリケーションでは、株価の検索と株の購入(のシミュレーション)を行えるようになっています。実際に株の取引をチャットインターフェースで行うのは怖いと感じる人が多いかもしれません。
では、企業のサポートサイトでチャットボットを用意することを考えてみましょう。RAGなどを利用してお客様の質問にあわせた回答ができれば便利です。それに加えて、チャットインターフェース上で、設定変更申し込みなど、ある種の手続きを完結できれば便利ではないでしょうか。検索についてもこみいった情報は詳細ドキュメントへのリンクを表示するように、課金のような重大な操作については(現時点では)操作するページを案内するという棲み分けはありえるのではないでしょうか。
OpenAI APIを使った関数呼び出し
さて、用途がわかったところで、関数呼び出しの概要です。
ChatGPTのAPI呼び出しにおける処理では、AIモデルにユーザーの入力を渡す時に、「こんな目的に使えるこんな関数があるよ」という情報を付随する形です。生成AI側は、ユーザーの入力内容を元に、関数が不要な時は普通に応答を返し、関数を呼び出した方が良さそうな時は、この関数を呼び出してね、という応答を返すので、アプリケーションはその応答内容を元に、関数を呼び出して、その結果をユーザーに返す、という形になります。ちなみに、関数を呼び出した方が良さそうだけど呼び出しに必要な情報が足りない、という時は生成AIが、その情報を求めるような応答を返してくれて、次のユーザーの入力の時に、それら情報を使って関数呼び出しを求めます。
ちなみに、どんな時にどう関数を呼び出そうとするかはAIモデルによって変わるため、GPT-4の方がGPT-3.5よりも適切に関数呼び出しを求める、といったことはよく起きます。
下記が呼び出し例ですが、最初の呼び出しの応答を確認して呼び出し方を変えていることがわかると思います。やや煩雑な印象を受ける人もいると思います。
  const response = await openai.chat.completions.create({
    model: "gpt-3.5-turbo-0125",
    messages: messages,
    tools: tools,
    tool_choice: "auto", // auto is default, but we'll be explicit
  });
  const responseMessage = response.choices[0].message;
  // Step 2: check if the model wanted to call a function
  const toolCalls = responseMessage.tool_calls;
  if (responseMessage.tool_calls) {
    // Step 3: call the function
    // Note: the JSON response may not always be valid; be sure to handle errors
    const availableFunctions = {
      get_current_weather: getCurrentWeather,
    }; // only one function in this example, but you can have multiple
    messages.push(responseMessage); // extend conversation with assistant's reply
    for (const toolCall of toolCalls) {
      const functionName = toolCall.function.name;
      const functionToCall = availableFunctions[functionName];
      const functionArgs = JSON.parse(toolCall.function.arguments);
      const functionResponse = functionToCall(
        functionArgs.location,
        functionArgs.unit
      );
      messages.push({
        tool_call_id: toolCall.id,
        role: "tool",
        name: functionName,
        content: functionResponse,
      }); // extend conversation with function response
    }
    const secondResponse = await openai.chat.completions.create({
      model: "gpt-3.5-turbo-0125",
      messages: messages,
    }); // get a new response from the model where it can see the function response
AI SDKを用いた関数呼び出し
VercelのAI SDK 3.0は関数呼び出しをどのように扱っているのでしょうか。下記のように、処理を関数に切り出して記述することができるようになります。getWeatherは普通のサーバー関数として書けばOKです。
async function submitMessage(userInput) { // 'What is the weather in SF?'
  'use server'
  return render({
    provider: openai,
    model: 'gpt-4-0125-preview',
    messages: [
      { role: 'system', content: 'You are a helpful assistant' },
      { role: 'user', content: userInput }
    ],
    text: ({ content }) => <p>{content}</p>,
    tools: {
      get_city_weather: {
        description: 'Get the current weather for a city',
        parameters: z.object({
          city: z.string().describe('the city')
        }).required(),
        render: async function* ({ city }) {
          yield <Spinner/>
          const weather = await getWeather(city)
          return <Weather info={weather} />
        }
      }
    }
  })
}
指定は関数呼び出しを行わない時と同じです。
export const AI = createAI({
  actions: {
    submitUserMessage,
    confirmPurchase,
  },
  initialUIState,
  initialAIState,
});
        <AI>
                {children}
        </AI>
Mulai3での関数呼び出し
Mulai3では上記サンプルを拡張し、関数呼び出し可能なOpenAIのモデルと関数呼び出しできない他社のモデルを組み合わせて利用できるようにしています。初期状態のモデルは現状固定値としていますが、URLなどにより動的に切り替えることも可能でしょう。
export default function Page() {
  const ais = [
    {modelValue: 'firefunction-v1', className: ""},
    {modelValue: 'gpt-3.5-turbo', className: ""},
    {modelValue: 'gpt-4', className: ""},
  ]
  
  const AIActions:typeof AIAction[] = ais.map((ai) => 
    createAIAction({initialModel: getModelByValue(ai.modelValue)!})
  )
  return (
    <div className='h-full flex flex-col'>
      <div className='h-full flex-1 flex flex-row text-xs overflow-auto min-h-0'>
        {ais.map((ai, index) => {
          const CustomAIAction = AIActions[index]
          return <Fragment key={index}>
            {index === 0 ? null : 
              // cursor-col-resize
              <div className="w-1 h-full bg-teal-600"> </div>
            }
            <CustomAIAction>
              <ChatPane className={ai.className} />
            </CustomAIAction>
          </Fragment>
        })}
      </div>
      
      <Broadcast className="w-screen bottom-0 flex min-h-12" />
    </div>
  )
}
createAIActionがサンプルのcreateAIに相当する部分で、action.tsxからファイル名を変えて少し改変したai-action.tsxで実装しています。
export function createAIAction({initialModel}:{initialModel:ChatModel}):typeof AIAction {
  return createAI({
    actions: {
      submitUserMessage
    },
    // Each state can be any shape of object, but for chat applications
    // it makes sense to have an array of messages. Or you may prefer something like { id: number, messages: Message[] }
    initialUIState,
    initialAIState: {...initialAIState, model: initialModel},
  });
}
関数呼び出しできるモデルにはtoolsオブジェクトを渡しますが、できないモデルにtoolsオブジェクトを渡すとエラーになってしまうので、モデルによって場合分けをしています。
  const ui:React.ReactNode = render({
    model: aiState.get().model.sdkModelValue,
    provider: getProvider(aiState.get().model),
    messages: // 略
    // Some models (fireworks, perplexity) just ignore and some (groq) throw errors
    ...(doesCallTools ? {
      tools: {
       // 関数
      } : {}),
  })      
Mulai3の画像生成例
関数のうち、画像生成の例はこちらです(一部簡略化しています)。dall-e-2とdall-e-3に同時に画像生成リクエストを投げて、それぞれからの画像応答をまとめて表示しています。
        generate_images: {
          description: 'Generate images based on the given prompt',
          parameters: z.object({
            prompt: z.string().describe('the image description to be generated'),
          }),
          render: async function* ({prompt}:{prompt:string}) {
            console.log('generate_images', prompt);
            const modelNames = ['dall-e-2', 'dall-e-3'] as const
              yield (
                <Card className="m-1 p-3">
                  <CardContent className="flex flex-row gap-3 justify-center">
                    {modelNames.map((modelName) => {
                      const generatingTitle = `${modelName} generating an image: ${prompt}`;
                      return (<div key={modelName} title={generatingTitle} className='size-64 border animate-pulse grid place-content-center place-items-center gap-3'>
                        <div className="rounded-3xl bg-slate-200 size-24 mx-auto"></div>
                        <div className="rounded w-32 h-3 bg-slate-200"></div>
                        <div className="rounded w-32 h-3 bg-slate-200"></div>
                      </div>)
                    })}
                  </CardContent>
                </Card>
              )
              
              const results = await Promise.all(
                modelNames.map((modelName) => generateImages(prompt, modelName)))
              const images = results.flat()
              aiState.done({
                ...aiState.get(),
                messages: [
                  ...aiState.get().messages,
                  {
                    role: "function",
                    name: "generate_images",
                    content: JSON.stringify(images),
                  },
                ]
              });
              return (
                <Card className="m-1 p-3">
                  <CardContent className="flex flex-row gap-3 justify-center">
                    {images.map((image) => {
                      const title = image.model + ': ' + (image.revised_prompt ?? prompt)
                      return (<Image key={image.url} src={image.url!} title={title} alt={title} width={256} height={256} className='size-64 border' />)
                    })}
                  </CardContent>
                </Card>
              )
          }
        } as any,
yieldの呼び出しにより、レスポンスが出ない状況の応答を簡単に返せるところもポイントです。ちなみにgenerateImageの呼び出しは下記のようなシンプルなものです。
async function generateImages(prompt:string, modelValue:'dall-e-2'|'dall-e-3') {
  // https://platform.openai.com/docs/api-reference/images/create
  const baseParams:ImageGenerateParams = { prompt: prompt, response_format: 'url' }
  const e2Params:ImageGenerateParams = { ...baseParams, model: 'dall-e-2', size: '256x256' }
  const e3Params:ImageGenerateParams = { ...baseParams, model: "dall-e-3", size: '1024x1024' }
  const params = modelValue == 'dall-e-3' ? e3Params : e2Params
  const responseImage = await openai.images.generate(params);
  const data = responseImage.data.map((image) => ({...image, model: modelValue}))
  return data
}
これがすべての実装ですが、これだけでChatGPTのWeb UI同様に、依頼に応じて画像生成してくれるのは嬉しいですね。
その他参照
その他実装はこちらをご覧ください。
ai-action.tsxについてはサンプルのaction.tsxの実装と比べていただくのもわかりやすいかも知れません。
動作するMulai3アプリはこちらからお試しください。
こちらのサンプルアプリケーションに関する記事は、タグMulaiをご利用ください。