ローカルLLM×「構造化出力(Structured Output)」実践ガイド【2026年版】——Ollama+Qwen 3・Llama 4でJSON Schema準拠の出力を100%保証する方法

  1. はじめに——LLMの出力を「使えるデータ」に変える
  2. 構造化出力(Structured Output)とは何か
    1. なぜ「自由形式のテキスト」では業務に使えないのか
    2. 構造化出力が解決する3つの課題
    3. 「プロンプト指示」と「制約付き生成」の決定的な違い
  3. Ollamaの構造化出力機能——formatパラメータの使い方
    1. format: “json” vs format: {JSON Schema}
    2. Pythonでの実装——Pydantic連携
    3. JavaScript/TypeScriptでの実装——Zod連携
    4. 実装上の重要なポイント
  4. 制約付き生成の仕組み——Grammar-based Sampling
    1. llama.cppのGBNF文法とは
    2. JSON SchemaからGBNF文法への自動変換
    3. Outlinesによるさらに高度な制約生成
  5. 推奨モデル——Qwen 3とLlama 4の構造化出力性能
    1. モデル選定のポイント
    2. 日本語業務での推奨構成
  6. 業務ユースケース——構造化出力が活きる3つの実務シナリオ
    1. ユースケース1:請求書データの自動抽出
    2. ユースケース2:アンケート自由記述の分析
    3. ユースケース3:システムログの分類
  7. 出力が壊れたときの対処——リトライ戦略とフォールバック設計
    1. 構造化出力でも失敗するケース
    2. 3層フォールバック戦略
    3. Instructorライブラリによるリトライ自動化
  8. OpenAI互換APIモードでの構造化出力
    1. OllamaのOpenAI互換エンドポイント
    2. vLLM・SGLangでの構造化出力
  9. Function Callingとの関係——構造化出力はなぜ「前段階」なのか
    1. Function Callingの仕組みと構造化出力の位置づけ
  10. まとめ——構造化出力を導入するための5ステップ
  11. 参考リンク

はじめに——LLMの出力を「使えるデータ」に変える

「ローカルLLMで請求書からデータを抜き出したいのに、毎回フォーマットが違う」「LLMにJSON形式で返させたいのに、余計な説明文がついてくる」——こんな経験はありませんか?

ローカルLLMの活用が広がるなか、「出力を構造化データとして確実に返させる」ことが、業務自動化の最大のボトルネックになっています。いくら優秀なモデルでも、出力がパースできなければパイプラインは止まります。

Function Calling(関数呼び出し)が注目されていますが、実はその前段階——LLMの出力そのものを型安全な構造化データにする技術——を押さえなければ、Function Callingも安定しません。Function Callingは「どのツールを呼ぶか」を決める仕組みであり、ツールに渡す引数が壊れていれば意味がないからです。

この記事では、Ollama+Qwen 3・Llama 4を使って、JSON Schema準拠の構造化出力を実現する方法を、基礎概念からリトライ戦略まで体系的に解説します。2026年4月時点の最新情報を反映しています。


構造化出力(Structured Output)とは何か

なぜ「自由形式のテキスト」では業務に使えないのか

LLMは本質的に「次のトークンを確率的に予測する」仕組みです。自然な文章を生成するのは得意ですが、出力されるテキストの形式は保証されません。

たとえば、「この請求書の情報をJSONで返してください」とプロンプトで指示しても、以下のような問題が頻繁に起こります。

  • JSONの前後に「はい、以下がJSONです:」のような説明文がつく
  • マークダウンのコードブロック(“`json … “`)で囲まれる
  • シングルクォートが使われてJSONとして不正になる
  • 必須フィールドが欠落する、またはスキーマにないフィールドが追加される
  • 数値フィールドに文字列(「不明」「該当なし」など)が入る

プロンプトエンジニアリングだけでこれらを完全に防ぐことは不可能です。「pretty please use JSON」と書こうが、チップを約束しようが、確率的な生成プロセスである以上、100%の保証はできません。

構造化出力が解決する3つの課題

