AI

LLM出力をTypeScriptで型安全に:Zodスキーマバリデーション実装パターン

Claude APIを使ってJSONを返させる実装は、ローカルでは問題なく動いていても、本番環境でフィールドの欠落やデータ型の不一致が原因のランタイムエラーに悩まされることがあります。LLM出力を「たぶん合っているだろう」で扱うのは、静的型付けのない時代のJavaScriptと同じリスクを抱えています。本記事では、TypeScriptとZodを組み合わせてLLM出力を型安全に扱う実践的なパターンを解説します。

LLM出力がスキーマに従わない理由

LLMは与えられたプロンプトに対して確率的にトークンを生成します。「必ずJSON形式で返してください」と明示しても、モデルがスキーマに100%準拠することは保証されません。

よくある失敗パターンを挙げてみましょう。

  • フィールド名の揺れuserId のつもりが user_idid になる
  • 型の不一致:数値を期待しているフィールドに文字列 "42" が来る
  • 必須フィールドの欠落:モデルが不要と判断してフィールドを省略する
  • 余分なフィールドの混入:プロンプトで触れなかったキーが追加される
  • JSONの構文エラー:長い出力でトークン上限に達し、途中で切れる

AnthropicのTool UseやJSON modeを活用するとリスクは下がりますが、ゼロにはなりません。フィールドの型や値の範囲まで保証する仕組みは、現時点ではAPIレベルに存在しないためです。

TypeScriptの型チェックがLLM出力に効かない理由

TypeScriptの型システムはコンパイル時にしか機能しません。APIレスポンスやLLM出力のような外部データは、ランタイムでは unknown または any として扱われます。

// 型アサーションは「コンパイラへの約束」であってランタイムの保証ではない
const response = await anthropic.messages.create({ ... });
const parsed = JSON.parse(response.content[0].text) as MySchema; // ← 危険
console.log(parsed.userId.toUpperCase()); // userIdがundefinedなら即クラッシュ

as MySchema というキャストはコンパイラを黙らせるだけで、実際のデータ構造をチェックしません。JSON.parse が成功しても、中身が期待通りかどうかはまったく別問題です。

LLM出力を型安全に扱うには「TypeScriptの型情報」と「ランタイムバリデーション」の両方が必要です。

ZodによるLLM出力のバリデーション

スキーマ定義とバリデーションの基本

Zod はスキーマ定義からTypeScriptの型を自動生成し、ランタイムバリデーションも同時に行えるライブラリです。エラーメッセージが詳細でデバッグしやすい点も、LLM出力との相性が良い理由のひとつです。

import { z } from "zod";

const ProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(200),
  price: z.number().positive(),
  category: z.enum(["electronics", "clothing", "food"]),
  tags: z.array(z.string()).optional().default([]),
});

// スキーマからTypeScript型を導出(二重管理不要)
type Product = z.infer<typeof ProductSchema>;

LLMレスポンスを受け取るラッパー関数

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

async function extractProduct(userInput: string): Promise<Product> {
  const message = await client.messages.create({
    model: "claude-opus-4-5",
    max_tokens: 1024,
    messages: [
      {
        role: "user",
        content: `以下のテキストから商品情報をJSON形式で抽出してください。
フィールド: id(UUID), name, price(数値), category(electronics/clothing/food), tags(配列)

テキスト: ${userInput}

JSONのみを返してください。`,
      },
    ],
  });

  const rawText = message.content[0].type === "text" ? message.content[0].text : "";

  // モデルが ```json ... ``` で囲むケースにも対応
  const jsonMatch = rawText.match(/```json\s*([\s\S]*?)\s*```/)
    ?? rawText.match(/({[\s\S]*})/);

  if (!jsonMatch) {
    throw new Error("レスポンスにJSONが含まれていません");
  }

  const parsed = JSON.parse(jsonMatch[1]);

  // バリデーション失敗時はZodErrorをスロー
  return ProductSchema.parse(parsed);
}

例外を使いたくない場合は safeParse を使うと、結果をオブジェクトで受け取れます。

const result = ProductSchema.safeParse(parsed);

if (!result.success) {
  console.error("バリデーション失敗:", result.error.flatten());
  // result.error.issues で各フィールドのエラー詳細を確認できる
} else {
  const product = result.data; // ここではProduct型が確定している
}

