▪️

VercelのAI SDKでChatGPT風ストリーミングチャットを作る

に公開

はじめに

普段はChatGPTやCursorに頼っていますが、今回は「AIを組み込む側」に立ってみました。
VercelのAI SDK を使って AI チャットアプリを作る過程で学んだことを共有します。

AI SDK とは

The AI SDK is the TypeScript toolkit designed to help developers build AI-powered applications and agents with React, Next.js, Vue, Svelte, Node.js, and more.

参考:AI SDK公式ドキュメント

AI SDKを使うと、LLMを活用したアプリケーションをTypeScriptでサクッと作れます。
テキストだけでなく、画像やファイルも入力として扱え、外部APIとの連携もできます。
React、Next.js、Vue、Svelte、Node.jsなど主要なフレームワークに対応しています。

LLM プロバイダーの選択

AI SDKの大きな特徴の1つが、複数のLLMプロバイダーを統一的に扱えることです。

OpenAI、Anthropic、Googleなど、各社が独自のAPIを提供しています。
問題は、それぞれAPIの仕様が異なることです。

例えば、OpenAIからClaudeに乗り換えようとすると:

  • リクエスト形式が違う
  • レスポンス構造が違う
  • エラーハンドリングが違う

結果として、プロバイダーを変えるたびにコードを大幅に書き換える必要があります。

AI SDK はこの問題を解決します。
どのプロバイダーでも同じコードで扱えるため、
モデルの切り替えが文字列を変えるだけで済みます。

// モデル指定を変えるだけで、他のコードはそのまま
const result = streamText({
  model: "openai/gpt-4o",  // ← ここを変えるだけ
  messages: [...],
});

AI SDK の3つの主要機能

1. Prompts

プロンプトは、LLMに「何をしてほしいか」を伝える指示です。
各プロバイダーごとに書き方が違って面倒ですが、AI SDKはこれを統一してくれます。

2. Tools

LLMは文章生成は得意ですが、計算や最新情報の取得は苦手です。
Toolsを使えば、「東京の天気は?」と聞かれたときに天気APIを呼び出して回答できます。

3. Streaming

LLMの回答生成は、長いと10秒以上かかることもあります。
Streamingを使えば、ChatGPTのように文字が流れてくる体験を実装できます。
ユーザーを待たせずに済むので、体験が格段に向上します。

この記事では、特にStreamingに焦点を当てて解説します。

LLM へのアクセス方法

AI SDK では、3 つの方法で LLM にアクセスできます。

1. AI Gateway(今回使用)

1 つの API キーで複数プロバイダーにアクセスできます。

  1. Vercel Dashboard で API キーを発行
  2. プロジェクトに .env.local を作成:
.env.local
VERCEL_API_KEY=your_vercel_api_key
const result = streamText({
  model: "openai/gpt-4o",  // 文字列でモデル指定
  messages: [...],
});

2. プロバイダー別パッケージ

各プロバイダーのパッケージを個別にインストールします。

npm install @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google
import { openai } from "@ai-sdk/openai";

const result = streamText({
  model: openai("gpt-4o"),  // 関数でモデル指定
  messages: [...],
});

3. カスタムプロバイダー

ローカル LLM(Ollama など)や独自 API に接続する場合に使用します。

今回作るもの

シンプルなAIチャットアプリを作ります。

  • Next.jsのAPI RouteでLLMにリクエストを送るサーバーと、
    useChat フックでチャットUIを構築するクライアントの構成
  • サーバーからの回答はストリーミングで返り、マークダウン形式でレンダリング
  • モデルはフロントから動的に切り替え可能(OpenAI / Anthropic / Google)

前提条件

  • Next.js 15 + App Router のプロジェクトがある
  • Vercel アカウントを持っている
セットアップがまだの場合
npx create-next-app@latest ai-chat-app
cd ai-chat-app

必要なパッケージ

npm install ai @ai-sdk/react
パッケージ 用途
ai サーバー側(streamText, generateText など)
@ai-sdk/react クライアント側(useChat, useCompletion など)

基本実装

サーバー側の実装

API ルートを作成します。

app/api/chat/route.ts
import { convertToModelMessages, streamText, UIMessage } from "ai";

