テクノロジー

構造化出力で挙動を安定させる:JSONスキーマ活用の実践

管理者2026.06.11 公開 ・ 18 min read
構造化出力で挙動を安定させる:JSONスキーマ活用の実践

01はじめに

LLMを使ったシステムを本番運用していると、ある日突然パースエラーが発生することがあります。 昨日まで動いていたのに、今日は JSON.parse が例外を投げる。 そういった経験が1度でもあれば、この記事は役に立てると思います。

この記事の対象読者

  • LLMの出力をJSONとしてパースし、後続処理に渡す実装をしている方
  • 本番でパースエラーが出て困っている方
  • 「とりあえず動く」から「安定して動く」に移行したい方

TypeScript(Node.js v20以降 / Next.js 14以降)を前提にコードを書きます。 使用するライブラリのバージョンは各セクションで明記します。

この記事で得られること

  • なぜ自由文パースが壊れるのか、その根本的な理由
  • OpenAI / Anthropic SDK のスキーマ強制機能(Structured Outputs / tool use)の使い方
  • Zod によるバリデーション層の組み方
  • 失敗時のリトライ戦略と実装パターン
  • 3層をまとめた実践的なラッパーの設計

TL;DR

  1. スキーマ強制: APIレベルで構造を強制し、自由文の混入を防ぐ
  2. バリデーション: Zodで型安全に検証し、アプリ側の型と一致させる
  3. リトライ: 失敗時にエラー内容をフィードバックして再生成させる

この3層を組み合わせると、LLMの出力起因のランタイムエラーをほぼゼロにできます(筆者の環境での経験則です)。


02なぜ自由文パースは壊れるのか

LLMの出力は確率的です

LLMは毎回同じ入力を与えても、同じ出力を返す保証がありません。 温度パラメータ(temperature)が0でも、モデルのバージョン更新やサンプリングの実装差で出力が変わることがあります。なお、GPT-5系(gpt-5.5等)ではサンプリングパラメータが原則指定できず、reasoning_effort 等で挙動を制御します。

よくある壊れ方を列挙すると、次のようなパターンになります。

# パターン1: コードブロックで囲まれる
```json
{"name": "Alice", "age": 30}

パターン2: 余分な説明文が前後に付く

以下がJSONです: {"name": "Alice", "age": 30} これがあなたの求めていた形式です。

パターン3: 末尾カンマや不正な構文

{"name": "Alice", "age": 30,}

パターン4: 数値が文字列になる

{"name": "Alice", "age": "30"}

パターン5: フィールドが欠落する

{"name": "Alice"}


これらはすべてプロンプトで「JSONのみを返してください」と指示しても発生します。
指示を守らないのではなく、確率的にそうなってしまう場合があるのです。

### 正規表現で抽出する実装は脆いです

よく見かける応急処置として、次のような実装があります。

```typescript
// 壊れやすい実装の例
function extractJson(text: string): unknown {
  const match = text.match(/\{[\s\S]*\}/);
  if (!match) throw new Error("JSON not found");
  return JSON.parse(match[0]);
}

これは多くのケースで動きますが、ネストされたオブジェクトや配列を含む場合に最外側のブレースのマッチがずれることがあります。 また、JSONが見つかっても型が期待通りとは限りません。

根本的な解決策は、「そもそもLLMに自由文を返させない」か、「返ってきても型安全に検証する」かのどちらかです。


03第1層:スキーマ強制(Structured Outputs / tool use)

OpenAI の Structured Outputs

2024年8月にOpenAIがリリースした Structured Outputs は、response_format にJSONスキーマを渡すことで、モデルが原則としてそのスキーマに沿ったJSONを返すよう強制する機能です(2026年6月時点、gpt-5.5 / gpt-5.4-mini 系列で利用可能)。ただし、コンテンツフィルターによる拒否(refusal)や出力長の上限超過などの場合はスキーマ準拠の出力が得られないことがあります。

// openai@4.x を使用
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";

const client = new OpenAI();

// 期待する出力のスキーマを定義します
const UserProfileSchema = z.object({
  name: z.string().describe("ユーザーの氏名"),
  age: z.number().int().min(0).max(150).describe("年齢(整数)"),
  occupation: z.string().describe("職業"),
  skills: z.array(z.string()).describe("スキルのリスト"),
});

type UserProfile = z.infer<typeof UserProfileSchema>;

