Gemini API に構造化出力(Structured Output / responseSchema)を使い始めて、しばらくは「これでもう JSON.parse の例外を恐れる必要はない」と思っていました。実際、開発中はほぼ 100% 期待通りのスキーマで返ってきます。ところが本番に出して数週間後、ある日突然「JSON 構造は正しいのに、enum 値が見たことのない文字列になっている」というエラーが立て続けに発生しました。
調べてみると、Gemini の構造化出力は「JSON として valid な出力を出す」ことは保証してくれますが、「スキーマで宣言した制約を完全に守る」ことは保証していません。enum、min/max、required、pattern といった制約は、モデル側のベストエフォートで満たされる、というのが実情です。本番で大量に動かすと、必ずどこかで漏れます。
私が運用している「Gemini API のレスポンスを Schema 検証で守る」ための多層防御の設計を実例とともに整理しました。Zod / Pydantic を使った検証、失敗時の縮退戦略、再試行と修復プロンプトの設計まで、コードレベルで残します。
構造化出力モードでも壊れる 3 つの典型パターン
私が観測した「想定外フォーマットが本番で漏れる」事例を分類すると、次の 3 パターンに収まります。
ひとつ目は enum 値の崩れです。["positive", "negative", "neutral"] を期待していたフィールドに、"mixed" や "slightly positive" が入ってきます。日本語で出力させていると "ポジティブ" のように、英語側の enum 値が翻訳された値が紛れることもあります。プロンプトで「enum の値以外を返さないでください」と書いても、3% 程度の頻度で漏れます。
ふたつ目は 数値範囲の逸脱です。{ "score": { "type": "number", "minimum": 0, "maximum": 100 } } と宣言していても、稀に 105 や -3 が返ってきます。私の手元のデータでは、感情分析タスクで 0.5% 程度の頻度で範囲外が出ます。レアですが本番に流すと月に数件は当たります。
みっつ目は 配列要素数の崩れです。「上位 3 件のキーワードを返す」と宣言しても、稀に 2 件、稀に 5 件返ります。フロント側で「3 件決め打ち」のレイアウトを組んでいると UI が崩れます。
これらは Gemini 側の不具合というより、現在の構造化出力 API の本質的な制約です。本番運用するなら、API のレスポンスを「契約として信頼する」のではなく「契約に違反していないかをこちら側で検証する」設計に切り替える必要があります。
多層検証の全体像 — 3 段の防御線
私が運用している多層検証は、次の 3 段構成です。
- 第 1 段: スキーマ検証(Zod / Pydantic)
- レスポンスが宣言したスキーマに完全準拠しているかを判定する
- 第 2 段: 修復プロンプトによる再試行
- 第 1 段で失敗したとき、エラー内容を含めて Gemini に再生成させる
- 第 3 段: 縮退ハンドラ
- 第 2 段でも失敗したとき、空値や近似値で UI を壊さないように吸収する
それぞれの段を順に見ていきます。
第 1 段: スキーマ検証 — Zod / Pydantic で型を確定する
Gemini の構造化出力で responseSchema を渡しても、こちら側でも同じ意味のスキーマを別途定義します。これは二重管理に見えますが、本番防御の観点では必須です。
TypeScript なら Zod、Python なら Pydantic を使います。私の運用例(TypeScript / Zod)はこんな形です。
import { z } from "zod";
const SentimentEnum = z.enum(["positive", "negative", "neutral"]);
const AnalysisResultSchema = z.object({
sentiment: SentimentEnum,
score: z.number().min(0).max(100),
keywords: z.array(z.string()).length(3),
summary: z.string().min(10).max(500),
});
type AnalysisResult = z.infer<typeof AnalysisResultSchema>;
function parseGeminiResponse(rawJson: string): AnalysisResult {
const parsed = JSON.parse(rawJson);
return AnalysisResultSchema.parse(parsed);
}ここで重要なのは、Gemini に渡す responseSchema と、Zod スキーマが同一の意味を持つように同期させることです。私はこれを 1 つの定義から両方を生成する形にしています。Zod スキーマから zod-to-json-schema で JSON Schema を出力し、それを Gemini に渡しています。
import { zodToJsonSchema } from "zod-to-json-schema";
const responseSchemaForGemini = zodToJsonSchema(AnalysisResultSchema, {
target: "openApi3",
});
const result = await model.generateContent({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: "application/json",
responseSchema: responseSchemaForGemini as any,
},
});二重定義しないことで、スキーマの意味のずれが起きません。Pydantic を使う場合も同様に model_json_schema() で JSON Schema を出力できます。
第 2 段: 修復プロンプトによる再試行
第 1 段で ZodError が出たとき、即座に縮退に落とすのではなく、Gemini にエラー内容を教えて再生成させます。これが「修復プロンプト」と呼んでいる仕組みです。
async function generateWithRepair(
prompt: string,
maxAttempts: number = 2,
): Promise<AnalysisResult> {
let lastError: z.ZodError | null = null;
let lastRawResponse: string | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const finalPrompt = lastError
? buildRepairPrompt(prompt, lastRawResponse!, lastError)
: prompt;
const raw = await callGemini(finalPrompt);
try {
return parseGeminiResponse(raw);
} catch (err) {
if (err instanceof z.ZodError) {
lastError = err;
lastRawResponse = raw;
continue;
}
throw err;
}
}
throw new SchemaValidationFailed(lastError!, lastRawResponse!);
}
function buildRepairPrompt(
originalPrompt: string,
lastResponse: string,
error: z.ZodError,
): string {
const issues = error.issues
.map((i) => `- ${i.path.join(".")}: ${i.message}`)
.join("\n");
return `${originalPrompt}
前回の応答は次の通りでしたが、スキーマ違反が含まれていました:
\`\`\`json
${lastResponse}
\`\`\`
次の問題を修正して、もう一度同じスキーマに従って出力してください:
${issues}
`;
}修復プロンプトの効果は私の運用で見えていて、第 1 段で 3% 失敗していたものが、第 2 段の 1 回再試行で 0.3% まで下がります。さらに 2 回目の再試行で 0.05% 以下になります。コストは増えますが、サンプリング率を見ると失敗時のみのコスト増なので、月単位でみても数 % 程度の増加で済んでいます。
注意点として、修復プロンプトの中に「前回の応答」を載せるとコンテキスト長が膨らみます。私の運用では、再試行は最大 2 回までに制限し、3 回目で第 3 段の縮退に落とします。
第 3 段: 縮退ハンドラ — UI を壊さない最後の防御
第 2 段でも失敗するレスポンスは、月に数件のオーダーで必ず出ます。ここで例外を投げてしまうと、ユーザーには「処理に失敗しました」というエラー画面しか見せられません。これは UX として良くありません。
縮退ハンドラは、スキーマ違反を許容できる形に吸収して、UI が破綻しないようにします。私の運用ではフィールドごとに縮退戦略を決めています。
function fallbackForSchemaFailure(
raw: unknown,
error: z.ZodError,
): AnalysisResult {
const partial = raw as Partial<AnalysisResult>;
return {
sentiment: SentimentEnum.safeParse(partial.sentiment).success
? (partial.sentiment as "positive" | "negative" | "neutral")
: "neutral",
score: clamp(typeof partial.score === "number" ? partial.score : 50, 0, 100),
keywords: Array.isArray(partial.keywords)
? partial.keywords.slice(0, 3).concat(Array(3).fill("")).slice(0, 3)
: ["", "", ""],
summary: typeof partial.summary === "string"
? partial.summary.slice(0, 500).padEnd(10, " ")
: "(要約を生成できませんでした)",
};
}縮退戦略のポイントは、**「壊れた値を捨てて中央値で埋める」のではなく、「使える値はできる限り使う」**ことです。例えば sentiment が "slightly positive" なら、第 1 段では落としますが、縮退時には "positive" に丸めて使います。score が 105 なら 100 にクリップします。
ただし、縮退で吸収したことは必ずログに残します。これがないと「気づいたら全部のレスポンスが縮退で動いていた」という事故が起きます。
logger.warn("schema_fallback_used", {
task_id: taskId,
zod_issues: error.issues.map((i) => ({
path: i.path.join("."),
code: i.code,
})),
});このログを Honeycomb / Datadog に流して、縮退率が 1% を超えたらアラートを上げる、という運用にしています。
スキーマ管理ルール — 二重定義しないために
3 段防御を運用していると、スキーマ変更のたびに「Zod / Pydantic 側」「Gemini に渡す responseSchema 側」「縮退ハンドラ側」の 3 箇所を同期する必要が出てきます。これを手作業でやるとずれます。
私が運用しているルールは次の通りです。
第一に、Zod / Pydantic スキーマを唯一の真実とする。Gemini に渡す JSON Schema は必ず生成、縮退ハンドラはスキーマからフィールド一覧を取り出すヘルパで生成します。
第二に、スキーマファイルを src/schemas/gemini/ に集約する。1 ファイル 1 スキーマで管理し、ファイル名はタスク名と一致させる。sentiment_analysis.ts、keyword_extraction.ts のように。
第三に、スキーマ変更時に旧スキーマでのレスポンスを壊さない。スキーマを後方互換的に拡張するためのルールを決めておく。新規フィールドは optional から始める、enum は値を増やすだけにして消さない、など。
このルールを守っていると、3 段防御の保守コストが大きく下がります。
運用 1 ヶ月で見えた数字 — 私の実例
3 段防御を運用 1 ヶ月で、私のリポジトリではこんな数字が見えています。参考までに残しておきます。
- 第 1 段スキーマ検証の失敗率: 約 2.8%
- 第 2 段修復プロンプトでの回復率: 89%(残り 11% が第 3 段に落ちる)
- 第 3 段縮退ハンドラの発動率: 約 0.3%
- 本番でユーザーに「エラー画面」を出した件数: 0 件
第 3 段が 0.3% 発動しているということは、月数千リクエストの規模だと月数十件は縮退で吸収しています。これらが全部スキーマ違反として例外になっていたら、ユーザー体験は確実に悪化していました。
この多層防御は最初の構築に半日〜1 日かかります。ですが構築後は、Gemini API のスキーマ変更や、新規タスクへの応用が定型化できます。「Gemini に構造化出力を出させて、たまに壊れる」という不安定さに悩んでいる方は、まず Zod / Pydantic スキーマを 1 つ書いて、第 1 段だけ実装するところから始めてみてください。