ARCHININGEN TECH BLOG
← 記事一覧へ戻る

Structured Outputs のスキーマには、書いても効かない制約がある — それでも全部書き、保証はコードに置く

CONTENTS

制約違反からの自己修正ループを示した図。左から右へ、モデルの生成 → コードの検証、と流れ、検証で不合格になると「エラー内容+改善提案」の文章が次の user message となって生成へ戻る矢印が描かれる。戻る矢印には previous_response_id と書かれ、失敗した試行も会話履歴に残ることを示す。ループは最大 3 回で、合格すれば確定・保存へ抜け、3 回失敗すればエラーとして終了する。図の結びは「バリデータの返り値が、次のプロンプトになる」

Structured Outputs を有効にすると、モデルの出力は JSON Schema に従うようになります。required に入れたキーは必ず揃い、enum の語彙から外れた値は返ってこない。この確実さに一度慣れると、期待は自然に広がります — minimum: 0.4 と書けば、0.4 未満は返ってこないはずだ、と。ところが、範囲外の値は普通に返ってきます。

バグではありません。Azure OpenAI の公式ドキュメントには「サポートされないキーワード」の表があり、数値の minimum / maximum / multipleOf も、配列の minItems も、strict モードで強制されないキーワードとしてそこに載っています。本番の挙動もその通りです。

では、効かない制約はスキーマから消すべきでしょうか。このシステムの答えは逆で、知ったうえで全部書き続けています。

本記事の主張は次の 1 文です。効かない制約はモデルへのヒントとしてスキーマに書いてよい。ただし実効的な保証は必ずコード側に置き、検証に失敗したらエラーコードではなく「モデルが読んで直せる文章」を返して自己修正させる。 つまりこれは、制約をどこに置くかという配置の設計の話です。スキーマ・コード・プロンプト・会話履歴の 4 つの置き場所を、このシステムがどう使い分けているかを書きます。

題材はこれまでと同じ、B2B の業務 SaaS のバックエンドで動く AI エージェント API です。Azure Functions v4 (Node.js 22 / TypeScript) 上で動き、モデルは Azure OpenAI の gpt-5.4-mini、API バージョンは 2025-04-01-preview。チャットでヒアリングした内容を、条件の抽出・配分の試算・設計書の出力へと引き渡していきます。構造化出力を使うスキーマは 5 つあります。

JSON Schema の文法解説と、Structured Outputs の有効化手順は書きません。公式ドキュメントで足ります。

前提: Responses API では text.format に渡す

1 点だけ前提を書きます。Chat Completions ではスキーマを response_format.json_schema に渡しますが、Responses API では text.format に渡します。ネストが 1 段浅くなり、type / name / strict / schema が同じ階層に並びます。

JAVASCRIPT
// src/shared/responses.ts
function buildTextParam(schema) {
  if (!schema) return undefined;
  return {
    format: {
      type: "json_schema",
      name: schema.name,
      strict: schema.strict ?? true,
      schema: schema.schema,
    },
  };
}

Chat Completions から移行するとき、スキーマ本体は 1 文字も変えずに済みますが、この包み方だけは変わります。移行で必ず踏む差分なので先に書いておきました。以降はスキーマの中身の話です。

効く制約と効かない制約を、意図的に混ぜる

代表として、ターゲットカテゴリを出力するスキーマの一部を引用します。

JAVASCRIPT
// src/shared/schemas.ts より
weighted_categories: {
  type: "array",
  items: {
    type: "object",
    properties: {
      name: {
        type: "string",
        description: "カテゴリ名(分類マスタの小分類から選定)",
      },
      weight: {
        type: "number",
        minimum: 0.4,
        maximum: 1.0,
        multipleOf: 0.1,
        description: "関連度(0.4=下限、1.0=最も関連が強い、0.1刻み)",
      },
    },
    required: ["name", "weight"],
    additionalProperties: false,
  },
  minItems: 1,
  description: "カテゴリと関連度の重みづけ",
},

この短い断片の中に、性質の違う制約が同居しています。

効く制約 — strict モードが機械的に保証するもの。required(全キーが必ず出力される)、additionalProperties: false(定義外のキーは出力されない)、enum(別のスキーマでは区分値のフィールドを enum で固定しており、語彙が完全に閉じる)。これらが破られた JSON は届きません(refusal 応答や max_output_tokens による途中打ち切りという例外はあります)。