バリデーション失敗時のリトライ戦略

LLM出力のバリデーション失敗は一過性のことも多く、リトライが有効です。重要なのは「何が失敗したか」をモデルにフィードバックすることです。

import { ZodError } from "zod";

async function extractProductWithRetry(
  userInput: string,
  maxRetries = 2
): Promise<Product> {
  let lastError: ZodError | null = null;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const errorContext =
      lastError && attempt > 0
        ? `\n前回の出力はバリデーションエラーでした: ${JSON.stringify(lastError.flatten())}\n修正してください。`
        : "";

    const message = await client.messages.create({
      model: "claude-opus-4-5",
      max_tokens: 1024,
      messages: [
        {
          role: "user",
          content: `商品情報をJSON形式で抽出してください。${errorContext}
テキスト: ${userInput}`,
        },
      ],
    });

    const rawText =
      message.content[0].type === "text" ? message.content[0].text : "";

    try {
      const jsonMatch = rawText.match(/({[\s\S]*})/);
      if (!jsonMatch) throw new Error("JSONが見つかりません");

      const parsed = JSON.parse(jsonMatch[1]);
      return ProductSchema.parse(parsed);
    } catch (e) {
      if (e instanceof ZodError) {
        lastError = e;
        continue; // ZodエラーならZodErrorの詳細を次のプロンプトへ
      }
      throw e; // JSONパースエラーなど他のエラーは即スロー
    }
  }

  throw new Error(`${maxRetries + 1}回試行しましたがバリデーションを通過できませんでした`);
}

フォールバックとして「部分的に有効なデータだけ使う」という選択肢もあります。各フィールドを .optional() にしたゆるめのスキーマで受け取り、必須フィールドだけ別途チェックするアプローチで、ユーザー体験を損なわない範囲で段階的に対応できます。

スキーマ設計の実践的なポイント

バリデーション失敗のログを記録する

本番で突然エラーが起きてから原因を調べるのでは遅すぎます。開発段階からバリデーション失敗を構造化して記録しておくと、モデルの出力傾向や特定プロンプトとの相関が見えてきます。

type ValidationLog = {
  timestamp: string;
  prompt: string;
  rawOutput: string;
  errors: z.ZodIssue[];
  attempt: number;
};

function logValidationFailure(
  prompt: string,
  rawOutput: string,
  error: ZodError,
  attempt: number
) {
  const log: ValidationLog = {
    timestamp: new Date().toISOString(),
    prompt,
    rawOutput,
    errors: error.issues,
    attempt,
  };

  if (process.env.NODE_ENV === "development") {
    console.warn("[ValidationFailure]", JSON.stringify(log, null, 2));
  }
}

ログを集計すると「price フィールドで文字列が返るケースが30%」のような傾向が分かります。その場合は z.coerce.number() で文字列から数値への変換を許容するのが現実的な対応です。スキーマを厳格にするか変換を許容するかのトレードオフは、ログデータを見ながら判断しましょう。

strict と strip の使い分け

Zodはデフォルト(strip モード)で未知のフィールドを除去します。strict() にすると未知のフィールドで例外になるため、LLMが余分なフィールドを返しやすいケースでは strip の方が現実的です。

const ProductSchema = z.object({ ... });         // strip(デフォルト): 余分なフィールドを無視
const StrictProductSchema = z.object({ ... }).strict(); // 未知フィールドでエラー

スキーマはモジュールレベルで定義する

スキーマをリクエストのたびに z.object({...}) で再生成するコードをよく見かけますが、モジュールレベルで一度定義しておくのが基本です。ホットパスでは特に意識しておきましょう。

また、バリデーションと後続の変換処理は分離しておくと可読性が上がります。

// バリデーションのみのスキーマ
const RawProductSchema = z.object({ ... });

// 変換はドメイン層で行う
function toProductEntity(raw: z.infer<typeof RawProductSchema>): ProductEntity {
  return { ...raw, displayPrice: `¥${raw.price.toLocaleString()}` };
}

型チェックとランタイムバリデーションの両輪を整えることで、LLM出力を組み込んだシステムの信頼性は大きく向上します。まずは最もクリティカルな出力から safeParse を導入し、ログを取りながらスキーマを育てていくアプローチが、現場で無理なく続けられる方法です。