🤖

Vercel製AIツール三種の神器で実現する - モダンなAIチャット開発

に公開

はじめに

AIチャットアプリケーションを開発していると、従来のWeb開発とは異なる課題に直面します。レスポンスが少しずつ生成されるストリーミング処理、不完全なMarkdownのリアルタイムレンダリング、複雑なチャット状態の管理など、AI特有の要件への対応が必要です。

この記事では、これらの課題を解決するVercel製の3つのツールを紹介します。実際のプロダクション環境での使用経験を基に、実装パターンと注意点を解説します。

ツール 役割 解決する課題
Vercel AI SDK 基盤層 プロバイダAPI統一、ストリーミング処理
AI Elements UI層 チャットUI構築、状態管理
Streamdown Markdownレンダリング層 不完全Markdown処理

第1部:Vercel AI SDK

Vercel AI SDKは、異なるAIプロバイダーAPIを統一インターフェースで扱えるTypeScriptライブラリです。一貫性のあるAPIデザインで複数のAIモデルを扱え、ストリーミング形式の応答処理もシンプルに記述できます。

コアアーキテクチャ

マルチフレームワーク対応

AI SDKはReactだけでなく、モダンなJavaScriptフレームワーク全般をサポートしています。Next.js、Nuxt、SvelteKit、さらにはReact NativeのExpoでのモバイル開発でも可能です。各フレームワークに最適化された統合を提供し、それぞれのエコシステムに自然に溶け込むように設計されています。

プロバイダー非依存性

SDKは20以上の公式プロバイダーを統一されたインターフェースでサポートしています。主要なプロバイダーとして、OpenAI、Anthropic、Google(Generative AIとVertex AI)、Azure、Mistral、Groq、xAI Grokなどがあり、OpenAI互換エンドポイントやコミュニティプロバイダーも含めて幅広い選択肢があります。

// プロバイダーの切り替えが簡単
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { google } from '@ai-sdk/google';

// 同じインターフェースで異なるプロバイダーを使用
const openaiResult = await streamText({
  model: openai('gpt-4o'),
  messages,
});

const anthropicResult = await streamText({
  model: anthropic('claude-3.5-sonnet'),
  messages,
});

// 複数プロバイダーの並行使用も可能
const [gpt4Response, claudeResponse] = await Promise.all([
  streamText({ model: openai('gpt-4o'), messages }),
  streamText({ model: anthropic('claude-3.5-sonnet'), messages })
]);

この抽象化層により、アプリケーションロジックを変更することなくプロバイダーを切り替えたり、用途に応じて最適なモデルを選択したりできます。各プロバイダーは画像入力、オブジェクト生成、ツール使用など異なる機能をサポートしており、要件に応じて柔軟に選択できます。

コアコンセプトとAPI

streamText関数

streamText関数は、Vercel AI SDKの中核を担う最も重要なAPIです。AIモデルからリアルタイムでテキストレスポンスを生成し、ユーザーがレスポンスを待つ時間を短縮できます。

基本的な使い方

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

const result = await streamText({
  model: openai('gpt-4o'),
  prompt: '量子コンピューティングを簡単に説明してください。',
});

// ストリームを処理
for await (const textPart of result.textStream) {
  console.log(textPart);
}

この例では、AIが生成したテキストが少しずつ(チャンク単位で)コンソールに出力されます。ユーザーは完全な回答を待つことなく、生成過程をリアルタイムで確認できます。

詳細なオプション設定

プロダクション環境では、生成品質やパフォーマンスを細かく制御します。streamTextは次のような豊富なオプションを提供します。

const result = await streamText({
  model: openai('gpt-4o'),
  prompt: 'テキスト生成のプロンプト',

  // 生成制御パラメータ
  temperature: 0.7,              // 0-2: 創造性の制御(高いほど多様)
  maxOutputTokens: 1000,         // 最大出力トークン数
  // topP: 0.9,                  // temperatureとの併用は非推奨
  frequencyPenalty: 0.1,         // 繰り返し抑制(範囲はプロバイダ依存)
  presencePenalty: 0.1,          // 新規トピック促進(範囲はプロバイダ依存)
  seed: 123,                     // 再現性のための固定シード(モデル依存)
  stopSequences: ['END'],        // 生成を停止するテキスト

  // ストリーミング最適化(日本語向け設定)
  experimental_transform: smoothStream({
    // 日本語は単語境界がないため正規表現でチャンク分割
    chunking: /[\u3040-\u309F\u30A0-\u30FF]|\S+\s+/,
    delayInMs: 20,
  }),

  // ライフサイクルコールバック
  onChunk: ({ chunk }) => {
    // text/reasoning/tool-call等、多数のタイプが届く
    if (chunk.type === 'text') console.log('テキスト:', chunk.text);
  },
  onFinish: ({ text, usage, finishReason, totalUsage }) => {
    // 最終テキスト、トークン使用量、終了理由等を一括取得
    console.log('生成完了:', { text, usage, finishReason, totalUsage });
  },
  onError: ({ error }) => {
    // エラーは例外ではなくストリームに埋め込まれる
    console.error('ストリームエラー:', error);
  },
});

// ストリーム消費(消費しないと生成が完了しない)
// 以下のいずれかの方法でストリームを消費する必要があります:

// 方法1: テキストストリームを逐次処理
for await (const part of result.textStream) {
  process.stdout.write(part);
}

// 方法2: 全文を最後に取得(内部で自動消費)
const text = await result.text;

// 方法3: HTTPレスポンスへパイプ(Next.js等)
// return result.toTextStreamResponse();

const usage = await result.usage;      // トークン使用量を取得
const finishReason = await result.finishReason;  // 終了理由を取得