async function extractUserProfile(text: string): Promise<UserProfile> {
  const response = await client.chat.completions.parse({
    model: "gpt-5.5",
    messages: [
      {
        role: "system",
        content: "テキストからユーザー情報を抽出してください。",
      },
      {
        role: "user",
        content: text,
      },
    ],
    response_format: zodResponseFormat(UserProfileSchema, "user_profile"),
  });

  const parsed = response.choices[0].message.parsed;
  if (!parsed) {
    throw new Error("モデルが出力を拒否しました(content_filter等)");
  }

  return parsed;
}

// 実行例
const result = await extractUserProfile(
  "田中太郎、32歳、ソフトウェアエンジニア。TypeScriptとRustが得意です。"
);
console.log(result);

実行結果は以下の通りです。

{
  "name": "田中太郎",
  "age": 32,
  "occupation": "ソフトウェアエンジニア",
  "skills": ["TypeScript", "Rust"]
}

client.chat.completions.parse を使うと、SDKが自動的にJSONをパースし、スキーマの型として返してくれます。 response.choices[0].message.parsedUserProfile | null 型になり、コンテンツフィルターなどで出力が拒否された場合に null になります。

Anthropic の tool use でスキーマ強制する

AnthropicのAPIでも、tool use(function calling相当)を使ってスキーマ準拠の出力を強制できます。また2026年時点では strict: true を指定したツール呼び出しによるスキーマ強制(Strict tool use)も提供されています(2026年6月時点、claude-sonnet-4-6 系列で動作確認)。

// @anthropic-ai/sdk@0.24.x を使用
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

const client = new Anthropic();

const UserProfileSchema = z.object({
  name: z.string(),
  age: z.number().int().min(0).max(150),
  occupation: z.string(),
  skills: z.array(z.string()),
});

type UserProfile = z.infer<typeof UserProfileSchema>;

async function extractUserProfileClaude(text: string): Promise<UserProfile> {
  const jsonSchema = zodToJsonSchema(UserProfileSchema, {
    name: "UserProfile",
    nameStrategy: "title",
  });

  const response = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    tools: [
      {
        name: "extract_user_profile",
        description: "テキストからユーザー情報を抽出します",
        // zod-to-json-schema で変換したスキーマを渡します
        input_schema: (jsonSchema as { definitions: unknown; $schema: unknown })
          .definitions
          ? (jsonSchema as { definitions: { UserProfile: unknown } })
              .definitions.UserProfile
          : (jsonSchema as Anthropic.Tool["input_schema"]),
      },
    ],
    // tool_choice を指定してツール呼び出しを強制します
    tool_choice: { type: "tool", name: "extract_user_profile" },
    messages: [
      {
        role: "user",
        content: `以下のテキストからユーザー情報を抽出してください:\n\n${text}`,
      },
    ],
  });

  const toolUse = response.content.find((block) => block.type === "tool_use");
  if (!toolUse || toolUse.type !== "tool_use") {
    throw new Error("ツール呼び出しが見つかりませんでした");
  }

  return UserProfileSchema.parse(toolUse.input);
}

:::note 上記の model は執筆時点(2026年6月)のモデルIDです。最新のモデルIDはAnthropic公式のモデル一覧をご確認ください。 :::

tool_choice: { type: "tool", name: "..." } を指定することで、モデルが必ずそのツールを呼び出すよう強制します。 これにより、テキスト応答の混入を防げます。

ツールを使わない場合の最低限の対策

何らかの理由でtool useを使えない場合(コスト最適化のためにキャッシュを優先するなど)は、プロンプトでの指示に加えて、出力テキストからJSONを抽出する処理を堅牢にする工夫が有効です。

/**
 * テキストからJSONオブジェクトを抽出します。
 * コードブロック記法への対応と、複数のJSONが含まれる場合の最初のオブジェクト抽出を行います。
 */
