2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIの出力をregexで抜くのは、もう卒業しませんか — 構造化出力(Structured Outputs)でLLMを"壊れない部品"にする実践ガイド

2
Posted at

はじめに — デモでは動いたのに、本番で静かに壊れるやつ

LLMをアプリに組み込み始めたとき、たぶん多くの人が一度はこういうコードを書きます。

# よくある「お願いベース」のコード(本番で静かに壊れる)
import json
import re

prompt = "次の問い合わせメールを分類して、JSONで返して。{category, priority, summary}"
response_text = call_llm(prompt)  # ← LLMの生テキスト

# テキストからJSONっぽい部分を正規表現で抜き出す
match = re.search(r"\{.*\}", response_text, re.DOTALL)
data = json.loads(match.group())  # ← ここでよく落ちる

ローカルで5回くらい試して、ちゃんと動いて、「よし、いけた」と思ってデプロイする。そして本番で、ある日突然 json.JSONDecodeError で落ちる。ログを見たら、LLMがこんなのを返していた…という経験、ありませんか。

承知しました。以下が分類結果です。

```json
{
  "category": "請求",
  "priority": "high",
  "summary": "二重請求についての問い合わせ"
}
```

ご確認ください。

前置きの「承知しました」、Markdownのコードフェンス、後ろの「ご確認ください」。re.search(r"\{.*\}") がこの全部をうまく拾えるかは、その日のLLMの気分次第。LLMの出力を「ただの文字列」として扱った瞬間に、本番は静かに壊れ始めます。

この記事は、その「お願いして、regexで抜いて、祈る」というやり方を卒業して、出力の形をこっちが先に決めて、その形でしか返ってこないようにする「構造化出力(Structured Outputs)」へ移行するための、実践的なガイドです。

正直に言いましょう。これは派手な話ではないです。でも、LLMを「すごいデモ」から「本番で何ヶ月も動き続ける部品」に変えるための、いちばん地味で、いちばん効く一歩なんですよね。AI開発にまだ不慣れな方でも、用語の意味・なぜ大事か・どこで使うか・最初の一歩まで、省略せずいきます。

この記事で持って帰れるもの:

  • 「JSON mode」「構造化出力(strict)」「function calling(tool use)」の 違いと使い分け
  • OpenAI / Claude / Gemini の 2026年時点の挙動の違い(ここを知らないと事故ります)
  • Python(Pydantic)と TypeScript(Zod)の、そのまま動く実装(検証+自動リトライつき)
  • そして、ほとんどの記事が触れない 「構文は正しいのに中身が間違っている」問題 への対処

1. そもそも「構造化出力」って何なんでしょう

専門用語をいきなり並べる前に、一個だけ比喩を置かせてください。

構造化出力とは、AIに「自由作文」をさせるのではなく、こっちが用意した"記入用紙"の枠の中だけで答えさせる仕組みのことです。

役所の窓口を想像してください。「あなたのことを自由に書いてください」と白紙を渡すと、人によって便箋にびっしり書く人もいれば、一行で終わる人もいる。これを集計するのは地獄です。だから役所は 記入欄が決まった用紙 を配る。「氏名」「住所」「生年月日(西暦)」と枠が決まっていれば、誰が書いても同じ形で集まる。

構造化出力は、これとまったく同じ発想です。LLMに白紙の作文をさせるのをやめて、「ここに category、ここに priority、ここに summary を書いてね」という**枠(スキーマ)**を先に渡す。すると、出力が毎回同じ形で返ってくる。

登場人物を整理しておきましょう

この分野、言葉が多くて最初みんな混乱します。先に登場人物を紹介しておきます。

  • JSON Schema(ジェイソン・スキーマ): 「JSONはこういう形であるべき」を機械が読める形で書いた仕様書。これが"記入用紙の設計図"です。「category は文字列」「priority は high/medium/low のどれか」みたいに書く。
  • JSON mode: LLMに「とにかく壊れていないJSONを返してね」とだけお願いするモード。形(スキーマ)までは強制しない。構文は守るけど、中身の項目までは保証しないゆるめのやつ。
  • 構造化出力(Structured Outputs / strict mode): JSON Schema を渡して、その形でしか返せないように生成を制約する仕組み。いちばん強い。
  • function calling(tool use / ツール呼び出し): 本来は「AIに道具を使わせる」機能だけど、その引数が JSON Schema で定義されるので、構造化出力の手段としても使える
  • Pydantic(パイダンティック)/ Zod(ゾッド): それぞれ Python と TypeScript で、「データがこの形になっているか」を検証するためのライブラリ。スキーマをコードで書いて、受け取ったデータを叩いて確かめる"検品係"だと思ってください。

