# Vercel AI SDK v6で本番LLMアプリを作る：ストリーミング・tool calling・構造化出力・RAGを実コードで

> TypeScriptで本番品質のLLMアプリを作るための実務ガイド。Vercel AI SDK v6 と AI Gateway を軸に、generateText/streamText、Zodスキーマによる構造化出力、tool callingとエージェント、useChatのストリーミングUI、embed/embedManyによるRAG、そしてコスト・信頼性・セキュリティ・可観測性まで、動くコードと判断軸で解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: TypeScript, RAG, Next.js, Vercel, AI, Claude, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/vercel-ai-sdk-production-llm-apps-streaming-tools-rag

## 要点

- 新規の本番LLMアプリは AI Gateway をデフォルトにし、provider/model 文字列でプロバイダ非依存・自動フォールバックを得る
- 人間が読む長文応答は streamText、機械が次に使う結果は generateText で、v6では maxOutputTokens / stopWhen を使う
- 構造化出力は Output.object＋Zod で parse し、as キャストは禁じ手。スキーマが型・検証・指示の SSoT になる
- tool は inputSchema＋execute で定義するが、静的に書ける分岐は決定的コードに寄せる方が速く安く確実
- コスト（モデルルーティング）・信頼性（タイムアウト/フォールバック）・セキュリティ（鍵/インジェクション）・eval を最初から設計に織り込む

---

「LLMを叩くだけなら `fetch` で十分だ」——最初はそう思っていました。実際、デモを作るだけなら各プロバイダのSDKを直接呼べば動きます。しかし、**プロバイダを乗り換えたくなった瞬間**、**ストリーミングUIをフロントに繋ぐ瞬間**、**ツール呼び出しでエージェント化する瞬間**、**LLMの出力を型として信頼したい瞬間**——このどれかで、手書きの薄いラッパーは破綻します。

私はこれまで、[LangChain + Pinecone のRAGプラットフォーム](/blog/langchain-pinecone-production-rag-system)、[Bedrock × pgvector の音声AI接客エージェント](/blog/production-voice-ai-sales-agent-bedrock-pgvector)、AI動画ローカライズ基盤、放送局の社内マルチAIプラットフォームなど、**Python / AWS 系での生成AI本番システム**を設計・実装してきました。本記事は、その経験を**TypeScript / Next.js / Vercel エコシステム**の角度から書き直すものです。深いRAGの理論や音声パイプラインは上記2記事に譲り、ここでは「**Vercel AI SDKで本番LLMアプリをどう組み立てるか**」に集中します。