構造化出力(Structured Output)は、LLMの生成プロセスそのものに制約をかけることで、出力を事前定義されたスキーマに準拠させる技術です。これにより、以下の3つの課題を解決します。

1. パース可能性の保証——出力が常に有効なJSONであることが保証されるため、json.loads()やPydanticのバリデーションが確実に成功します。正規表現でJSONブロックを抽出するような脆弱なコードは不要になります。

2. 型安全性——JSON Schemaで「このフィールドはintegerで必須」と定義すれば、モデルはそのとおりに生成します。金額フィールドに「不明」と入ることはなくなり、後続の処理でTypeErrorが発生するリスクが排除されます。

3. 下流システムとの統合——APIエンドポイント、データベースINSERT、ETLパイプラインなど、構造化データを受け取る下流システムとの接続がシームレスになります。

「プロンプト指示」と「制約付き生成」の決定的な違い

構造化出力を実現するアプローチは、大きく2つに分かれます。

アプローチ仕組み信頼性
プロンプト指示「JSONで返してください」とプロンプトで依頼するモデルの判断次第。70〜90%程度
制約付き生成(Constrained Decoding)トークン生成時に文法制約を適用し、スキーマに違反するトークンの生成を物理的にブロックする構文レベルで100%保証

制約付き生成では、モデルが次のトークンを生成する際に、定義された文法(Grammar)に基づいて無効なトークンの確率をゼロにします。これは「出力を後から修正する」のではなく、「生成の瞬間に制約をかける」ため、構文レベルでの有効性が100%保証されます。


Ollamaの構造化出力機能——formatパラメータの使い方

format: “json” vs format: {JSON Schema}

Ollamaは構造化出力をネイティブサポートしており、formatパラメータで制御します。2つのモードがあります。

モード1:format: “json”——汎用JSONモード

出力を「有効なJSON」に制約しますが、フィールド名や型は指定できません。モデルが自由にキーを決めるため、出力のスキーマは毎回異なる可能性があります。

curl http://localhost:11434/api/chat \
  -d '{
    "model": "qwen3:8b",
    "messages": [{"role": "user", "content": "東京の天気を教えて。JSONで返して"}],
    "format": "json",
    "stream": false
  }'

モード2:format: {JSON Schema}——スキーマ指定モード(推奨)

JSON Schemaを直接渡すことで、フィールド名、型、必須フィールド、配列の要素型まで指定できます。Ollamaはこのスキーマからllama.cppのGBNF文法を自動生成し、制約付き生成を実行します。

curl http://localhost:11434/api/chat \
  -d '{
    "model": "qwen3:8b",
    "messages": [{"role": "user", "content": "東京の天気を教えて"}],
    "format": {
      "type": "object",
      "properties": {
        "city": {"type": "string"},
        "temperature_celsius": {"type": "number"},
        "condition": {"type": "string"},
        "humidity_percent": {"type": "integer"}
      },
      "required": ["city", "temperature_celsius", "condition"]
    },
    "stream": false
  }'

Pythonでの実装——Pydantic連携

実務では、PythonのPydanticモデルでスキーマを定義し、Ollama Pythonライブラリと連携するのが最も効率的です。Pydanticモデルは型ヒント付きのクラスであり、スキーマの定義とレスポンスのバリデーションの両方に使えます。

from ollama import chat
from pydantic import BaseModel

class Invoice(BaseModel):
    vendor_name: str
    invoice_number: str
    date: str
    total_amount: float
    tax_amount: float
    items: list[str]

response = chat(
    model='qwen3:8b',
    messages=[{
        'role': 'user',
        'content': '以下の請求書テキストから情報を抽出してください:...'
    }],
    format=Invoice.model_json_schema(),
)

# レスポンスをPydanticモデルとしてバリデーション
invoice = Invoice.model_validate_json(response.message.content)
print(f"取引先: {invoice.vendor_name}")
print(f"合計金額: {invoice.total_amount}円")

JavaScript/TypeScriptでの実装——Zod連携