この5人が分かれば、もう8割は読めます。


2. なぜ「お願いベース」だと、本番で静かに壊れるのか

「プロンプトに『JSONで返して』って書けば、だいたい返ってくるじゃないですか」。そうなんです。だいたいは返ってくる。問題は、その「だいたい」が99%とかで、残りの1%が本番で牙をむくところなんですよね。

LLMが「お願いベース」のときにやらかす、典型的な壊れ方を並べておきます。

  1. 前置き・後置きの混入 — 「承知しました」「以上です」が本文にくっつく
  2. コードフェンスjson ... で囲んでくる
  3. フィールドの欠落summary を書き忘れる、あるいは勝手に増やす
  4. 型のゆらぎpriority"high" ではなく "とても高い" を入れてくる
  5. 末尾カンマ・シングルクォート — JSONとして不正な構文を混ぜる

ここで大事なのは、これはLLMが「バカだから」起きるんじゃないということです。LLMは「それっぽいテキストを続ける」装置なので、放っておけば自然な文章として前置きをつけたくなる。枠をはめていないこっちの設計ミスなんですよね。

構造化出力(strict)が効くのは、ここに技術的なカラクリがあるからです。難しく聞こえるかもしれませんが、一言で言うと 「文法制約デコード(constrained decoding)」

LLMは1文字ずつ「次に来そうな文字」を選んで出力していきます。構造化出力では、スキーマ上「次は " か、決められた値しか来てはいけない」という場面で、それ以外の文字の確率を強制的にゼロにする。つまり、ルール違反の文字は物理的に出力できないようにする。だから「だいたい」が「ほぼ必ず」に変わるわけです。

OpenAI はこの仕組みを使った構造化出力(strict mode)について、公式に失敗率0.1%未満と説明しています。「お願いベースで祈る」のとは、信頼性の桁が変わるんですよね。


3. 3つの方式の使い分け(と、2026年の各社事情)

ここがこの記事のキモのひとつです。「どれを使えばいいの?」に決着をつけましょう。

方式 何をするか 強制力 向いている場面
JSON mode 壊れていないJSONであることだけ保証 弱(構文のみ) スキーマがゆるくていい・とにかく落ちないJSONが欲しい
構造化出力(strict) JSON Schemaの形で生成を制約 強(構文+形) 出力をそのままDB・APIに流す・形が崩れたら困る
function calling(tool use) ツールの引数として構造化データを得る 中〜強(モデル依存) AIに複数の道具から選ばせる・エージェント的な処理

そして、ここから先が 2026年時点で本当に大事な注意点 です。各社で挙動が違います。

  • OpenAI: 構造化出力(strict mode)が成熟していて、前述の通り文法制約デコードでスキーマ準拠をほぼ保証します(失敗率0.1%未満)。response_format に JSON Schema を渡すか、SDKの .parse() を使う。
  • Anthropic(Claude): 構造化出力は tool use(function calling)の仕組み を通して行います。ただし 2026年4月時点の公式ドキュメントで、「strict パラメータは現状ツール定義では無視される。Claudeはベストエフォートで有効な引数を返すが、スキーマ準拠は保証しない」 と明記されています。つまりClaudeでは「strictだから安心」は通用せず、受け取った後の検証とリトライが必須になります。
  • Gemini: 2.0以降は responseJsonSchema / response_schema というネイティブ機能があり、additionalProperties: false(=決めた項目以外は禁止)にも対応しています。コスト対信頼性のバランスが良いと言われています。

ここから導ける、迷ったときの判断はシンプルです。

どのモデル・どの方式を使っても、受け取った後に必ず自分で検証する。