これらのパラメータにより、AIの創造性レベル、出力の長さ、繰り返しの制御など、用途に応じた細かな調整が可能です。特に日本語の場合、smoothStreamchunkingオプションで正規表現を使用することで、より自然なストリーミング体験を提供できます。

チャット形式での会話実装

単発の質問ではなく、継続的な会話を実装する場合はmessages配列を使用します。これにより、文脈を保持した自然な対話が可能になります。

const result = await streamText({
  model: openai('gpt-4o'),
  messages: [
    { role: 'system', content: 'あなたは親切なアシスタントです。' },
    { role: 'user', content: '量子コンピューティングを簡単に説明してください。' },
    { role: 'assistant', content: '量子コンピューティングは...' },
    { role: 'user', content: 'もっと詳しく教えてください。' }
  ],
});

実際のプロダクション環境では、このmessages配列はDBから取得したチャット履歴を基に構築され、レスポンス完了後に保存されるのが一般的です。

メッセージ管理とチャットコンテキスト

AI SDK v5の特徴の一つは、UIMessageとModelMessageを明確に分離したことです。UIMessageはアプリケーションの情報源として、UI表示に必要なすべての情報を保持します。一方、ModelMessageはAIモデルに送信する最小限の情報のみを含みます。

import { UIMessage, convertToModelMessages } from 'ai'

// parts配列による多様なコンテンツ表現
const messages: UIMessage[] = [
  {
    id: 'msg-1',
    role: 'user',
    parts: [
      { type: 'text', text: '東京の天気を教えてください' }
    ],
    metadata: { userId: 'user123', timestamp: Date.now() - 5000 }
  },
  {
    id: 'msg-2',
    role: 'assistant',
    parts: [
      { type: 'text', text: '東京の天気情報を検索しました' },
      // カスタムデータパーツ(data-<name>形式)
      { type: 'data-weather', data: { temp: 22, condition: '晴れ' } },
      { type: 'data-notification', data: { message: '検索完了' } }
    ],
    metadata: { userId: 'assistant', timestamp: Date.now() }
  }
];

// ModelMessageへの変換(AIモデル送信時)
// カスタムパーツは変換時に適切に処理される
const modelMessages = convertToModelMessages(messages);

この分離により、UIの複雑な状態管理とAIモデルへの入力を独立して最適化できます。

データフローの理解:バックエンドからフロントエンドへ

Vercel AI SDKにおけるデータフローを理解することは、レスポンス時間の短縮とメモリ使用量の削減を実現するAIアプリケーション構築に不可欠です。以下の図は、バックエンドのstreamTextからフロントエンドのuseChat、そして最終的にUIMessageへの変換プロセスを示しています。

実装例
バックエンド
// app/api/chat/route.ts
import { streamText, convertToModelMessages, smoothStream, createIdGenerator, consumeStream } from 'ai';
import { openai } from '@ai-sdk/openai';

// ④ で [DONE] までSSEを流すHTTP Responseを返す。
// (toUIMessageStreamResponse が SSEを生成し終端マーカー [DONE] も付与)
export async function POST(req: Request) {
  const { messages } = await req.json();

  // ① streamText 呼び出し(useChatから来るUIMessage[] → ModelMessage[]に明示変換)
  //    v5ではUIMessageとModelMessageを明確に分離。ModelMessageをモデルへ送るのが推奨。
  const modelMessages = convertToModelMessages(messages);

  const result = streamText({
    model: openai('gpt-4o'),
    messages: modelMessages,

    // ② smoothStream 等の transform を適用
    //    日本語の字単位/語感に合わせたチャンク分割(公式の例をベースに遅延20ms)
    experimental_transform: smoothStream({
      delayInMs: 20,
      // 日本語向け推奨パターン(語区切りが空白でない言語向けの例)
      chunking: /[\u3040-\u309F\u30A0-\u30FF]|\S+\s+/,
    }),

    // (任意)サーバ観測やRUM向け
    onChunk: ({ chunk }) => {
      // text-delta / step-start / finish 等が来る
      // console.debug('chunk', chunk);
    },
  });

  // ③ toUIMessageStreamResponse で UI Message Stream (SSE) を生成
  //    v5はメタデータやID生成などをここで型安全に差し込める
  return result.toUIMessageStreamResponse({
    // 元メッセージ(UIMessage)を渡すとサーバ側で一貫したID生成・突合せがしやすい
    originalMessages: messages,

    // 一貫性のあるメッセージID(保存・再開・重複防止に有効)
    // 自動採番で十分な場合は省略可能
    generateMessageId: createIdGenerator({ prefix: 'msg' }),

    // メッセージ単位のメタデータを安全に付与(モデルID・トークン使用量など)
    messageMetadata: ({ part }) => {
      if (part.type === 'start') {
        return {
          model: 'gpt-4o',
          createdAt: Date.now(),
        };
      }
      if (part.type === 'finish') {
        return {
          totalTokens: part.totalUsage?.totalTokens,
        };
      }
      return undefined;
    },

    // 中断時でも確実に呼ばれるようにconsumeStreamを指定
    consumeSseStream: consumeStream,

    // 完了時フック(保存・計測など)
    onFinish: async ({ messages: finalMessages, responseMessage, isAborted, isContinuation }) => {
      // isAborted: ユーザーがstop()を呼び出して中断した場合にtrue
      // isContinuation: 継続処理の場合にtrue
      // 中断時でも保存処理を行う
      // await saveToDatabase({ finalMessages, responseMessage, isAborted });
    },

    // エラー表示のセキュリティ(詳細はログへ、ユーザーには汎用メッセージ)
    onError: (error) => {
      console.error('Stream error:', error); // 詳細はサーバログへ
      return 'Something went wrong. Please try again.'; // ユーザーには汎用メッセージ
    },
  });
}
フロントエンド
'use client';