function extractJsonSafe(text: string): unknown {
  // コードブロックを除去します
  const stripped = text
    .replace(/```json\s*/gi, "")
    .replace(/```\s*/g, "")
    .trim();

  // JSONオブジェクトまたは配列を探します
  // ネストに対応するため、最初の { または [ から対応する閉じ括弧まで抽出します
  const firstBrace = stripped.search(/[{[]/);
  if (firstBrace === -1) {
    throw new Error("JSONが見つかりませんでした");
  }

  const openChar = stripped[firstBrace];
  const closeChar = openChar === "{" ? "}" : "]";
  let depth = 0;
  let endIndex = -1;
  let inString = false;
  let escape = false;

  for (let i = firstBrace; i < stripped.length; i++) {
    const char = stripped[i];

    if (escape) {
      escape = false;
      continue;
    }

    if (char === "\\") {
      escape = true;
      continue;
    }

    if (char === '"') {
      inString = !inString;
      continue;
    }

    if (!inString) {
      if (char === openChar) depth++;
      else if (char === closeChar) {
        depth--;
        if (depth === 0) {
          endIndex = i;
          break;
        }
      }
    }
  }

  if (endIndex === -1) {
    throw new Error("JSONの終端が見つかりませんでした");
  }

  const jsonStr = stripped.slice(firstBrace, endIndex + 1);
  return JSON.parse(jsonStr);
}

ただし、この実装はあくまで補助的な手段です。 第1層のスキーマ強制と組み合わせて使うか、スキーマ強制が使えない箇所に限定して適用することをお勧めします。


04第2層:バリデーション(Zod によるスキーマ検証)

なぜスキーマ強制だけでは不十分なのか

スキーマ強制はJSONとして正しい形式を保証しますが、ビジネスロジック上の制約までは保証しません。

たとえば次のようなケースが考えられます。

  • age-5999 のような非現実的な値になる
  • email フィールドがメールアドレスの形式でない
  • 必須のネストフィールドが空文字列になる
  • 列挙型フィールドに定義外の文字列が入る

Zodを使うと、これらの制約をコードで表現できます。

Zod スキーマの設計パターン

// zod@3.x を使用
import { z } from "zod";

// 基本的な型制約の例
const ContactSchema = z.object({
  name: z.string().min(1, "名前は必須です").max(100),
  email: z.string().email("有効なメールアドレスを入力してください"),
  phone: z
    .string()
    .regex(/^[0-9\-+() ]+$/, "電話番号の形式が不正です")
    .optional(),
  age: z.number().int().min(0).max(150),
  role: z.enum(["admin", "editor", "viewer"]),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

// ネストしたオブジェクトの例
const OrderSchema = z.object({
  orderId: z.string().uuid(),
  customer: z.object({
    id: z.string(),
    name: z.string().min(1),
  }),
  items: z
    .array(
      z.object({
        productId: z.string(),
        quantity: z.number().int().positive(),
        unitPrice: z.number().positive(),
      })
    )
    .min(1, "注文には最低1つの商品が必要です"),
  totalAmount: z.number().positive(),
  status: z.enum(["pending", "processing", "shipped", "delivered", "cancelled"]),
  createdAt: z.string().datetime(),
});

type Order = z.infer<typeof OrderSchema>;

バリデーションエラーを構造化して扱う

Zodのバリデーションエラーは、z.ZodError として詳細な情報を持っています。 これをLLMへのフィードバックとして使うのが、第3層のリトライにつながります。

import { z } from "zod";

/**
 * LLMの出力をバリデーションし、エラーを構造化して返します。
 */
function validateLlmOutput<T>(
  schema: z.ZodSchema<T>,
  rawOutput: unknown
):
  | { success: true; data: T }
  | { success: false; errors: Array<{ path: string; message: string }> } {
  const result = schema.safeParse(rawOutput);

  if (result.success) {
    return { success: true, data: result.data };
  }

  // ZodErrorを人間が読みやすい形式に変換します
  const errors = result.error.issues.map((issue) => ({
    path: issue.path.join(".") || "(root)",
    message: issue.message,
  }));

  return { success: false, errors };
}

// 使用例
const rawOutput = {
  name: "田中太郎",
  age: -5, // 不正な値
  email: "not-an-email", // 不正な形式
};

const result = validateLlmOutput(ContactSchema, rawOutput);

if (!result.success) {
  console.log(result.errors);
}

実行結果は以下の通りです。

[
  {
    "path": "age",
    "message": "Number must be greater than or equal to 0"
  },
  {
    "path": "email",
    "message": "有効なメールアドレスを入力してください"
  }
]

このエラー情報を次のリクエストのプロンプトに埋め込むことで、LLMに修正を促せます。

partial スキーマで段階的バリデーションする

エージェントが複数ターンで情報を収集するシステムでは、途中段階のオブジェクトは必然的に不完全です。 Zodの .partial() を活用すると、収集済みのフィールドだけを検証できます。

// FullSchema の全フィールドをオプショナルにした部分スキーマ
const PartialOrderSchema = OrderSchema.partial();
type PartialOrder = z.infer<typeof PartialOrderSchema>;

// 収集済みデータのバリデーション(型のチェックのみ)
function validatePartialOrder(data: unknown): PartialOrder {
  return PartialOrderSchema.parse(data);
}

// 完全なデータのバリデーション(全フィールドの存在と型をチェック)
function validateCompleteOrder(data: unknown): Order {
  return OrderSchema.parse(data);
}

(関連記事: 成果物ドリブン設計:完成形から逆算して対話を組む)


05第3層:リトライ(失敗時の再生成)

リトライの基本設計

スキーマ強制とバリデーションを通過できなかった場合、最終手段はリトライです。 ただし、ただリトライするだけでは同じエラーを繰り返す可能性が高いです。

有効なアプローチは、「なぜ失敗したか」をLLMにフィードバックして再生成させることです。

import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";

const client = new OpenAI();

interface RetryOptions {
  maxAttempts?: number;
  onRetry?: (attempt: number, errors: Array<{ path: string; message: string }>) => void;
}

/**
 * バリデーションエラーをフィードバックしながら再生成します。
 */
async function generateWithRetry<T>(
  schema: z.ZodSchema<T>,
  schemaName: string,
  messages: OpenAI.Chat.ChatCompletionMessageParam[],
  options: RetryOptions = {}
): Promise<T> {
  const { maxAttempts = 3, onRetry } = options;

  const conversationMessages = [...messages];

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const response = await client.chat.completions.parse({
      model: "gpt-5.5",
      messages: conversationMessages,
      response_format: zodResponseFormat(schema, schemaName),
    });

    const rawOutput = response.choices[0].message.parsed;

    // コンテンツフィルターなどで出力が拒否された場合
    if (rawOutput === null) {
      if (attempt === maxAttempts) {
        throw new Error(`${maxAttempts}回試みましたが出力を生成できませんでした`);
      }
      continue;
    }

    // Zodで追加バリデーションを実行します
    const validationResult = schema.safeParse(rawOutput);

    if (validationResult.success) {
      return validationResult.data;
    }

    // バリデーション失敗: エラーを整形してフィードバックします
    const errors = validationResult.error.issues.map((issue) => ({
      path: issue.path.join(".") || "(root)",
      message: issue.message,
    }));

    if (attempt === maxAttempts) {
      throw new Error(
        `バリデーションに失敗しました(${maxAttempts}回試行):\n` +
          errors.map((e) => `  - ${e.path}: ${e.message}`).join("\n")
      );
    }

    onRetry?.(attempt, errors);

    // エラー内容を会話に追加して次のリクエストに引き継ぎます
    const errorFeedback = errors
      .map((e) => `- フィールド "${e.path}": ${e.message}`)
      .join("\n");

    conversationMessages.push({
      role: "assistant",
      // アシスタントの応答として前回の出力を追加します
      content: JSON.stringify(rawOutput),
    });

    conversationMessages.push({
      role: "user",
      content: `出力にバリデーションエラーがありました。以下の問題を修正して再度出力してください:\n\n${errorFeedback}`,
    });
  }

  // ここには到達しませんが、TypeScriptの型チェックのために記述します
  throw new Error("想定外のエラー");
}

リトライの実行例

const ContactSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
});

type Contact = z.infer<typeof ContactSchema>;

const result = await generateWithRetry(
  ContactSchema,
  "contact",
  [
    {
      role: "system",
      content: "ユーザーの入力から連絡先情報を抽出してください。",
    },
    {
      role: "user",
      content: "山田花子、35歳、メールはhanako at example.com",
    },
  ],
  {
    onRetry: (attempt, errors) => {
      console.log(`試行 ${attempt} 失敗。エラー:`, errors);
    },
  }
);

console.log(result);

実行結果(1回目でバリデーションが通った場合)は以下の通りです。

{
  "name": "山田花子",
  "age": 35,
  "email": "hanako@example.com"
}

hanako at example.com という自然言語のメールアドレス表記を、LLMが hanako@example.com に変換してくれる場合がほとんどです。 もし変換に失敗した場合は、バリデーションエラーのフィードバックを受けて再試行します。

リトライ回数の設計指針

リトライ回数は多ければ良いわけではありません。 次の観点で設計することをお勧めします。

状況 推奨リトライ数 理由
スキーマが単純(フラットなオブジェクト) 2〜3回 1回目でほぼ修正できることが多いです
スキーマが複雑(深いネスト・多数の制約) 3〜5回 複数の問題が一度に解決されないことがあります
コスト敏感なワークフロー 1〜2回 3回目以降は改善率が下がる傾向があります
ユーザーを待たせる対話システム 2回 レイテンシとのトレードオフを考慮します

また、同じエラーが連続して発生している場合は、スキーマの定義やプロンプトに問題がある可能性があります。 リトライで解決しようとするより、根本原因を調べる方が生産的なことが多いです。


063層を統合した実践的なラッパー

設計の概要

ここまで解説した3層をまとめたラッパーを設計します。 次の図は処理フローを示しています。

flowchart TD
    IN["入力メッセージ"]
    L1["第1層: スキーマ強制\nStructured Outputs / tool use\nAPIレベルでJSON形式を強制"]
    L2["第2層: バリデーション\nZod\nZodで型安全に検証"]
    CHECK{"バリデーション成功?"}
    L3["第3層: リトライ\nエラーフィードバック\n最大N回まで繰り返す"]
    LIMIT{"リトライ上限到達?"}
    ERR["エラーをスロー"]
    OUT["型付きオブジェクト(完了)"]

    IN --> L1
    L1 -->|"JSONオブジェクト"| L2
    L2 --> CHECK
    CHECK -->|"Yes"| OUT
    CHECK -->|"No"| L3
    L3 --> LIMIT
    LIMIT -->|"Yes"| ERR
    LIMIT -->|"No(再試行)"| L1

ラッパーの実装

// lib/llm-structured.ts
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";

export interface StructuredLlmOptions<T> {
  schema: z.ZodSchema<T>;
  schemaName: string;
  model?: string;
  maxRetries?: number;
  onRetry?: (
    attempt: number,
    errors: Array<{ path: string; message: string }>
  ) => void;
}

export interface StructuredLlmResult<T> {
  data: T;
  attempts: number;
}

const defaultClient = new OpenAI();

/**
 * 3層(スキーマ強制・バリデーション・リトライ)を統合したLLM呼び出しラッパーです。
 *
 * @param messages OpenAI形式のメッセージ配列
 * @param options スキーマ・モデル・リトライ設定
 * @returns バリデーション済みの型付きオブジェクトと試行回数
 */
export async function callStructured<T>(
  messages: OpenAI.Chat.ChatCompletionMessageParam[],
  options: StructuredLlmOptions<T>,
  client = defaultClient
): Promise<StructuredLlmResult<T>> {
  const {
    schema,
    schemaName,
    model = "gpt-5.5",
    maxRetries = 3,
    onRetry,
  } = options;

  const conversationMessages = [...messages];
  let attempts = 0;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    attempts = attempt;

    // 第1層: API レベルのスキーマ強制
    // openai SDK の parse() は length / content_filter 終了時に例外を投げます
    let parsedMessage: Awaited<ReturnType<typeof client.chat.completions.parse>>["choices"][0]["message"];
    try {
      const response = await client.chat.completions.parse({
        model,
        messages: conversationMessages,
        response_format: zodResponseFormat(schema, schemaName),
      });
      parsedMessage = response.choices[0].message;
    } catch (err) {
      // LengthFinishReasonError: max_completion_tokens 超過
      if (err instanceof OpenAI.LengthFinishReasonError) {
        throw new Error(
          "max_completion_tokens に達しました。出力が途中で切れています。max_completion_tokens を増やすか、スキーマを簡略化してください。"
        );
      }
      throw err;
    }

    const rawOutput = parsedMessage.parsed;

    // コンテンツフィルター等による出力拒否
    if (rawOutput === null) {
      if (attempt >= maxRetries) {
        throw new Error(
          `出力が生成されませんでした(コンテンツフィルターまたは拒否応答)`
        );
      }
      continue;
    }

    // 第2層: Zod バリデーション
    const validationResult = schema.safeParse(rawOutput);

    if (validationResult.success) {
      return { data: validationResult.data, attempts };
    }

    const errors = validationResult.error.issues.map((issue) => ({
      path: issue.path.join(".") || "(root)",
      message: issue.message,
    }));

    if (attempt >= maxRetries) {
      const errorDetails = errors
        .map((e) => `  ${e.path}: ${e.message}`)
        .join("\n");
      throw new Error(
        `バリデーションエラーが解消されませんでした(${maxRetries}回試行):\n${errorDetails}`
      );
    }

    // 第3層: エラーフィードバック付きリトライ
    onRetry?.(attempt, errors);

    const errorFeedback = errors
      .map((e) => `- "${e.path}": ${e.message}`)
      .join("\n");

    conversationMessages.push({
      role: "assistant",
      content: JSON.stringify(rawOutput),
    });

    conversationMessages.push({
      role: "user",
      content: [
        "出力にバリデーションエラーがありました。",
        "以下の問題をすべて修正して、再度出力してください。",
        "",
        errorFeedback,
      ].join("\n"),
    });
  }

  // コンパイラの網羅性チェックのためにここは到達しません
  throw new Error("想定外のコードパスに到達しました");
}