OpenAIの保証が0.1%未満でも、ゼロではない。Claudeに至っては公式に「保証しない」と言っている。だから「プロバイダが保証してるから検証はいらない」は、いちばんやってはいけない油断なんですよね。 ここは太字で胸に刻んでおきましょう。保証ありでも、検証は省かない。


4. 実装①:Python で「スキーマファースト+検証+自動リトライ」

では、実際に「壊れない部品」を作っていきます。設計の順番がすごく大事で、プロンプトから書き始めないまずスキーマ(記入用紙)から書く。これを「スキーマファースト」と言います。

ここでは Pydantic を使います。Pydantic は「データがこの形か」をPythonのクラスで定義して検証できるライブラリです。

from enum import Enum
from pydantic import BaseModel, Field

# --- Step 1: まず「記入用紙」を定義する(スキーマファースト)---

class Priority(str, Enum):
    high = "high"
    medium = "medium"
    low = "low"

class Category(str, Enum):
    billing = "billing"      # 請求
    technical = "technical"  # 技術的問題
    account = "account"      # アカウント
    other = "other"          # その他

class TicketDraft(BaseModel):
    # description は飾りではなく、LLMへの"記入の指示書"になる。必ず書く。
    category: Category = Field(description="問い合わせの主題。最も近いものを1つ選ぶ")
    priority: Priority = Field(description="緊急度。即時対応が要るものだけ high")
    summary: str = Field(description="問い合わせ内容を日本語50字以内で要約")
    needs_human: bool = Field(description="人間のオペレーターによる確認が必要なら true")

ポイントは2つ。Enum で値を閉じること(priority に「とても高い」が入る事故を構造で防ぐ)。そして description を全フィールドに書くこと。この description は、人間向けのコメントではなく、LLMが「ここに何を書けばいいか」を読むための指示書になります。ここを丁寧に書くだけで精度がぐっと上がるんですよね。

次に、これを使って実際にLLMを呼び、検証して、ダメなら自動でやり直すループを書きます。これは「Instructorパターン」と呼ばれる、2026年の定番です。

from pydantic import ValidationError

def extract_ticket(email_text: str, max_retries: int = 2) -> TicketDraft:
    """問い合わせメールからチケット草案を構造化抽出する。
    検証に失敗したら、エラー内容をLLMに伝えて自動リトライする。
    """
    messages = [
        {"role": "system", "content": "あなたはサポート問い合わせを分類する係です。"},
        {"role": "user", "content": f"次のメールを分類してください:\n\n{email_text}"},
    ]

    last_error = None
    # max_retries は必ず上限を設ける。無限ループはコスト爆発の元。
    for attempt in range(max_retries + 1):
        # OpenAIの構造化出力(strict)を使う例。response_formatにスキーマを渡す。
        completion = client.beta.chat.completions.parse(
            model="gpt-5.4",
            messages=messages,
            response_format=TicketDraft,  # ← ここでスキーマを強制
        )
        raw = completion.choices[0].message

        try:
            # .parsed は構造化出力をPydanticモデルに変換済み。だが念のため再検証する。
            ticket = TicketDraft.model_validate(raw.parsed.model_dump())
            return ticket  # 検証OK。これでようやく「信頼できる部品」になった
        except ValidationError as e:
            last_error = e
            # 失敗の理由をそのままLLMに伝えて、次の試行で直してもらう
            messages.append({"role": "assistant", "content": str(raw.parsed)})
            messages.append({
                "role": "user",
                "content": f"前回の出力は検証に失敗しました。次のエラーを直して再出力してください:\n{e}",
            })

    raise RuntimeError(f"{max_retries}回リトライしても検証に通りませんでした: {last_error}")

このコードの肝は3つあります。

  1. response_format=TicketDraft でスキーマを強制している — 構文の崩れはここでほぼ防げる
  2. 受け取った後に model_validate で再検証している — 保証ありでも、ここを省かない
  3. 失敗したら、エラー文をそのままLLMに食わせてリトライ — しかも 回数に上限がある