import { useState } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import type { UIMessage } from 'ai';

// (型強化)UIMessageにメタデータやカスタムDataパーツを型付け
// data-<name> 形式でカスタムデータパーツを定義
type ChatMessage = UIMessage<
  { model?: string; totalTokens?: number; createdAt?: number }, // metadata
  {
    'data-notification': { message: string; level: 'info' | 'warning' | 'error' };
    'data-weather': { city: string; status: 'loading' | 'success'; weather?: string };
    'data-source': { url: string; title: string };
  }
>;

export default function ChatPage() {
  const [input, setInput] = useState('');

  // ⑤ useChat / DefaultChatTransport で /api/chat に接続
  const {
    messages,          // ⑦ messages 配列として提供(UIMessage[])
    sendMessage,
    stop,
    regenerate,
    resumeStream,      // ネットワーク途切れからの再開
    status,
    error,
    addToolResult,     // ツール結果の反映
    clearError,
    // その他利用可能なメソッド: setMessages
  } = useChat<ChatMessage>({
    // DefaultChatTransportはデフォルトだが、明示して拡張余地を示す
    transport: new DefaultChatTransport({
      api: '/api/chat',
      // 静的な値として設定(動的な値はsendMessage時に指定)
      headers: { 'X-Client': 'web' },
    }),

    // ストリームの自動再開設定(任意)
    // resume: true, // ネットワーク切断時の自動再開

    // ⑥ UI Message Stream を解釈:transientな data-<name> は onData でのみ捕捉
    onData: (part) => {
      if (part.type === 'data-notification') {
        // 例:トースト表示など
        // showToast(part.data.message, part.data.level);
      }
    },

    // 受信完了
    onFinish: ({ message, messages }) => {
      // console.log('finished', { message, messages });
    },

    onError: (err) => {
      console.error(err);
    },
  });

  // ⑧ UI コンポーネントでレンダリング(partsを優先して描画)
  // 重要: contentよりpartsベースでレンダリングすることで、RAG/ツール/ファイルに拡張しやすくなる
  return (
    <div className="max-w-2xl mx-auto p-4 space-y-4">
      {messages.map((m) => (
        <div key={m.id} className="rounded-lg border p-3">
          <div className="text-xs text-gray-500 mb-1">
            {m.role.toUpperCase()} {/* user / assistant */}
            {m.metadata?.model && <> · {m.metadata.model}</>}
            {typeof m.metadata?.totalTokens === 'number' && <> · {m.metadata.totalTokens} tok</>}
          </div>

          {/* text */}
          {m.parts
            .filter((p) => p.type === 'text')
            .map((p, i) => (
              <div key={i} className="whitespace-pre-wrap">
                {/* @ts-expect-error narrowed above */}
                {p.text}
              </div>
            ))}

          {/* file(画像など) */}
          {m.parts
            .filter((p) => p.type === 'file')
            .map((p, i) => {
              // 画像は mediaType が image/* の file パーツとして届く
              const file = p as Extract<(typeof m.parts)[number], { type: 'file' }>;
              return file.mediaType.startsWith('image/') ? (
                <img key={i} src={file.url} alt={file.filename ?? ''} className="mt-2 max-h-64 rounded" />
              ) : null;
            })}

          {/* data-source(RAGの参照など - データパーツ) */}
          {m.parts
            .filter((p) => p.type === 'data-source')
            .map((p, i) => {
              const s = p as Extract<(typeof m.parts)[number], { type: 'data-source' }>;
              return (
                <div key={i} className="text-xs text-blue-700 mt-2">
                  参照: <a href={s.data.url} target="_blank" rel="noreferrer">{s.data.title ?? s.data.url}</a>
                </div>
              );
            })}

          {/* data-weather(サーバからの持続データパーツ) */}
          {m.parts
            .filter((p) => p.type === 'data-weather')
            .map((p, i) => {
              const w = p as Extract<(typeof m.parts)[number], { type: 'data-weather' }>;
              return (
                <div key={i} className="text-sm mt-2">
                  {w.data.status === 'loading'
                    ? <>天気取得中: {w.data.city}</>
                    : <>天気: {w.data.city} {w.data.weather}</>}
                </div>
              );
            })}
        </div>
      ))}

      {/* 入力フォーム */}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          // ⑦ onData で transient を受けつつ、messages は自動更新
          // 動的データが必要な場合は第2引数で指定
          sendMessage(
            { role: 'user', parts: [{ type: 'text', text: input }] },
            // { body: { sessionId: 'dynamic-session-id' } } // 動的データの例
          );
          setInput('');
        }}
        className="flex gap-2"
      >
        <input value={input} onChange={(e) => setInput(e.target.value)} className="flex-1 border rounded px-2" />
        <button type="submit">送信</button>
        <button type="button" onClick={stop}>停止</button>
        <button type="button" onClick={() => regenerate()}>再生成</button>
        <button type="button" onClick={resumeStream}>再接続</button>
      </form>

      {status === 'streaming' && <div className="text-xs text-gray-500">ストリーミング中…</div>}
      {error && <div className="text-xs text-red-600">Error: {String(error.message || error)}</div>}
    </div>
  );
}

ストリーミングプロトコルの詳細

AI SDK では、Server-Sent Events(SSE)形式(サーバーからブラウザに一方向で流し続ける仕組み)でstart/delta/endパターンを採用しています。イベント種別の詳細はStream Protocolsの公式ドキュメントを参照してください。

