Bedrock Claude の Tool Use で LLM 出力を構造化する ─ JSON.parse() も正規表現も要らなくなった話

Bedrock Claude の Tool Use で LLM 出力を構造化する ─ JSON.parse() も正規表現も要らなくなった話

LLMのJSON出力を正規表現やjson.loads()でパースする従来手法の問題点と、BedrockのTool Use(tool_choice強制)で構造化出力を実現するアプローチを、実際のコードとBefore/Afterで紹介します。
2026.07.13

はじめに

LLM にJSON で回答させたいとき、こんなコードを書いたことはないでしょうか。

raw_text = llm_response["output"]["message"]["content"][0]["text"]

# LLM がマークダウンコードブロックで囲んでくることがあるので正規表現で抽出
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", raw_text)
json_str = json_match.group(1).strip() if json_match else raw_text.strip()
parsed = json.loads(json_str)

「LLM にJSON を返させて json.loads() でパースする」── 一見シンプルですが、実運用で確実に壊れます。

本記事では、Amazon Bedrock の Tool Use(toolChoice 強制) を使って、正規表現も json.loads() も不要にしたアプローチを紹介します。社内向けサービスデスクAI(RAG + Claude)を開発する中で実際に遭遇した問題と、Tool Use 移行によって削除できたコードを具体的に示します。

なぜ「LLM に JSON を返させる」は壊れるのか

システムプロンプトで「必ず JSON で返してください」と指示し、レスポンスを json.loads() でパースするアプローチには、3 つの構造的な問題があります。

1. マークダウンコードブロックで囲まれる

LLM は親切心から JSON を ```json ... ``` で囲んで返すことがあります。json.loads() はコードブロック記法を理解しないので失敗します。正規表現で剥がすコードを書くことになりますが、これ自体が脆弱なパーサーです。

2. 余計なコメンタリーが混入する

「以下が結果です:」のような前置きテキストが JSON の前に付くことがあります。プロンプトで「JSON だけを返して」と強調しても、モデルの更新や入力の変化で再発します。

3. max_tokens で途中切断される

最も厄介な問題です。レスポンスがトークン上限に達すると、JSON が途中で切り捨てられます。