効かない制約 — Azure OpenAI の公式ドキュメントが「サポートされないキーワード」として列挙しているもの。数値の minimum / maximum / multipleOf、配列の minItems / maxItems、文字列の pattern / format などです。このシステムでは開始日・終了日にも pattern: "^\\d{4}-\\d{2}-\\d{2}$" を書いていますが、これも強制されません。この環境(2025-04-01-preview)では書いてもエラーにならず、黙って受け取られ、強制もされない — 実質的には description の一種です。ただしこの挙動は一般化できません。OpenAI 本家はサポート外のスキーマを strict: true で渡すとエラーになると明記しており、しかも現在の本家ドキュメントではこれらのキーワード自体がベースモデルのサポート対象(=強制される側)に移っています。効く・効かない・エラーになるの線引きは、プロバイダと API バージョンで動くということです。

それでも書いているのは、スキーマがモデルに渡る文書でもあるからです。description がモデルへの指示として機能するのと同じチャネルで、minimum: 0.4 は「0.4 未満を出すな」という指示として読まれます。経験上おおむね守られます。ただし守られる「ことが多い」だけで、保証ではありません。だからこのシステムでは、効かない制約 1 つ 1 つにコード側の受け皿を対で用意しています。

  • weight の範囲・予算の下限 → src/utils/budgetValidation.ts のバリデータ(後述)
  • name が分類マスタに実在するか → src/utils/fuzzyMatch.ts の正規化(エイリアス → 完全一致 → あいまい一致 → 一致なし、の 4 段階)

スキーマに書くのは伝達のため、コードに書くのは保証のため。同じ制約が 2 箇所に書いてあるのは、この構成では冗長ではなく役割分担です。

strict の「全 required」を、逆に仕様として使う

strict モードには「全プロパティを required に入れる」という制約があります。省略可能なフィールドが作れないという不便として語られがちですが、このシステムでは逆に、これを仕様として当てにしています。予算配分スキーマの配分先プロパティです。

JAVASCRIPT
// src/shared/schemas.ts  予算配分スキーマより
targets: {
  type: "object",
  properties: {
    "1":  { type: "number", minimum: 0, description: "配分先 A(従量課金)" },
    "4":  { type: "number", minimum: 0, description: "配分先 B(定額)" },
    "12": { type: "number", minimum: 0, description: "配分先 C(従量課金)" },
    // ...(実際には十数件続く)
  },
  required: ["1", "4", "12", /* ...全配分先の ID */],
  additionalProperties: false,
},

全配分先が required なので、モデルは選ばなかった配分先にも明示的に 0 を書きます。「キーが無い」という状態が存在しなくなるので、「未選定なのか、書き忘れなのか」という曖昧さが消え、下流の処理は budget > 0 だけで選定判定できます。副次効果として、モデルは全配分先を一度は検討することになります。

additionalProperties: false も、ここでは幻覚対策として効いています。プロパティ名は配分先マスタの ID(数値文字列)なので、存在しない ID をモデルが発明する余地が構造的にありません。そしてプロパティ名が "1""12" である以上、それが何の配分先かをモデルに伝える手段は description しかない — つまりこのスキーマでは description が唯一のセマンティクス供給源です。「description はおまけ」ではなく、これが無いとスキーマが意味を成しません。

バリデータの返り値を、プロンプトとして設計する

ここが本記事の核です。効かない制約の受け皿であるバリデータは、検証結果を boolean とエラーコードではなく、エラー内容と改善提案の 2 つの文章で返します。

JAVASCRIPT
// src/utils/budgetValidation.ts  validateBudgetAllocation() より
if (budget < minFee) {
  violations.push(`target_id=${targetId}: ${budget.toLocaleString()}円`);
  suggestions.push(
    `target_id=${targetId}${minFee.toLocaleString()}円以上に増額するか、0円にして選定から外す`,
  );
}
// ...(中略)
if (totalAllocated < minFeeTotal) {
  const shortfall = minFeeTotal - totalAllocated;
  const errorMsg = `案件全体の最低予算を満たしていません: 合計${totalAllocated.toLocaleString()}円 < 最低${minFeeTotal.toLocaleString()}円`;
  const suggestion =
    `【改善提案】\n- 合計予算を${shortfall.toLocaleString()}円以上増額してください\n- 配分先を追加するか、既存配分先の予算を増やしてください`;
  return { isValid: false, errorMsg, suggestion };
}

