「LLMチャットボット」から「作業を実行するLLM搭載システム」への転換は、構造化出力と関数呼び出しの層で起こります。ここでLLMは、単に文章を生成するだけでなく、データを生成し、操作を決定し、ほかのインフラと統合するようになります。
本番環境での「構造化出力」は、「テストで一度JSONモードが動いた」という意味ではありません。モデルのばらつき、不正な出力、部分的な障害、スキーマの進化、そして常に指示に従うとは限らないLLMの現実に対処できる、堅牢なパイプラインを意味します。
この記事では、本番環境で実際に機能するパターンを扱います。基本事項(OpenAIの tool_choice、Anthropicのtool use、JSONスキーマ制約を利用した経験)は理解しているものとします。ここでは、システムの信頼性を支える要素を掘り下げます。
二つのモード
関連しているものの、異なる二つの機能があります。
構造化出力: LLMがスキーマ(通常はJSON)に準拠した出力を生成します。LLMの回答をプログラムで扱える形式にする必要がある場合に使います。
関数 / ツール呼び出し: 呼び出し可能な一連の関数をLLMに与え、どれを呼び出すか(または呼び出さないか)を判断させ、呼び出しの引数を生成させます。ホストシステムが関数を実行し、結果を返します。LLMはさらに関数を呼び出すか、最終回答を生成できます。
モデルAPIは通常、次の形で機能を公開します。
- 通常の構造化出力では、JSONスキーマを受け取る
response_format(または同等の)パラメーターを使います。OpenAIの「Structured Outputs」やGoogle GeminiのresponseSchemaなどです。 - 利用可能な関数を記述する
tools配列と、呼び出し時に返されるツール呼び出しレスポンスを使います。Anthropicのtool-use APIは、出力をスキーマに制約する方法も兼ねています。必要なスキーマを持つツールを一つ定義し、モデルにその呼び出しを強制します。
どちらも機能し、互いに関連しています。「関数呼び出し」は本質的に、関数シグネチャをスキーマとする構造化出力です。
パターン1:厳密で明示的なスキーマ
信頼性を最も大きく向上させるのは、スキーマです。
緩いスキーマ:
{
"type": "object",
"properties": {
"category": { "type": "string" },
"priority": { "type": "string" }
}
}
厳密なスキーマ:
{
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["billing", "technical", "account", "feature_request", "complaint"],
"description": "チケットのカテゴリー。製品のバグには 'technical'、ログインまたはパスワードの問題には 'account' を使用します。"
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "urgent"],
"description": "'urgent' は障害または事業に重大な影響がある場合に限ります。'high' は重要顧客に影響するブロッキング問題、'medium' は通常の影響、'low' はあると望ましい事項に使用します。"
}
},
"required": ["category", "priority"],
"additionalProperties": false
}
厳密な方には、次の利点があります。
- 値を既知のenumに限定します(自由形式のドリフトがありません)。
- インラインプロンプトとして機能する説明を含みます(モデルが利用します)。
- フィールドを必須にします(部分的な回答を防ぎます)。
- 追加フィールドを禁止します(無作為にハルシネーションされたキーを防ぎます)。
本番環境では、すべてのスキーマフィールドに説明を付け、すべてのenumを明示し、すべての必須フィールドを指定してください。これは「プロンプトとしてのスキーマ」です。スキーマ自体がプロンプトエンジニアリングを担います。
パターン2:制約付き生成
主要プロバイダーは現在、デコードの段階で有効な出力だけを生成できるようモデルを制約する、制約付き生成をサポートしています。
- OpenAI:
response_format: { type: "json_schema", json_schema: { ..., strict: true } } - Anthropic:厳密なスキーマを持つTools。
- オープンソース:
outlines、lm-format-enforcer、jsonformer、vLLMの文法ベースのデコード。
常に使用してください。不正なJSON、ハルシネーションされたフィールド、必須フィールドの欠落という一連の障害を排除できます。性能への影響は無視できる程度です。
制約付き生成を利用できない場合(一部のオープンモデルや構成)は、検証と再試行がフォールバックになります(パターン4を参照)。
パターン3:スキーマのバージョン管理
スキーマは進化します。フィールドの追加や廃止、enumの変更が発生します。
スキーマ変更はコード変更であり、次の要件があります。
- バージョン管理する。各スキーマにバージョン番号を付ける。
- テストする。デプロイ前に新しいスキーマに対して評価スイートを実行する。
- 周知する。出力を利用する下流のコンシューマーへ変更を伝える。
- 可能な限り後方互換にする。新しい任意フィールドを追加し、必須フィールドは削除しない。
有効なパターンは、スキーマをTypeScriptの型またはPydanticモデルとして保存し、ソース管理でバージョン管理して、そこからJSON Schemaを生成することです。型はモデルAPIとアプリケーションコードの両方で使えます。
class TicketClassificationV2(BaseModel):
category: Literal["billing", "technical", "account", "feature_request", "complaint"]
priority: Literal["low", "medium", "high", "urgent"]
confidence: float = Field(ge=0, le=1, description="この分類に対する確信度(0~1)")
needs_human_review: bool = Field(description="いずれかのフィールドの確信度が低い、または異常なシグナルがある場合はTrue")
reasoning: str = Field(description="分類の簡潔な理由。特に判断が明白でない場合に記載")
Pydanticモデルはスキーマを定義し、出力を検証し、Pythonコード内の型として機能します。信頼できる唯一の情報源になります。
パターン4:検証と再試行
制約付き生成を使っていても、出力は利用前に検証します。
from pydantic import ValidationError
def call_with_validation(prompt, schema, max_retries=2):
for attempt in range(max_retries + 1):
response = llm_call(prompt, response_format=schema)
try:
parsed = schema.model_validate_json(response.content)
return parsed
except ValidationError as e:
if attempt < max_retries:
prompt = build_retry_prompt(prompt, response.content, e)
continue
raise
再試行プロンプトには、元の指示、モデルの直前の出力、問題点の具体的な説明を含めます。
直前の回答に検証エラーがありました:
{error message}
直前の出力:
{previous output}
問題を修正し、有効な回答を生成してください。
再試行は驚くほどよく機能し、通常は1回でモデルの誤りから回復できます。
ただし、無制限に再試行せず、最大2~3回にしてください。検証以外のエラー(レート制限、コンテンツフィルターなど)では再試行しないでください。再試行率を監視できるよう記録してください。上昇はモデルのドリフトやプロンプトの問題を示します。
パターン5:結果の振り返り
影響の大きい関数呼び出しでは、ツール結果を利用する前にモデルに振り返らせます。
単純なループ:
1. 利用可能なツールとともにLLMを呼び出す。
2. モデルがツールXの呼び出しを決定する。
3. Xを実行する。
4. 結果をモデルへ返す。
5. モデルが最終回答を生成する。
振り返りループ:
1. 利用可能なツールとともにLLMを呼び出す。
2. モデルがツールXの呼び出しを決定する。
3. Xを実行する。
4. 結果をモデルへ返す。
5. モデルが評価する:結果は期待と一致するか。結果に基づいて操作すべきか。
6. 一致すれば最終回答を生成する。一致しなければ別のツールを呼ぶか、確認を求める。
これにより、次のようなケースを検出できます。
- データが返るはずなのにツールの結果が0件だった → モデルが空のケースを認識する。
- ツールがエラーを返した → 無視せず明示的に処理する。
- ツールが予期しないデータを返した → モデルが気付き、対応を変える。
実装では、構造化された「評価してから実行する」パターンなどを使い、ツール結果を明示的に評価するようモデルへ指示します。
レイテンシーとトークンは増えます。メール送信、支払い処理、レコード変更など、影響の大きい操作には価値があります。影響の小さい情報検索では省略してください。
パターン6:冪等性
LLMは同じツールを2回呼び出したり、すでに成功した呼び出しを再試行したりすることがあります。冪等性がなければ、返金、メール送信、レコード作成が二重になります。
冪等性のパターンは次のとおりです。
冪等性キー。 各ツール呼び出しに一意のキーを付けます(クライアント側で生成して呼び出しに含めます)。下流APIまたはツールラッパーがキーで重複を検出し、既存の結果を返します。
取得または作成のセマンティクス。 レコード作成ツールは、最初に検索します。「メールアドレスXの顧客を作成する」場合、まず同じアドレスの顧客が存在するか確認し、存在すれば重複を作らず既存レコードを返します。
操作ログ。 ツールはすべての操作を記録します。ラッパーは実行前にログを確認し、実行済みならキャッシュした結果を返します。
保守的なツール設計。 重大な操作を行うツールには、明示的な確認または人の承認を必須にします。LLMが短いループ内で誤って実行できないようにします。
副作用を持つツールはすべて、冪等性を考慮して設計してください。省略すると、本番バグの主要因になります。
パターン7:ツール呼び出しの可観測性
ツール呼び出しで何が起きているか把握する必要があります。呼び出しごとに次を記録します。
- タイムスタンプ。
- ツール名と引数。
- 結果(またはエラー)。
- 所要時間。
- 対象のユーザー / セッション。
- このターンにおける呼び出しチェーン(長いチェーンの一部だったか)。
このデータからダッシュボードを構築します。一般的な表示内容は次のとおりです。
- ツール別の呼び出し量。
- ツール別のエラー率。
- ツール別の平均所要時間。
- ツールシーケンスのパターン(「どのツールが一緒に呼ばれる傾向があるか」)。
- ハルシネーションされたツール呼び出し(存在しないツールをLLMが呼ぼうとしたケース)。
これにより、システムの障害箇所とコストの高い箇所が明らかになります。
パターン8:ハルシネーションされた引数
LLMは、ツール引数の値を作り上げることがあります。search_customers(email="...") にユーザーの実際の質問と一致しないメールアドレスを渡したり、言及されていない日付で book_meeting(date="...") を呼び出したりします。
軽減策は次のとおりです。
説明付きの厳密なスキーマ。 「user_idは会話中に先に言及されたものを使う必要があります。IDを作り上げないでください。」
ツールラッパーでの検証。 値が妥当でない場合(user_idが存在しない、日付が過去など)、ツールが構造化エラーを返し、モデルに再検討させます。
振り返り。 「このツールを呼び出す前に、使用する値が会話に基づいていることを確認してください。」
制限されたツールの説明。 特定のエンティティを操作するツールでは、会話中に先に取得したエンティティIDだけを公開します。無制限の検索を公開しないでください。
監査ログ。 引数のハルシネーションパターンを検出し、プロンプトやスキーマを調整します。
パターン9:グレースフルデグラデーション
ツールは失敗します。APIは停止し、レート制限に達します。適切な対応が「何も動かないとユーザーに伝える」ことになる場合は、ほとんどありません。
パターンは次のとおりです。
キャッシュまたは古いデータ。 ライブデータソースを利用できない場合、古いことを明記してキャッシュデータを返します。
部分的な完了。 5個のサブタスクのうち3個が成功した場合、完了したものと未完了のものを報告します。
フォールバック経路。 主ツールが失敗した場合に使う代替手段をモデルに知らせます。たとえば、search_documents が失敗したら、適切な注意事項を付けて search_web にフォールバックします。
ユーザーに見えるエラー状態。 ツールが本当に完了できない場合、成功を装わず、ユーザーへ明確なエラーメッセージを返します。
モデルはこれらのパターンを知る必要があります。system promptに記載してください。
ツールがエラーを返した場合:
- 代替ツールがあれば試す。
- ユーザーがすでに情報を提供している場合は、部分的な結果を明確に報告する。
- ツールがエラーを返したときは、決して成功したと主張しない。
パターン10:構造化出力のストリーミング
UXの観点では、構造化出力の一部をストリーミングすると効果的です。ユーザーは待つ代わりに、結果がリアルタイムで形成される様子を確認できます。
実装:
- 最新のモデルAPIの大半は、JSON出力をトークン単位でストリーミングします。
- 部分的なJSONを逐次解析します(
partial-json-parserなどのライブラリ、または小さなストリーミングパーサーを使用します)。 - フィールドの到着に応じてUIを更新します。
複数セクションを持つ出力で特に有効です。長い製品説明、複数の洞察を含む分析、複数の指摘を含むコードレビューは、ストリーミングすると応答性が大幅に高く感じられます。
注意点として、部分出力に基づいて判断してはいけません。表示にはストリーミングを使い、構造化結果に基づく操作は完了を待ってから行います。
パターン11:関数呼び出しか、明示的な「決定」呼び出しか
ネイティブな関数呼び出しは便利であり、ツールを呼ぶタイミングをモデルが「決定」します。しかし、一部のワークフローでは、明示的な「決定」呼び出しの方が信頼できます。
例として、複数の操作から一つを選ぶ必要があるカスタマーサポートのワークフローを考えます。
ネイティブな関数呼び出し: モデルに5個のツール(refund、send_article、escalate_to_human、ask_clarifying_question、close_ticket)を与え、判断させます。
明示的な決定呼び出し: 最初に、一つの引数として操作を受け取る単一ツール decide_action を使ってモデルを呼び出します。その決定に基づき、関連するツールだけを与えてモデルを再度呼び出します。
明示的な方法は低速で冗長ですが、信頼性が高くなります。各段階でモデルの焦点が絞られ、ホストシステムがワークフローをより強く制御できます。
影響の大きいワークフローでは、明示的な方法が優位になることがよくあります。探索的または単純なワークフローでは、ネイティブな関数呼び出しで十分です。
パターン12:ツール結果の形式
ツール結果を返す方法は重要です。モデルが結果を読むため、形式が影響します。
悪い例:
{"id": "cus_123", "n": "John", "p": "12345"}
より良い例:
{
"customer_id": "cus_123",
"name": "John Doe",
"phone": "+1-555-0123",
"tier": "premium",
"open_tickets": 0
}
最良の例(一部のケース):
顧客が見つかりました:
- ID:cus_123
- 名前:John Doe
- ティア:Premium
- 電話番号:+1-555-0123
- 未解決チケット:0
この顧客はpremiumティアで、未解決チケットはありません。
「最良」の形式は人が読みやすく、コンテキストを含み、モデルが後続の生成で使いやすくなります。「より良い」形式は構造化され、機械可読性に優れています。下流タスクでモデルが最も適切に扱える形式を使用してください(テストが必要です)。
ツールによっては、構造化形式と説明形式の両方(「結果:[説明]。生データ:[JSON]」)を返す方法が有効です。
パターン13:スキーマを考慮した再試行
検証エラーには、回復不能なもの(モデルがタスクを根本的に誤解した場合)と、簡単に修正できるものがあります。
有効なパターンは、エラーを分類して対応を変えることです。
def handle_validation_error(error):
if "missing required field" in str(error):
return retry_with_message("必須フィールドXがありません。追加してください。")
elif "value not in enum" in str(error):
return retry_with_message("値Xは許可された集合にありません。次から選択してください:...")
elif "type mismatch" in str(error):
return retry_with_message("フィールドXは文字列ではなく数値である必要があります。")
else:
# 不明なエラー — 汎用的な再試行は1回だけ
return retry_with_message("回答にエラーがありました。もう一度試してください。")
エラーに合わせた再試行は、汎用的な再試行より成功率が高くなります。
パターン14:合成可能性
ツールは組み合わせられるようにします。一つのことだけを行う、小さく目的の明確なツールをモデルが組み合わせることで、複雑なワークフローを構築できます。
すべてを行うモノリシックな process_customer_request(query) ツールはブラックボックスです。モデルは内部ロジックを観察したり制御したりできません。
目的を絞った一連のツール、すなわち search_customer(email)、get_recent_orders(customer_id)、check_subscription_status(customer_id)、escalate_to_human(reason) なら、状況ごとに適切なフローへモデルが組み合わせられます。
適切な粒度でツールを設計してください。各ツールは一つのことを行い、組み合わさってワークフローになります。
パターン15:「分からない」ことを表すスキーマ
見落としやすいパターンとして、不確実性をスキーマで明示的にモデル化します。
class CustomerInfo(BaseModel):
name: str
name_confidence: Literal["high", "medium", "low"]
needs_clarification: bool
clarification_question: Optional[str] = None
モデルは情報を作り上げる代わりに、確認の質問とともに「確信度が低い」と返せます。
常に自信を持ってフィールドを埋め、ときにはハルシネーションしたデータを入れるモデルより、はるかに優れています。
実例:請求書処理
すべてをまとめるため、本番品質の請求書処理システムを示します。
入力: メールに添付されたPDF請求書。 目標: 構造化データを抽出し、会計システムへ送る。
スキーマ:
class LineItem(BaseModel):
description: str
quantity: float
unit_price: float
total: float
confidence: Literal["high", "medium", "low"]
class Invoice(BaseModel):
vendor_name: str
vendor_id: Optional[str] = None # 自社レコードにない場合はnull
invoice_number: str
invoice_date: date
due_date: Optional[date] = None
line_items: List[LineItem]
subtotal: float
tax: float
total: float
currency: str # ISO 4217
confidence: Literal["high", "medium", "low"]
needs_review: bool
review_reasons: List[str] # レビューが必要な具体的理由
ワークフロー:
- OCRステップ: ビジョンモデルがPDFからテキストを抽出する。
- 抽出ステップ: 上記スキーマを使い、制約付き生成を有効にしてLLMを呼び出す。
- 検証ステップ: Pydanticで検証する。検証エラー時は、エラーのフィードバックとともに1回再試行する。
- 照合ステップ: ツールで自社レコード内のベンダーを検索する。
vendor_nameが既知のベンダーと一致すればvendor_idを付け、一致しなければneeds_review=trueにする。 - 計算確認ステップ:
sum(line_items.total) ≈ subtotalおよびsubtotal + tax ≈ totalを検証する。一致しなければneeds_review=trueにする。 - 確信度確認ステップ:
confidenceがlow、またはいずれかの明細の確信度がlowならneeds_review=trueにする。 - ルーティング:
needs_review=trueなら人のレビューキューへ送り、それ以外は会計システムへ送る。 - ログ: 各ステップの入力、出力、所要時間、エラーを記録する。
対処済みの失敗モード:
- 不正なJSON:制約付き生成で防ぎ、エッジケースは再試行で処理する。
- ハルシネーションされたフィールド:スキーマを厳密にする。
- 計算エラー:検証する。
- 未知のベンダー:フラグを付ける。
- 低い確信度:フラグを付ける。
- ツールエラー:明示的に処理する。
本番性能: 請求書の約95%を人手なしで処理し、5%にレビューのフラグが付きます。自動処理分のエラー率は0.5%未満(許容範囲内)です。レビューキューの約80%は正しいと確認され、20%は修正が必要です。
これが本番品質の構造化出力です。単に「JSONモードが一度動いた」ということではなく、現実の失敗モードを処理するパイプラインです。
よくある間違い
繰り返し目にするパターンを挙げます。
間違い1:検証しない。 Pydantic、zodなど、何を使ってもよいので検証してください。モデルを信用してはいけません。
間違い2:曖昧な説明。 「category: string」ではモデルの役に立ちません。「category: billing、technical、account_accessのいずれか。billingは……を含む」のように記述します。
間違い3:ツールが多すぎる。 30個のツールを利用可能にすると、モデルが誤ったものを選びます。呼び出しごとに関連ツールを10個未満へ絞ってください。
間違い4:検証失敗時に再試行しない。 不正な出力が一つあるだけで、フロー全体が停止します。フィードバックを付けて1回再試行してください。
間違い5:可観測性がない。 本番環境でツール呼び出しが失敗したとき、トレースがなければ診断できません。
間違い6:副作用を持つツールに冪等性がない。 返金やメールが重複します。予測可能なバグです。
間違い7:LLMが決めた引数を検証せず信用する。 ユーザーIDや日付がハルシネーションされます。実行前にツール引数を検証してください。
間違い8:スキーマのバージョン管理を省略する。 スキーマ変更によって下流のコンシューマーが壊れます。バージョン管理してください。
デモから本番システムへ
構造化出力と関数呼び出しは、「話すLLM」から「作業を実行するLLM」への橋渡しです。適切に実装すれば本番AIを実現でき、不適切に実装すれば、予想外かつ高コストな形で壊れます。
重要なパターンは、厳密なスキーマ、制約付き生成、検証と再試行、ツール結果の振り返り、冪等性、グレースフルデグラデーション、スキーマを考慮したエラー処理、エンドツーエンドの可観測性です。
これらのパターンの一つひとつが、デモと本番システムを分けます。最初から組み込んでください。