JavaScript/TypeScript環境では、ZodスキーマライブラリとzodToJsonSchema変換を使います。

import ollama from 'ollama'
import { z } from 'zod'
import { zodToJsonSchema } from 'zod-to-json-schema'

const InvoiceSchema = z.object({
  vendor_name: z.string(),
  invoice_number: z.string(),
  date: z.string(),
  total_amount: z.number(),
  tax_amount: z.number(),
  items: z.array(z.string()),
})

const response = await ollama.chat({
  model: 'qwen3:8b',
  messages: [{ role: 'user', content: '請求書テキストから情報を抽出...' }],
  format: zodToJsonSchema(InvoiceSchema),
})

const invoice = InvoiceSchema.parse(JSON.parse(response.message.content))
console.log(`取引先: ${invoice.vendor_name}`)

実装上の重要なポイント

Ollamaの構造化出力を使う際に押さえておくべきポイントがいくつかあります。

プロンプトにも「JSONで返して」と書く——Ollamaのformatパラメータはトークン生成時の制約にのみ使われ、モデルのシステムプロンプトには注入されません。モデルはスキーマの存在を「知らない」ため、プロンプトでもJSON出力を明示的に指示することで、フィールドの値の品質が向上します。

temperatureは0(または低い値)に設定する——構造化出力ではスキーマへの準拠が最優先です。ランダム性を下げることで、フィールド値の一貫性が高まります。Ollamaの公式ドキュメントでもtemperature: 0を推奨しています。

構文の有効性と意味の正確性は別問題——制約付き生成はJSONの構文が有効であることを保証しますが、フィールドの値が意味的に正しいことは保証しません。金額フィールドに0が入ったり、日付フィールドに不正な日付が入る可能性はあります。値レベルのバリデーションは別途必要です。


制約付き生成の仕組み——Grammar-based Sampling

llama.cppのGBNF文法とは

Ollamaの構造化出力の裏側では、llama.cppのGBNF(GGML Backus-Naur Form)文法が動いています。GBNFは、形式文法を定義してモデルの出力を制約するためのフォーマットです。

通常のLLM推論では、モデルは語彙全体(数万トークン)から次のトークンを選びます。Grammar-based Samplingでは、このプロセスに文法の状態マシンが介入し、現在の文法状態で許可されないトークンの確率をゼロにマスクします。