export async function POST(req: Request) {
  const { messages, model }: { messages: UIMessage[]; model?: string } =
    await req.json();

  const result = streamText({
    model: model || "openai/gpt-4o",
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

各関数の役割:

関数名 説明
convertToModelMessages UI メッセージを LLM 用の形式に変換
streamText LLM にリクエストを送信し、ストリーミング応答を取得
toUIMessageStreamResponse ストリーミング応答を UI 用の形式に変換

動作の流れ:

  1. convertToModelMessagesmessages を LLM 用の形式に変換
  2. streamText で LLM にリクエストを送信
  3. toUIMessageStreamResponse で UI 用の形式に変換して返す

UIMessage はフロントエンド用、ModelMessage は LLM API 用の形式です。

convertToModelMessages の詳細

なぜ変換が必要か?

useChat フックから送られてくるメッセージは UIMessage[] 型ですが、
streamTextmessages パラメータは ModelMessage[] を期待します。
この2つは用途が異なるため、形式も異なります。

UIMessage の構造:

interface UIMessage<METADATA = unknown, DATA_PARTS extends UIDataTypes = UIDataTypes, TOOLS extends UITools = UITools> {
  /**
   * A unique identifier for the message.
   */
  id: string;

  /**
   * The role of the message.
   */
  role: 'system' | 'user' | 'assistant';

  /**
   * The metadata of the message.
   */
  metadata?: METADATA;

  /**
   * The parts of the message. Use this for rendering the message in the UI.
   */
  parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>;
}

UIMessagePart の型(テキストの場合):

type TextUIPart = {
  type: 'text';
  text: string;
  state?: 'streaming' | 'done';
};

ModelMessage の構造:

// システムメッセージ
type SystemModelMessage = {
  role: 'system';
  content: string;
};

// ユーザーメッセージ
type UserModelMessage = {
  role: 'user';
  content: string | Array<TextPart | ImagePart | FilePart>;
};

// アシスタントメッセージ
type AssistantModelMessage = {
  role: 'assistant';
  content: string | Array<TextPart | ToolCallPart>;
};

// ツールメッセージ
type ToolModelMessage = {
  role: 'tool';
  content: Array<ToolResultPart>;
};

主な違い:

項目 UIMessage ModelMessage
用途 UI表示・状態管理 LLM APIリクエスト
構造 parts 配列 content
ID 必須 不要
状態 state あり なし

convertToModelMessages が行う変換:

  • id を削除
  • partscontent に変換
  • state 等のUI専用情報を削除
// 変換イメージ
// UIMessage(変換前)
{
  id: "msg-123",
  role: "user",
  parts: [{ type: "text", text: "こんにちは", state: "done" }]
}

// ModelMessage(変換後)
{
  role: "user",
  content: "こんにちは"
}

参考:

クライアント側の実装

useChat フックを使ってチャット UI を作成します。

app/page.tsx
"use client";

import { useChat } from "@ai-sdk/react";

export default function Chat() {
  const { messages, sendMessage } = useChat();

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          {message.parts.map((part, i) => {
            if (part.type === "text") {
              return <p key={i}>{part.text}</p>;
            }
          })}
        </div>
      ))}
    </div>
  );
}

useChat フックが提供するもの:

名前 説明
messages 会話履歴を保持する配列
sendMessage メッセージを送信する関数

動作の流れ:

  1. sendMessage を呼ぶと、自動的に /api/chat にリクエストが送られます
  2. サーバーから返ってくるストリーミング応答が、リアルタイムで messages に追加されます
  3. 画面が自動的に再レンダリングされ、文字が流れるように表示されます

機能の追加

動的モデル選択

sendMessage の第2引数でサーバーに追加データを渡し、モデルを動的に切り替えます。

フロントエンド:

const [selectedModel, setSelectedModel] = useState("openai/gpt-4o");
const { messages, sendMessage } = useChat();

const handleSubmit = () => {
  sendMessage(
    { text: input },
    { body: { model: selectedModel } } // ← body でサーバーに渡す
  );
};

サーバー側:

export async function POST(req: Request) {
 // body の中身は req.json() で取得できる
  const { messages, model } = await req.json();

  const result = streamText({
    model: model || "openai/gpt-4o", // フロントから受け取ったモデルを使用
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

利用可能なモデル例:

プロバイダー モデル ID 特徴
OpenAI openai/gpt-4o 高速・高品質
Anthropic anthropic/claude-sonnet-4.5 長文・コーディング
Google google/gemini-2.0-flash-exp 高速

トークン使用量の監視

AI API はトークン課金なので、使用量を把握することが重要です。
onFinish コールバックを使って、トークン使用量を監視できます。

const result = streamText({
  model: selectedModel,
  messages: await convertToModelMessages(messages),
  onFinish: ({ text, usage, finishReason }) => {
    console.log("=== 生成完了 ===");
    console.log("生成テキスト:", text);
    console.log("トークン使用量:", usage);
    console.log("終了理由:", finishReason);
  },
});

出力例:

トークン使用量: {
  inputTokens: 19,      // 入力(プロンプト)
  outputTokens: 345,    // 出力(生成されたテキスト)
  totalTokens: 364,     // 合計
}
終了理由: stop  // stop = 正常終了, length = トークン上限

コスト計算の目安:

モデル 入力 出力
GPT-4o $2.50 / 1M tokens $10.00 / 1M tokens
Claude Sonnet $3.00 / 1M tokens $15.00 / 1M tokens

1M tokens = 約 75 万語

マークダウンレンダリング

AI の回答はマークダウン形式(**太字**、リストなど)で返ってくることが多いです。
そのまま表示すると読みにくいので、react-markdown でレンダリングします。

マークダウン適用前:

パッケージのインストール:

npm install react-markdown @tailwindcss/typography

Tailwind CSS の設定:

Tailwind 4 を使っている場合、globals.css にプラグインを追加します。

app/globals.css
@import "tailwindcss";
@plugin "@tailwindcss/typography";

実装:

import ReactMarkdown from "react-markdown";

// メッセージ表示部分
{message.parts.map((part, i) => {
  if (part.type === "text") {
    return message.role === "assistant" ? (
      <div key={i} className="prose prose-sm max-w-none">
        <ReactMarkdown>{part.text}</ReactMarkdown>
      </div>
    ) : (
      <p key={i}>{part.text}</p>
    );
  }
})}

マークダウン適用後:

おわりに

AI SDK を使って、シンプルなチャットアプリを作ってみました。

プロバイダーの違いを気にせず、
数行のコードでストリーミング応答が実装できたのは驚きでした。

今回はテキストチャットだけでしたが、
冒頭で触れたように画像・ファイル入力や外部API連携も同じ仕組みで実装できます。
次はツール呼び出しを使ったエージェント的なアプリにも挑戦してみたいです。

AIを「使う側」から「組み込む側」に回ると、仕組みがグッと身近になります。
ぜひ試してみてください!

参考リンク

Discussion