使い方の例

// 使用例: ユーザーの問い合わせを構造化データとして抽出します
import { callStructured } from "./lib/llm-structured";
import { z } from "zod";

const InquirySchema = z.object({
  category: z.enum(["billing", "technical", "general", "cancellation"]),
  urgency: z.enum(["low", "medium", "high"]),
  summary: z.string().min(10).max(200),
  contactEmail: z.string().email().nullable(),
  productName: z.string().nullable(),
});

type Inquiry = z.infer<typeof InquirySchema>;

async function classifyInquiry(userMessage: string): Promise<Inquiry> {
  const { data, attempts } = await callStructured(
    [
      {
        role: "system",
        content: [
          "お客様からの問い合わせを分類し、構造化データとして返してください。",
          "urgencyの判断基準: high=当日対応が必要, medium=3日以内, low=それ以外",
        ].join("\n"),
      },
      { role: "user", content: userMessage },
    ],
    {
      schema: InquirySchema,
      schemaName: "inquiry",
      maxRetries: 3,
      onRetry: (attempt, errors) => {
        console.warn(`[classifyInquiry] リトライ ${attempt}:`, errors);
      },
    }
  );

  console.log(`試行回数: ${attempts}`);
  return data;
}

// 実行例
const inquiry = await classifyInquiry(
  "先月から請求が二重に来ています。至急確認してください。メールはuser@example.com です。"
);