data: {"type":"start","messageId":"msg_123"}
data: {"type":"text-start","id":"text_68679a45"}
data: {"type":"text-delta","id":"text_68679a45","delta":"こんにちは"}
data: {"type":"text-delta","id":"text_68679a45","delta":"、どのように"}
data: {"type":"text-delta","id":"text_68679a45","delta":"お手伝いできますか?"}
data: {"type":"text-end","id":"text_68679a45"}
data: {"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":10,"outputTokens":20,"totalTokens":30}}
data: [DONE]

useChatフックが自動的にチャンクを結合し、完全なメッセージとして提供します。

// フロントエンド側で受け取るメッセージ
const { messages } = useChat();

// messagesの中身(チャンクが自動的に結合されている)
console.log(messages);
// [
//   {
//     id: 'msg-1',
//     role: 'assistant',
//     content: 'こんにちは、どのようにお手伝いできますか?', // 完全な文章
//     // ↑ 実際は「こん」「にちは」「、どの」「ように」... と
//     // 細かく分割されて送信されてきたものが自動結合されている
//   }
// ]

// 開発者は単純に完成したメッセージを扱うだけ
return (
  <div>
    {messages.map(m => (
      <div key={m.id}>{m.content}</div> // すでに結合済みのテキスト
    ))}
  </div>
);

このように、バックエンドからは細かいチャンクでストリーミングされてきますが、useChatが自動的にそれらを結合し、開発者には完全なメッセージとして提供されます。

ツール呼び出しと関数統合

AI SDKは、型安全な定義でツール呼び出しのファーストクラスサポートを提供します。AIモデルは外部API、データベース、計算処理などの機能を実行できます。