3つ目、地味だけど超重要です。リトライは無限ループにすると、LLMがずっと同じ間違いを繰り返してAPI課金だけが膨らむという、笑えない事故が起きます。リトライには必ず上限を。 これも明日の自分を守る一行なんですよね。


5. 実装②:TypeScript で Zod を使う

フロントやNode.jsのバックエンドなら、Pydanticの相棒にあたる Zod を使います。考え方はまったく同じ。まずスキーマから書く。

import { z } from "zod";
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";

const client = new OpenAI();

// --- Step 1: 記入用紙をZodで定義 ---
const TicketDraft = z.object({
  category: z
    .enum(["billing", "technical", "account", "other"])
    .describe("問い合わせの主題。最も近いものを1つ選ぶ"),
  priority: z
    .enum(["high", "medium", "low"])
    .describe("緊急度。即時対応が要るものだけ high"),
  summary: z.string().max(50).describe("問い合わせ内容を日本語50字以内で要約"),
  needsHuman: z.boolean().describe("人間の確認が必要なら true"),
});

type TicketDraft = z.infer<typeof TicketDraft>;

// --- Step 2: 呼び出し+検証 ---
async function extractTicket(emailText: string): Promise<TicketDraft> {
  const completion = await client.chat.completions.parse({
    model: "gpt-5.4",
    messages: [
      { role: "system", content: "あなたはサポート問い合わせを分類する係です。" },
      { role: "user", content: `次のメールを分類してください:\n\n${emailText}` },
    ],
    // Zodスキーマをそのまま構造化出力のフォーマットに変換して渡す
    response_format: zodResponseFormat(TicketDraft, "ticket"),
  });

  const parsed = completion.choices[0].message.parsed;
  // 念押しでZodでも検証する(保証ありでも省かない)
  return TicketDraft.parse(parsed);
}

z.inferスキーマからTypeScriptの型が自動で生まれるのが気持ちいいところです。スキーマ・検証・型が一本につながるので、「LLMの戻り値、any でいいか…」みたいな妥協がなくなる。これも未来の自分への小さなプレゼントですね。


6. 最大の落とし穴 — 「構文が正しい」と「中身が正しい」は、別の話

ここからが、この記事でいちばん伝えたいところです。多くの記事は「構造化出力を使えば信頼できる」で終わります。でも、それは半分しか本当じゃないんですよね。

落ち着いて考えてみましょう。構造化出力が保証してくれるのは、「形(構文・スキーマ)が正しいこと」だけです。中身(意味)が正しいことは、一切保証してくれません。

たとえば、さっきの TicketDraft。構造化出力を通れば、priority には必ず high/medium/low のどれかが入ります。でも、それが「正しい緊急度か」は別問題です。LLMが「パスワードを忘れた」という軽い問い合わせを high に分類しても、スキーマ的にはピカピカに正しい。summary に事実と違う要約が入っていても、文字列であることは満たしている。

もっと怖い例を挙げます。LLMに「確信度(confidence)を0〜1で返して」とスキーマで強制したとします。返ってくる confidence: 0.95 は、それっぽい数字を生成しただけで、統計的な根拠はどこにもない。構造化出力は、捏造された数字に「正しい型の服」を着せてしまうんです。これは知らないと、本当に怖い。

構文準拠(schema compliance)は、意味的信頼(semantic reliability)ではない。
構文的に正しいJSONでも、誤った注文IDや、捏造された確信度を含みうる。

だから、構造化出力の後ろに、もう一段 「セマンティック検証(意味の検証)」 を置きます。これは、そのドメインのビジネスルールで中身を叩く処理です。

def semantic_check(ticket: TicketDraft, email_text: str) -> list[str]:
    """構文ではなく「意味・業務ルール」で中身を検証する。
    返り値は違反メッセージのリスト(空なら合格)。
    """
    issues: list[str] = []

    # ルール1: high と言うからには、本文に緊急性の根拠があるはず
    urgent_words = ["至急", "今すぐ", "止まっ", "ログインできない", "二重請求"]
    if ticket.priority == Priority.high and not any(w in email_text for w in urgent_words):
        issues.append("priority=high だが、本文に緊急性を示す語が見当たらない")

    # ルール2: 要約が本文よりやたら長い、または空はおかしい
    if not ticket.summary.strip():
        issues.append("summary が空")
    if len(ticket.summary) > len(email_text):
        issues.append("summary が原文より長い(要約になっていない)")

    # ルール3: 請求カテゴリなのに人間確認不要は、運用ポリシー上あやしい
    if ticket.category == Category.billing and not ticket.needs_human:
        issues.append("請求案件は needs_human=true が原則(要確認)")

    return issues

