LLM をアプリに組み込むとき、最初にぶつかる壁が「出力が安定しない」ことです。プロンプトでは「JSON だけ返して」とお願いしているのに、前置きの文章が混ざる、コードブロックで囲まれる、末尾でトークンが切れて括弧が閉じない、ときどき余計なフィールドが増える。デモでは動いていたのに、本番に載せた途端にパースエラーで落ちる、というのはよくある話です。
この記事では、LLM の出力を JSON などの決まった形式で確実に受け取るための設計を、スキーマ定義から出力強制、バリデーション、自動リトライ、ストリーミングの扱いまで実装視点で整理します。TypeScript を前提にしたコード例を交えながら、アプリに組み込んでも壊れない構造化出力の作り方を示します。LLM を製品に統合する全体像は AI 駆動開発でプロダクトを作るサービス でも触れていますが、本記事はその中でも一番地味で一番効く「出力をどう受け取るか」に絞ります。
なぜ LLM の出力は崩れるのか - 構造化が必要な場面
LLM は本質的に「次の単語を確率的に選ぶ」モデルです。プロンプトで形式を指示しても、それはあくまで「そう書きやすくする」だけで、文法的に正しい JSON を必ず吐く保証にはなりません。とくに次のような状況で崩れが起きます。
- 出力が長くなり、最大トークン数に達して途中で切れる
- 指示が曖昧で、説明文と JSON が混在する
- 数値であるべき項目を文字列で返す、あるいはその逆
- 列挙すべき値が指示外の表記ゆれになる (
"high"のはずが"High") - 配列が空であるべき場面で
nullを返す
人間が目視で読む用途ならこの程度のゆらぎは許容できます。問題は、出力を後続の処理に機械的に渡すときです。分類結果で分岐する、抽出したフィールドを DB に保存する、関数の引数に詰める。こうした場面では出力が「決まった形」であることが前提になり、一度でも形が崩れると処理全体が止まります。
構造化出力が必要になるのは、おおよそ次のような場面です。問い合わせ文をカテゴリと優先度に分類する、自由記述から氏名・日付・金額といった項目を抽出する、複数候補からどのツールを呼ぶか AI に選ばせる、長文を見出しと要約の階層構造に変換する。いずれも「自然言語を構造に落とす」処理で、ここを安定させられるかどうかが LLM アプリの信頼性を大きく左右します。
裏を返すと、出力が読み物として人間に届くだけのチャット UI であれば、無理に構造化する必要はありません。構造化のコストを払うべきは「機械が次に使う出力」だけ、と切り分けると設計がすっきりします。
スキーマ定義のコツと過不足のない型設計
構造化出力の起点は、欲しい形を JSON Schema として定義することです。ここで重要なのは「過不足のない型」を作ること。リッチにしすぎるとモデルが埋めきれずに崩れ、緩すぎると後段で結局バリデーションが必要になります。
実務でうまくいくスキーマ設計の勘所は次のとおりです。
第一に、フィールドを増やしすぎない。一度の呼び出しで 10 個も 20 個も項目を埋めさせると、後半ほど精度が落ち、トークンも嵩みます。本当に必要な項目だけに絞り、関連する項目はネストせず一段で持つほうが安定します。
第二に、値の取りうる範囲は enum で固定する。優先度や分類のような有限の選択肢は、自由記述にせず列挙型にします。表記ゆれが消え、後段の分岐がそのまま書けます。
第三に、「分からない」を表現できるようにする。抽出系では、該当する値が文中に無いことが普通に起こります。これを無理に埋めさせると幻覚 (hallucination) の温床になるため、null 許容にするか found: boolean のようなフラグを併せて持たせます。無い値を捏造させない設計を含め、誤回答を抑える打ち手は ハルシネーション対策の設計 で体系的に整理しています。
第四に、説明 (description) を各フィールドに添える。JSON Schema の description はモデルへのミニプロンプトとして働きます。「日付は YYYY-MM-DD 形式」「金額は税抜の整数」のように制約を書くと、出力の質が目に見えて上がります。
TypeScript なら、スキーマと型を二重に書かずに済む Zod を使うのが定石です。
import { z } from "zod";
export const InquirySchema = z.object({
category: z
.enum(["bug", "feature_request", "billing", "other"])
.describe("問い合わせの種別。当てはまらない場合は other"),
priority: z
.enum(["low", "medium", "high"])
.describe("対応の緊急度。明示がなければ medium"),
summary: z.string().describe("問い合わせ内容の一文要約 (日本語、80 字以内)"),
contactName: z
.string()
.nullable()
.describe("差出人の氏名。文中に無ければ null"),
});
export type Inquiry = z.infer<typeof InquirySchema>;Zod を起点にすれば、z.infer でそのまま TypeScript の型が得られ、後述のバリデーションにも同じスキーマを使い回せます。型定義・出力指示・実行時検証を 1 つの真実 (single source of truth) にまとめられるのが大きな利点です。
出力を形式に固定する方法 - Structured Outputs と function calling
スキーマができたら、それをモデルに強制します。主要な手段は 2 つで、Structured Outputs と function calling です。どちらも内部的には JSON Schema で出力を縛りますが、用途が違います。
Structured Outputs は「この形の JSON を必ず返せ」とモデルに制約をかける仕組みです。OpenAI 系では response_format に JSON Schema を渡し、strict: true を指定すると、構文上は必ずスキーマに沿った JSON が返ります。Zod を使うなら、SDK のヘルパーでスキーマをそのまま渡せます。
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { InquirySchema } from "./schema";
const client = new OpenAI();
const completion = await client.chat.completions.parse({
model: "gpt-4.1",
messages: [
{ role: "system", content: "問い合わせ文を解析して構造化する。" },
{ role: "user", content: rawInquiryText },
],
response_format: zodResponseFormat(InquirySchema, "inquiry"),
});
const inquiry = completion.choices[0].message.parsed; // 型は InquiryClaude などスキーマ強制のモードが用意されていないモデルでも、tool (function) として JSON Schema を渡し、その tool を必ず使うよう指定すれば、実質的に同じ効果が得られます。「出力フォーマットを定義した tool を 1 つだけ用意し、それを強制的に呼ばせる」というのが、モデルに依存しない安定パターンです。
function calling 本来の用途は、モデルに「どの処理を呼ぶか」を選ばせることです。在庫照会・予約作成・キャンセルといった複数の関数を定義し、ユーザーの意図に最も合致する関数と引数を AI が選びます。引数は JSON Schema で縛られるので、ここでも構造化出力の考え方がそのまま効きます。エージェント的に処理を分岐させる設計の勘所は AI エージェントの設計パターン で掘り下げています。
整理すると、出力そのものが欲しいだけなら Structured Outputs、AI に行動を選ばせたいなら function calling です。迷ったらまず Structured Outputs で形を固定し、分岐が必要になった段階で function calling へ広げると、設計を後戻りさせずに済みます。
なお、スキーマ強制が使えない古いモデルや、プロバイダの制約で strict モードに頼れない場合は、プロンプトで「JSON のみを返し、説明文やコードブロックは付けない」と明示したうえで、次節のバリデーションとリトライで守る方針になります。強制が効く環境ではできるだけ強制を使い、効かない環境では後段で吸収する、という二段構えが現実的です。
バリデーションと自動リトライで壊れを吸収する
スキーマ強制で構文の壊れはほぼ防げますが、それでも本番では「意味的におかしい出力」が混ざります。型は合っているのに値が空、enum 外ではないが業務上ありえない組み合わせ、といったものです。だからこそ、受け取った直後に必ずバリデーションを通す層を置きます。
ここで Zod が再び活きます。出力を safeParse に通せば、構文と型と制約をまとめて検査でき、失敗時には「どこがどう不正か」が構造化されたエラーとして返ります。
import { InquirySchema, type Inquiry } from "./schema";
async function parseInquiry(rawText: string): Promise<Inquiry> {
const maxRetries = 2;
let lastError = "";
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const messages = [
{ role: "system" as const, content: SYSTEM_PROMPT },
{ role: "user" as const, content: rawText },
];
// 直前の失敗内容をフィードバックして修正を促す
if (lastError) {
messages.push({
role: "user" as const,
content: `前回の出力は次の理由で不正でした。修正して再出力してください: ${lastError}`,
});
}
const raw = await callModel(messages); // モデル呼び出し (文字列を返す想定)
const result = InquirySchema.safeParse(safeJsonParse(raw));
if (result.success) {
return result.data;
}
lastError = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
}
throw new Error(`構造化出力の取得に失敗しました: ${lastError}`);
}この設計のポイントは、失敗の理由をそのままモデルに返して限定的にリトライすることです。「priority が enum 外」「summary が空」のように具体的に伝えると、モデルは次の試行でほぼ確実に直します。漠然と「もう一度」と言うより成功率が段違いです。
リトライ回数は 2〜3 回で打ち切ります。それを超えても直らない出力は、入力かスキーマ側に根本原因があることが多く、回数を増やしてもコストが嵩むだけです。打ち切ったあとは、デフォルト値にフォールバックするか、人間の確認キューに回すフローへ落とします。LLM アプリでは「直らないものは諦めて安全側に倒す」設計が、無限ループと予期せぬ課金を防ぎます。
もう一点、トークン上限切れによる途中終了は、リトライではなく finish_reason (Claude なら stop_reason) で検知して別扱いにします。length で終わっていたら出力が長すぎる証拠なので、リトライではなく出力項目を減らす、max_tokens を増やす、長いテキストは分割するといった対処に切り替えます。原因の違う失敗を同じリトライに丸めないことが、安定運用のコツです。
ストリーミングと部分出力をどう扱うか
UI の体感速度を上げるためにストリーミングを使いたくなりますが、構造化出力とストリーミングは相性に注意が必要です。JSON は途中の状態が常に不完全 ({"category": "bu のような断片) なので、トークンが届くたびに JSON.parse してもエラーになります。
対処は用途によって分かれます。
最終的な構造化結果だけが欲しく、体感速度は二の次なら、ストリーミングを使わず一括で受け取り、完成した文字列をパースするのが一番堅牢です。構造化出力では、まずこの「待ってからパース」を基本に据えるのをおすすめします。
途中経過もどうしても見せたい場合は、不完全な JSON を許容してパースする増分パーサ (partial JSON parser) を挟みます。届いた範囲までで「埋まったフィールド」だけを取り出し、UI を逐次更新する方式です。多くの SDK には部分パースのヘルパーが用意されているので、自前で書く前に確認します。それでも、確定値として後続処理に渡すのはストリームが完了したあとにする、という線引きは守ります。表示用の部分出力と、処理用の確定出力を混同しないことが重要です。
実装上もう 1 つ気をつけたいのは、ストリーミング時の finish_reason を最後まで確認することです。途中まで綺麗な JSON が流れてきても、length で打ち切られていれば未完成です。「最後のチャンクで正常終了を確認できてから確定する」というガードを必ず入れます。
TypeScript での実装パターンと型安全な受け取り
ここまでの要素を、型安全な 1 つのインターフェースにまとめます。狙いは「呼び出し側はスキーマを渡すだけで、検証済みの型付きオブジェクトを受け取れる」状態にすることです。
import { z } from "zod";
interface ExtractOptions<T extends z.ZodTypeAny> {
schema: T;
system: string;
input: string;
maxRetries?: number;
}
export async function extractStructured<T extends z.ZodTypeAny>(
opts: ExtractOptions<T>,
): Promise<z.infer<T>> {
const { schema, system, input, maxRetries = 2 } = opts;
let lastError = "";
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const raw = await callModelWithSchema({ schema, system, input, lastError });
const result = schema.safeParse(raw);
if (result.success) {
return result.data; // 戻り値の型は z.infer<T> に確定
}
lastError = formatZodError(result.error);
}
throw new StructuredOutputError(lastError);
}ジェネリクスでスキーマを受け取り z.infer<T> を返すことで、呼び出し側は型注釈を書かずとも正しい型を得られます。スキーマが single source of truth なので、項目を 1 つ足すだけで型・出力指示・バリデーションがすべて追従します。型が崩れる隙を設計段階で潰しておくこの考え方は、AI 駆動開発でプロダクトを作るサービス で扱う「AI が壊しにくい型設計」とも通じます。
運用面では、次の三点を最初から仕込んでおくと後で楽です。
- 生の出力 (パース前の文字列) をログに残します。失敗時の原因調査が一気に楽になります。
- リトライ回数・失敗率・
finish_reasonの分布をメトリクスとして取ります。モデルやプロンプトを変えたときの劣化に気づけます。 - スキーマと SYSTEM プロンプトはコードと一緒にバージョン管理し、変更を差分で追えるようにします。プロンプト変更で挙動が変わったときの切り分けが容易になります。
構造化出力をアプリに組み込む AI 駆動開発の実装
構造化出力は、LLM を「面白いデモ」から「壊れない製品機能」へ引き上げる要の技術です。スキーマで形を固定し、Structured Outputs か function calling で出力を強制し、Zod で検証してから限定的にリトライする。ストリーミングでは表示用と確定用を分け、最後まで finish_reason を見る。これらを 1 つの型安全なインターフェースに束ねれば、LLM 機能はアプリの他の関数と同じ感覚で安心して呼べるようになります。
製品全体で見ると、構造化出力は単体で完結せず、ツール実行やエージェントのオーケストレーション、外部システムとの接続と組み合わせて使うことになります。AI に外部のデータや操作を安全に渡す設計は MCP サーバーの作り方 で、複数ステップを安定して回すエージェント設計は AI エージェントの設計パターン で扱っています。本記事の「出力を確実に受け取る」基礎は、その土台になります。
FIXIT は AI 駆動開発のクリエイティブスタジオとして、LLM を実プロダクトに組み込む開発を数多く手がけてきました。構造化出力の安定化、エージェントの設計、既存システムへの AI 機能の組み込みなど、実装で詰まりがちな部分の知見を持っています。LLM 組み込みアプリの開発で「デモは動くが本番で壊れる」を解消したい方は、AI 駆動開発の無料相談 からお気軽にご相談ください。