import { streamText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

const weatherTool = tool({
  description: '現在の天気情報を取得',
  inputSchema: z.object({
    location: z.string().describe('都市と国'),
    units: z.enum(['celsius', 'fahrenheit']).default('celsius')
  }),
  // 型は inputSchema から自動推論される
  execute: async ({ location, units }) => {
    const weather = await fetchWeatherAPI(location, units);
    return weather;  // tool-result として自動的に次の推論に渡される
  }
});

const result = await streamText({
  model: openai('gpt-4o'),
  messages,
  tools: { getWeather: weatherTool },
  toolChoice: 'auto', // 'none' | 'required' | 'auto' | { type: 'tool', toolName: string }
  // maxSteps: 3,  // マルチステップでツールを複数回実行可能
});

// ストリーミング中のツール呼び出しを観測
for await (const part of result.fullStream) {
  switch (part.type) {
    case 'tool-call':
      console.log('ツール呼び出し:', part.toolName, part.args);
      break;
    case 'tool-result':
      console.log('ツール結果:', part.result);
      break;
  }
}

ツール呼び出しの詳細な実装パターン、並列実行、エラーハンドリング、カスタムツールの作成方法については、公式ドキュメントを参照してください。

構造化出力

AI SDK v5では、JSONなどの構造化データを生成する際は、専用のstreamObject関数を使用します。この関数は型安全性を保ちながら、複雑なデータ構造をリアルタイムで生成できる強力な機能です。

import { streamObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

// Zodスキーマで出力構造を定義
const weatherSchema = z.object({
  city: z.string().describe('都市名'),
  temperature: z.number().describe('気温(摂氏)'),
  condition: z.enum(['sunny', 'cloudy', 'rainy']).describe('天気状況'),
  humidity: z.number().describe('湿度(%)'),
});

// 構造化出力のストリーミング
const result = streamObject({
  model: openai('gpt-4o'),
  schema: weatherSchema,
  prompt: '東京の現在の天気をJSON形式で返してください',
});

// 部分オブジェクトを逐次処理(DeepPartial<T>型・未検証)
for await (const partial of result.partialObjectStream) {
  console.log('Partial:', partial);
  // Partial: { city: '東京' }
  // Partial: { city: '東京', temperature: 22 }
  // Partial: { city: '東京', temperature: 22, condition: 'sunny' }
  // Partial: { city: '東京', temperature: 22, condition: 'sunny', humidity: 65 }
}

// 完了後に最終オブジェクト(Promise<T>型・スキーマ検証済み)
const finalObject = await result.object;
console.log('Complete:', finalObject);
// Complete: { city: '東京', temperature: 22, condition: 'sunny', humidity: 65 }

// 他の利用可能なストリーム
// result.textStream - JSONテキストのストリーム
// result.fullStream - イベント全体のストリーム

詳細は構造化出力のドキュメントを参照してください。

高度なストリーミングパターン

Vercel AI SDKは、単純なテキストストリーミングを超えた高度なパターンをサポートしています。これらのパターンは、プロダクション環境でのAIアプリケーション構築における一般的な課題を解決します。

RAGシステムと社内ナレッジ活用の統合

「なぜAIチャットアプリを作るのか」と問われれば、筆者はこの社内ナレッジ活用を挙げます。LLMがどれだけ優秀でも、組織固有の知識までは知りません。先週決まったプロジェクトの方針、3年前の障害対応ノウハウ等々。これらの暗黙知を新メンバーが習得するには何ヶ月もかかりますが、RAGシステムがあれば誰もが組織の集合知にアクセスできるようになります。

AI SDKはこのRAG(Retrieval-Augmented Generation)システムの実装をシンプルにしてくれます.

社内ナレッジ活用の実装パターン

企業内の知識は様々なシステムに分散しているのが現実です。これらを統合的に検索し、AIのコンテキストとして活用する際は、複数のナレッジベースから並行検索を行い、重複を除去した上で関連度による再ランキングを実施します。検索結果には作成者、更新日、信頼度などのメタデータを付与することで、AIが文書の信頼性や新しさを考慮した判断ができます。

Rerankモデルによる精度向上

AI SDK自体にはRerank機能が組み込まれていませんが、外部のRerankサービスと組み合わせることで、社内文書の関連度を向上させることができます。コミュニティからはRerankサポートの要望が上がっており、将来的な実装が期待されています。

RAGシステムの処理フローを以下に示します。

AWS Bedrock Rerankの実装
import {
  BedrockAgentRuntimeClient,
  RerankCommand
} from "@aws-sdk/client-bedrock-agent-runtime";

const client = new BedrockAgentRuntimeClient({
  region: "ap-northeast-1",
  // 必要に応じて認証情報を設定
});

// 検索結果をRerankする(50-200件から5件に絞る)
const rerankResponse = await client.send(new RerankCommand({
  queries: [{
    type: "TEXT",
    textQuery: { text: userQuery }
  }],
  sources: searchResults.map(doc => ({
    type: "INLINE",
    inlineDocumentSource: {
      type: "TEXT",
      textDocument: { text: doc.content }
    }
  })),
  rerankingConfiguration: {
    type: "BEDROCK_RERANKING_MODEL",
    bedrockRerankingConfiguration: {
      modelConfiguration: {
        modelArn: process.env.BEDROCK_RERANKING_MODEL_ARN
      },
      numberOfResults: 5 // 上位5件を取得
    }
  }
}));

// AI SDKのUIMessageにsourcesとして出典を追加
const sources = rerankResponse.results?.map((result, i) => ({
  type: 'source' as const,
  value: {
    id: searchResults[result.index || i].id,
    title: searchResults[result.index || i].title,
    url: searchResults[result.index || i].url,
    relevanceScore: result.relevanceScore
  }
})) || [];
主要なRerankサービスの選択肢

1. Cohere Rerank v3.5

  • 多言語対応、金融・EC・プロジェクト管理など幅広いドメインで高精度
  • AWS Bedrock経由でも利用可能(@aws-sdk/client-bedrock-agent-runtime
  • コンテキスト長:4K tokens

2. Amazon Bedrock Rerank

  • AWS環境との親和性が高い(bedrock-agent-runtime:Rerank APIを使用)
  • modelIdはcohere.rerank-v3-5:0などリージョン別に指定
  • IAM権限設定ではbedrock-agent-runtime:Rerank権限が必要

3. Voyage AI Rerank-2

  • OpenAI埋め込みモデルと組み合わせて高精度
  • コンテキスト長:16K tokens(Cohereの4倍)
  • 軽量版のrerank-2-lite(8K tokens)も選択可能

参照情報は、プロンプトを汚染しないようUIMessagesourcesデータパーツとして送信します。これによりトークンを節約しつつ、UI側で出典を適切に表示できます。エラー時のフォールバック戦略も重要で、Rerankサービスが利用できない場合は埋め込みベクトルの類似度計算やRRF(Reciprocal Rank Fusion)、シンプルな上位N件選択に切り替えることで、サービスの可用性を保ちます。

このような社内ナレッジ活用システムにより、組織の知的資産を最大限に活用できます。新入社員でもベテランの知識を活用でき、組織全体の生産性向上に貢献します。

データストリーミング

テキストメッセージのストリーミング中に、異なる種類のデータを挿入できる機能です。これはAI SDK v5のcreateUIMessageStream関数を使用して実現します。例えば、AIがテキストを生成している最中に、画像生成の結果、データベース検索結果、グラフデータ、ファイルのアップロード状態などを同じストリーム内で送信できます。

// バックエンド:カスタムデータのストリーミング
const stream = createUIMessageStream({
  async execute({ writer }) {
    // テキストと並行してカスタムデータを送信
    writer.write({ type: 'text', value: '検索中...' });
    writer.write({
      type: 'data-weather',
      value: { status: 'loading', city: 'Tokyo' }
    });

    const weather = await fetchWeather('Tokyo');
    writer.write({
      type: 'data-weather',
      value: { status: 'success', ...weather }
    });
  }
});

// フロントエンド:カスタムデータの受信
import { useChat } from '@ai-sdk/react';

const { messages } = useChat({
  onData: (data) => {
    if (data.type === 'data-weather') {
      updateWeatherWidget(data.value);
    }
  }
});

詳細はストリーミングデータストリームプロトコルのドキュメントを参照してください。

エラーハンドリングと復元力

AI SDK v5は、プロダクション環境での堅牢性を確保するため、複数レベルのエラーハンドリングメカニズムを提供します。

// バックエンド:エラーの安全な処理
import { streamText, APICallError } from 'ai';
import { openai } from '@ai-sdk/openai';

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai('gpt-4o'),
    messages,
    maxRetries: 3,
    // ログやメトリクス用(エラーを投げずに通知)
    onError: ({ error }) => {
      console.error('streamText error:', error);
    },
  });

  // streamTextはエラーを投げずにストリームに流すため、
  // toUIMessageStreamResponseのonErrorで制御
  return result.toUIMessageStreamResponse({
    onError: (error) => {
      // APICallErrorの詳細判定とユーザー向けメッセージ
      if (APICallError.isInstance(error) && error.statusCode === 429) {
        return 'ただいま混雑中です。しばらくしてから自動再試行します。';
      }
      if (APICallError.isInstance(error) && error.statusCode >= 500) {
        return 'サービスに一時的な問題が発生しています。';
      }
      return '一時的な問題が発生しました。少々お待ちください。';
    },
  });
}

// フロントエンド:自動復旧とエラー処理
import { useChat } from '@ai-sdk/react';