ポイントは、セマンティック検証はLLMに任せず、人間が決めたルールをコードで書くということです。ここはAIに丸投げしてはいけない領域。「請求案件は必ず人間が確認する」みたいな業務上の約束ごとは、こっちが責任を持って明文化する。AIは形を埋める係、人間は意味の番人。この役割分担がはっきりすると、システムが一気に頑丈になるんですよね。


7. スキーマ設計の原則 — 失敗率は「設計」で下げられる

精度が出ないとき、つい「プロンプトをもっと工夫しなきゃ」と思いがちです。でも実は、スキーマの設計を直すほうが効くことが多いんですよね。2026年時点で共有されている原則を、表にまとめておきます。

原則 なぜ効くか 具体的にどうする
フラットに保つ ネストが深いほど失敗率が上がる 3階層以上の入れ子は避け、可能なら平らに展開する
description を全部書く LLMへの記入指示になる 各フィールドに「何を・どんな粒度で」を1文
enum で値を閉じる 表記ゆれ・想定外の値を構造で禁止 自由文字列より列挙型を優先する
additionalProperties: false 余計な項目の追加を禁止する 「決めた枠以外は書けない」状態にする
Optional は慎重に 「なくてもいい」が増えると曖昧になる 必須は必須と明示。null許容かどうかを決め切る
ハイブリッドルーティング 全部を高級モデルに投げると高い 単純なスキーマは安いモデル、複雑なものだけ堅いモデルへ

最後の ハイブリッドルーティング は、コスト面で効きます。簡単な分類は安いモデル、複雑で重要なスキーマだけ高信頼モデルに回すと、構造化出力のコストを40〜60%削れるという報告もあります。全部を一番高いモデルに投げるのは、気持ちはわかるけど、もったいないんですよね。

additionalProperties: false だけ補足しておくと、これは「この記入用紙に書いていい欄は、ここで挙げたものだけ。勝手に欄を増やすの禁止」という指定です。これを入れておくと、LLMが気を利かせて余計なフィールドを足してくる事故を防げます。


8. 人間とAIの役割分担 — どこを設計し、どこを任せるか

この記事を貫いている問いは、結局これです。人間は何を設計し、何を判断し、何をAIに任せるのか。 構造化出力という文脈で、はっきり線を引いておきましょう。

工程 主担当 中身
スキーマ(記入用紙)の設計 人間 どの項目を・どんな型で・どこを必須にするか
description の言語化 人間 各欄の記入指示。ここの質が精度を決める
枠の中を埋める AI 制約された形の中で、最尤の値を生成する
構文・型の検証 コード Pydantic / Zod で機械的に叩く
意味・業務ルールの検証 人間が書いたコード セマンティック検証。業務の約束ごと
検証失敗時のリトライ判断 コード(上限つき) 何回までやり直すか、超えたらどうするか
最終的な責任 人間 この出力を本番に流していいかの線引き

見てのとおり、AIが担うのは真ん中の「枠を埋める」一箇所だけなんですよね。その前後(枠の設計と、中身の検証)は人間とコードが握る。「AIにお任せ」ではなく、「AIに、安全に任せられる範囲を、人間が設計する」。これが構造化出力の本質だと思っています。


9. そのまま使えるプロンプト例3本

スキーマ設計や検証ルールを考えるとき、AI自身に壁打ち相手になってもらうと早いです。汎用化したプロンプトを3本置いておきます(固有名や社内情報は入れず、必ず一般化して使ってください)。

プロンプト1:スキーマ設計レビュー

あなたはLLMの構造化出力に詳しいシニアエンジニアです。
次のユースケースに対するJSON Schema案をレビューしてください。