> 本記事の基準バージョン：**Vercel AI SDK v6**（`ai` パッケージ）。モデル接続は原則 **Vercel AI Gateway** 経由（`"provider/model"` 文字列）。コードはすべて TypeScript / App Router 前提で、実際に手を動かせる粒度で載せます。公式ドキュメントは [ai-sdk.dev](https://ai-sdk.dev/docs/introduction) と [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) を随所でリンクします。

## 0. 全体像：AI SDKの「3層」をどう組み合わせるか

Vercel AI SDK を本番で使うとは、実質この3層を設計することです。

| 層 | 役割 | 中心API | この記事の章 |
| --- | --- | --- | --- |
| **接続層** | プロバイダ・モデルへの統一接続、フォールバック、課金観測 | AI Gateway（`"provider/model"` 文字列） | §1 |
| **コア層** | テキスト生成・構造化出力・ツール呼び出し・埋め込み | `generateText` / `streamText` / `Output` / `tool` / `embed` | §2〜§4・§6 |
| **UI層** | ストリーミングするチャットUI、ツール状態の描画 | `useChat`（`@ai-sdk/react`） | §5 |

下から順に「**Gatewayで繋ぎ、Coreで考えさせ、UIで見せる**」。この流れで進めます。最後の§7・§8で、本番運用の横断的関心（コスト・信頼性・セキュリティ・可観測性）と落とし穴をまとめます。

---

## 1. なぜ統一SDK + AI Gateway なのか（プロバイダ直叩きとの比較）

最初の設計判断がここです。**プロバイダ固有SDK（`@anthropic-ai/sdk` 等）を直接使うか、AI SDK + AI Gateway を使うか。**

結論から言うと、新規の本番アプリでは **AI Gateway をデフォルト**にします。理由は次の通りです（[公式](https://vercel.com/docs/ai-gateway) の機能と一致）。

- **1つの鍵で数百モデル**。`AI_GATEWAY_API_KEY` ひとつで OpenAI / Anthropic / Google などを横断できる。
- **プロバイダ非依存**。`model` を文字列1行で差し替えられる。ベンダーロックインを構造的に避けられる。
- **自動フォールバック**。あるプロバイダが落ちたら別プロバイダへ自動リトライ（信頼性 §7）。
- **支出の可観測性**。プロバイダ横断でトークン・コストを観測できる。
- **トークンに上乗せなし**。プロバイダ直契約と同コスト（[公式](https://vercel.com/docs/ai-gateway) 明記）。

```ts
// .env.local — Vercel本番ではOIDCで自動認証されるため不要。ローカル/他基盤ではこれを使う
// AI_GATEWAY_API_KEY=your_api_key_here
```

```ts
// lib/ai/models.ts — モデルIDを1箇所に集約（DRY / 差し替え容易性）
// AI Gateway は AI SDK の「既定のグローバルプロバイダ」なので、文字列だけで繋がる
export const MODELS = {
  // 重い推論・最終回答
  reasoning: "anthropic/claude-opus-4.8",
  // 通常のチャット・要約・抽出（速度とコストのバランス）
  chat: "anthropic/claude-sonnet-4.6",
  // 分類・ルーティングなど軽量タスク（最安・最速）
  fast: "anthropic/claude-haiku-4.5",
  // RAG用の埋め込み
  embedding: "openai/text-embedding-3-small",
} as const;
```

> **モデルIDは記憶で書かない**。`curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '.data[].id'` で最新IDを確認するのが公式の作法です（[Gateway docs](https://vercel.com/docs/ai-gateway)）。上の例は2026-06時点の最新Claudeに合わせています。

**では、いつプロバイダ直叩きにするか。** プロバイダ固有のプレビュー機能（特定のベータAPIや、Gateway未対応の最新フラグ）を即日使いたい時だけです。その場合は `@ai-sdk/anthropic` のような専用プロバイダパッケージを使い、`model: anthropic("claude-...")` と書きます。ただしこれは**例外運用**。デフォルトはGateway文字列、と決めておくと設計がぶれません。

---

## 2. generateText と streamText：いつストリームするか

テキスト生成の入口は2つだけ。`generateText`（完成形を一括で返す）と `streamText`（トークンを逐次流す）です。両方 `ai` パッケージからインポートします（[公式: Generating Text](https://ai-sdk.dev/docs/ai-sdk-core/generating-text)）。

### 2-1. 判断軸：3つのAPIの使い分け

実務で迷うのは「`generateText` か `streamText` か、あるいは構造化出力か」です。先に全体像を表で固定します。

| API | 返るもの | いつ使うか | 体感速度 |
| --- | --- | --- | --- |
| `generateText` | 完成したテキスト/構造化結果を一括 | バッチ処理・サーバー内部の中間処理・短い応答・構造化抽出 | 完了まで待つ |
| `streamText` | トークンの逐次ストリーム | ユーザーが画面で読む長文応答・チャット | 最初の1文字が速い |
| `generateText` + `Output` | Zodスキーマで検証済みの**型付きオブジェクト** | LLMの出力を後続コードで型として使う時（§3） | 完了まで待つ |

原則：**人間が読む長い応答はストリーム、機械が次に使う結果は一括**。沈黙の体感は人間にだけ効きます。

### 2-2. Server Action での一括生成（generateText）

サーバー内部で完結する処理——要約してDBに保存、分類して分岐——は `generateText` が素直です。Next.js の Server Action にそのまま置けます。

```ts
// app/actions/summarize.ts
"use server";

import { generateText } from "ai";
import { z } from "zod";
import { MODELS } from "@/lib/ai/models";

// 境界での入力検証：外部入力は常にZodで絞る（CLAUDE.md準拠）
const InputSchema = z.object({ text: z.string().min(1).max(20_000) });

export async function summarize(raw: unknown): Promise<string> {
  const { text } = InputSchema.parse(raw);

  const result = await generateText({
    model: MODELS.chat,
    system: "あなたは編集者です。日本語で3文以内に要約してください。",
    prompt: text,
    maxOutputTokens: 512, // v6では maxTokens ではなく maxOutputTokens
    temperature: 0.3,
  });

  return result.text;
}
```

> v6での命名変更に注意。`maxTokens` は **`maxOutputTokens`** に、後述する `maxSteps` は **`stopWhen: stepCountIs(n)`** に変わりました（[Common Errors](https://ai-sdk.dev/docs/troubleshooting)）。古い記事のコピペが最も事故るポイントです。

### 2-3. Route Handler でのストリーミング（streamText）

ユーザーが画面で読む応答は `streamText` で逐次返します。App Router の Route Handler では、結果を**そのままレスポンスに変換**できます。

```ts
// app/api/generate/route.ts
import { streamText } from "ai";
import { z } from "zod";
import { MODELS } from "@/lib/ai/models";

export const maxDuration = 30; // Vercel Functionsのタイムアウト上限（秒）

const BodySchema = z.object({ prompt: z.string().min(1).max(4_000) });

export async function POST(req: Request) {
  // 外部入力は必ず検証してから渡す
  const parsed = BodySchema.safeParse(await req.json());
  if (!parsed.success) {
    return Response.json({ error: "invalid input" }, { status: 400 });
  }

  const result = streamText({
    model: MODELS.chat,
    prompt: parsed.data.prompt,
    // 失敗を握り潰さない：監視基盤へ送る
    onError: ({ error }) => console.error("streamText error", error),
  });

  // チャットUI(useChat)で受けるなら toUIMessageStreamResponse、
  // 素のテキストストリームでよいなら toTextStreamResponse
  return result.toTextStreamResponse();
}
```

`result.textStream` は **ReadableStream かつ AsyncIterable** なので、サーバー内で `for await (const chunk of result.textStream)` と回すこともできます。`fullStream` を使えばツール呼び出しやステップ境界を含む全イベントを観測できます。

---

## 3. 構造化出力：LLMの境界で「parse, don't validate」

ここが、手書きラッパーと決定的に差がつく機能です。**LLMの出力を `JSON.parse` して `as MyType` でキャストするのは、本番では事故のもと**。LLMはJSONを壊しますし、フィールドを足したり減らしたりします。

v6では `generateText` に **`Output`** を渡し、Zodスキーマで**検証済みの型付きオブジェクト**を得ます（旧 `generateObject` は非推奨化され、`Output` API に統合されました — [Generating Structured Data](https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data)）。スキーマが**唯一の真実（SSoT）**になり、型・検証・プロンプト指示が一本化されます。

### 3-1. Output.object：単一オブジェクトの抽出

```ts
// app/actions/extract-invoice.ts
"use server";

import { generateText, Output } from "ai";
import { z } from "zod";
import { MODELS } from "@/lib/ai/models";

// このスキーマが「型」「実行時検証」「LLMへの指示」すべての源
const Invoice = z.object({
  vendor: z.string().describe("請求元の会社名"),
  total: z.number().describe("税込合計金額（円・整数）"),
  dueDate: z.string().describe("支払期日 ISO 8601 (YYYY-MM-DD)"),
  lineItems: z.array(
    z.object({ name: z.string(), amount: z.number() }),
  ),
});

export async function extractInvoice(ocrText: string) {
  const result = await generateText({
    model: MODELS.chat,
    output: Output.object({ schema: Invoice }),
    prompt: `次のOCRテキストから請求情報を抽出してください:\n${ocrText}`,
  });

  // result.output は z.infer<typeof Invoice> として型付け＆検証済み
  const invoice = result.output;
  return invoice; // ここから先は安全に型として扱える
}
```

スキーマに `.describe()` を付けると、その説明がLLMへのフィールド指示として効きます。**プロンプトに「JSONで返して」と書く必要が消える**のが本質です。

### 3-2. Output.array と Output.choice

配列の抽出や、固定選択肢への分類はそれぞれ専用の出力モードがあります。

```ts
import { generateText, Output } from "ai";
import { z } from "zod";

// 配列：要素スキーマを element に渡す
const tasks = await generateText({
  model: MODELS.fast,
  output: Output.array({
    element: z.object({ title: z.string(), priority: z.enum(["high", "low"]) }),
  }),
  prompt: "次の議事録からToDoを抽出: ...",
});
// tasks.output: { title: string; priority: "high" | "low" }[]

// 分類：選択肢を固定（ハルシネーションで未知ラベルが出ない）
const sentiment = await generateText({
  model: MODELS.fast,
  output: Output.choice({ options: ["positive", "negative", "neutral"] as const }),
  prompt: "この声を分類: 「最高の製品でした」",
});
// sentiment.output: "positive" | "negative" | "neutral"
```

`Output.choice` は分類タスクで特に強力です。**モデルが選択肢の外を返せなくなる**ため、後続の分岐ロジックが破綻しません。これは「ハルシネーションを構造で排除する」考え方で、[音声AIの記事](/blog/production-voice-ai-sales-agent-bedrock-pgvector)で型番誤りを潰した発想と同じです。

> ストリーミングで構造化結果を逐次描きたい場合は `streamText` + `output` で `partialOutputStream` を使います。フォームのプレビューを生成しながら埋める、といったUXに向きます。

---

## 4. tool calling とエージェント：いつモデルに道具を渡すか

LLMに**外部世界へのアクセス**（DB検索・API呼び出し・計算）を与えるのが tool calling です。v6では `tool()` ヘルパーで定義します。**引数スキーマは `inputSchema`**（v6で `parameters` から改名）で、ここでもZodがSSoTです（[Tools and Tool Calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling)）。

### 4-1. tool() の定義

```ts
// lib/ai/tools/get-order.ts
import { tool } from "ai";
import { z } from "zod";
import { findOrder } from "@/lib/db/orders";

export const getOrder = tool({
  description: "注文IDから注文状況を取得する。ユーザーが配送状況を尋ねた時に使う。",
  // inputSchema が引数の型・検証・モデルへの説明を兼ねる
  inputSchema: z.object({
    orderId: z.string().describe("注文ID（例: ORD-12345）"),
  }),
  execute: async ({ orderId }) => {
    // execute の中は普通のTypeScript。ここでDB/APIに触る
    const order = await findOrder(orderId);
    if (!order) return { found: false as const };
    return { found: true as const, status: order.status, eta: order.eta };
  },
});
```

### 4-2. マルチステップ・エージェント（stopWhen）

ツールを渡して `stopWhen: stepCountIs(n)` を指定すると、モデルは「ツールを呼ぶ → 結果を読む → さらに考える」を**自律的にループ**します（v6では旧 `maxSteps` ではなくこれ）。

```ts
// app/api/agent/route.ts
import { streamText, stepCountIs, convertToModelMessages, type UIMessage } from "ai";
import { getOrder } from "@/lib/ai/tools/get-order";
import { MODELS } from "@/lib/ai/models";

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

  const result = streamText({
    model: MODELS.chat,
    system: "あなたはカスタマーサポートです。注文照会にはツールを使うこと。",
    messages: convertToModelMessages(messages),
    tools: { getOrder },
    stopWhen: stepCountIs(5), // 暴走防止のステップ上限は必須
    onStepFinish: ({ toolCalls, toolResults }) => {
      // 可観測性：どのツールをどう呼んだかを必ずログ
      console.log("step", { toolCalls, toolResults });
    },
  });

  return result.toUIMessageStreamResponse();
}
```

エージェントを構造化したい場合、v6には **`ToolLoopAgent`** クラスがあり、`InferAgentUIMessage<typeof agent>` でUI側まで型を伝播できます（[型安全エージェント](https://ai-sdk.dev/docs/agents)）。ツールを `lib/tools/`、エージェントを `lib/agents/` に分けるのが推奨構成です。

### 4-3. 判断軸：ツールを渡す vs 決定的コード

ここが設計者の腕の見せ所です。**何でもツールにすると、遅く・高く・不安定**になります。

| | ツールを渡す（LLMに判断させる） | 決定的コード（自分で書く） |
| --- | --- | --- |
| 向くケース | 入力が自然言語で、どの操作が必要か事前に決まらない | 入力も処理フローも確定している |
| 速度・コスト | ステップ毎にLLM往復（遅い・高い） | LLM呼び出しゼロ〜1回（速い・安い） |
| 信頼性 | モデル次第でブレる | 決定的・テスト容易 |
| 例 | 「先月の遅延注文を要約して」 | 注文IDが分かっている単純照会 |

原則：**分岐が静的に書けるなら、書く。** LLMには「自然言語を構造化された意図に変換する」境界部分だけ任せ、その先は決定的コードで処理する——これが速度・コスト・テスト容易性のすべてで勝ちます。`getOrder` のようなツールは便利ですが、「常に注文照会しかしない画面」ならツールを渡さず、§3の構造化出力で `orderId` を抽出して直接 `findOrder` を呼ぶ方が健全です。

---

## 5. useChat：ストリーミングUIをa11yまで含めて作る

フロントのチャットUIは `useChat`（`@ai-sdk/react`）で組みます。**v6で大きく変わった**点が2つあるので、古い知識のままだと必ず詰まります（[Chatbot](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot)）。

1. **入力state は自前管理**になった（`input` / `handleInputChange` / `handleSubmit` は廃止）。
2. API指定は **`transport: new DefaultChatTransport({ api })`** 経由になった。
3. メッセージは `content` ではなく **`parts` 配列**（テキスト・ツール呼び出しなどの混在）。

```tsx
// app/chat/chat.tsx
"use client";

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

export function Chat() {
  const [input, setInput] = useState("");
  const { messages, sendMessage, status, stop, regenerate } = useChat({
    transport: new DefaultChatTransport({ api: "/api/agent" }),
  });

  const isBusy = status === "submitted" || status === "streaming";

  function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!input.trim() || isBusy) return;
    sendMessage({ text: input });
    setInput("");
  }

  return (
    <div>
      {/* a11y: 生成テキストはライブリージョンで読み上げる */}
      <div aria-live="polite" aria-atomic="false" role="log">
        {messages.map((m) => (
          <article key={m.id} aria-label={m.role === "user" ? "あなた" : "アシスタント"}>
            {m.parts.map((part, i) => {
              if (part.type === "text") return <p key={i}>{part.text}</p>;
              // 型付きツールパート: tool-getOrder / states で安全に分岐
              if (part.type === "tool-getOrder") {
                if (part.state === "output-available") {
                  return <p key={i}>注文状況: {String(part.output.found)}</p>;
                }
                return <p key={i}>注文を照会中…</p>;
              }
              return null;
            })}
          </article>
        ))}
      </div>

      <form onSubmit={onSubmit}>
        <label htmlFor="msg" className="sr-only">メッセージ</label>
        <input
          id="msg"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          disabled={isBusy}
          aria-disabled={isBusy}
        />
        {isBusy ? (
          // 停止ボタン：長い生成を中断できることはUXとコストの両面で重要
          <button type="button" onClick={stop} aria-label="生成を停止">停止</button>
        ) : (
          <button type="submit" aria-label="送信">送信</button>
        )}
        <button type="button" onClick={() => regenerate()} disabled={isBusy}>
          再生成
        </button>
      </form>
    </div>
  );
}
```

a11yの要点を3つに絞ります。

- **ライブリージョン**：`aria-live="polite"` で囲うと、ストリームされる本文がスクリーンリーダーに逐次読み上げられます。`assertive` は割り込みが激しいので会話ログには `polite`。
- **停止ボタン**：`status` が `streaming` の間は `stop()` を出す。読み上げ中の暴走を止められ、無駄なトークン課金も止まります。
- **状態の可視化**：`status`（`submitted` / `streaming` / `ready` / `error`）でローディングとエラーを明示。`error` を握り潰さないこと。

ツールパートは `tool-{toolName}` という型付き名で届き、`state`（`input-streaming` / `input-available` / `output-available`）で段階的に描けます。`part.input` / `part.output` は対応する状態でのみ存在するため、**stateチェックなしにアクセスするとTSが弾きます**——これは安全装置として歓迎すべき挙動です。

---

## 6. RAGの要点：embed / embedMany と、深掘りは別記事へ

RAG（検索拡張生成）の本質は「**質問に関連する文書をベクトル検索し、プロンプトに注入する**」ことです。AI SDKは埋め込みAPI（`embed` / `embedMany` / `cosineSimilarity`、すべて `ai` から）を提供します（[Embeddings](https://ai-sdk.dev/docs/ai-sdk-core/embeddings)）。

```ts
// lib/ai/rag.ts
import { embed, embedMany, cosineSimilarity, generateText } from "ai";
import { MODELS } from "@/lib/ai/models";

// インデックス時：文書を一括で埋め込む（embedMany はバッチで効率的）
export async function indexDocuments(chunks: string[]) {
  const { embeddings } = await embedMany({
    model: MODELS.embedding,
    values: chunks,
  });
  // embeddings[i] を chunks[i] と一緒にベクトルDBへ保存（pgvector / Pinecone 等）
  return chunks.map((text, i) => ({ text, embedding: embeddings[i] }));
}

// 検索時：質問を埋め込み、類似文書を取る
export async function answer(question: string, store: { text: string; embedding: number[] }[]) {
  const { embedding } = await embed({ model: MODELS.embedding, value: question });

  // 本番ではベクトルDB側でANN検索する。ここは概念を示すためのインメモリ例
  const top = store
    .map((d) => ({ ...d, score: cosineSimilarity(embedding, d.embedding) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 4);

  const context = top.map((d) => d.text).join("\n---\n");
  const { text } = await generateText({
    model: MODELS.chat,
    system: "提供されたコンテキストのみを根拠に回答。無い情報は『分かりません』と答える。",
    prompt: `コンテキスト:\n${context}\n\n質問: ${question}`,
  });
  return text;
}
```

ただし**本番RAGは、この10行では終わりません**。チャンク分割戦略、メタデータフィルタ、リランキング、ハルシネーション対策、精度の定量評価、コスト試算——ここが本当の勝負どころです。私はこれを Python / LangChain / Pinecone で実装し尽くしました。**深掘りは [LangChain + Pinecone で構築するプロダクションRAGシステム](/blog/langchain-pinecone-production-rag-system) と [pgvectorによる音声AIのRAG設計](/blog/production-voice-ai-sales-agent-bedrock-pgvector) に書いています**。本記事のTypeScript実装は、その設計思想を Vercel エコシステムに移植したものだと捉えてください。

> **言語の使い分けの正直な話**：重い前処理・評価パイプライン・既存のML資産があるなら Python が依然有利。一方、**Webアプリと密結合した薄めのRAG**（社内ドキュメント検索を Next.js アプリに同居させる等）なら、TypeScript + AI SDK で一気通貫にした方が運用がシンプルです。両方やった上での判断軸です。

---

## 7. 本番運用の勘所：コスト・信頼性・セキュリティ・可観測性

ここまでが「機能」。本番に耐えるための横断的設計を、効いた順にまとめます。

### 7-1. コスト：モデルルーティングとトークン予算

最大のコスト削減は「**全部を最上位モデルで処理しない**」ことです。

- **モデルルーティング**：分類・抽出・ルーティングは `haiku`、通常応答は `sonnet`、難問だけ `opus`。§1の `MODELS` 定数がこの分岐の土台です。タスクに対して**過剰なモデルを使わない**だけで、コストは桁で変わります。
- **トークン予算**：`maxOutputTokens` で上限を必ず設定。青天井の出力は事故です。
- **プロンプトキャッシュ**：長い固定system/コンテキストは、プロバイダのプロンプトキャッシュが効くよう**可変部分を末尾に寄せる**。
- **出力をストリームして早期停止**：UIで `stop()` を提供（§5）。読まれない長文を生成し続けない。

### 7-2. 信頼性：タイムアウト・リトライ・フォールバック

- **タイムアウト**：Route Handler に `maxDuration` を設定（§2-3）。クライアントには `AbortController`。
- **フォールバック**：AI Gateway は障害時に別プロバイダへ自動リトライします（[公式](https://vercel.com/docs/ai-gateway)）。これが「統一SDK + Gateway」を選ぶ最大の実利のひとつ。直叩きだと、このフォールバックを全部自前で書くことになります。
- **冪等性**：生成結果でDB更新するなら冪等キーで重複を吸収。LLM呼び出しはネットワーク越しで普通に失敗・重複します。この設計思想は[音声AIの記事](/blog/production-voice-ai-sales-agent-bedrock-pgvector)で詳述しています。

### 7-3. セキュリティ：鍵・プロンプトインジェクション

- **APIキーをクライアントに出さない**。LLM呼び出しは**必ずサーバー（Route Handler / Server Action）から**。`AI_GATEWAY_API_KEY` はサーバー専用環境変数で、`NEXT_PUBLIC_` を付けない。ブラウザから直接プロバイダを叩く構成は鍵漏洩そのものです。
- **ユーザー入力を検証・サニタイズ**。§2・§3で示した通り、外部入力は境界で必ずZodに通す。長さ制限も忘れずに（トークン爆撃対策）。
- **プロンプトインジェクション**を前提に設計する。ユーザー入力やRAGで注入する外部文書は**信頼しない**。systemプロンプトと**明確に区切り**、ツールには「破壊的操作は確認を必須にする」等のガードを `execute` 内に置く。LLMの出力をそのまま `eval`・SQL・shellに渡すのは厳禁。**最小権限の原則**でツールの実行権限を絞ります。

### 7-4. 可観測性とeval：測れないものは直せない

- **必ずログする**：使用モデル・入出力トークン（`result.usage`）・レイテンシ・`finishReason`・どのツールを呼んだか。`onFinish` / `onStepFinish` がフックです。AI Gateway のダッシュボードでも支出を横断観測できます。
- **eval（評価）**：本番投入前に、代表的な入力セットに対する**期待出力を固定したテスト**を持つ。構造化出力（§3）はスキーマ検証がそのまま最低限のevalになります。「目視で良さそう」で出さない——これは生成AIで最も省略されがちで、最も事故る工程です。
- **テスト容易性**：`tool` の `execute` は純粋なTypeScript関数として単体テストできます。LLM呼び出し本体はモックし、**ツールのロジックと、構造化出力のスキーマを別々にテスト**するのが現実的な戦略です。

---

## 8. よくある落とし穴

実プロジェクトで踏む（踏んだ）地雷を、対策とセットで並べます。

| 落とし穴 | 何が起きるか | 対策 |
| --- | --- | --- |
| LLM出力を `as MyType` で信用 | 本番で型が崩れてクラッシュ | `Output.object` + Zod で**parse**する（§3） |
| `maxTokens` / `maxSteps` をコピペ | v6で動かない | `maxOutputTokens` / `stopWhen: stepCountIs(n)`（§2・§4） |
| APIキーをクライアントに露出 | 鍵漏洩・課金爆発 | LLM呼び出しはサーバーのみ（§7-3） |
| タイムアウト・フォールバック無し | プロバイダ障害でアプリ全停止 | `maxDuration` + Gatewayの自動フォールバック（§7-2） |
| プロンプトインジェクション無視 | 注入文書にsystemを上書きされる | 入力を信頼せず区切る・ツール権限を絞る（§7-3） |
| 何でもツールに任せる | 遅い・高い・不安定 | 静的に分岐できるなら決定的コード（§4-3） |
| eval無しで本番投入 | 「いつの間にか劣化」に気付けない | 期待出力を固定したテスト＋スキーマ検証（§7-4） |
| 旧 `useChat` の `input`/`handleSubmit` | v6で存在せずビルド不能 | 入力state自前管理 + `DefaultChatTransport`（§5） |
| 最上位モデルで全処理 | コストが桁で膨らむ | タスク別モデルルーティング（§7-1） |

---

## まとめ：AI SDKは「速く・安く・安全に」LLMアプリを組む土台

Vercel AI SDK v6 を本番で使うとは、**Gatewayで繋ぎ、Coreで考えさせ、UIで見せ、横断的な運用設計で守る**ことです。要点を5行で。

1. **接続は AI Gateway + `"provider/model"` 文字列**。プロバイダ非依存・自動フォールバック・支出観測がデフォルトで効く。鍵はサーバーのみ。
2. **人間が読む応答は `streamText`、機械が次に使う結果は `generateText`**。v6では `maxOutputTokens` / `stopWhen`。
3. **構造化出力は `Output.object` + Zod で parse する**。スキーマがSSoT。`as` キャストは禁じ手。
4. **ツールは `tool({ inputSchema, execute })`、エージェントは `stopWhen: stepCountIs(n)`**。ただし**静的に書ける分岐は決定的コードに寄せる**のが速度・コスト・信頼性の最適解。
5. **コスト（モデルルーティング）・信頼性（タイムアウト/フォールバック）・セキュリティ（鍵/インジェクション）・eval を最初から設計に織り込む**。

私は Python / AWS で生成AIの本番システムを複数作ってきました（[RAGプラットフォーム](/blog/langchain-pinecone-production-rag-system)・[音声AI接客](/blog/production-voice-ai-sales-agent-bedrock-pgvector)・AI動画ローカライズ・放送局の社内マルチAI基盤）。その「**デモと本番の距離**」を埋める設計を、TypeScript / Vercel エコシステムへ移植したのが本記事です。

「一人 × 生成AI（Claude Code）で、速く・安く・安全に」LLM機能を既存プロダクトへ統合したい——そのご相談は、関連事例の[生成AI音声チャットボット](/case-studies/ai-voice-chatbot)をご覧のうえ、[お問い合わせ](/contact)からどうぞ。要件に対して**過剰な技術を使わない**判断軸ごと、設計をご提供します。