export default function Chat() {
  const { error, regenerate, clearError, resumeStream } = useChat({
    onError: (err) => {
      // サーバーのonErrorで設定したメッセージが入る
      if (err?.message?.includes('自動再試行')) {
        setTimeout(() => {
          clearError();
          regenerate();
        }, 60000);
        toast.info('1分後に自動的に再試行します');
      }
    },
    onFinish: ({ isDisconnect, isError }) => {
      // ネットワーク切断時は自動復旧を試みる
      // 注:resumeStreamは一時的な切断の"その場レジューム"用
      // ページ離脱→復帰の永続的な再開には、useChat({ resume: true })と
      // 専用のGET /resume エンドポイントが必要(Redis等での短期保存と併用)
      if (isDisconnect && !isError) {
        resumeStream();
      }
    },
  });

  return (
    <>
      {error && (
        <div className="error">
          {error.message}
          <button onClick={() => { clearError(); regenerate(); }}>
            再試行
          </button>
        </div>
      )}
    </>
  );
}

詳細はエラーハンドリングエラーリファレンスのドキュメントを参照してください。

第2部:AI Elements

AI Elements は、shadcn/ui をベースにした入力→ストリーミング→(思考/生成/ツール実行)→完了という多段階ライフサイクルを前提としたオープンソースのReactコンポーネント集+カスタムレジストリです。

https://ai-sdk.dev/elements/overview

AI Elementsの特徴:

  • フルコントロール可能 - UIプリミティブとして完全な制御が可能
  • AI SDK統合 - @ai-sdk/react の useChat 等と自然に連携
  • 拡張性 - Reasoning、Branch、Code Blockなどチャット以外のAIパターンも既に提供

導入方法と注意点

AI Elements CLIは、shadcn/uiが未導入のプロジェクトでも自動的に初期化してくれます。ただし、プロダクションで既存のUI基盤がある場合は、実装を参考に自前のUIシステムへ合わせて移植することも検討してください。

また各コンポーネントARIA/キーボード操作/スクリーンリーダーに配慮していますが、WCAG適合レベルの保証までは謳っていないため、実サービスの基準での検証を推奨します。

実際に導入したコンポーネント

Conversationコンポーネント

https://ai-sdk.dev/elements/components/conversation

Conversationコンポーネントは、新規メッセージで自動的に最下部へスクロールし、ユーザーがスクロール操作を始めたら自動スクロールを一時停止します。最下部にいない時だけスクロールボタンを表示するという、シンプルながら効果的な解決策を提供していました。

筆者のプロダクション環境ではshadcn/uiを採用していなかったため、CLIでの直接導入はできませんでしたが、コンポーネントの実装アプローチは大いに参考になりました。

// Conversationをプロダクション環境で活用する統合例
export function ConversationIntegration({
  messages,
  status,
  chatHistoryId,
  children,
  onInstanceReady,
}) {
  // 空チャットでは中央配置レイアウト
  const isEmptyChat = messages.length === 0 && !chatHistoryId;

  if (isEmptyChat) {
    return (
      <div className="flex items-center h-full">
        <div className="max-w-[742px] w-full mx-auto px-4">
          {children}
        </div>
      </div>
    );
  }

  // メッセージがある場合はスクロール可能なConversation
  return (
    <Conversation className="h-full">
      {(ctx) => (
        <>
          <ConversationContent className="max-w-[742px] mx-auto space-y-4 px-4">
            {children}
          </ConversationContent>
          <ConversationScrollButton onClick={ctx.scrollToBottom} />
        </>
      )}
    </Conversation>
  );
}
活用例
// 実際のチャットレイアウトでの使用例
function ChatLayout() {
  // useChat を内部で使用しているコンテキスト
  const { messages, status, handleSubmit, input } = useChatContext();
  const contextRef = useRef(null);

  // メッセージ送信時にスクロール制御を行うハンドラー
  const handleSubmitWithScroll = () => {
    if (input.trim()) {
      handleSubmit();
      // 送信直後に最下部へスクロール(ユーザーが上を見ている場合は条件分岐可能)
      if (contextRef.current?.isAtBottom !== false) {
        contextRef.current?.scrollToBottom();
      }
    }
  };

  return (
    <div className="flex h-full">
      <Sidebar />
      {/* 重要:min-h-0を設定してスクロール領域を作成 */}
      <main className="flex-1 min-h-0">
        {/* Conversationに固定高さまたはh-fullでスクロール箱を作成 */}
        <Conversation className="h-full" contextRef={contextRef}>
          <ConversationContent className="max-w-[742px] mx-auto space-y-4 px-4">
            {/* メッセージレンダリング */}
          </ConversationContent>
          <ConversationScrollButton />
        </Conversation>
      </main>
      <footer>
        <PromptInput onSubmit={handleSubmitWithScroll} />
      </footer>
    </div>
  );
}

スクロール制御の実装

AIアプリケーションでは、新しいメッセージが送信された際に自動的に最新の内容へスクロールします。Conversationコンポーネントはデフォルトで自動スクロール機能を内蔵しており、新しいメッセージが追加されると自動的に最下部へスクロールします。

その他のコンポーネント

AI Elementsには他にも以下のコンポーネントが用意されています。

  • Message - ユーザーとAIのメッセージ表示(アバター、タイムスタンプ、アクション付き)
  • Response - AIレスポンスの表示
  • Inline Citation - インライン引用の表示
  • Actions - レスポンスアクションの表示
  • Suggestion - サジェスト機能
  • Tool - ツール呼び出しの表示
  • Image - 画像表示
  • Loader - ローディング表示
  • Web Preview - Webページのプレビュー(iframeベース)
  • Branch - 分岐表示(履歴管理は各自実装)
  • Reasoning - 推論過程の表示
  • Code Block - コードブロックの表示

詳細は公式ドキュメントを参照してください。

第3部:Streamdown

