LLMのJSON出力が壊れる原因|Structured Outputs・Zodで安全に検証する方法

LLMのJSON出力が壊れる原因|Structured Outputs・Zodで安全に検証する方法 AI開発

LLMへ「JSON形式で回答してください」と指示しても、必ず正しいデータが返ってくるとは限りません。

JSONの前後に説明文が付く、Markdownのコードブロックで囲まれる、必要なキーが欠ける、数値が文字列になる、存在しない値が列挙型へ入るといった問題が発生します。

見た目が正しいJSONでも、アプリケーションが必要とする型や条件を満たしていない可能性があります。

特に、LLMの出力をデータベースへ保存したり、商品価格や予約日時として使用したり、別のAPIへ送信したりする場合、JSON.parse()に成功しただけで信用するのは危険です。

OpenAI APIには、指定したJSON Schemaに出力を従わせるStructured Outputsが用意されています。JavaScript・TypeScriptではZodと組み合わせることで、スキーマの定義、APIへの送信、レスポンスの解析、TypeScriptの型推論を一つの定義から行えます。

この記事では、LLMのJSON出力が壊れる原因と、OpenAI APIのStructured Outputs、ZodのsafeParse()を使って安全に扱う方法を解説します。OpenAI APIそのものの基本は【TypeScript】OpenAI API入門、Zodの基本文法は【TypeScript】Zod 完全ガイドもあわせてご覧ください。

スポンサーリンク

LLMへJSON形式を指示するだけでは不十分

次のようなプロンプトでも、期待どおりのJSONが返ってくるとは限りません。

プロンプトの例
const prompt = `
次の商品説明から情報を抽出してください。
回答はJSON形式にしてください。

商品説明:
${productText}
`;

モデルは「JSON形式」という要求を理解していても、通常の文章生成として回答しています。

そのため、次のように説明文やMarkdownが追加されることがあります。

モデルの出力例
抽出結果は以下のとおりです。

```json
{
  "name": "ワイヤレスマウス",
  "price": 4980
}
```

この文字列をそのままJSON.parse()へ渡すと、JSONの前に説明文があるため解析に失敗します。

失敗する例
const data = JSON.parse(modelOutput);

発生するエラーは実行環境によって異なりますが、Unexpected tokenUnexpected characterなどが表示されます。

「JSON以外を出力しないでください」と強く指示すれば発生率を下げられる場合はあります。しかし、プロンプトによるお願いだけでは、アプリケーション側のデータ契約にはなりません。

JSON出力が壊れる主な原因

LLMが返すJSONには、文法上の問題とデータ構造上の問題があります。

文法上の問題とは、JSON.parse()そのものに失敗する状態です。

たとえば、プロパティ名がダブルクォートで囲まれていない場合や、末尾に余分なカンマがある場合は正しいJSONではありません。

文法エラーの例
{
  name: "ワイヤレスマウス",
  "price": 4980,
}

回答が出力トークンの上限へ達したり、通信が途中で切れたりした場合は、閉じ括弧のないJSONが返ることもあります。

