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を使うと、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 キーで複数プロバイダーにアクセスできます。
- Vercel Dashboard で API キーを発行
- プロジェクトに
.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 ルートを作成します。
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 用の形式に変換 |
動作の流れ:
-
convertToModelMessagesでmessagesを LLM 用の形式に変換 -
streamTextで LLM にリクエストを送信 -
toUIMessageStreamResponseで UI 用の形式に変換して返す
UIMessage はフロントエンド用、ModelMessage は LLM API 用の形式です。
convertToModelMessages の詳細
なぜ変換が必要か?
useChat フックから送られてくるメッセージは UIMessage[] 型ですが、
streamText の messages パラメータは 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を削除 -
parts→contentに変換 -
state等のUI専用情報を削除
// 変換イメージ
// UIMessage(変換前)
{
id: "msg-123",
role: "user",
parts: [{ type: "text", text: "こんにちは", state: "done" }]
}
// ModelMessage(変換後)
{
role: "user",
content: "こんにちは"
}
参考:
クライアント側の実装
useChat フックを使ってチャット UI を作成します。
"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 |
メッセージを送信する関数 |
動作の流れ:
-
sendMessageを呼ぶと、自動的に/api/chatにリクエストが送られます - サーバーから返ってくるストリーミング応答が、リアルタイムで
messagesに追加されます - 画面が自動的に再レンダリングされ、文字が流れるように表示されます
機能の追加
動的モデル選択
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/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 にプラグインを追加します。
@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