「LLMを叩くだけなら fetch で十分だ」——最初はそう思っていました。実際、デモを作るだけなら各プロバイダのSDKを直接呼べば動きます。しかし、プロバイダを乗り換えたくなった瞬間、ストリーミングUIをフロントに繋ぐ瞬間、ツール呼び出しでエージェント化する瞬間、LLMの出力を型として信頼したい瞬間——このどれかで、手書きの薄いラッパーは破綻します。
私はこれまで、LangChain + Pinecone のRAGプラットフォーム、Bedrock × pgvector の音声AI接客エージェント、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 と Vercel 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 をデフォルトにします。理由は次の通りです(公式 の機能と一致)。
- 1つの鍵で数百モデル。
AI_GATEWAY_API_KEYひとつで OpenAI / Anthropic / Google などを横断できる。 - プロバイダ非依存。
modelを文字列1行で差し替えられる。ベンダーロックインを構造的に避けられる。 - 自動フォールバック。あるプロバイダが落ちたら別プロバイダへ自動リトライ(信頼性 §7)。
- 支出の可観測性。プロバイダ横断でトークン・コストを観測できる。
- トークンに上乗せなし。プロバイダ直契約と同コスト(公式 明記)。
// .env.local — Vercel本番ではOIDCで自動認証されるため不要。ローカル/他基盤ではこれを使う
// AI_GATEWAY_API_KEY=your_api_key_here
// 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)。上の例は2026-06時点の最新Claudeに合わせています。
では、いつプロバイダ直叩きにするか。 プロバイダ固有のプレビュー機能(特定のベータAPIや、Gateway未対応の最新フラグ)を即日使いたい時だけです。その場合は @ai-sdk/anthropic のような専用プロバイダパッケージを使い、model: anthropic("claude-...") と書きます。ただしこれは例外運用。デフォルトはGateway文字列、と決めておくと設計がぶれません。
2. generateText と streamText:いつストリームするか
テキスト生成の入口は2つだけ。generateText(完成形を一括で返す)と streamText(トークンを逐次流す)です。両方 ai パッケージからインポートします(公式: 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 にそのまま置けます。
// 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)。古い記事のコピペが最も事故るポイントです。
2-3. Route Handler でのストリーミング(streamText)
ユーザーが画面で読む応答は streamText で逐次返します。App Router の Route Handler では、結果をそのままレスポンスに変換できます。
// 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)。スキーマが**唯一の真実(SSoT)**になり、型・検証・プロンプト指示が一本化されます。
3-1. Output.object:単一オブジェクトの抽出
// 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
配列の抽出や、固定選択肢への分類はそれぞれ専用の出力モードがあります。
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の記事で型番誤りを潰した発想と同じです。
ストリーミングで構造化結果を逐次描きたい場合は
streamText+outputでpartialOutputStreamを使います。フォームのプレビューを生成しながら埋める、といったUXに向きます。
4. tool calling とエージェント:いつモデルに道具を渡すか
LLMに外部世界へのアクセス(DB検索・API呼び出し・計算)を与えるのが tool calling です。v6では tool() ヘルパーで定義します。引数スキーマは inputSchema(v6で parameters から改名)で、ここでもZodがSSoTです(Tools and Tool Calling)。
4-1. tool() の定義
// 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 ではなくこれ)。
// 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側まで型を伝播できます(型安全エージェント)。ツールを 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)。
- 入力state は自前管理になった(
input/handleInputChange/handleSubmitは廃止)。 - API指定は
transport: new DefaultChatTransport({ api })経由になった。 - メッセージは
contentではなくparts配列(テキスト・ツール呼び出しなどの混在)。
// 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)。
// 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システム と pgvectorによる音声AIのRAG設計 に書いています。本記事の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 は障害時に別プロバイダへ自動リトライします(公式)。これが「統一SDK + Gateway」を選ぶ最大の実利のひとつ。直叩きだと、このフォールバックを全部自前で書くことになります。
- 冪等性:生成結果でDB更新するなら冪等キーで重複を吸収。LLM呼び出しはネットワーク越しで普通に失敗・重複します。この設計思想は音声AIの記事で詳述しています。
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行で。
- 接続は AI Gateway +
"provider/model"文字列。プロバイダ非依存・自動フォールバック・支出観測がデフォルトで効く。鍵はサーバーのみ。 - 人間が読む応答は
streamText、機械が次に使う結果はgenerateText。v6ではmaxOutputTokens/stopWhen。 - 構造化出力は
Output.object+ Zod で parse する。スキーマがSSoT。asキャストは禁じ手。 - ツールは
tool({ inputSchema, execute })、エージェントはstopWhen: stepCountIs(n)。ただし静的に書ける分岐は決定的コードに寄せるのが速度・コスト・信頼性の最適解。 - コスト(モデルルーティング)・信頼性(タイムアウト/フォールバック)・セキュリティ(鍵/インジェクション)・eval を最初から設計に織り込む。
私は Python / AWS で生成AIの本番システムを複数作ってきました(RAGプラットフォーム・音声AI接客・AI動画ローカライズ・放送局の社内マルチAI基盤)。その「デモと本番の距離」を埋める設計を、TypeScript / Vercel エコシステムへ移植したのが本記事です。
「一人 × 生成AI(Claude Code)で、速く・安く・安全に」LLM機能を既存プロダクトへ統合したい——そのご相談は、関連事例の生成AI音声チャットボットをご覧のうえ、お問い合わせからどうぞ。要件に対して過剰な技術を使わない判断軸ごと、設計をご提供します。