Vercel AI SDKでClaudeストリーミングエージェントを作る

Vercel AI SDKでClaudeストリーミングエージェントを作る

Vercel AI SDK v6 + @ai-sdk/anthropicでNext.js App RouterからClaudeストリーミングチャットとツール呼び出しエージェントを実装する実践ガイド。streamText、generateObject、ツールループパターンをコードで学ぶ。

const result = streamText({
  model: anthropic('claude-sonnet-4-6'),
  prompt: 'こんにちは、ストリーミングテストです',
});

for await (const chunk of result.textStream) {
  process.stdout.write(chunk);
}

このコードを初めて実行したときの感覚が意外と複雑だった。Claudeからテキストが一文字ずつ出力されることが驚きではなく、これが5行で実現できるという事実に戸惑った。Anthropic SDKを直接使う場合と比べると、設定コードが半分以下だ。

Vercel AI SDKを初めて知ったのは、社内のSlackで誰かがシェアしたリンクだった。「Next.jsでAIチャットを10分で」という類いのタイトルだったが、実際にやってみると依存パッケージのインストール段階でつまずくことが多い。半信半疑で試してみたら、実際に速かった。それ以来、プロトタイピング時によく使うようになった。

Vercel AI SDKを本格的に使ってみると、長所と短所がはっきりしていた。この記事では「どう使うか」を説明しながら、詰まった部分も正直に書く。すでに使っている方は「ツール呼び出し」と「本番環境での考慮事項」のセクションから読むといいだろう。

なぜVercel AI SDKなのか — 他の選択肢と直接比較

他の方法を先に試した。Anthropic SDKの直接使用、LangChain.js、そしてVercel AI SDK。

Anthropic SDKの直接使用は最も柔軟だが、ストリーミングレスポンスをフロントエンドに送るボイラープレートが予想より多い。SSEフォーマット処理、フロントエンドフックの実装、エラー処理まですべて手書きが必要だ。機能自体はシンプルなのに、コード行数が不必要に増える。

// Anthropic SDKを直接使う場合のストリーミング設定 — これより長くなる
const stream = await anthropic.messages.stream({
  model: 'claude-sonnet-4-6',
  max_tokens: 1024,
  messages: [{ role: 'user', content: prompt }],
});