この文章の読者は人間ではなく、次のターンのモデルです。その前提で 3 つの設計判断をしています。

1. 正解ルートを 2 つ提示する。 「増額するか、0 円にして選定から外す」。最低予算違反の解は増額だけではありません。制約充足問題の解空間を明示的に教えることで、モデルが「全配分先を少しずつ増額する」ような望ましくない方向へ探索するのを防ぎます。

2. 数値を toLocaleString() で整形する。 100000 ではなく 100,000 として渡します。人間向け UI の整形をそのまま流用しているように見えますが、意図は逆で、LLM が読む前提のフォーマットです。桁区切りのある数値のほうが、金額としての大小関係を扱い誤りにくいという判断です(なお toLocaleString() の出力は実行環境のロケールに依存します。ここでは Node.js の既定ロケールで桁区切りになることを前提にしています)。

3. shortfall(不足額)を先に計算して渡す。 「合計を X 円以上増額」の X はコードが引き算した結果です。モデルに「最低額と現在額の差を計算して埋めよ」と言うのではなく、答えに必要な算数はコード側で済ませてから渡す。モデルには判断だけをさせます。

制約値そのものを、コードで計算する

このバリデータが使う minFee は、定数ではありません。実施期間から導出されます。

JAVASCRIPT
// src/utils/budgetValidation.ts(単価と期間単位の実数値は伏せています)
export function calculateMinimumFees(startDate, endDate) {
  const start = new Date(startDate);
  const end = new Date(endDate);
  if (isNaN(start.getTime()) || isNaN(end.getTime())) {
    return DEFAULT_FEES;                               // 日付が不正
  }
  if (start > end) {
    return DEFAULT_FEES;                               // 開始 > 終了
  }
  const days =
    Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
  const periodUnits = Math.ceil(days / PERIOD_DAYS);   // (実施日数 / 期間単位) を切り上げ
  return { minFee: periodUnits * UNIT_FEE, minFeeTotal: periodUnits * UNIT_FEE_TOTAL };
}

実施日数を一定の期間単位で切り上げ、配分先ごと・案件全体それぞれの単価を掛ける。日付が不正・逆順・パース不能なら既定値へ落とす。つまり LLM に課す制約そのものが、リクエストごとに計算されます。仮に minimum が strict モードで強制されるようになったとしても、この制約はスキーマに書けません。スキーマは静的で、制約値は動的だからです。「保証はコードに置く」というのは、効かないキーワードの穴埋めであると同時に、スキーマという静的な器では原理的に表現できない制約の置き場所でもあります。

失敗した試行も履歴に残して、自己修正させる

バリデータが返した文章は、そのまま次のリクエストになります。

JAVASCRIPT
// src/functions/formSimulation.ts より(中略あり)
const maxRetries = 3;
let currentPrevId = params.previousResponseId;
let currentMessage = dynamicInfo;

for (let attempt = 0; attempt < maxRetries; attempt++) {
  const result = await sendMessage({
    userMessage: currentMessage,
    previousResponseId: currentPrevId,
    schema: schemaObj,
    // ...
  });

  currentPrevId = result.responseId;   // 失敗した試行も履歴に残す

  const output = jsonSafe(result.outputText);
  const validation = validateBudgetAllocation(output, minFee, minFeeTotal);
  if (validation.isValid) break;

  if (attempt < maxRetries - 1) {
    currentMessage = `
予算配分が制約条件を満たしていません。

【エラー内容】
${validation.errorMsg}

${validation.suggestion}

上記の改善提案を参考に、制約条件を満たす予算配分を再度出力してください。
`;
  }
}

要点は currentPrevId = result.responseId の 1 行です。検証に失敗した応答の ID でも、次のリクエストの previous_response_id に使います。つまり失敗した試行が会話履歴から消えません。モデルは「自分がさっき出した配分」と「それがどの制約にどれだけ足りなかったか」の両方を文脈に持った状態で、修正案を出します。失敗を無かったことにして同じ入力を再送するリトライとは、収束のしかたが違います。

