本稿の背景資料: https://www.aceround.app/ja/blog/ai-interviewer-follow-up-questions/
LLMのStructured Outputで「format」を分離する:TypeScriptで壊れにくいスキーマ設計
LLMにJSONを返させるとき、スキーマを渡しているのにリクエスト自体が400になることがあります。原因がモデルの出力ではなく、スキーマに含めたJSON Schemaのformatキーワードを、互換ゲートウェイが受け付けないことだと、ログを見ても気づきにくいです。
結論はシンプルです。
- モデルに渡すスキーマは、構文と必須項目だけを表す「受理用」にする
- モデルから返った値は、アプリケーション境界で「業務用」の厳格なスキーマで検証する
- 2つのスキーマを同じ名前で使い回さず、テストで境界を固定する
この分離により、ゲートウェイの制約で推論が始まらない問題と、形式が正しくないデータを保存する問題を同時に扱えます。
まず「どこで失敗したか」を分ける
Structured Outputの失敗は、少なくとも次の3段階に分けてログに残すべきです。
- リクエスト検証: ゲートウェイがスキーマを受理したか
- JSON解析: モデルの返した文字列をJSONとして解釈できたか
- 業務検証: JSONの値がメールアドレスやURLなどの規則を満たすか
format: "email" や format: "uri" は、3番目の業務検証には便利です。しかし、1番目のゲートウェイが対応していない場合、モデルを呼び出す前に拒否されます。つまり「モデルが形式を守れなかった」のではなく、「モデルに到達していない」のです。
受理用と業務用のスキーマを分ける
例として、LLMに採用担当者の連絡先候補を抽出させるケースを考えます。ゲートウェイには形式を付けず、文字列であることだけを要求します。
type ContactCandidate = {
name: string;
email: string;
sourceUrl: string;
};
const modelSchema = {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string" },
email: { type: "string" },
sourceUrl: { type: "string" },
},
required: ["name", "email", "sourceUrl"],
} as const;
ここで email と sourceUrl を文字列にしているのは、検証を弱めるためではありません。ゲートウェイとの契約を最小限にするためです。ゲートウェイが認識できるキーワードだけで、LLMから返るオブジェクトの形を固定します。
一方、保存や画面表示に進める前には、厳格な業務検証を行います。
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const urlPattern = /^https?:\/\/[^\s]+$/;
function validateContact(value: ContactCandidate): ContactCandidate {
if (!value.name.trim()) {
throw new Error("name must not be empty");
}
if (!emailPattern.test(value.email)) {
throw new Error("invalid email");
}
if (!urlPattern.test(value.sourceUrl)) {
throw new Error("invalid sourceUrl");
}
return value;
}
実際のプロジェクトでZodなどを使う場合も考え方は同じです。モデル用スキーマでは z.string() を使い、永続化用スキーマでは z.email() や z.url() を使います。モデル用スキーマから生成したJSON Schemaに、対応していない format が混ざっていないことをテストで確認します。
ゲートウェイの制約を小さなテストで再現する
本番のゲートウェイを毎回呼ばなくても、スキーマに未対応キーワードが混入したら検出できます。次のテストは、format を見つけたら失敗する最小の「ゲートウェイ・プローブ」です。
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
function findFormats(value: JsonValue, path = "$"): string[] {
if (Array.isArray(value)) {
return value.flatMap((item, index) =>
findFormats(item, path + "[" + index + "]"),
);
}
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, child]) => {
const nextPath = path + "." + key;
return key === "format"
? [nextPath + "=" + String(child)]
: findFormats(child, nextPath);
});
}
return [];
}
function assertGatewaySchema(schema: JsonValue): void {
const formats = findFormats(schema);
if (formats.length > 0) {
throw new Error("unsupported schema formats: " + formats.join(", "));
}
}
テストでは、受理用スキーマが通り、誤って業務用スキーマを渡した場合だけ失敗することを確認します。
assertGatewaySchema(modelSchema);
const invalidForGateway = {
...modelSchema,
properties: {
...modelSchema.properties,
email: { type: "string", format: "email" },
},
};
try {
assertGatewaySchema(invalidForGateway);
throw new Error("expected the gateway probe to fail");
} catch (error) {
if (!(error instanceof Error) || !error.message.includes("format")) {
throw error;
}
}
このテストは、特定ベンダーのSDKに依存しません。JSON Schemaを生成するライブラリやAI SDKを更新したときにも、実際にゲートウェイへ送る直前の形を検査できます。
ランタイムの処理順序
処理順序を固定すると、エラーの意味が混ざりません。
- modelSchema を送る前に、format の有無を検査する
- ゲートウェイのレスポンスをJSONとして解析する
- 解析結果を業務用バリデータへ渡す
- 不正なら保存せず、フィールド名と値の出所をログに残す
ここで重要なのは、3番目の検証を省略しないことです。受理用スキーマは「LLMに仕事を始めてもらう」ためのものであり、「データが正しい」と保証するものではありません。
失敗時に残すコンテキスト
実運用では、次の情報を別々に記録すると調査が速くなります。
- ゲートウェイ拒否: 使用したタスク名、スキーマのハッシュ、HTTPステータス
- JSON解析失敗: レスポンスID、先頭だけをマスキングした本文、モデル名
- 業務検証失敗: ユーザーやジョブのID、失敗したフィールド、検証ルール名
メールアドレスやURLの実値をそのままログへ書く必要はありません。値のハッシュやドメインだけに留めると、後から同じ入力を追跡しながら個人情報を増やさずに済みます。
まとめ
Structured Outputを安定させるポイントは、LLMに厳しい正規表現を理解させることではありません。
- ゲートウェイが受理できる最小スキーマをモデルへ渡す
- 返却後に業務用スキーマでメール・URL・列挙値を検証する
- スキーマ生成物を検査するテストをCIに置く
- 失敗段階ごとにログのコンテキストを分ける
この境界を守れば、ゲートウェイを乗り換えてもモデル呼び出しは壊れにくく、業務データの品質はアプリケーション側で一貫して守れます。