Zenn
🐘

Vercel AI SDKのstreamObject/useObjectでストリーミングで順次表示されるカードUIを作る

2025/01/27に公開

作ったもの

カードといえば・・・5枚のカードを引くアレですよね!


⚠️モンスターの画像はAIによる生成ではありません

この記事ではカードを表現するオブジェクトの構造をAIに出力させ、その構造の通りに順次ストリーミング表示されるUI体験を実装します。

ChatGPTなどのLLMが普及した今、チャットの文字ベースのストリーミング表示は皆さんにとっておなじみかと思いますが、UIそのものがストリーミングで順次表示されていくようなUI体験は実装例があまり見当たらないなと思いトライしてみました。

セットアップ

Vercel AI公式のGetting Startedの手順を参考にNext.js App Routerのプロジェクトを作成しました。

実装

Schemaの定義

zodのスキーマを以下のように定義しました。

// api/cards/schema.ts
export const cardSchema = z.object({
    name: z.string().describe('モンスターの名前'),
    image: z.string().describe('モンスターの画像URL'),
    hp: z.number().min(10).max(200).describe('モンスターのHP'),
    color: z.enum(['red', 'blue', 'green', 'yellow']).describe('モンスターの属性'),
    move: z.object({
        name: z.string().describe('技の名前'),
        power: z.number().min(10).max(200).describe('技の威力'),
    }).describe('モンスターの技'),
    description: z.string().describe('モンスターの説明'),
}).describe('モンスターのカード');

export const cardsSchema = z.object({
    concept: z.string().describe('生成されたカードのコンセプト'),
    cards: z.array(cardSchema).min(5).max(5),
}).describe('モンスターカードのリスト');

cardsフィールドにあるカードの定義だけでもよかったのですが、スキーマに定義された情報がどのように出力されているかがわかりやすくなるよう、conceptというシンプルなテキストのみのフィールドも加えることにしました。

プロンプト (APIサーバーの実装)

サーバーサイドはNext.jsのRoute Handlersで実装します。
OpenAIの呼び出しはAI SDKのstreamObject()を使用し、先ほど定義したスキーマ定義とプロンプトを与えています。

// api/cards/routes.ts
streamObject({
    model: openai('gpt-4o-mini'),
    schema: cardsSchema,
    prompt:
      `## 指示
モンスターをランダムに5つ生成してください。
はじめに入力のキーワードにあったコンセプトを決めてから、それにあったモンスターを5つ生成します。
コンセプトはアドベンチャーゲームとしてキャッチーなコンセプトを考えてください。

## ルール
* モンスターは5体生成します
* 言語は必ず日本語で生成してください
* カードのimageフィールドには以下の画像URLを使用してください
    * /images/daniel-vargas-c-ewKs54sw4-unsplash.jpg
    * /images/gene-taylor-JWlY4Z4_mCI-unsplash.jpg
    * /images/joel-herzog-ny_5l4QKBnE-unsplash.jpg
    * /images/team-mfina-1SHsDOlMuvI-unsplash.jpg
    * /images/tomas-malik-7PvyUeHp2ww-unsplash.jpg

## キーワード
${body.keyword}
`
});

スキーマにカードの構成がほぼ定義されているので、非常にシンプルな指示文で済んでいます。

UI

v0を使用してシンプルなカードの抽選UIを作成しました。
参考になりそうなカードの画像を渡し、同時にカードを構成するzodのスキーマ定義も渡しています。


念の為、スクリーンショットの一部を加工しています

抽選時のアニメーションも特に指示していなくても勝手につけてくれており、v0が非常に優秀だと感じました。
また、v0のAIは当然zodのスキーマ定義を理解してくれるため、APIサーバーとUIの実装が同じスキーマを元に構築でき両者の結合をとてもスムーズに行うことができます。

生成されたUIはAdd to Codebaseに記載されたコマンドにより、先ほどセットアップしたNext.jsプロジェクトに簡単にUIを追加することができます。
※追加されるコンポーネントのファイルは、プロジェクトに合うよう適宜移動します

# コマンド例
npx shadcn@latest add "https://v0.dev/chat/b/b_mCOSIw2woK3?token=***"

完成したコード

v0が生成したUIはデータの取得部分はあくまでモックで作成されています。
このUIに対してカードの抽選を行うAPIを呼び出すように修正を加え、サーバーとUIをつなぎむ部分を実装しました。
以下にソースコードを公開しています。

https://github.com/satoshun00/stream-card-ui-example

ポイント

出力されるレスポンス

AIがどのようなストリーミング出力をしているかがわかるよう、デバッグ用に出力を確認するUIを設置しました。
ストリーミングされてくる出力を順次ロギングしていくと以下のようになります。

{}
{"concept":"受験生"}
{"concept":"受験生の冒険"}
{"concept":"受験生の冒険","cards":[]}
{"concept":"受験生の冒険","cards":[{}]}
{"concept":"受験生の冒険","cards":[{"name":"知"}]}
{"concept":"受験生の冒険","cards":[{"name":"知識の"}]}
{"concept":"受験生の冒険","cards":[{"name":"知識の守護"}]}
{"concept":"受験生の冒険","cards":[{"name":"知識の守護者"}]}
…

このようにuseObjectではAIの出力が常にスキーマに一致するValidなJson(JSON.parse可能)であることが担保されています。

オブジェクトの構造が常にPartialである

カードを表現するオブジェクトはストリーミングが完了するまで、値が揃っているとは限りません。

TypeScriptの型としても以下のように表現されています。

よって、UIの表示においては、値がないことを考慮して実装をする必要があります。