# ユースケース
{何をするか・出力をどこに流すかを1〜3行で}

# 現在のスキーマ案
{JSON Schema または Pydantic/Zod 定義を貼る}

# 観点
1. フラットさ(ネストが深すぎないか、平らにできないか)
2. 各フィールドの description は記入指示として十分か
3. 自由文字列を enum に閉じられる箇所はないか
4. 必須/Optional の線引きは妥当か(曖昧なOptionalはないか)
5. additionalProperties: false にすべきか
出力は「指摘」「理由」「修正後スキーマ案」の3部構成で。

プロンプト2:壊れ方・エッジケースの洗い出し

次のスキーマに対して、LLMがやらかしがちな「壊れ方」と
業務的にありえない「意味の間違い」を、それぞれ列挙してください。

# スキーマ
{スキーマを貼る}

# 出力
- 構文・形の壊れ方(型ゆらぎ、欠落、想定外の値 など)を5件
- 意味の間違い(構文は正しいが業務的に誤り)を5件
- それぞれを再現するためのテスト用入力例(ダミーデータ)も添えてください
このリストは、後でセマンティック検証とテストケースに使います。

プロンプト3:セマンティック検証ルールの抽出

あなたはこのドメインの業務に詳しいレビュアーです。
次のスキーマと業務説明から、「構文は正しいが意味が間違っている」
パターンを検出するための検証ルールを抽出してください。

# スキーマ / 業務説明
{スキーマと、業務上の約束ごとを箇条書きで}

# 出力
- 検証ルールを「ルール名 / 何を防ぐか / 判定ロジック(擬似コード)」で列挙
- 各ルールが「コードで機械的に判定できる」ものか
  「人間の最終確認が要る」ものかを明記
擬似コードは後でPython/TypeScriptに落とします。

この3本を回すだけでも、「とりあえず動くスキーマ」から「壊れにくいスキーマ」への距離が、ぐっと縮まるはずです。


10. おわりに — 壊れない部品は、明日の自分へのプレゼント

長くなったので、要点だけ畳んでおきます。

  • LLMの出力を 「文字列」として扱うのをやめて、「枠(スキーマ)」を先に決める
  • 方式は JSON mode / 構造化出力(strict) / function calling を使い分ける
  • OpenAIは保証強め、Claudeは保証なし(2026-04時点)、Geminiはバランス型 — だが、どれでも受け取った後に必ず検証する
  • Python(Pydantic)/ TypeScript(Zod)で、検証+上限つき自動リトライを組む
  • そして 「構文OK ≠ 意味OK」。業務ルールでのセマンティック検証を、人間が責任を持って書く

僕がこの一連の作業を、地味だけど大事だと思っている理由は、結局これなんですよね。

構造化出力って、未来の自分とチームへのプレゼントなんです。

「お願いベースで祈る」コードは、書いてる今日の自分はちょっと楽です。スキーマも検証も書かなくていいから。でもそのツケは、必ず未来の誰かが払う。ある日の深夜、本番が JSONDecodeError で落ちて、叩き起こされるのは未来の自分か、チームの誰か。

逆に、今日スキーマを1枚ちゃんと書いて、検証を1個足しておくと、半年後の自分が「これ書いといてくれて、あざっす」って言ってくれる。僕はいつも、この「明日の自分があざっすって言うほうはどっち?」を選択の軸にしています。構造化出力は、まさにその軸にぴったりハマるんですよね。

最後に、完璧主義に倒れないでほしいので、これだけ。全部を一気にやらなくていいです。 まずは、いちばん壊れて困っている1機能に、検証を1枚足すところから。それだけで、明日の自分の安心がひとつ増えます。

今日からの最初の一歩チェックリスト

  • regexでJSONを抜いている箇所を1つ見つける
  • その出力の「記入用紙」を Pydantic か Zod で書いてみる
  • 受け取った後に validate / parse を1行足す
  • リトライがあるなら、上限がついているか確認する
  • 「構文は正しいけど業務的にありえない」パターンを1つ、コードで弾く

小さく、でも確実に。AIを「すごいデモ」から「明日も動いてる部品」へ。一緒に積んでいきましょう。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?