console.log(JSON.stringify(inquiry, null, 2));

実行結果は以下の通りです。

{
  "category": "billing",
  "urgency": "high",
  "summary": "先月から請求が二重に発生している問題。至急確認が必要。",
  "contactEmail": "user@example.com",
  "productName": null
}

productNamenull になっていますが、Zodスキーマで .nullable() を指定しているため問題ありません。 nullundefined の両方を許容したい場合は .nullish() を使うと明示的に扱えます。


07より複雑なケースへの対応

ユニオン型のスキーマ

LLMの出力が複数のスキーマのいずれかになりうる場合、Zodの z.discriminatedUnion が便利です。

import { z } from "zod";

// 応答のタイプをフィールドで判別するユニオン型
const LlmResponseSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("answer"),
    content: z.string().min(1),
    confidence: z.number().min(0).max(1),
    sources: z.array(z.string().url()).optional(),
  }),
  z.object({
    type: z.literal("clarification"),
    question: z.string().min(1),
    missingInfo: z.array(z.string()),
  }),
  z.object({
    type: z.literal("rejection"),
    reason: z.enum(["out_of_scope", "ambiguous", "insufficient_context"]),
    suggestion: z.string().optional(),
  }),
]);

type LlmResponse = z.infer<typeof LlmResponseSchema>;

