Bedrock Claude の Tool Use で LLM 出力を構造化する ─ JSON.parse() も正規表現も要らなくなった話
はじめに
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 の仕組みを借りているだけです。
仕組み

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 re と import json が不要になり、システムプロンプトから JSON フォーマット指示セクション(約30行)も削除できました。
まとめ
| 従来(正規表現 + json.loads) | Tool Use(tool_choice 強制) | |
|---|---|---|
| スキーマ準拠 | プロンプト頼み(保証なし) | API レベルで保証 |
| マークダウンフェンス | 正規表現で除去が必要 | 発生しない |
| 余計なコメンタリー | 混入リスクあり | 発生しない |
max_tokens 切断 |
json.loads() 失敗 → 生 JSON 表示 |
フォールバック戦略を組みやすい |
| スキーマ変更 | プロンプトとパーサーの二重管理 | Pydantic モデルのみ変更 |
Tool Use による構造化出力は「ツールを実行させる」ための機能ではなく、「LLM の出力フォーマットを API レベルで強制する」 ための手段として使えます。Bedrock でも Anthropic API 直接でも同じパターンが適用できるので、LLM の出力を構造化したいすべてのケースで検討する価値があります。