Streamdown は Vercel が公式に開発したオープンソースのMarkdownレンダラーで、AIストリーミング専用に設計され、react-markdownのドロップイン代替品として機能します。
ちなみにAI Elements の Response コンポーネント内部で使用されています。

https://github.com/vercel/streamdown

ストリーミングMarkdownの必要性

従来のMarkdownレンダラーは完全なドキュメントの存在を前提としているため、AIがリアルタイムで生成する不完全なMarkdownの表示に課題があります。開けっぱなしのコードブロック、未完成のリンク、閉じられていない太字タグなどが正しくレンダリングされません。Streamdownは、これらの不完全なMarkdownを適切に処理し、滑らかなユーザー体験を提供します。

Streamdownのコアは、いくつかの基本原則に基づいて構築されています。

Streamdownの特徴

StreamdownはAIチャットのレスポンスをリアルタイムで美しく表示するための、react-markdown互換のライブラリです。最大の特徴は、AIが生成途中の不完全なMarkdownでも適切にレンダリングできる点です。

実際の動作はStreamdownの公式サイトでご覧いただけます。ストリーミング中でも、コードブロックやリンク、太字などの装飾が途切れることなく、自然に表示される様子を確認できます。

基本的なMarkdown要素(見出し、リスト、テーブル、リンクなど)はもちろん、GitHub Flavored Markdown(GFM)にも完全対応しています。Shikiによるシンタックスハイライトやrehype-katexによる数式レンダリング、Mermaidダイアグラムも標準でサポートされています。既存のreact-markdownプロジェクトならドロップイン代替として使用できます。

これらの機能により、AIストリーミング環境に最適化された、美しくインタラクティブなMarkdown表示を実現できます。

実装例とカスタマイゼーション

Tailwindセットアップ

Streamdownを使用する際は、globals.cssに次の内容を追加して組み込みスタイルを有効化します。

/* globals.css */
@source "../node_modules/streamdown/dist/index.js";

この設定により、Streamdownの標準的なスタイリングが適用されます。

基本的な使用方法

import { Streamdown } from 'streamdown';

function StreamingMessage({ content, isStreaming }) {
  return (
    <Streamdown
      // ストリーミング中の未完Markdownを仮補完して崩れを抑える
      parseIncompleteMarkdown={isStreaming}
    >
      {content}
    </Streamdown>
  );
}

スタイルカスタマイゼーション

Streamdownは、data-streamdown属性を使った精密なスタイリングが可能です。

data-streamdown属性は最近利用可能になりましたが、公式ドキュメントはまだ整備中です。実際のDOMを検査して属性名を確認し、将来の変更に備えてテストを書くことを推奨します。

import { useMemo } from 'react';
import { Streamdown } from 'streamdown';

function StreamingMessage({ content, isStreaming }) {
  // componentsをメモ化して再レンダリングを最適化
  const components = useMemo(() => ({
    // テーブルスタイリング
    table: (props) => (
      <div className="overflow-x-auto my-4">
        <table className="min-w-full divide-y divide-gray-300" {...props} />
      </div>
    ),
    // カスタムリンク処理
    a: (props) => (
      <a
        {...props}
        target="_blank"
        rel="noopener noreferrer"
        className="text-blue-600 hover:text-blue-800 underline transition-colors"
      />
    ),
  }), []);

  return (
    <Streamdown
      parseIncompleteMarkdown={isStreaming}
      className={clsx([
        "max-w-none text-gray-900",
        // コードブロックのカスタマイズ
        "[&_[data-streamdown='code-block']]:border-gray-200",
        "[&_[data-streamdown='code-block']]:bg-gray-50",
        "[&_[data-streamdown='code-block']]:rounded-lg",
        // インラインコードのスタイリング
        "[&_[data-streamdown='inline-code']]:bg-gray-100",
        "[&_[data-streamdown='inline-code']]:px-1.5",
        "[&_[data-streamdown='inline-code']]:py-0.5",
        // Mermaidダイアグラムのスタイリング
        "[&_[data-streamdown='mermaid-block']]:border",
        "[&_[data-streamdown='mermaid-block']]:my-4",
      ])}
      components={components}
    >
      {content}
    </Streamdown>
  );
}

カスタム引用番号スタイリングの実装

社内ナレッジなどで引用番号が必要な場合は、remarkプラグインを使用。AST(抽象構文木) レベルで [1] のような引用番号だけを安全に検出し、code / inlineCode / link などの内部はスキップします。ストリーミングでノードが分割されても安定して処理できます。

Streamdownへの組み込み
import { Streamdown } from 'streamdown';
import { remarkCitations } from '@/libs/markdown/remark-citations';

export function StreamingMessage({ content, isStreaming = true }: Props) {
  return (
    <Streamdown
      parseIncompleteMarkdown={isStreaming}
      remarkPlugins={[remarkCitations]}
      className="max-w-none"
    >
      {content}
    </Streamdown>
  );
}
remark-citations.ts
import type {
  Root,
  Text,
  PhrasingContent,
  Parent,
  Content,
  Emphasis,
} from 'mdast';
import type { Node } from 'unist';
import visitParents from 'unist-util-visit-parents';

export type RemarkCitationsOptions = Readonly<{
  pattern?: RegExp;
  dataAttrName?: string;
}>;

const makeSpan = (
  props: Readonly<Record<string, unknown>>,
  children: ReadonlyArray<PhrasingContent>
): Emphasis => ({
  type: 'emphasis',
  data: { hName: 'span', hProperties: props },
  children: [...children],
});

const text = (value: string): Text => ({ type: 'text', value });

/** node が Parent(children を持つ)か判定する型ガード */
const isParent = (node: Node): node is Parent & { children: Content[] } => {
  return 'children' in node && Array.isArray((node as Parent).children);
};