<Card className={cn(
      "w-64 p-3 transition-transform hover:scale-105",
      // 👇 値がある場合のみ処理されるように
      card?.color && colorStyles[card.color]
    )}>
      <div className="flex justify-between items-center mb-2">
        {/* 👇 値がない時はスケルトンを表示 */}
        {card?.name ? <h3 className="text-lg font-bold">{card.name}</h3> : <NameSkeleton />}
        {card?.hp ? <span className="font-semibold">HP {card.hp}</span> : <HPSkeleton />}
      </div>
      /* 省略 */
</Card>

エラーハンドリング

AIが返してくる出力がきちんとスキーマに一致するかどうかは、Vercel AI SDKが内部でチェックをしてくれています。スキーマに一致しないときのエラーは、ストリーミングでレスポンスを受け取る場合、フロント側のuseObjectonFinishコールバック(すべてのストリーミングが完了した際のコールバック)でエラーを受け取ることができます。

const { object, submit, isLoading } = useObject({
    api: '/api/cards',
    headers: { 'Content-Type': 'application/json'},
    schema: cardsSchema,
    onFinish: ({ error }) => {
      setIsSubmitting(false)
      // ここでzodのバリデーションエラーが拾える
      if (error) {
        console.error("AIのレスポンスがスキーマに一致していません", error)
      }
    },
});

これはオブジェクトがすべてストリーミングされて初めてレスポンスをバリデーション可能になるので当然なのですが、最初はどこでエラーがキャッチできるのかわからず少し手間取ってしまったポイントでした。

streamObject()は何をしているのか

AIの呼び出しはVercel AI SDKが担ってくれているのでここまでとても簡単に実装できてしまいました。ですがその反面、実際に中でどのような処理が行われているかイメージがわかなかったため、深堀りをしてみました。

OpenAIのAPIをどのようにコールしているか

Vercel AI SDKのOpenAIモデル(@ai-sdk/openai)で使用されるfetchcreateOpenAI()インターフェースのオプションでカスタマイズすることができるため、以下のようにfetchに渡されるリクエストを覗き見してみます。

import { createOpenAI } from '@ai-sdk/openai';

const openai = createOpenAI({
  fetch: async (input, init) => {
    console.log("fetch", input, init); // 👈 OpenAI APIへのリクエストをログ出力する
    return fetch(input, init);
  },
});

streamObject({
    model: openai('gpt-4o-mini'),
    /* 省略 */
})

すると以下のようなリクエストが行われていることがわかりました。

どうやら、OpenAIのFunction callingの機能を使い、Functionの引数の型定義としてスキーマを渡すことで、AIがFunctionの引数に指定してくる引数の値が、スキーマに対してValidな出力であることを担保するような仕組みのようです。

また、Vercel AI SDKのコードを読み進めるとAIの出力が実際にスキーマに一致するかどうかは、Vercel AI SDKがコード内でバリデーションを実行しているようでした。(Error Handlingの項にもこの仕様に関する記載があります)

OpenAIのStructured Outputsとの違いについて

一方、OpenAIのAPIにはStructured Outputsという機能があり、これはVercel AI SDKのOpenAIプロバイダーにおいてもstructuredOutputsオプションとして、streamObject()とは別にオプションとして使用することができます。

前述のようにVercel AI SDKはデフォルトではFunction callingの機能を使用するため、Structured Outputsは使用していないようでした。

そこで、試しに公式の例にあるようにstructuredOutputsオプションを有効にして呼び出しをしてみます。

import { createOpenAI } from '@ai-sdk/openai';

const openai = createOpenAI({
  fetch: async (input, init) => {
    console.log("fetch", input, init);
    return fetch(input, init);
  },
});

streamObject({
    model: openai('gpt-4o-mini', {
        structuredOutputs: true // 👈 structuredOutputsをtrueに
    }),
    schema: cardsSchema,
    /* 省略 */
})

すると以下のようにOpenAIのAPIに対するリクエストの内容が変化しました。

response_format: {type: "json_schema", ...} のオプションがリクエストに渡されていることが確認できます。これはOpenAIのAPIネイティブのStructured Outputsのためのオプションですので、この機能を利用するようになったことがわかりました。これによりVercel AI SDKではなく、OpenAIネイティブの機能でAIの出力が実際にスキーマに一致することが担保できるようになりました。

ただし、ここで注意したいのが、こちらのStructured Outputsの方がリクエストはシンプルかつネイティブな機能を用いるようになった反面、サポートされるスキーマに制限がある点です。
今回のスキーマを例にすると、配列のmin,maxや文字数のmin,maxはOpenAIのStuructured Outputsではサポートされておらず、これらの定義を含むzodスキーマをオプションに渡すとOpenAI APIがInvalid schema for response_formatのエラーを返すようになってしまいました。

この機能ではOpenAIネイティブに出力のスキーマを担保することができるが、zodのスキーマをフルに使ったバリデーションはできないため、実際の用途に応じて使い分ける必要がありそうです。

まとめ

Vercel AI SDKで構造化されたデータをストリーミングし、UIに表示する実装をしてみました。Vercel AI SDKがほとんど処理をラップしてくれているので実装はとても簡単な反面、中で何が起こってるか全然わからないままになってしまいそうでした。

今回はリクエストを見てみたりSDKのコードを読んだりすることで理解を進めましたが、どうやらSDKにはOpenTelemetoryを使ったTracingの機能が実装されているため、時間があればそちらも触ってみたいなと思いました。

また、画像表示の部分も固定値でサボってしまったので、もしさらに時間があれば、画像もAIによって生成できるようにしてみようかなと思います。

Aidemy Tech Blog

Discussion

ログインするとコメントできます