// 型ガードで安全に各ケースを処理します
function handleLlmResponse(response: LlmResponse): void {
  switch (response.type) {
    case "answer":
      console.log("回答:", response.content);
      console.log("信頼度:", response.confidence);
      break;
    case "clarification":
      console.log("確認が必要:", response.question);
      console.log("不足情報:", response.missingInfo);
      break;
    case "rejection":
      console.log("処理できませんでした。理由:", response.reason);
      break;
  }
}

ストリーミング出力との組み合わせ

ストリーミングで出力を受け取りつつ、完了後にバリデーションするパターンです。 UX改善(「処理中...」の表示)とデータの信頼性を両立させたい場合に使います。

import OpenAI from "openai";
import { z } from "zod";

const client = new OpenAI();

const AnalysisSchema = z.object({
  sentiment: z.enum(["positive", "neutral", "negative"]),
  keywords: z.array(z.string()).min(1).max(10),
  summary: z.string().min(20).max(500),
});

type Analysis = z.infer<typeof AnalysisSchema>;

async function analyzeWithStreaming(text: string): Promise<Analysis> {
  let accumulatedContent = "";

  // ストリーミングで出力を受け取ります
  const stream = await client.chat.completions.create({
    model: "gpt-5.5",
    messages: [
      {
        role: "system",
        content: "テキストを分析し、JSON形式で返してください。",
      },
      { role: "user", content: text },
    ],
    response_format: { type: "json_object" },
    stream: true,
  });

  for await (const chunk of stream) {
    const delta = chunk.choices[0]?.delta?.content ?? "";
    accumulatedContent += delta;

    // ストリーミング中の進捗表示などをここで行えます
    process.stdout.write(delta);
  }

  console.log(); // 改行

  // 完了後にパースとバリデーションを実行します
  const parsed = JSON.parse(accumulatedContent);
  return AnalysisSchema.parse(parsed);
}

ストリーミング中は response_format: { type: "json_object" } を使い、完了後に Zod でバリデーションします。 Structured Outputs の client.chat.completions.parse はストリーミング版(client.chat.completions.stream)も提供されているため、最新のSDKドキュメントをご確認ください。

大規模な配列を含む出力

LLMに多数の要素を含む配列を返させると、途中で出力が切れることがあります。 その場合は、分割して取得する戦略が有効です。