/**
 * [1], [2] ... を <span data-citation="1"><span>1</span></span> に変換する remark プラグイン。
 * code / inlineCode / link / image などの内部はスキップ。
 */
export function remarkCitations(options?: RemarkCitationsOptions) {
  // 外部から g なし RegExp が来ても必ず global 化
  const base = options?.pattern ?? /\[(\d+)\]/g;
  const pattern = base.global ? base : new RegExp(base.source, base.flags + 'g');
  const dataAttrName = options?.dataAttrName ?? 'data-citation';

  const EXCLUDED = new Set<string>([
    'link',
    'linkReference',
    'definition',
    'image',
    'imageReference',
    'code',
    'inlineCode',
    'html',
    'math',
    'inlineMath',
    'footnoteReference',
    'footnoteDefinition',
  ]);

  return function transformer(tree: Root) {
    visitParents(tree, 'text', (node: Text, ancestors: Node[]) => {
      // 除外ノードの配下なら変換しない
      if (ancestors.some((a) => EXCLUDED.has((a as { type: string }).type))) return;

      const value = node.value;
      const matches = [...value.matchAll(pattern)];
      if (matches.length === 0) return;

      const parentNode = ancestors[ancestors.length - 1];
      if (!parentNode || !isParent(parentNode)) return;

      const result = matches.reduce(
        (acc, m) => {
          const start = m.index ?? 0;
          const end = start + m[0].length;
          const num = m[1];

          if (start > acc.cursor) {
            acc.parts.push(text(value.slice(acc.cursor, start)));
          }

          const outer: PhrasingContent = makeSpan(
            { [dataAttrName]: num, role: 'doc-noteref' },
            [makeSpan({}, [text(num)])]
          );

          acc.parts.push(outer);
          acc.cursor = end;
          return acc;
        },
        { parts: [] as PhrasingContent[], cursor: 0 }
      );

      if (result.cursor < value.length) {
        result.parts.push(text(value.slice(result.cursor)));
      }

      const idx = parentNode.children.findIndex((c) => c === node);
      if (idx === -1) return;

      parentNode.children = [
        ...parentNode.children.slice(0, idx),
        ...result.parts,
        ...parentNode.children.slice(idx + 1),
      ];
    });
  };
}
スタイル調整

Tailwind で見た目を変える場合は [data-citation] 選択子で追加スタイルを当ててください。

// 例: data 属性でセレクタを当てる
<Streamdown
  className={clsx([
    "max-w-none",
    "[&_[data-citation]]:align-baseline",
    "[&_[data-citation]]:mx-0.5",
  ]}
  /* ... */
>
  {content}
</Streamdown>

セキュリティ機能

Streamdownは、harden-react-markdownを基盤としています。

主なセキュリティ機能と注意点

  • 生HTMLの無効化 - デフォルトでは生HTMLはレンダリングされず、文字として表示されます
  • URLプレフィックス制限 - デフォルト値は['*'](全許可)なので本番環境では必ず制限
  • XSS防止 - javascript:などの危険なURLスキームを自動的にブロック

本番環境での推奨設定

import { Streamdown } from 'streamdown';

function SecureContent({ content, origin = 'https://example.com' }) {
  return (
    <Streamdown
      // 必須: URLスキームを制限
      allowedLinkPrefixes={['https:', 'mailto:', '/']}  // '/'で相対URLも許可
      allowedImagePrefixes={['https:', 'data:']}
      components={{
        // 追加のドメインチェック(オプション)
        a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {}
      }}
      {content}
    </Streamdown>
  );
}

生HTMLサポートについて

Streamdownはセキュリティファーストの設計により、デフォルトでは生HTMLはレンダリングされず、文字として表示されます。これは標準的なreact-markdownと同じ動作で、harden-react-markdownを基盤としてセキュリティが強化されています。

生HTMLをレンダリングしたい場合は、skipHtml={false}を設定し、rehypePluginsプロパティにrehype-rawプラグインを追加します。

import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeKatex from 'rehype-katex';

// 生HTMLを有効化
<Streamdown
  skipHtml={false}  // 生HTMLを許可
  rehypePlugins={[
    rehypeRaw,      // 生HTMLを処理
    rehypeSanitize, // セキュリティのためのサニタイズ
    rehypeKatex     // 数式レンダリング(既定プラグインの再指定)
  ]}
>
  {`これは<mark>ハイライト</mark>されたテキストです`}
</Streamdown>

ポイント

  • rehypePlugins配列を指定すると既定のプラグイン(rehypeKatex)が完全に置き換えられます(追加ではなく置換)
  • 数式レンダリングを維持したい場合は、rehypeKatexを明示的に再指定してください
  • skipHtml={false}の設定が生HTML処理には必須です
  • AI生成コンテンツで生HTMLを扱う場合は、XSS攻撃のリスクを考慮し、必ずrehype-sanitizeも併用してください

まとめ

  • 通信はSSE(Server-Sent Events)=サーバ→ブラウザに一方向で流し続ける仕組み。HTTPのまま使えるのでシンプルで堅牢。
  • サーバは Vercel AI SDK でSSEを出す(streamText → toUIMessageStreamResponse)。
  • フロントは AI Elements / useChat がSSEをそのまま受け取り、messages と status を自動で適切に更新。
  • 表示は Streamdown が未完成のMarkdownでも崩さず描画。data-streamdown で見た目の微調整も簡単。

導入は「MarkdownをStreamdownへ置換 → toUIMessageStreamResponse によるSSE配線 → 会話UIの適用」という順に進めると移行しやすく、後続のモデル変更や機能追加にも対応しやすくなります。

以上です!

chot Inc. tech blog

Discussion