// SSEレスポンスを手動で構成
const encoder = new TextEncoder();
const readable = new ReadableStream({
  async start(controller) {
    for await (const chunk of stream) {
      if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk.delta.text)}\n\n`));
      }
    }
    controller.close();
  },
});

これをフロントでパースするコードも加えると、かなりの量になる。細かい制御が必要な場合はこれが正しい方法だが、チャットアプリ一つ作るにはオーバーヘッドが大きすぎる。

LangChain.jsは使ってみて途中でやめた。バージョン間でAPIの変更が多く、ドキュメントが実際の動作と違うケースが何度もあった。GitHubのIssueを調べると「この機能は削除されました」という回答がよく出てくる。複雑なパイプラインには合うかもしれないが、素早いプロトタイピングには向いていない。

Vercel AI SDKの実質的なメリットは三つだ:

第一に、streamText() + useChat() の組み合わせで、サーバー・クライアント間のストリーミング接続が10行以内で完成する。第二に、Claude以外にもOpenAI、Gemini、Mistralへの切り替えがproviderの一行変更でできる。これは思ったより便利で、同じコードでモデル間の結果を比較できる。第三に、generateObject() でZodスキーマベースの構造化出力処理がすっきりしている。

デメリットもある。Vercelプラットフォームに最適化されているため、他のデプロイ環境では制約が生じる。エージェントループの細かい制御が必要な場合、Anthropic SDKを直接使うより拡張性が落ちる部分がある。この点については後で具体的に説明する。

Claude Managed Agentsを直接構築する方法と比べると、Managed Agentsはインフラなしで始めやすいが、カスタマイズの限界が明確だった。Vercel AI SDKはその中間にある — 直接実装より抽象化されていて、Managed Agentsより制御権が多い。

環境設定 — パッケージインストールから

前提条件

  • Node.js 20+
  • Anthropic APIキー(ANTHROPIC_API_KEY
  • Next.js 15(App Router)
# 新規プロジェクト
npx create-next-app@latest my-claude-app --typescript --app
cd my-claude-app

# AISDKコアパッケージをインストール
npm install ai @ai-sdk/anthropic zod

.env.localにAPIキーを追加する:

ANTHROPIC_API_KEY=sk-ant-api03-...

実際に試したとき、@ai-sdk/anthropicのインストールは問題なかったが、TypeScriptの型エラーが一つ出た。tsconfig.jsonmoduleResolutionbundlerまたはnode16以上である必要がある。create-next-appで作成した場合はデフォルト設定で解決されている。

ディレクトリ構造はこのように設定した:

app/
├── api/
│   ├── chat/
│   │   └── route.ts      # ストリーミングチャットAPI
│   └── extract/
│       └── route.ts      # generateObject API
├── page.tsx              # チャットUI
└── components/
    └── Message.tsx

複雑に見えないのが正しい。実際にこの構造で十分機能するチャットアプリが完成する。

streamTextでClaudeストリーミングを実装する

サーバーサイドのAPIルートを先に作る。

app/api/chat/route.ts

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

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

  const result = streamText({
    model: anthropic('claude-sonnet-4-6'),
    system: `あなたは親切な技術ブログアシスタントです。
コードの質問に実用的に回答し、わからないことは正直に言います。
回答は簡潔に保ちながら、重要な内容は漏らしません。`,
    messages,
    maxTokens: 2048,
    temperature: 0.7,
  });

  return result.toUIMessageStreamResponse();
}

toUIMessageStreamResponse()がポイントだ。このメソッド一つがSSEヘッダー設定、チャンクフォーマット、ストリーム終了処理をすべて担う。Anthropic SDKを直接使う場合にこの部分を実装すると20行は必要になる。

フロントエンド app/page.tsx

'use client';

import { useChat } from 'ai/react';
import { useEffect, useRef } from 'react';

export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
    api: '/api/chat',
  });
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
      <div className="flex-1 overflow-y-auto space-y-4 pb-4">
        {messages.length === 0 && (
          <p className="text-gray-400 text-center mt-8">
            何かお手伝いできますか?
          </p>
        )}
        {messages.map((m) => (
          <div
            key={m.id}
            className={`p-3 rounded-lg max-w-[85%] ${
              m.role === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'
            }`}
          >
            <p className="text-xs text-gray-400 mb-1">
              {m.role === 'user' ? '自分' : 'Claude'}
            </p>
            <div className="whitespace-pre-wrap text-sm">
              {m.content as string}
            </div>
          </div>
        ))}
        {isLoading && (
          <div className="bg-gray-100 p-3 rounded-lg text-gray-400 text-sm">
            Claudeが入力中...
          </div>
        )}
        {error && (
          <div className="text-red-500 text-sm p-2 bg-red-50 rounded">
            エラー: {error.message}
          </div>
        )}
        <div ref={bottomRef} />
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2 mt-4 border-t pt-4">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="メッセージを入力してください..."
          className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400"
          disabled={isLoading}
        />
        <button
          type="submit"
          disabled={isLoading || !input.trim()}
          className="bg-blue-500 text-white px-6 py-2 rounded-lg disabled:opacity-50"
        >
          送信
        </button>
      </form>
    </div>
  );
}

useChatがメッセージ状態管理、ストリーミング更新、ローディング状態、エラー処理をすべて担う。直接実装するとuseStateuseRefAbortController、SSEパース、リトライロジックなど相当な量になる。

npm run devを実行するとlocalhost:3000でチャットが動く。Claudeがタイピングするようにテキストが流れてくる。

ツール呼び出し — Claudeに実際に何かをさせる

チャット以上のことをさせたい場合、toolsオプションとmaxStepsを追加する。

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

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

  const result = streamText({
    model: anthropic('claude-sonnet-4-6'),
    system: '天気情報とToDoリストを管理するアシスタントです。',
    messages,
    maxSteps: 5,
    tools: {
      getWeather: tool({
        description: '特定の都市の現在の天気を取得します',
        parameters: z.object({
          city: z.string().describe('天気を取得する都市名'),
          unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
        }),
        execute: async ({ city, unit }) => {
          // 実際の実装では天気APIを呼び出す
          return {
            city,
            temperature: unit === 'celsius' ? 22 : 72,
            condition: '晴れ',
            humidity: 65,
            feelsLike: unit === 'celsius' ? 20 : 68,
          };
        },
      }),
      addTodo: tool({
        description: 'ToDoリストに新しい項目を追加します',
        parameters: z.object({
          title: z.string().describe('ToDoのタイトル'),
          priority: z.enum(['low', 'medium', 'high']).default('medium'),
          dueDate: z.string().optional().describe('期限(YYYY-MM-DD)'),
        }),
        execute: async ({ title, priority, dueDate }) => {
          const id = Math.random().toString(36).slice(2);
          return { id, title, priority, dueDate, created: new Date().toISOString() };
        },
      }),
    },
  });

  return result.toUIMessageStreamResponse();
}

maxSteps: 5が重要だ。ツール呼び出しの結果を受け取った後、Claudeが再び応答を生成するループを回すために必要だ。設定しないと、ツール呼び出し結果を受け取ってもClaudeがそれ以上応答を生成しない。

AIエージェントが複数のツールを組み合わせて作業するパターンは、maxSteps設定と各ツールのdescriptionの品質に大きく依存する。ツールの説明が曖昧だと、Claudeがいつどのツールを使うべきか判断できない。実際に最初は天気とToDoが混乱するケースがあったが、システムプロンプトに各ツールの使用シナリオを明記したら安定した。

フロントエンドでツール呼び出しの進行状況をリアルタイムで表示することもできる:

{messages.map((m) => (
  <div key={m.id}>
    {m.role === 'assistant' &&
      Array.isArray(m.toolInvocations) &&
      m.toolInvocations.map((ti) => (
        <div key={ti.toolCallId} className="text-xs text-gray-400 italic mb-1">
          {ti.state === 'call' && `⚙ ${ti.toolName} 呼び出し中...`}
          {ti.state === 'result' && `✓ ${ti.toolName} 完了`}
        </div>
      ))
    }
    <div className="text-sm">{m.content as string}</div>
  </div>
))}

generateObjectで構造化出力を抽出する

ストリーミングチャットとは別に、Claudeのレスポンスから特定の構造のデータを抽出する場合はgenerateObject()を使う。

// app/api/extract/route.ts
import { generateObject } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';

const ArticleMetaSchema = z.object({
  title: z.string().describe('記事タイトル(60字以内)'),
  summary: z.string().max(300).describe('3〜4文の要約'),
  tags: z.array(z.string()).min(2).max(5).describe('関連技術タグ'),
  difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
  estimatedReadTime: z.number().int().describe('推定読了時間(分)'),
  hasCodeExamples: z.boolean(),
  mainTopics: z.array(z.string()).max(3).describe('主要トピック3つ以内'),
});

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

  try {
    const { object } = await generateObject({
      model: anthropic('claude-sonnet-4-6'),
      schema: ArticleMetaSchema,
      prompt: `以下の技術ブログ記事を分析し、メタデータを抽出してください。
主な読者はバックエンド/フルスタック開発者です。

---
${content}
---`,
    });

    return Response.json(object);
  } catch (error) {
    return Response.json({ error: '分析失敗' }, { status: 500 });
  }
}

レスポンスはZodスキーマに合わせて型が保証されたオブジェクトとして返ってくる。JSONパースエラーや型不一致を別途処理する必要がない。

このパターンが適している場面:

  • ブログ記事の自動タグ付けとメタデータ生成
  • ユーザー入力の分類
  • 長いドキュメントからの構造化情報抽出
  • フォームの自動入力

このブログのカテゴリスコア抽出で実際に同様のパターンを使っている。Zodスキーマにdescribe()をしっかり書くことが出力品質を上げるための鍵だ。コンテキストエンジニアリングをきちんと適用すれば、スキーマ設計とプロンプト品質が抽出精度の80%を決める。

本番環境で遭遇した問題

ある程度使っていると、いくつかの制約が出てくる。

Edgeランタイムの制限

Vercel Edge Functionsで動かすと、Node.js専用パッケージが使えない。@ai-sdk/anthropicはEdgeで動くが、ツール関数内でNode.js専用パッケージをインポートするとデプロイ時にエラーになる。

// route.tsの先頭に明示的に宣言
export const runtime = 'nodejs'; // EdgeではなくNode.jsランタイムを使用

ほとんどの場合、runtime = 'nodejs'にしておくのが楽だ。

サーバーレスのタイムアウト

Vercel無料プランでサーバーレス関数のタイムアウトは10秒だ。Claudeが長いテキストを生成したり、複雑なツールループを回したりすると超過することがある。Proプラン以上なら60秒まで延びる。

これより長い作業が必要なら構造自体を変える必要がある。MCPサーバーを別途構築して長時間実行タスクを分離する方法が一つの代替案だ。

コンテキストの累積コスト

会話が長くなるにつれて、コンテキストにすべてのメッセージ履歴が入るためトークンコストが急増する。streamTextの結果から使用量を確認できる:

const result = streamText({ ... });

result.usage.then((usage) => {
  const inputCost = (usage.promptTokens / 1_000_000) * 3.0;
  const outputCost = (usage.completionTokens / 1_000_000) * 15.0;
  console.log(`トークン: 入力 ${usage.promptTokens}, 出力 ${usage.completionTokens}`);
  console.log(`コスト: $${(inputCost + outputCost).toFixed(5)}`);
});

実際のサービスではコンテキスト管理戦略が必要だ。最も単純な方法は最近のN件だけ保持すること:

// 最近10ターンだけ渡す
const recentMessages = messages.slice(-20);

const result = streamText({
  model: anthropic('claude-sonnet-4-6'),
  messages: recentMessages,
});

レートリミット

Anthropic APIのレートリミットに引っかかると429 Too Many Requestsエラーが出る。複数ユーザーが同時に使う環境ではリクエストキューやバックオフロジックが必要だ。aiパッケージ自体にリトライロジックはないため、直接実装するかミドルウェアが必要になる。

どんな状況で使うべきか

Vercel AI SDKが向いている場合:

  • Next.jsベースでAIチャット機能を素早く追加したいとき
  • Claude、OpenAI、Geminiなど複数のモデルを同じコードでテストしたいとき
  • useChatフックでフロントの状態管理を最小化したいとき
  • Vercelにデプロイ予定で、タイムアウト制限が問題にならない規模のとき

使わない方がいい場合:

  • エージェントループの細かい動作を完全に制御する必要があるとき — Anthropic SDKの直接使用が良い
  • Pythonバックエンドと連携する必要があるとき — このSDKはTypeScript専用だ
  • ユーザーごとに数十ターン以上の長期会話が基本となるサービス — コンテキスト管理戦略が必須

個人的には、新しいAI機能アイデアを検証するスピードが速いという点でよく使う。アイデアを30分以内に動くプロトタイプにできることは確かなメリットだ。ただし、本番環境レベルになると細かい制御が必要な部分が必ず出てくる。そのときはAnthropic SDKを直接使うか、このSDKの上にレイヤーを追加する選択が必要になる。

Vercel AI SDKは利便性と柔軟性のトレードオフを選択した。そのトレードオフが多くのユースケースに合うが、すべてに合うわけではない。「これで始めて、必要なら変える」という姿勢が現実的だ。


次に試したいのはAI SDK 6で追加されたhuman-in-the-loopのツール承認フローだ。エージェントが特定のツールを呼び出す前に人間が承認できる構造で、これが実際に本番環境でどれほど信頼できるかはまだ確認が必要だ。完全自律エージェントと手動作業の中間点を見つけることが、2026年のエージェント開発の核心課題の一つだ。

他の言語で読む

この記事は役に立ちましたか?

より良いコンテンツを作成するための力になります。コーヒー一杯で応援してください。

著者について

jw

Kim Jangwook

AI/LLM専門フルスタック開発者

10年以上のWeb開発経験を活かし、AIエージェントシステム、LLMアプリケーション、自動化ソリューションを構築しています。Claude Code、MCP、RAGシステムの実践的な知見を共有します。

ブログリストへ