import { callStructured } from "./lib/llm-structured";
import { z } from "zod";

const TagBatchSchema = z.object({
  tags: z.array(z.string().min(1).max(50)).min(1).max(10),
  hasMore: z.boolean(),
  nextBatchHint: z.string().optional(),
});

/**
 * 長いテキストからタグを分割して抽出します。
 * 一度に大量のタグを生成させると出力が途切れることがあるため、
 * バッチに分けて取得します。
 */
async function extractTagsInBatches(
  document: string,
  maxTotalTags = 30
): Promise<string[]> {
  const allTags: string[] = [];
  let hasMore = true;
  let hint = "";

  while (hasMore && allTags.length < maxTotalTags) {
    const remaining = maxTotalTags - allTags.length;
    const { data } = await callStructured(
      [
        {
          role: "system",
          content: [
            "文書からタグを抽出してください。",
            `既に抽出済みのタグ: ${allTags.join(", ") || "なし"}`,
            `追加で最大 ${Math.min(remaining, 10)} 個のタグを返してください。`,
            `まだ抽出できるタグがある場合は hasMore を true にしてください。`,
            hint ? `前回のヒント: ${hint}` : "",
          ]
            .filter(Boolean)
            .join("\n"),
        },
        { role: "user", content: document },
      ],
      {
        schema: TagBatchSchema,
        schemaName: "tag_batch",
      }
    );

    allTags.push(...data.tags);
    hasMore = data.hasMore && allTags.length < maxTotalTags;
    hint = data.nextBatchHint ?? "";
  }

  return [...new Set(allTags)]; // 重複を除去します
}

08筆者が実際に直面したトラブルと対処法

トラブル1: スキーマが複雑すぎてLLMが従えない

スキーマのネストが深かったり、制約が多すぎると、LLMが正確に従えないことがあります。 筆者が経験したのは、10層以上ネストした型に20以上の .refine() を組み合わせたケースです。 リトライを繰り返しても同じエラーが出続けました。

対処法は次の2つです。

  1. スキーマを単純化する: まず最小限のフィールドと制約で動かし、徐々に追加する
  2. 複数回に分けて取得する: 一度のリクエストで全フィールドを埋めようとせず、フェーズを分ける

BizPlan(事業計画エージェント)の設計でも、最初は100フィールドの大きなスキーマで動かそうとしたところ、安定しませんでした。 7〜10フィールド程度のスキーマに分割して段階的に取得する設計に切り替えると、安定度が大きく向上しました。 ただし、適切な分割数はスキーマの内容や使用するモデルにも依存するため、ご自身の環境でお試しください。

トラブル2: z.string().email() がモデルバージョンで挙動が変わる

2025年初頭頃、特定のモデルバージョンで contact@example.co.jp のような .co.jp ドメインが email() バリデーションをパスしなくなる事象を経験しました(正確な原因は不明で、ライブラリバージョンの変更の可能性もあります)。

教訓は「バリデーションが厳しすぎると、正しいデータを弾いてしまう」ことです。 メールアドレスのような表記揺れが生じやすいフィールドでは、厳密なバリデーションと緩やかなバリデーションを組み合わせることをお勧めします。

// 実用的なメールバリデーションの例
// z.string().email() は正規表現ベースの検証をします
// 必要に応じてカスタム検証を追加できます
const EmailSchema = z
  .string()
  .min(3)
  .max(254) // RFC 5321 の上限
  .regex(
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
    "メールアドレスの形式が不正です(user@example.com の形式で入力してください)"
  );

トラブル3: リトライ中にAPIレート制限に引っかかる

3回リトライするとAPIリクエストが最大4回になります(初回 + 3回のリトライ)。 レート制限が厳しい環境では、リトライ間に待機を挟む必要があります。

// エクスポネンシャルバックオフ付きのリトライ
async function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// リトライオプションにdelayを追加した例(実装は省略しています)
interface RetryOptionsWithDelay extends RetryOptions {
  baseDelayMs?: number; // 初回のリトライ待機時間(ミリ秒)
  backoffFactor?: number; // 待機時間の倍率
}

// attempt=1 → 500ms, attempt=2 → 1000ms, attempt=3 → 2000ms
const delayMs = baseDelayMs * Math.pow(backoffFactor, attempt - 1);
await sleep(delayMs);

09Anthropic SDK でのラッパー実装

OpenAI SDKに合わせて設計したラッパーをAnthropic用に対応させる例を示します。

// lib/llm-structured-anthropic.ts
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

const client = new Anthropic();