これは第 1 回で書いた「会話状態はサーバ側の previous_response_id チェーンに預ける」という設計の応用です。会話履歴を自前で組み立てていたら、失敗した試行を履歴に足す処理を自分で書くことになりますが、この構成ではポインタを進めるだけで済みます。

3 回失敗したら例外を投げて終わります。ここは fail-closed です。制約を満たさない予算配分が下流(実際の業務処理)へ流れることに比べれば、エラーで止まるほうが安い、という判断です。

スキーマで書けない制約は、プロンプトへ

ここまでで「構造はスキーマ、値はコード」と置き場所が決まりましたが、もう 1 種類残っています。フィールド間の整合です。

予算配分の出力には、JSON の budget_allocation と並んで、budget_and_strategy という日本語の戦略文フィールドがあります。JSON では配分先 A に 50 万円と書き、戦略文では「配分先 A に 60 万円配分」と書く — この食い違いは業務上致命的ですが、JSON Schema には「フィールド A の数値とフィールド B の記述を一致させよ」を表現する語彙がありません。

このシステムでは、プロンプトの検証ルールとして書いています。

JAVASCRIPT
// src/shared/prompts.ts  OUTPUT_SPECS の validationRules より
"budget_and_strategyに記載する配分先名・金額は、budget_allocation.targetsの値と完全に一致させること。配分先名は正式名称を使用し、略称や造語は禁止",
"導出順序: (1) budget_allocation.targetsで各配分先の金額を確定 → (2) 金額>0のIDを正式名称に変換 → (3) その名称と金額をbudget_and_strategyテキストに転記。テキストを先に書いてからJSONを合わせるのではなく、JSONを先に確定してからテキストを導出すること",

注目してほしいのは 2 つ目で、一致「させろ」ではなく生成の順序を指定しています。「JSON を先に確定してからテキストを導出する」。一致という結果だけを要求するより、一致が生まれる手順を指定するほうが守られやすい、という考え方です。このルール群は prompts.tsOUTPUT_SPECS に定数として置かれ、そこからプロンプトが機械的に組み立てられます。出力契約に関わる文言がコードレビューの対象になる、という運用上の意味もあります。

境界: 自由なキーの辞書を出したいとき

最後に、この記事の枠組みが崩れる境界を 1 つ正直に書いておきます。5 つのスキーマのうち 1 つだけ、毛色の違うものがあります。

JAVASCRIPT
// src/shared/schemas.ts  設計書出力用スキーマより(スキーマ名は伏せています)
{
  description: "設計書項目の柔軟な箇条書きテキスト出力用スキーマ",
  schema: {
    type: "object",
    additionalProperties: {
      type: "string",
    },
  },
},

設計書は案件ごとに項目が変わるため、キーを固定できず、「値が文字列でありさえすれば、キーは自由」という辞書型のスキーマになっています。一方でスキーマを包む getSchema() は、すべてのスキーマに無条件で strict: true を付けます。公式ドキュメントは「オブジェクトには常に additionalProperties: false を設定せよ」と書いているので、この組み合わせは仕様上グレーです。現に使っている preview の API では通っているように見えますが、保証されている状態とは言えません。

ここで注目してほしいのは、このスキーマでは本記事の主張が成立していないことです。キーの集合を固定できないので、strict が保証してくれる「構造」がほぼ残っていません。スキーマで構造を固定できるからこそ、値の保証をコードに逃がす分業が成り立つ — 構造そのものが動的なら、その前提が崩れます。制約の置き場所を設計できるのは、構造が静的な範囲まで。それがこの枠組みの境界です。

まとめ — スキーマはヒント、保証はコード、失敗はプロンプト

「効かない制約をスキーマに書くべきか」— 本記事の答えは明確です。書くべきです。判断の基準は「強制されるか」ではなく「モデルに伝わるか」であり、強制されない制約もモデルには伝わるからです。 ただし、伝わることと保証されることは別物です。だから同じ制約を、保証のためにコードへもう一度書きます。この二重記述は冗長ではなく、伝達と保証という別々の仕事の分担です。

