請求書の自動仕分けを動かしていたとき、月に数件だけ「合計金額が明細の足し算と合わない」レコードが混ざることに気づきました。JSON のパースは通っていますし、スキーマのバリデーションも緑です。それでも値が間違っている。
構造化出力を本番に載せると、多くの人がここでつまずきます。responseMimeType: "application/json" を付ければ確かに毎回パース可能な JSON が返ります。けれど「パースできる」ことと「業務的に正しい」ことは別の話です。この境界を最初に引いておかないと、静かに壊れたデータが下流へ流れていきます。
ここでは @google/genai の現行スキーマ設計から、二層検証、失敗の見分け方、回収の実装まで、運用で実際に効いた順に整理します。2026 年 6 月時点で既定モデルは Gemini 3.5 Flash に上がり、Structured Outputs も GA になりました。挙動が安定した今こそ、設計を固め直す良い時期だと考えています。
「構造化出力=安全」が成り立たない理由
構造化出力が保証してくれるのは、おおむね次の三つです。出力が指定した JSON 型に従うこと、required のフィールドが欠けないこと、enum で列挙した値の範囲を外れないこと。これは大きな前進で、自由形式テキストを正規表現で剥がしていた頃に比べれば段違いに堅牢です。
一方で、保証してくれないものもはっきりしています。数値が業務的に妥当か(明細合計と総額が一致するか)、日付が実在するか(2026-02-30 を弾けるか)、複数フィールド間の整合性(payment_status: paid なのに paid_date が空でないか)。これらはスキーマの外側にあります。
つまり構造化出力は「形」を保証する層であって、「意味」を保証する層ではありません。本番パイプラインでは、この二つを別々のコードとして持つのが結局いちばん壊れにくい、というのが個人開発で数か月運用して出した結論です。本番運用では、形のエラーと意味のずれを同じ対処で握りつぶさないことが効きます。
スキーマ設計 — モデルに「形」を教える
まずは現行 SDK での最小構成です。旧 @google/generative-ai から @google/genai へ移ったことで、呼び出しは ai.models.generateContent に集約され、設定は config に入ります。
// structured-review.ts
import { GoogleGenAI, Type } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
// description はモデルへの指示として効く。型名だけでなく「何を入れるか」を書く
const reviewSchema = {
type: Type.OBJECT,
properties: {
productName: { type: Type.STRING, description: "レビュー対象の製品名" },
rating: {
type: Type.INTEGER,
description: "1〜5 の整数。小数や範囲外は許容しない",
},
pros: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: "良い点。本文に根拠がある項目だけ",
},
cons: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: "改善点。本文に根拠がある項目だけ",
},
sentiment: {
type: Type.STRING,
enum: ["positive", "neutral", "negative"],
description: "全体の論調",
},
},
// propertyOrdering でモデルが生成する順序を固定する
propertyOrdering: ["productName", "rating", "pros", "cons", "sentiment"],
required: ["productName", "rating", "sentiment"],
};
export async function analyzeReview(reviewText: string) {
const res = await ai.models.generateContent({
model: "gemini-3.5-flash",
contents: `次のレビューを分析してください:\n\n${reviewText}`,
config: {
responseMimeType: "application/json",
responseSchema: reviewSchema,
},
});
return JSON.parse(res.text);
}
ここで地味に効くのが三点あります。
description は飾りではなく、実質的な指示として読まれます。「1〜5 の整数」とだけ書くより「小数や範囲外は許容しない」と添えるほうが、境界値の暴れが目に見えて減りました。型の説明ではなく、現場のルールを書く場所だと捉えると質が上がります。
propertyOrdering は見落とされがちですが、生成順を固定すると出力の安定度が上がります。モデルは前のフィールドを文脈にして次を埋めるため、たとえば rating を pros/cons より先に置くと、評点と理由がちぐはぐになりにくくなります。逆に重要な判断フィールドを末尾に置くと、手前の冗長な配列に引きずられることがあります。
required は最小限にとどめます。すべてを必須にすると、モデルは埋められない欄を無理やり捏造します。「無ければ省略してよい」とスキーマで許すほうが、結果的にハルシネーションが減ります。
ネストした抽出 — enum で揺れを閉じる
実務のスキーマはフラットでは済みません。請求書のようにネストと配列が混ざる場合、子オブジェクトにも required を効かせ、状態系は必ず enum で閉じます。
// invoice-schema.ts
import { Type } from "@google/genai";
export const invoiceSchema = {
type: Type.OBJECT,
properties: {
invoiceNumber: { type: Type.STRING, description: "請求書番号" },
issueDate: {
type: Type.STRING,
description: "発行日。必ず YYYY-MM-DD 形式の実在する日付",
},
vendor: {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING },
taxId: { type: Type.STRING, description: "登録番号。無ければ省略" },
},
required: ["name"],
},
lineItems: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
description: { type: Type.STRING },
quantity: { type: Type.NUMBER },
unitPrice: { type: Type.NUMBER, description: "税抜単価" },
amount: { type: Type.NUMBER, description: "数量 × 単価(税抜)" },
},
propertyOrdering: ["description", "quantity", "unitPrice", "amount"],
required: ["description", "quantity", "unitPrice", "amount"],
},
},
grandTotal: { type: Type.NUMBER, description: "税込総額" },
paymentStatus: {
type: Type.STRING,
enum: ["paid", "pending", "overdue"],
},
currency: { type: Type.STRING, enum: ["JPY", "USD", "EUR"] },
},
propertyOrdering: [
"invoiceNumber", "issueDate", "vendor",
"lineItems", "grandTotal", "paymentStatus", "currency",
],
required: ["invoiceNumber", "issueDate", "lineItems", "grandTotal", "paymentStatus", "currency"],
};
paymentStatus や currency のような状態フィールドを自由文字列にすると、Paid・PAID・支払済 のような表記揺れが必ず混ざります。enum で閉じておけば、下流の分岐が単純な等価比較で済みます。ここを締めるだけで、後段の表記揺れ起因のバグが体感でおよそ 50% 減りました。
二層検証 — 形と意味を分けて持つ
スキーマが保証するのは形まで。意味の検証は別レイヤーに切り出します。私は Zod を「スキーマの再確認」ではなく「業務ルールの番人」として使っています。
// invoice-validate.ts
import { z } from "zod";
const InvoiceZ = z.object({
invoiceNumber: z.string().min(1),
issueDate: z.string().refine(
(s) => !Number.isNaN(Date.parse(s)) && /^\d{4}-\d{2}-\d{2}$/.test(s),
{ message: "実在する YYYY-MM-DD ではありません" },
),
vendor: z.object({ name: z.string().min(1), taxId: z.string().optional() }),
lineItems: z.array(z.object({
description: z.string(),
quantity: z.number().positive(),
unitPrice: z.number().min(0),
amount: z.number().min(0),
})).min(1),
grandTotal: z.number().min(0),
paymentStatus: z.enum(["paid", "pending", "overdue"]),
currency: z.enum(["JPY", "USD", "EUR"]),
});
export type Invoice = z.infer<typeof InvoiceZ>;
// スキーマでは表現できない「複数フィールドにまたがる整合性」を検証する
export function checkConsistency(inv: Invoice): string[] {
const issues: string[] = [];
const lineSum = inv.lineItems.reduce((s, it) => s + it.amount, 0);
// JPY は最小単位が 1 円なので丸め誤差はほぼ出ない。多通貨なら許容幅を通貨別に持つ
const tolerance = inv.currency === "JPY" ? 1 : 0.01;
if (Math.abs(lineSum - inv.grandTotal) > tolerance) {
issues.push(`明細合計 ${lineSum} と総額 ${inv.grandTotal} が一致しません`);
}
for (const it of inv.lineItems) {
const expected = it.quantity * it.unitPrice;
if (Math.abs(expected - it.amount) > tolerance) {
issues.push(`「${it.description}」の小計が数量×単価と合いません`);
}
}
return issues;
}
export function validateInvoice(raw: unknown):
| { ok: true; data: Invoice; warnings: string[] }
| { ok: false; error: string } {
const parsed = InvoiceZ.safeParse(raw);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues.map((i) => i.message).join("; ") };
}
return { ok: true, data: parsed.data, warnings: checkConsistency(parsed.data) };
}
ポイントは、整合性違反を「即エラー」にしない設計です。スキーマ違反(形が壊れている)はリトライ対象ですが、整合性違反(合計が合わない)は人間のレビューキューに回すべき性質のものです。冒頭の「月に数件だけ合わない」は、まさにこの warnings に落ちてくれるので、自動処理を止めずに目視へ逃がせます。
冒頭で触れた壊れたレコードが下流へ漏れていたのは、この層が無かったからでした。形のバリデーションだけで「緑」と判断していたのです。
失敗の正体を finish_reason で見分ける
構造化出力でいちばん厄介な失敗は、JSON が途中で切れるケースです。長い配列を返そうとして maxOutputTokens に達すると、応答は中途半端な JSON のまま打ち切られます。原因を finish_reason で切り分けないと、リトライしても同じ場所でまた切れます。
// safe-generate.ts
import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
type GenResult<T> =
| { ok: true; data: T }
| { ok: false; reason: "truncated" | "blocked" | "parse" | "empty"; detail: string };
export async function safeGenerate<T>(
prompt: string,
schema: object,
validate: (raw: unknown) => T,
): Promise<GenResult<T>> {
const res = await ai.models.generateContent({
model: "gemini-3.5-flash",
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: schema,
maxOutputTokens: 8192,
},
});
const finish = res.candidates?.[0]?.finishReason;
// MAX_TOKENS は「もっと枠が要る」のサイン。同じ枠でリトライしても無駄
if (finish === "MAX_TOKENS") {
return { ok: false, reason: "truncated", detail: "maxOutputTokens に達しました" };
}
if (finish === "SAFETY" || finish === "PROHIBITED_CONTENT") {
return { ok: false, reason: "blocked", detail: `安全フィルタ: ${finish}` };
}
const text = res.text;
if (!text) return { ok: false, reason: "empty", detail: "本文が空です" };
try {
return { ok: true, data: validate(JSON.parse(text)) };
} catch (e) {
return { ok: false, reason: "parse", detail: e instanceof Error ? e.message : String(e) };
}
}
truncated と parse を同じ「JSON エラー」として握りつぶすと、原因がまったく違うのに同じ回収をしてしまいます。truncated は枠を広げるかバッチを分割する話で、parse は素直にもう一度投げれば直ることが多い話です。この二つを分けてログに出すだけで、障害の切り分け時間が大きく縮みました。
抑制つきリトライと並行制御
最後に、複数ドキュメントをまとめて処理するパイプラインです。reason に応じて回収方針を変え、同時実行数を絞ってレート制限を踏まないようにします。
// pipeline.ts
import { safeGenerate } from "./safe-generate";
async function processOne<T>(
doc: { id: string; content: string },
schema: object,
validate: (raw: unknown) => T,
maxRetries = 2,
): Promise<{ id: string; status: "ok" | "review" | "failed"; data?: T; note?: string }> {
let attempt = 0;
while (attempt <= maxRetries) {
const r = await safeGenerate(`抽出対象:\n\n${doc.content}`, schema, validate);
if (r.ok) return { id: doc.id, status: "ok", data: r.data };
// truncated と blocked はリトライしても直らない。即座に人間へ
if (r.reason === "truncated" || r.reason === "blocked") {
return { id: doc.id, status: "review", note: r.detail };
}
// parse / empty は指数バックオフで投げ直す
await new Promise((res) => setTimeout(res, 800 * 2 ** attempt));
attempt++;
}
return { id: doc.id, status: "failed", note: "リトライ上限に到達" };
}
// 同時実行数を絞るシンプルなワーカープール
export async function runPipeline<T>(
docs: { id: string; content: string }[],
schema: object,
validate: (raw: unknown) => T,
concurrency = 3,
) {
const results: Awaited<ReturnType<typeof processOne<T>>>[] = [];
const queue = [...docs];
const workers = Array.from({ length: concurrency }, async () => {
let doc;
while ((doc = queue.shift())) {
results.push(await processOne(doc, schema, validate));
}
});
await Promise.all(workers);
return results;
}
status を ok / review / failed の三状態にしているのが肝です。二値(成功・失敗)にすると、「人が見れば救えるが自動では無理」なレコードが失敗の山に埋もれます。review を独立させると、自動化率を落とさずに、本当に人手が必要なものだけが手元に残ります。
concurrency の既定を 3 にしているのは、無料・低額枠のレート制限を踏まないための保守値です。Flash 系は速いので、枠に余裕があれば上げて構いませんが、まずは低めから始めて 429 が出ない範囲を測るのが安全だと感じています。
運用に乗せてから見ている数字
設計を固めたあとは、計測の対象も「成功率」から少し移りました。私自身が個人開発のパイプラインで定点観測しているのは、次の三つです。
- 整合性
warnings が付いた率 — 形は正しいが意味が怪しいレコードの割合。ここが上がるときは抽出ではなく検証ルールを疑います。
truncated の発生率 — スキーマが重すぎる、あるいは配列が長すぎるサイン。maxOutputTokens の調整か分割の判断材料です。
review キューの滞留件数 — 人手の負荷が見える唯一の指標。ここが詰まると自動化率の意味が薄れます。
これらは単なる失敗率より、パイプラインのどこを直すべきかをはっきり指してくれます。
既定モデルが 3.5 Flash に上がったことで、同じスキーマでも truncated がやや増える場面がありました。出力が饒舌になった分、配列系で枠に当たりやすくなった印象です。対処として maxOutputTokens を一段引き上げるか、配列を別コールに分けるかは、truncated 率を見て判断しています。
構造化出力は「形を保証する層」だと割り切り、意味の番人を別に立て、失敗を原因別に回収する。この三つを分けて持つだけで、本番運用のデータパイプラインはずいぶん静かになります。同じように請求書や問い合わせの自動処理を組んでいる方の、設計の足場になれば嬉しいです。