{
  "results": [...],
  "general_advice": "対処法は以下の通りです。\n\n## パターン1\n\n1. 管理画面

json.loads() は当然失敗します。フォールバックで general_advice = raw_text としていたため、切り捨てられた生 JSON がそのままユーザーに表示されるという不具合が発生しました。日本語テキストは英語の約 1.5〜2 倍のトークンを消費するため、max_tokens の見積もりが特に難しいです。

解決策:Tool Use による構造化出力

Claude(および Bedrock 上の Claude)には Tool Use 機能があります。本来は「LLM が外部ツールを呼び出す」ための仕組みですが、tool_choice で特定のツールを強制すると、LLM の出力を必ず指定した JSON Schema に従わせることができます。

ポイントは、実際にツールを実行する必要がないことです。LLM に「このスキーマに従って出力せよ」と伝えるために Tool Use の仕組みを借りているだけです。

仕組み

bedrock-tool-use-structured-output-flow

API に tools(スキーマ定義)と tool_choice(使用強制)を渡すと、LLM は tool_use ブロックとしてレスポンスを返します。このブロックの input フィールドは API レベルでスキーマに準拠した JSON であることが保証されます。

実装

Step 1: Pydantic モデルでスキーマを定義する

まず、LLM に返してほしい構造を Pydantic モデルで定義します。

from pydantic import BaseModel, Field
from typing import Literal

class TicketResult(BaseModel):
    issue_id: int
    subject: str
    relevance: Literal["high", "medium", "low"]
    summary: str

class ChatToolInput(BaseModel):
    """LLM が tool use 経由で生成するフィールド。"""

    response_type: Literal["clarification", "results"]
    general_advice: str = Field(
        default="", description="担当者向けの回答(マークダウン形式)"
    )
    question: str = ""
    results: list[TicketResult] = []

Field(description=...) は JSON Schema の description に反映され、LLM がフィールドの意味を理解する手がかりになります。

Step 2: tool_choice で出力を強制する

Anthropic SDK(anthropic[bedrock])を使った例です。

from anthropic.lib.bedrock import AsyncAnthropicBedrock

client = AsyncAnthropicBedrock(aws_region="ap-northeast-1")

tool_schema = ChatToolInput.model_json_schema()

response = await client.messages.create(
    model="ap-northeast-1.anthropic.claude-sonnet-4-5-20250514-v1:0",
    messages=[{"role": "user", "content": user_message}],
    system=system_prompt,
    max_tokens=8192,
    temperature=0.3,
    tools=[{
        "name": "submit_response",
        "description": "Submit the structured helpdesk response",
        "input_schema": tool_schema,
    }],
    # これが鍵。LLM にこのツールの使用を強制する
    tool_choice={"type": "tool", "name": "submit_response"},
)

tool_choice={"type": "tool", "name": "submit_response"} が鍵です。これにより LLM は必ず submit_response ツールを呼び出す形式で応答し、input_schema で定義したスキーマに沿った JSON を生成します。

Step 3: レスポンスを取り出す

for block in response.content:
    if block.type == "tool_use" and block.name == "submit_response":
        result = ChatToolInput(**block.input)
        # result.response_type, result.general_advice, result.results が
        # 型安全に取得できる

正規表現も json.loads() も不要です。block.input はすでにパース済みの dict であり、Pydantic に渡すだけで型バリデーション付きのオブジェクトが得られます。

boto3 を使う場合

Anthropic SDK ではなく boto3 の Converse API を使う場合は、パラメータ名が異なります。

client = boto3.client("bedrock-runtime", region_name="ap-northeast-1")

response = client.converse(
    modelId="ap-northeast-1.anthropic.claude-sonnet-4-5-20250514-v1:0",
    messages=messages,
    system=[{"text": system_prompt}],
    inferenceConfig={"maxTokens": 8192, "temperature": 0.3},
    toolConfig={
        "tools": [{
            "toolSpec": {
                "name": "submit_response",
                "description": "Submit the structured helpdesk response",
                "inputSchema": {"json": tool_schema},
            }
        }],
        "toolChoice": {"tool": {"name": "submit_response"}},
    },
)

注意: boto3 の Converse API は JSON Schema の $defs / $ref をサポートしません。Pydantic v2 の model_json_schema() はネストしたモデルを $defs で生成するため、インライン展開が必要です。

def _resolve_schema_refs(schema: dict) -> dict:
    """$ref をインライン展開して Bedrock 互換にする(再帰的に解決)。"""
    defs = schema.pop("$defs", {})

    def _resolve(obj):
        if isinstance(obj, dict):
            if "$ref" in obj:
                ref_name = obj["$ref"].split("/")[-1]
                return _resolve(dict(defs[ref_name]))
            return {k: _resolve(v) for k, v in obj.items()}
        if isinstance(obj, list):
            return [_resolve(item) for item in obj]
        return obj

    return _resolve(schema)

# boto3 では展開が必要
tool_schema = _resolve_schema_refs(ChatToolInput.model_json_schema())

Anthropic SDK(anthropic[bedrock])を使う場合、内部的に Messages API が使われ $defs / $ref がネイティブサポートされるため、この展開処理は不要です。

同じパターンを複数の用途に適用する

Tool Use による構造化出力は、一つのプロジェクト内で複数の用途に使い回せます。スキーマ(Pydantic モデル)を変えるだけで、同じパターンが適用できます。

ストリーミングとの組み合わせ

Tool Use はストリーミングとも併用できます。tool_choice を強制した状態でストリーミングすると、JSON が部分的に(トークン単位で)到着します。

async with client.messages.stream(
    model=model_id,
    messages=messages,
    system=system_prompt,
    max_tokens=8192,
    tools=[{
        "name": "submit_response",
        "description": "Submit the structured helpdesk response",
        "input_schema": tool_schema,
    }],
    tool_choice={"type": "tool", "name": "submit_response"},
) as stream:
    async for event in stream:
        if event.type == "input_json":
            chunk = event.partial_json  # JSON の断片が到着

ただし、ストリーミングで到着するのは JSON 文字列の断片です。"general_advice": "対処法は... のようなテキストをリアルタイムに表示したい場合は、JSON の構造を解析するステートマシンを実装する必要があります。これについてはまた別の機会に詳しく紹介します。

max_tokens 到達時のフォールバック

Tool Use でも max_tokens に達すると JSON が途中で切れる可能性はあります。ただし、ストリーミング中にすでに取得したデータをフォールバックに使えます。

try:
    tool_input = ChatToolInput.model_validate_json(full_tool_input)
    result = ChatResponse(
        response_type=tool_input.response_type,
        general_advice=tool_input.general_advice,
        results=tool_input.results[:3],
    )
except Exception:
    # max_tokens 到達で JSON が不完全な場合
    # ストリーミング中に取得済みの general_advice をフォールバックとして使う
    result = ChatResponse(
        response_type="results",
        general_advice=streamed_advice or "回答の生成中にエラーが発生しました。",
    )

従来のアプローチでは、json.loads() 失敗時に生 JSON がそのままユーザーに表示されていました。Tool Use + ストリーミングの組み合わせでは、途中まで受信した general_advice テキストを使えるため、ユーザー体験が大幅に改善されます。

Before / After

Before: 正規表現 + json.loads() パーサー

# システムプロンプトに JSON フォーマットを指示
RESPONSE_FORMAT = """{
  "results": [...],
  "general_advice": "...",
}"""

# レスポンスのパース
raw_text = "".join(block.get("text", "") for block in output)

try:
    json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", raw_text)
    json_str = json_match.group(1).strip() if json_match else raw_text.strip()
    parsed = json.loads(json_str)
except (json.JSONDecodeError, AttributeError):
    return [], raw_text  # 生テキストをそのまま返す(生 JSON が表示される原因)

results = [TicketResult(**r) for r in parsed.get("results", [])]
general_advice = parsed.get("general_advice", "")

After: Tool Use

tool_schema = ChatToolInput.model_json_schema()

response = await client.messages.create(
    ...,
    tools=[{
        "name": "submit_response",
        "description": "Submit the structured helpdesk response",
        "input_schema": tool_schema,
    }],
    tool_choice={"type": "tool", "name": "submit_response"},
)

for block in response.content:
    if block.type == "tool_use":
        result = ChatToolInput(**block.input)  # 型安全。パースエラーなし。

import reimport json が不要になり、システムプロンプトから JSON フォーマット指示セクション(約30行)も削除できました。

まとめ

従来(正規表現 + json.loads) Tool Use(tool_choice 強制)
スキーマ準拠 プロンプト頼み(保証なし) API レベルで保証
マークダウンフェンス 正規表現で除去が必要 発生しない
余計なコメンタリー 混入リスクあり 発生しない
max_tokens 切断 json.loads() 失敗 → 生 JSON 表示 フォールバック戦略を組みやすい
スキーマ変更 プロンプトとパーサーの二重管理 Pydantic モデルのみ変更

Tool Use による構造化出力は「ツールを実行させる」ための機能ではなく、「LLM の出力フォーマットを API レベルで強制する」 ための手段として使えます。Bedrock でも Anthropic API 直接でも同じパターンが適用できるので、LLM の出力を構造化したいすべてのケースで検討する価値があります。

参考


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事