たとえば、JSONオブジェクトの開始時点では { のみが許可され、キー名の後には : のみが許可される、といった具合です。

JSON SchemaからGBNF文法への自動変換

Ollamaはv0.5以降、JSON Schemaを受け取ると自動的にGBNF文法を生成します。この変換はllama.cppのjson-schema-to-grammar.cppで処理され、以下のJSON Schema機能をサポートしています。

  • オブジェクト型:properties、required、additionalProperties
  • 配列型:items、minItems、maxItems
  • 数値型:minimum、maximum(integerのみ)
  • 文字列型:pattern(正規表現)、enum
  • 複合型:anyOf、allOf(一部制限あり)

ただし、深いネストや再帰的なスキーマでは変換が失敗したり、サンプリング速度が大幅に低下する場合があります。実務では、スキーマはできるだけフラットに保つことが推奨されます。

Outlinesによるさらに高度な制約生成

llama.cppのGBNF以外にも、Outlines(dottxt社開発)というPythonライブラリが構造化生成の有力な選択肢です。

Outlinesは、JSON SchemaやPydanticモデルから有限状態マシン(FSM)をコンパイルし、各生成ステップでO(1)の計算量で有効トークンを判定します。llama.cppのGBNFが「文法ルールに基づくマスキング」であるのに対し、Outlinesは「事前コンパイルされたインデックスによる高速マスキング」を行います。

Outlinesの特徴は以下のとおりです。

  • 複数バックエンド対応:vLLM、transformers、Ollamaなど複数の推論エンジンと連携可能
  • 正規表現による制約:JSON Schemaだけでなく、任意の正規表現パターンで出力を制約できる
  • Rustコア実装:outlines-coreはRustで実装されており、Pythonバインディング経由で高速に動作する
  • 選択肢の制約:モデルの出力を事前定義された選択肢のみに制限するchoice生成
from outlines import models, generate
from pydantic import BaseModel

model = models.transformers("Qwen/Qwen3-8B")

class SurveyResponse(BaseModel):
    sentiment: str  # "positive", "negative", "neutral"
    confidence: float
    keywords: list[str]

generator = generate.json(model, SurveyResponse)
result = generator("このアンケート回答を分析してください:大変満足しています")
print(result)
# SurveyResponse(sentiment='positive', confidence=0.95, keywords=['満足'])

推奨モデル——Qwen 3とLlama 4の構造化出力性能

モデル選定のポイント

すべてのモデルが構造化出力に同等の性能を発揮するわけではありません。モデルのサイズ、学習データ、アーキテクチャによって、スキーマへの準拠度やフィールド値の品質に大きな差があります。

モデルパラメータVRAM目安(Q4量子化)構造化出力の特徴
Qwen 3 8B8B約6GB日本語の構造化抽出に強い。Function CallingとJSONモード両対応
Qwen 3 14B14B約10GB8Bより複雑なスキーマへの対応力が高い。ビジネス文書の処理に最適
Llama 4 Scout17B active(109B total)約12GBMoEアーキテクチャ。英語の構造化出力に高い精度。多言語対応
Gemma 4 12B12B約8GBApache 2.0ライセンスで商用完全自由。コストパフォーマンスが高い

日本語業務での推奨構成

日本語のビジネス文書から構造化データを抽出する用途では、Qwen 3 8Bがコストパフォーマンスと日本語性能のバランスで最も推奨されます。16GB以上のVRAMが使える環境では、Qwen 3 14Bにアップグレードすることで、複雑なスキーマや長い入力テキストへの対応力が向上します。

Ollama上でのセットアップは以下のとおりです。

# Qwen 3 8Bのダウンロードと実行
ollama pull qwen3:8b
ollama run qwen3:8b

# Llama 4 Scoutのダウンロード
ollama pull llama4:scout

業務ユースケース——構造化出力が活きる3つの実務シナリオ

ユースケース1:請求書データの自動抽出

紙やPDFの請求書をOCRでテキスト化した後、LLMで構造化データとして抽出するパイプラインです。

from ollama import chat
from pydantic import BaseModel
from typing import Optional

class InvoiceItem(BaseModel):
    description: str
    quantity: int
    unit_price: float
    amount: float

class InvoiceData(BaseModel):
    vendor_name: str
    invoice_number: str
    issue_date: str
    due_date: Optional[str]
    subtotal: float
    tax_rate: float
    tax_amount: float
    total: float
    items: list[InvoiceItem]

def extract_invoice(ocr_text: str) -> InvoiceData:
    response = chat(
        model='qwen3:8b',
        messages=[
            {'role': 'system', 'content': 
                '請求書のテキストから情報を抽出し、JSON形式で返してください。'
                '金額は数値(円単位)で返してください。'
                '日付はYYYY-MM-DD形式で返してください。'},
            {'role': 'user', 'content': ocr_text}
        ],
        format=InvoiceData.model_json_schema(),
        options={'temperature': 0}
    )
    return InvoiceData.model_validate_json(response.message.content)

ユースケース2:アンケート自由記述の分析

自由記述欄の回答を、センチメント・カテゴリ・キーワードに構造化分類します。

class SurveyAnalysis(BaseModel):
    sentiment: str  # positive / negative / neutral
    category: str   # product / service / price / other
    keywords: list[str]
    urgency: int    # 1-5のスケール
    summary: str    # 50文字以内の要約

# バッチ処理
results = []
for response_text in survey_responses:
    analysis = chat(
        model='qwen3:8b',
        messages=[{
            'role': 'user',
            'content': f'以下のアンケート回答を分析してください:\n{response_text}'
        }],
        format=SurveyAnalysis.model_json_schema(),
        options={'temperature': 0}
    )
    results.append(SurveyAnalysis.model_validate_json(analysis.message.content))

ユースケース3:システムログの分類

非構造化のシステムログから、エラーレベル、発生コンポーネント、推定原因を構造化します。

class LogEntry(BaseModel):
    timestamp: str
    level: str        # ERROR / WARNING / INFO
    component: str    # auth / database / network / application
    message: str
    root_cause: str
    recommended_action: str

class LogAnalysis(BaseModel):
    entries: list[LogEntry]
    critical_count: int
    most_affected_component: str

出力が壊れたときの対処——リトライ戦略とフォールバック設計

構造化出力でも失敗するケース

制約付き生成は構文レベルの有効性を保証しますが、以下のケースでは「有効なJSONだが使えないデータ」が生成されることがあります。

  • トークン切れ:max_tokensに達してJSON生成が途中で止まった場合、閉じ括弧がないまま生成が終了する可能性がある(llama.cppのGrammar Samplingは途中停止後のバリデーションを行わない)
  • 意味的不正確:金額フィールドに0が入る、日付フィールドに「2025-13-45」が入る、enumフィールドに想定外の値が入る
  • ハルシネーション:入力テキストに存在しない情報がフィールドに埋め込まれる
  • 複雑スキーマでの劣化:深いネストや多数のフィールドを持つスキーマでは、フィールド値の品質が低下する

3層フォールバック戦略

本番環境では、以下の3層構造でフォールバックを設計することを推奨します。

import json
from pydantic import BaseModel, ValidationError
from ollama import chat

def extract_with_fallback(
    prompt: str, 
    schema_model: type[BaseModel],
    model_name: str = 'qwen3:8b',
    max_retries: int = 3
) -> BaseModel | None:

    for attempt in range(max_retries):
        try:
            # 第1層:構造化出力で生成
            response = chat(
                model=model_name,
                messages=[{'role': 'user', 'content': prompt}],
                format=schema_model.model_json_schema(),
                options={'temperature': 0}
            )

            # 第2層:Pydanticバリデーション
            result = schema_model.model_validate_json(
                response.message.content
            )

            # 第3層:ビジネスロジックバリデーション
            if not validate_business_rules(result):
                print(f"Attempt {attempt+1}: ビジネスルール違反、リトライ")
                continue

            return result

        except json.JSONDecodeError:
            print(f"Attempt {attempt+1}: JSONパース失敗、リトライ")
        except ValidationError as e:
            print(f"Attempt {attempt+1}: スキーマ違反: {e}、リトライ")

    # 全リトライ失敗時:上位モデルにフォールバック
    print("フォールバック:上位モデルで再試行")
    return extract_with_fallback(
        prompt, schema_model, 
        model_name='qwen3:14b', 
        max_retries=1
    )

def validate_business_rules(data: BaseModel) -> bool:
    """ビジネスロジックに基づく追加バリデーション"""
    # 例:金額が0以上、日付が妥当な範囲内、等
    return True  # 実際のルールを実装

Instructorライブラリによるリトライ自動化

Instructorライブラリを使えば、リトライ戦略をさらに簡潔に実装できます。InstructorはOllamaのOpenAI互換モードと連携し、Pydanticモデルベースの自動バリデーションとリトライを提供します。

import instructor
from pydantic import BaseModel

client = instructor.from_provider(
    "ollama/qwen3:8b",
    mode=instructor.Mode.JSON,
)

class ExtractedData(BaseModel):
    name: str
    amount: float
    category: str

result = client.create(
    messages=[{"role": "user", "content": "請求書テキスト..."}],
    response_model=ExtractedData,
    max_retries=3,
    timeout=30.0,  # 全リトライ合計のタイムアウト
)

Instructorは、バリデーション失敗時にエラーメッセージをモデルにフィードバックして再生成する仕組みを内蔵しており、リトライごとに出力品質が向上する傾向があります。


OpenAI互換APIモードでの構造化出力

OllamaのOpenAI互換エンドポイント

Ollamaは/v1/chat/completionsエンドポイントでOpenAI互換APIを提供しています。これにより、OpenAI SDKやLangChainなど、OpenAI APIを前提としたツールからOllamaのローカルモデルを呼び出せます。

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama"  # ダミー値でOK
)