途中で切れたJSONの例
{
  "name": "ワイヤレスマウス",
  "description": "軽量で持ち運びやすい

一方、次のデータは文法上は正しいJSONです。

型が不正な例
{
  "name": "ワイヤレスマウス",
  "price": "四千九百八十円",
  "category": "パソコン本体"
}

しかし、アプリケーションがpriceに数値、categoryに決められたカテゴリ名を求めている場合、このデータは利用できません。

JSON.parse()が確認するのは、文字列がJSONとして解析できるかどうかだけです。必要なキー、値の型、列挙値、数値の範囲、フィールド間の整合性までは検証しません。

TypeScriptの型アサーションでも検証できない

TypeScriptでは、JSON.parse()の結果に型を指定するコードを見かけます。

型アサーションの例
type Product = {
  name: string;
  price: number;
};

const product = JSON.parse(modelOutput) as Product;

このas Productは、実行時にデータを検証しているわけではありません。

実際のJSONが次の内容でも、型アサーションによってProductとして扱われます。

実際の不正な値
{
  "name": 123,
  "price": "無料"
}

TypeScriptの型は、JavaScriptへ変換されたあとには基本的に残りません。

外部API、ユーザー入力、データベース、LLMなどから取得したデータは、実行時のバリデーションが必要です。

JSON modeとStructured Outputsの違い

OpenAI APIには、JSON modeとStructured Outputsがあります。

JSON modeは、モデルの出力を有効なJSONにするための機能です。ただし、指定したスキーマへの適合までは保証しません。

Structured Outputsは、JSONとして解析できるだけでなく、指定したJSON Schemaに出力を従わせる機能です。必須キーの欠落や、許可していない列挙値の生成を防ぐ目的では、JSON modeよりStructured Outputsが適しています。OpenAIも、利用できる場合はJSON modeよりStructured Outputsを使うことを推奨しています。

たとえば、次のJSONは文法上は正しいため、JSON modeでは生成される可能性があります。

JSON modeで生成されうる例
{
  "product_name": "ワイヤレスマウス",
  "amount": "4980円"
}

しかし、アプリケーションが次の構造を要求している場合は不正です。

アプリが要求する構造
{
  "name": "ワイヤレスマウス",
  "price": 4980
}

Structured Outputsでは、キー名や値の型をJSON Schemaで指定し、出力をその構造に制約できます。

Structured Outputsを使う準備

Node.js・Next.jsなどのTypeScript環境では、OpenAIの公式SDKとZodをインストールします。

インストール
npm install openai zod

環境変数にはOpenAI APIキーと利用するモデル名を設定します。

.env
OPENAI_API_KEY=your_api_key
OPENAI_MODEL=your_model_name

コードにモデル名を直接書くこともできますが、モデル変更や環境ごとの切り替えを考えると、環境変数で管理するほうが扱いやすくなります。

Zodで出力スキーマを定義する

商品情報を抽出する例では、次のようなZodスキーマを定義します。Zodの基本的な書き方は【TypeScript】Zod 完全ガイドで解説しています。

src/product-schema.ts
import { z } from "zod";

export const ProductOutputSchema = z.object({
  name: z.string(),
  price: z.number(),
  currency: z.enum(["JPY", "USD"]),
  category: z.enum([
    "computer",
    "display",
    "keyboard",
    "mouse",
    "other",
  ]),
  inStock: z.boolean(),
  salePrice: z.number().nullable(),
  features: z.array(z.string()),
  notes: z.string().nullable(),
});

export type ProductOutput = z.infer<
  typeof ProductOutputSchema
>;

z.inferを使うと、ZodスキーマからTypeScriptの型を生成できます。

スキーマとTypeScriptの型を別々に手書きすると、片方だけを変更したときに内容がずれる可能性があります。OpenAIも、JSON Schemaとプログラミング言語側の型が分離しないよう、JavaScriptではZodのSDKサポートを利用することを推奨しています。

このスキーマから推論される型は、おおむね次の内容です。

推論される型
type ProductOutput = {
  name: string;
  price: number;
  currency: "JPY" | "USD";
  category:
    | "computer"
    | "display"
    | "keyboard"
    | "mouse"
    | "other";
  inStock: boolean;
  salePrice: number | null;
  features: string[];
  notes: string | null;
};

Responses APIとzodTextFormatを使う

OpenAIのResponses APIでは、responses.parse()zodTextFormat()を組み合わせます。

src/extract-product.ts
import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";
import {
  ProductOutputSchema,
  type ProductOutput,
} from "./product-schema";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

export async function extractProduct(
  productText: string,
): Promise<ProductOutput> {
  const model = process.env.OPENAI_MODEL;

  if (!model) {
    throw new Error(
      "OPENAI_MODELが設定されていません。",
    );
  }

  const response = await openai.responses.parse({
    model,
    input: [
      {
        role: "system",
        content: `
あなたは商品情報を構造化するアシスタントです。
入力文に書かれている情報だけを使用してください。
確認できない値を推測してはいけません。
販売価格がない場合、priceには0を設定してください。
セール価格がない場合、salePriceにはnullを設定してください。
備考がない場合、notesにはnullを設定してください。
      `.trim(),
      },
      {
        role: "user",
        content: productText,
      },
    ],
    text: {
      format: zodTextFormat(
        ProductOutputSchema,
        "product_output",
      ),
    },
    max_output_tokens: 1_000,
  });

  if (!response.output_parsed) {
    throw new Error(
      "構造化された商品情報を取得できませんでした。",
    );
  }

  return response.output_parsed;
}

zodTextFormat()は、ZodスキーマをOpenAI APIで利用するJSON Schemaへ変換します。

responses.parse()はレスポンスを解析し、検証済みの値をresponse.output_parsedへ格納します。OpenAIの公式ドキュメントでも、Responses APIとZodを使うJavaScript例としてこの構成が案内されています。

呼び出し側では、解析済みのデータを通常のオブジェクトとして使用できます。

呼び出し例
async function main(): Promise<void> {
  const product = await extractProduct(`
製品名はLight Mouse Xです。
価格は4,980円で、現在在庫があります。
重量55g、最大8,000Hzポーリングレートに対応します。
  `);

  console.log(product.name);
  console.log(product.price);
  console.log(product.features);
}

void main();

Structured Outputsを使う場合、Markdownのコードブロックを取り除いたり、JSON文字列へ正規表現を適用したりする処理は基本的に不要です。

OptionalではなくNullableを使う

Structured Outputsでは、すべてのフィールドを必須として定義する必要があります。

値が存在しない可能性があるフィールドは、キー自体を省略可能にするのではなく、nullを許可します。OpenAIのJSON Schemaでは、必須フィールドにしたうえで型にnullを含める方法が案内されています。

そのため、次のようなoptional()は避けます。

非推奨:optional()
const UnsupportedProductSchema = z.object({
  name: z.string(),
  salePrice: z.number().optional(),
});

代わりにnullable()を使用します。

推奨:nullable()
const ProductSchema = z.object({
  name: z.string(),
  salePrice: z.number().nullable(),
});

セール価格が確認できない場合の出力は、次のようになります。

出力例
{
  "name": "Light Mouse X",
  "salePrice": null
}

キーが常に存在するため、呼び出し側では「キーがない場合」と「値がnullの場合」を別々に処理する必要がありません。

ルートをオブジェクトにする

Structured Outputsのルートスキーマはオブジェクトである必要があります。

ルートに配列やanyOfを生成するスキーマは使用できません。ZodのdiscriminatedUnion()をルートへ直接指定すると、JSON SchemaのルートにanyOfが生成されるため、エラーになる可能性があります。

次の構成は避けます。

非推奨:ルートにdiscriminatedUnion
const SuccessSchema = z.object({
  status: z.literal("success"),
  data: ProductOutputSchema,
});

const FailureSchema = z.object({
  status: z.literal("failure"),
  message: z.string(),
});

const ResultSchema = z.discriminatedUnion(
  "status",
  [SuccessSchema, FailureSchema],
);

代わりに、ルートを一つのオブジェクトとして設計します。

推奨:ルートを単一オブジェクトに
const ResultSchema = z.object({
  status: z.enum(["success", "failure"]),
  data: ProductOutputSchema.nullable(),
  message: z.string().nullable(),
});

成功時はdataへ値を入れてmessagenullにし、失敗時はdatanullにしてmessageへ理由を入れる設計にできます。

入力に情報がない場合の動作を決める

Structured Outputsは、指定されたスキーマへ回答を合わせようとします。

そのため、入力文に必要な情報がない場合でも、モデルが空欄を埋めるために値を推測する可能性があります。OpenAIも、ユーザー入力がスキーマに合わない場合の処理をプロンプトで指定しないと、無関係な入力に対して値を作り出す可能性があると説明しています。

たとえば商品価格を抽出する場合、価格が見つからないときのルールを明記します。

プロンプトの例
入力に価格が記載されていない場合は、
priceに0を設定し、
priceFoundをfalseにしてください。
推測した価格を出力してはいけません。

スキーマ側にも判定結果を持たせます。

src/price-schema.ts
const PriceSchema = z.object({
  priceFound: z.boolean(),
  price: z.number(),
  evidence: z.string().nullable(),
});

出力例は次のようになります。

出力例
{
  "priceFound": false,
  "price": 0,
  "evidence": null
}

値だけを返させるよりも、その値が入力から確認できたかどうかを同時に返させることで、後続処理の安全性を高められます。

Structured Outputsでも拒否や未完了は発生する

Structured Outputsを使っても、すべてのリクエストでスキーマに沿ったオブジェクトを取得できるわけではありません。

安全上の理由でモデルが回答を拒否した場合、拒否メッセージは指定したスキーマに従わず、refusalとして返されます。

また、最大出力トークン数に達した場合など、レスポンスが未完了になることもあります。OpenAIの公式ドキュメントでも、拒否や出力上限による未完了は、スキーマに一致する回答を生成できない例外として扱われています。

本番用の実装では、output_parsedだけでなく、レスポンスの状態と拒否を確認します。

src/extract-product-safely.ts
import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";
import { ProductOutputSchema } from "./product-schema";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

function findRefusal(
  response: Awaited<
    ReturnType<typeof openai.responses.parse>
  >,
): string | null {
  for (const output of response.output) {
    if (output.type !== "message") {
      continue;
    }

    for (const content of output.content) {
      if (content.type === "refusal") {
        return content.refusal;
      }
    }
  }

  return null;
}

export async function extractProductSafely(
  productText: string,
) {
  const model = process.env.OPENAI_MODEL;

  if (!model) {
    throw new Error(
      "OPENAI_MODELが設定されていません。",
    );
  }

  const response = await openai.responses.parse({
    model,
    input: [
      {
        role: "system",
        content: `
入力された商品説明を構造化してください。
書かれていない情報を推測してはいけません。
情報がないフィールドには、指定された空の値またはnullを設定してください。
        `.trim(),
      },
      {
        role: "user",
        content: productText,
      },
    ],
    text: {
      format: zodTextFormat(
        ProductOutputSchema,
        "product_output",
      ),
    },
    max_output_tokens: 1_000,
  });

  const refusal = findRefusal(response);

  if (refusal) {
    throw new Error(
      `モデルが回答を拒否しました: ${refusal}`,
    );
  }

  if (response.status === "incomplete") {
    const reason =
      response.incomplete_details?.reason ??
      "unknown";

    throw new Error(
      `構造化出力が未完了です: ${reason}`,
    );
  }

  if (response.status === "failed") {
    throw new Error(
      response.error?.message ??
        "OpenAI APIの処理に失敗しました。",
    );
  }

  if (!response.output_parsed) {
    throw new Error(
      "構造化出力を解析できませんでした。",
    );
  }

  return response.output_parsed;
}

出力が未完了の場合、途中まで生成されたJSONを修復してそのまま利用するのは避けます。

閉じ括弧を機械的に追加してJSON.parse()へ通しても、必要なフィールドが生成される前に回答が終わっている可能性があるためです。

APIのスキーマと業務ルールを分ける

Structured Outputsは、JSONの構造や型を安定させる機能です。

しかし、「セール価格は通常価格以下でなければならない」「在庫なしの商品に即日発送を設定してはいけない」といった業務ルールまで、自動的に正しいとは限りません。

OpenAIも、Structured Outputsで形式を固定しても、出力内容そのものには誤りが含まれる可能性があると説明しています。

APIへ渡すスキーマは、Structured Outputsが扱いやすい単純な構造にします。

src/product-api-schema.ts
const ProductApiSchema = z.object({
  name: z.string(),
  price: z.number(),
  salePrice: z.number().nullable(),
  inStock: z.boolean(),
  shippingDays: z.number().nullable(),
});

取得後に、別のZodスキーマで業務ルールを検証します。

src/product-business-schema.ts
import { z } from "zod";

const ProductBusinessSchema =
  ProductApiSchema.superRefine(
    (product, context) => {
      if (product.price < 0) {
        context.addIssue({
          code: "custom",
          path: ["price"],
          message:
            "価格を負の数にはできません。",
        });
      }

      if (
        product.salePrice !== null &&
        product.salePrice > product.price
      ) {
        context.addIssue({
          code: "custom",
          path: ["salePrice"],
          message:
            "セール価格が通常価格を超えています。",
        });
      }

      if (
        !product.inStock &&
        product.shippingDays !== null
      ) {
        context.addIssue({
          code: "custom",
          path: ["shippingDays"],
          message:
            "在庫なしの商品には発送日数を設定できません。",
        });
      }
    },
  );

検証にはsafeParse()を使用します。

検証する例
const result =
  ProductBusinessSchema.safeParse(
    response.output_parsed,
  );

if (!result.success) {
  console.error(result.error.issues);

  throw new Error(
    "商品情報が業務ルールを満たしていません。",
  );
}

const product = result.data;

ZodのsafeParse()は、検証成功時には型付けされたdata、失敗時にはZodErrorを含む結果オブジェクトを返します。例外を使わず、成功と失敗を分岐できるのが特徴です。

Zodのエラー内容を読みやすくする

safeParse()に失敗した場合、error.issuesから問題のあるフィールドを確認できます。

失敗例
const result =
  ProductBusinessSchema.safeParse({
    name: "Light Mouse X",
    price: "4980",
    salePrice: 5980,
    inStock: false,
    shippingDays: 1,
  });

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

Zod 4では、z.prettifyError()を使ってエラーを読みやすい文字列へ変換できます。複雑な入れ子構造ではz.treeifyError()、比較的平坦なフォームではz.flattenError()も利用できます。

読みやすいエラー表示
if (!result.success) {
  const message = z.prettifyError(
    result.error,
  );

  console.error(message);
}

ログへ記録するときは、LLMへ送信した全文をそのまま保存するのではなく、エラーのパス、エラーコード、スキーマ名、リクエストIDなどを中心に記録します。

入力に個人情報や機密情報が含まれている場合、モデル出力やプロンプト全文をログへ保存すると、別の情報漏えいリスクが発生します。

Structured Outputsを使えない場合はJSON.parseとsafeParseを分ける

利用しているモデルやAPIがStructured Outputsに対応していない場合は、JSON modeまたは通常のテキスト出力をZodで検証します。

Responses APIのJSON modeでは、text.formatjson_objectを指定します。

src/extract-with-json-mode.ts
import OpenAI from "openai";
import { z } from "zod";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const ProductSchema = z.strictObject({
  name: z.string(),
  price: z.number(),
  inStock: z.boolean(),
});

type Product = z.infer<typeof ProductSchema>;

export async function extractWithJsonMode(
  productText: string,
): Promise<Product> {
  const model = process.env.OPENAI_MODEL;

  if (!model) {
    throw new Error(
      "OPENAI_MODELが設定されていません。",
    );
  }

  const response =
    await openai.responses.create({
      model,
      input: [
        {
          role: "system",
          content: `
商品情報をJSONで出力してください。
JSON以外の文字を出力してはいけません。
JSONにはname、price、inStockを含めてください。
priceは数値、inStockは真偽値にしてください。
          `.trim(),
        },
        {
          role: "user",
          content: productText,
        },
      ],
      text: {
        format: {
          type: "json_object",
        },
      },
      max_output_tokens: 500,
    });

  if (response.status !== "completed") {
    throw new Error(
      `レスポンスが完了していません: ${response.status}`,
    );
  }

  let unknownData: unknown;

  try {
    unknownData = JSON.parse(
      response.output_text,
    );
  } catch (error) {
    throw new Error(
      "モデルの出力をJSONとして解析できません。",
      {
        cause: error,
      },
    );
  }

  const result =
    ProductSchema.safeParse(unknownData);

  if (!result.success) {
    console.error(
      z.prettifyError(result.error),
    );

    throw new Error(
      "JSONは取得できましたが、必要な構造と一致しません。",
    );
  }

  return result.data;
}

ここでは、処理を二段階に分けています。

最初のJSON.parse()ではJSON文法を確認し、その後のsafeParse()でキーや値の型を確認します。

JSON modeは有効なJSONの生成を目的とした機能であり、特定のスキーマへの適合は保証しません。JSON modeを利用する場合は、Zodなどによる実行時検証が必要です。

また、JSON modeでは、会話内にJSONを生成する明示的な指示が必要です。OpenAIの公式ドキュメントでは、JSONの指示がない場合に空白が生成され続け、トークン上限まで処理が続く可能性があると説明されています。

JSONを正規表現で修復する方法は避ける

壊れたJSONへの対策として、Markdownのコードブロックを削除したり、シングルクォートをダブルクォートへ置換したりする実装があります。

非推奨:正規表現での修復
const cleaned = output
  .replace(/```json/g, "")
  .replace(/```/g, "")
  .replace(/'/g, '"');

const data = JSON.parse(cleaned);

この方法は安全ではありません。

文章中のアポストロフィまで変換したり、文字列内のコードブロック記号を削除したり、意図しないデータを正しいものとして受け入れたりする可能性があります。

たとえば次の文字列に対して、すべてのシングルクォートを置換すると商品名そのものが変わります。

問題のある例
{
  "name": "Developer's Keyboard"
}

壊れたJSONを推測で修復するより、Structured Outputsを使用するか、検証失敗として再生成するほうが安全です。

検証失敗時の再生成は回数を制限する

JSON modeなどでスキーマ検証に失敗した場合、エラー内容をモデルへ返して再生成させる方法があります。

ただし、成功するまで無制限に繰り返す実装は避けます。API料金が増え、同じ不正出力を繰り返す可能性があるためです。

src/format-issues.ts
import { z } from "zod";

const OutputSchema = z.strictObject({
  title: z.string(),
  score: z.number(),
  tags: z.array(z.string()),
});

function formatZodIssues(
  error: z.ZodError,
): string {
  return error.issues
    .map((issue) => {
      const path =
        issue.path.length > 0
          ? issue.path.join(".")
          : "root";

      return `${path}: ${issue.message}`;
    })
    .join("\n");
}

再生成するときは、最大試行回数を設定します。

src/validate-with-retry.ts
async function validateWithRetry(
  generate: (
    correction: string | null,
  ) => Promise<string>,
  maxAttempts = 2,
) {
  let correction: string | null = null;

  for (
    let attempt = 1;
    attempt <= maxAttempts;
    attempt += 1
  ) {
    const text = await generate(correction);

    let value: unknown;

    try {
      value = JSON.parse(text);
    } catch {
      correction =
        "前回の出力は有効なJSONではありませんでした。JSON以外を含めず、最初から生成してください。";

      continue;
    }

    const result =
      OutputSchema.safeParse(value);

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

    correction = `
前回のJSONはスキーマに一致しませんでした。
次の問題を修正し、JSON全体を再生成してください。

${formatZodIssues(result.error)}
    `.trim();
  }

  throw new Error(
    "最大試行回数までに有効なJSONを取得できませんでした。",
  );
}

再生成時には、前回のエラーをそのまま大量に送るのではなく、問題のあるパスと理由だけを渡します。

Structured Outputsを利用できる場合は、このような形式修正のリトライより、最初からスキーマ制約を使用するほうが簡単です。

ストリーミング中のJSONをすぐ解析しない

JSONをストリーミングすると、最初に届くデータは未完成です。

未完成のJSON
{
  "name": "Light Mou

この時点でJSON.parse()へ渡せば、当然エラーになります。

通常のストリーミング処理では、完了イベントを受信するまで文字列を連結してから解析します。

ストリーミング中のJSONの取り扱い
let jsonText = "";

for await (const event of stream) {
  if (
    event.type ===
    "response.output_text.delta"
  ) {
    jsonText += event.delta;
  }

  if (
    event.type === "response.completed"
  ) {
    const value: unknown =
      JSON.parse(jsonText);

    const result =
      ProductOutputSchema.safeParse(value);

    if (!result.success) {
      throw result.error;
    }

    console.log(result.data);
  }
}

OpenAIは、Structured Outputsをストリーミングする場合、SDKのストリーミング機能を利用して構造化データを処理することを推奨しています。

途中のJSON文字列を独自に修復しながら解析するより、SDKが提供するイベントや完了後の解析結果を使用するほうが安全です。

Structured Outputsのスキーマ制限にも注意する

Structured OutputsはJSON Schemaのすべての機能を無制限に利用できるわけではありません。

ルートはオブジェクトにする必要があり、すべてのフィールドを必須にします。また、オブジェクトではadditionalProperties: falseが必要です。Zod用のSDKヘルパーを利用すれば、JSON Schemaを手書きする負担を減らせます。

一部の複合条件やJSON Schemaキーワードは対応していません。allOfnotifthenelseなどを含むスキーマは使用できません。対応していないスキーマをstrict: trueで送信すると、APIからエラーが返されます。

スキーマが複雑になりすぎる場合は、APIへ渡す構造と、アプリケーション内部で行う詳細なバリデーションを分けます。

Structured Outputsでは基本的な型、必須キー、列挙値、入れ子構造を固定し、複雑な業務ルールはレスポンス取得後にZodのsafeParse()superRefine()で検証する設計が扱いやすくなります。

LLMの出力をそのままデータベースへ保存しない

Structured OutputsとZodを使っても、モデルが抽出した事実が正しいとは限りません。

たとえば商品説明に価格が書かれていないのに、モデルが一般的な販売価格を推測する可能性があります。JSON Schemaに一致しているため、型の検証だけではこの誤りを検出できません。

重要なデータでは、元の文章と抽出値を対応させます。

src/extracted-value-schema.ts
const ExtractedValueSchema = z.object({
  value: z.string().nullable(),
  found: z.boolean(),
  evidence: z.string().nullable(),
});

次のように根拠となる原文も返させます。

出力例
{
  "value": "4980",
  "found": true,
  "evidence": "価格は4,980円です"
}

アプリケーション側では、foundtrueでもevidenceが空の場合は保存しないといった追加ルールを設定できます。

決済金額、契約条件、医療情報、権限設定など、誤りの影響が大きいデータでは、人間による確認や別データとの照合も必要です。

本番環境で記録しておきたい情報

構造化出力に失敗した場合は、エラーメッセージだけでなく、利用モデル、スキーマ名、レスポンスの状態、拒否の有無、未完了理由、Zodのエラーパスを記録します。

OpenAI APIのリクエストIDも保存しておくと、API通信単位で問題を追跡しやすくなります。

ただし、プロンプトやモデル出力をそのままログへ保存すると、個人情報や機密情報が残る可能性があります。

入力全文ではなく、入力文字数、処理種別、匿名化したユーザーID、Zodのエラーコードなど、原因調査に必要な範囲へ絞ることが重要です。

LLMのJSON出力に関するよくある質問

QJSON modeとStructured Outputsはどちらを使うべき?

A利用しているモデルやAPIがStructured Outputsに対応している場合は、Structured Outputsを優先してください。JSON modeは有効なJSONの生成は保証しますが、指定したスキーマへの適合までは保証しません。Structured Outputsに対応していない場合のみ、JSON modeとZodのsafeParse()を組み合わせる方法を検討してください。

QTypeScriptの型アサーション(as Product)だけでは不十分?

Aはい、不十分です。as Productはコンパイル時の表記であり、実行時にデータの中身を検証しているわけではありません。LLMの出力のように実行時に取得するデータは、ZodのsafeParse()のような実行時バリデーションが必要です。

QStructured Outputsを使えば出力内容の正確性も保証される?

Aいいえ。Structured Outputsが保証するのは、指定したJSON Schemaへの構造的な適合(キー・型・列挙値など)です。モデルが入力から誤った値を推測したり、事実と異なる内容を出力したりする可能性は別問題として残ります。重要なデータでは、根拠となる原文も一緒に出力させる、業務ルールを別途検証する、といった対策が必要です。

Qoptional()とnullable()はどちらを使うべき?

AStructured Outputsではすべてのフィールドが必須になるため、値が存在しない可能性があるフィールドにはoptional()ではなくnullable()を使用してください。キー自体を省略可能にするoptional()はStructured Outputsの制約と合わず、エラーの原因になります。

まとめ

LLMへ「JSONで回答してください」と指示するだけでは、安定したデータ連携は実現できません。

JSONの前後に説明文が付く、コードブロックで囲まれる、出力が途中で切れるといった文法上の問題だけでなく、必須キーの欠落、型の違い、誤った列挙値など、JSON.parse()では検出できない問題もあります。

OpenAI APIを利用する場合は、対応モデルでStructured Outputsを使用し、ZodスキーマをzodTextFormat()へ渡す方法が基本です。

Responses APIではresponses.parse()からoutput_parsedを取得できます。ただし、モデルによる拒否、出力トークン不足による未完了、APIの失敗は別に処理する必要があります。

Structured Outputsを利用できない場合は、JSON.parse()でJSON文法を確認したあと、ZodのsafeParse()で構造と型を検証します。TypeScriptのasによる型アサーションだけでは、実行時の安全性は得られません。

また、Structured Outputsが保証するのは主にデータ構造です。値の事実性や業務ルールまでは保証されないため、取得後にZodのsuperRefine()や外部データとの照合を行い、重要な処理へ未検証の値を渡さない設計が必要です。基本的なOpenAI APIの使い方は【TypeScript】OpenAI API入門、Zodの詳しい文法は【TypeScript】Zod 完全ガイドもあわせてご覧ください。