この一文を決めると、制約の置き場所はすべて従属的に決まります。

制約の種類 置き場所 なぜそこか
構造 — キーの集合・型・語彙 スキーマ(enum / required / additionalProperties: false strict モードが機械的に保証する。全 required と定義外キーの禁止は、曖昧さと幻覚を構造的に消す道具として使える
値 — 範囲・刻み・件数 スキーマにはヒントとして書き、保証はコードのバリデータ minimum / multipleOf / minItems / pattern は強制されない。ただしモデルへの伝達としては機能する
実行時に決まる制約値 コードで計算し、検証と改善提案に注入 スキーマは静的な器なので、リクエストごとに変わる制約は原理的に書けない
フィールド間の整合 プロンプト(一致の要求ではなく、生成順序の指定) JSON Schema に表現する語彙がない
制約違反からの回復 会話履歴(失敗した試行も previous_response_id で文脈に残すリトライ) バリデータの返り値が次のプロンプトになる。モデルは自分の間違いを見ながら直す

この配置のいちばんの強みは、「どの制約が効くか」という前提が動いても壊れないことです。実際、執筆時点で Azure OpenAI のドキュメントは minimum を非サポートと明記し、OpenAI 本家は同じキーワードをサポート対象に列挙しています。効く・効かない・エラーになるの線引きは、プロバイダと API バージョンで現に動いています。スキーマの強制力に保証を預ければ、この揺れがそのままシステムの揺れになります。保証をコードに置いていれば、スキーマ側の仕様がどちらへ転んでも、変わるのは「ヒントの効き具合」だけです。

そして検証の失敗は、コードからモデルへの伝達 — つまりプロンプトです。スキーマで伝え、コードで保証し、失敗を文章で返して直させる。この循環が閉じていれば、strict モードが保証してくれない部分も、システムとしては保証できます。一文に圧縮すれば、こうなります。スキーマはヒント、保証はコード、失敗はプロンプト。

次回は、この記事で受け皿の 1 つとして触れたカテゴリ名の正規化について書きます。LLM が出力したカテゴリ名をマスタの語彙空間に射影する、エイリアス → 完全一致 → あいまい一致 → 一致なし、の 4 段階フォールバックの話です。


検証環境は Node.js 22 / Azure Functions v4 / Azure OpenAI api-version: 2025-04-01-preview / gpt-5.4-mini、2026 年 8 月時点のものです。Structured Outputs のサポート範囲(強制されるキーワード・されないキーワード)はプロバイダと API バージョンで異なり、今後も変わりえます。実際、OpenAI 本家のドキュメントでは minimumpattern などが既にベースモデルのサポート対象として列挙されています(非サポートは fine-tuned モデル限定)。最新の情報は公式ドキュメントで確認してください。なお、本文中のコード引用は、題材システムのドメイン固有の語彙・識別子・一部の数値を一般化した改変版です(設計の構造は実装のとおりです)。

参考リンク

  • How to use structured outputs (Microsoft Learn) — サポートされる JSON Schema のサブセット。「サポートされないキーワード」の表(文字列の pattern / format、数値の minimum / maximum / multipleOf、配列の minItems / maxItems 等)、全フィールドを required に含める制約、オブジェクトには常に additionalProperties: false を設定する旨
  • Structured Outputs (OpenAI API) — strict モードの仕様とサポートされるスキーマ機能の一覧、サポート外のスキーマを strict: true で渡すとエラーになる旨。本家では minimum / pattern 等がベースモデルのサポート対象として列挙されている(非サポートは fine-tuned モデル限定)

連載「AI エージェント API の本番設計」全 4 回

  1. Azure OpenAI Responses API の会話状態設計 — サーバに預けられるもの、手元に残るもの
  2. SSE の done は「出力完了」ではなく「副作用の確定」 — ストリーミング API の完了をどう定義するか
  3. Structured Outputs のスキーマには、書いても効かない制約がある — それでも全部書き、保証はコードに置く(この記事)
  4. LLM が出力した名前は、マスタと微妙に違う — 表記揺れを前提にした正規化と観測の設計

更新履歴

    • Structured Outputs の制約の置き場所設計の記事を追加