export async function callStructuredClaude<T>(
  userMessage: string,
  schema: z.ZodSchema<T>,
  toolName: string,
  toolDescription: string,
  options: {
    model?: string;
    maxRetries?: number;
    systemPrompt?: string;
  } = {}
): Promise<T> {
  const {
    model = "claude-sonnet-4-6",
    maxRetries = 3,
    systemPrompt,
  } = options;

  // Zod スキーマを JSON Schema に変換します
  const rawSchema = zodToJsonSchema(schema, "output");
  // $schema や definitions を除いた input_schema 用のオブジェクトを準備します
  const inputSchema =
    "definitions" in rawSchema && rawSchema.definitions
      ? (rawSchema.definitions as Record<string, unknown>)["output"]
      : rawSchema;

  const conversationMessages: Anthropic.MessageParam[] = [
    { role: "user", content: userMessage },
  ];

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const response = await client.messages.create({
      model,
      max_tokens: 4096,
      system: systemPrompt,
      tools: [
        {
          name: toolName,
          description: toolDescription,
          input_schema: inputSchema as Anthropic.Tool["input_schema"],
        },
      ],
      tool_choice: { type: "tool", name: toolName },
      messages: conversationMessages,
    });

    const toolUse = response.content.find(
      (block): block is Anthropic.ToolUseBlock => block.type === "tool_use"
    );

    if (!toolUse) {
      if (attempt >= maxRetries) {
        throw new Error("ツール呼び出しが返されませんでした");
      }
      continue;
    }

    const validationResult = schema.safeParse(toolUse.input);

    if (validationResult.success) {
      return validationResult.data;
    }

    if (attempt >= maxRetries) {
      const errors = validationResult.error.issues.map(
        (i) => `  ${i.path.join(".")}: ${i.message}`
      );
      throw new Error(
        `バリデーション失敗(${maxRetries}回試行):\n${errors.join("\n")}`
      );
    }

    const errors = validationResult.error.issues
      .map((i) => `- ${i.path.join(".")}: ${i.message}`)
      .join("\n");

    // Anthropic の会話履歴にツール呼び出し結果とフィードバックを追加します
    conversationMessages.push({
      role: "assistant",
      content: response.content,
    });

    conversationMessages.push({
      role: "user",
      content: [
        {
          type: "tool_result",
          tool_use_id: toolUse.id,
          content: `バリデーションエラー:\n${errors}\n\n修正して再度呼び出してください。`,
          is_error: true,
        },
      ],
    });
  }

  throw new Error("想定外のコードパスに到達しました");
}

10まとめ

LLMの出力を安定させるには、単一の対策だけでは不十分です。 スキーマ強制・バリデーション・リトライの3層を組み合わせることで、本番環境でも耐えられる実装になります。

各層の役割を整理すると次の通りです。

目的 主なツール
スキーマ強制 JSON形式自体を保証する OpenAI Structured Outputs / Anthropic tool use
バリデーション 型安全と制約の検証 Zod
リトライ 失敗時の自己修正 エラーフィードバックを添えた再リクエスト

筆者が実装してみて感じたのは、「3層のうちどれか1つを外すと、必ずどこかで壊れる」ということです。 特にスキーマ強制なしのバリデーションだけの実装は、正常系では動くように見えても、モデルのバージョンアップや入力の揺れで突然壊れます。

逆に言えば、この3層を一度作っておけば、スキーマを入れ替えるだけで様々なユースケースに転用できます。 まずは今の実装のどこが弱いかを確認し、一番壊れやすい箇所から補強していくのが現実的だと思います。


11参考文献

  • OpenAI Structured Outputs ドキュメント (platform.openai.com/docs/guides/structured-outputs, 2026年6月時点)
  • Anthropic Tool Use ドキュメント (docs.anthropic.com/en/docs/build-with-claude/tool-use, 2026年6月時点)
  • Zod 公式ドキュメント (zod.dev, v3系, 2026年6月時点)
  • zod-to-json-schema README (github.com/StefanTerdell/zod-to-json-schema, 2026年6月時点)
  • openai npm パッケージ v4系 リリースノート (github.com/openai/openai-node, 2026年6月時点)
Author
管理者
Agent Store

記事で紹介した技術を、実際の業務でお試しください。

業務に合うエージェントを条件で絞り込んで選べます。すべて無料で、今すぐ利用できます。

エージェント一覧を見る →

コメント

まだコメントはありません。最初のコメントを投稿してみましょう。

コメントを投稿

ゲストコメントは管理者の承認後に公開されます。 ログインするとすぐにコメントが公開されます。

当サイトではCookieを使用しています。詳しくはCookieポリシーをご覧ください。