response = client.chat.completions.create(
    model="qwen3:8b",
    messages=[
        {"role": "user", "content": "東京の天気を教えて"}
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "weather",
            "schema": {
                "type": "object",
                "properties": {
                    "city": {"type": "string"},
                    "temperature": {"type": "number"},
                    "condition": {"type": "string"}
                },
                "required": ["city", "temperature", "condition"]
            }
        }
    }
)

vLLM・SGLangでの構造化出力

より高いスループットが必要な本番環境では、vLLMやSGLangといった高速推論サーバーの利用も選択肢になります。これらはOutlinesの構造化生成エンジンを内蔵しており、OpenAI互換のresponse_formatパラメータでJSON Schemaを指定できます。

ただし、これらの高速推論サーバーはセットアップの複雑さが上がるため、まずはOllamaで構造化出力のワークフローを確立し、スケールが必要になった段階で移行するのが現実的なアプローチです。


Function Callingとの関係——構造化出力はなぜ「前段階」なのか

Function Callingの仕組みと構造化出力の位置づけ

Function Calling(関数呼び出し)は、LLMが「どのツール(関数)を呼ぶか」と「どんな引数を渡すか」を決定する仕組みです。AIエージェントの核心技術であり、MCP(Model Context Protocol)サーバー連携にも不可欠です。

しかし、Function Callingが正しく動作するためには、モデルが生成する「関数名」と「引数のJSON」が構造的に正しくなければなりません。つまり、構造化出力はFunction Callingの基盤技術です。

技術レイヤー役割
構造化出力(本記事)LLMの出力を型安全なJSONにする{“city”: “Tokyo”, “temp”: 22}
Function Callingどのツールをどんな引数で呼ぶか決定するget_weather(city=”Tokyo”)
MCPサーバー連携外部システムと標準プロトコルで接続するMCPサーバー経由でDB操作

構造化出力をマスターすれば、Function CallingやMCPサーバー連携への移行がスムーズになります。当サイトの「ローカルLLM×Function Calling実践ガイド」や「MCPサーバー連携ガイド」と合わせて読むことで、ローカルLLMのエージェント活用を体系的に理解できます。


まとめ——構造化出力を導入するための5ステップ

ローカルLLMの構造化出力は、「LLMの出力を業務で使えるデータにする」ための必須技術です。以下の5ステップで導入を始めましょう。

ステップ1:Ollamaをインストールし、Qwen 3 8Bをダウンロードする。ローカル環境のセットアップが最初のハードルですが、OllamaならワンコマンドでGPU対応のモデル実行環境が整います。

ステップ2:業務で抽出したいデータのスキーマをPydanticモデルで定義する。最初はフィールド数3〜5個のシンプルなスキーマから始め、動作確認後に徐々に拡張します。

ステップ3:formatパラメータにJSON Schemaを渡して構造化出力を試す。プロンプトにも「JSONで返してください」と明記し、temperature: 0で実行します。

ステップ4:Pydanticバリデーションとビジネスルールチェックを追加する。構文の有効性だけでなく、値の妥当性も検証するバリデーション層を実装します。

ステップ5:リトライ戦略とフォールバック設計を実装する。本番環境では、バリデーション失敗時のリトライと上位モデルへのフォールバックが不可欠です。

構造化出力は、プロンプトエンジニアリングの「お願い」から、制約付き生成の「保証」へのパラダイムシフトです。この技術を基盤に、Function Calling、RAG、MCPサーバー連携へと段階的にローカルLLMの活用範囲を広げていきましょう。


参考リンク

免責事項: 本記事は2026年4月時点の公開情報に基づく情報提供であり、各ツール・ライブラリのバージョンアップにより仕様が変更される場合があります。最新情報は各公式ドキュメントで確認してください。

コメント

タイトルとURLをコピーしました