Strands AgentsのStructured Output(構造化出力)を使ってみた

Strands AgentsのStructured Output(構造化出力)を使ってみた

2025.12.15

はじめに

こんにちは、スーパーマーケットが好きなコンサルティング部の神野です。

LLMを使ったアプリケーションを開発していると、LLMからの出力を構造化されたデータとして受け取りたいというケースがあるかと思います。
そこで今回は、Strands AgentsのStructured Output(構造化出力)を試してみました!

https://strandsagents.com/latest/documentation/docs/user-guide/concepts/agents/structured-output/

構造化出力

例えば、ユーザーからの質問を、OpenSearchなどのメタデータフィルタリング用のクエリ作成、後続の検索のパラメーターに活用したいなどのケースがあるかと思います。
そのためにプロンプトで出力指示をしたり、パースできるようにマーカーを入れたりと工夫したりするケースもあるかと思いますが、不安定な印象があったりフォールバック処理なども考える必要があって若干コストを感じるポイントがあると思います。そこを構造化出力で解消できると嬉しいですよね。

具体的にはLLMからの出力は生のテキストとして返されるため、アプリケーション側でパース処理を行う必要があります。
例えば「JSON出力して」と依頼した場合、下記のようにJSONだけではなく枕詞が付与されるケースもあるため、システムプロンプトで「JSONだけ出力して」、やタグで構造化したデータを抽出できるような仕組みを考えたりするケースがあると思います。

) 名前:田中太郎
年齢:21

これをJSON出力して。

# ここが不要!!!
LLM(Nova 2 Lite))JSON形式で出力すると以下のようになります:

# 欲しいのはここだけ
```json
{"名前": "田中太郎", "年齢": 21}
```

こういったケースで構造化出力が便利で、LLMからの応答を事前に定義したスキーマに従った形式で取得できる機能です。
Pydanticモデルでスキーマを定義するだけで、検証済みのPythonオブジェクトとして結果を受け取ることができます。

メリットとしては、

  • 生の文字列ではなく、型指定されたPythonオブジェクトを取得できる
  • Pydanticがスキーマに対してレスポンスを自動的に検証してくれる
  • LLM生成応答に対してもIDEの型ヒントが効く

などが挙げられます。

パース処理を自前で書く必要がなくなるのは嬉しいですね!
Strands Agentsの場合だと、Strandsがサポートしている全てのプロバイダーモデルでも対応していると書いてあるので、モデル自体が対応していなくても構造化できるのも良きですね。

All of the model providers supported in Strands can work with Structured Output.

補足ではありますが、OpenAIAnthropicでもそれぞれのAPIで対応しているモデルはあります。

今回はAmazon Nova2 liteを使ってこの機能を試してみます。

前提条件 / 環境

今回の検証環境は以下の通りです。

  • Python 3.13
  • uv 0.6.12
  • strands-agents 1.19.0
  • strands-agents-tools 0.2.17
  • pydantic 2.12.5
  • boto3 1.42.4

実際にやってみる

それでは、実際に構造化出力を試していきましょう!

環境のセットアップ

まずは、uvを使ってプロジェクトを作成し、必要なライブラリをインストールします。

# プロジェクトの作成
uv init strands-structured-output
cd strands-structured-output

# 必要なライブラリをインストール
uv add strands-agents strands-agents-tools pydantic boto3

基本的な使い方

最初に、シンプルな例から試してみます。テキストから書籍情報を抽出するエージェントを作成してみましょう。

main.pyを作成します。

main.py
from pydantic import BaseModel, Field
from strands import Agent
from strands.models import BedrockModel

# Pydanticモデルを定義
class BookInfo(BaseModel):
    """書籍の情報を表すモデル"""
    title: str = Field(description="書籍のタイトル")
    author: str = Field(description="著者名")
    price: int = Field(description="価格(円)")
    genre: str = Field(description="ジャンル")

# Bedrockモデルを設定
bedrock_model = BedrockModel(
    model_id="us.amazon.nova-2-lite-v1:0",
    temperature=0.0
)

# エージェントを作成
agent = Agent(model=bedrock_model)

# テキストから情報を抽出
text = """
『クラウドネイティブアーキテクチャ入門』は田中太郎氏による技術書で、
AWSやKubernetesを使ったモダンなシステム設計について解説しています。
価格は3,200円で、IT技術書のジャンルに分類されます。
"""

result = agent(
    text,
    structured_output_model=BookInfo
)

# 結果にアクセス
book: BookInfo = result.structured_output
print(f"タイトル: {book.title}")
print(f"著者: {book.author}")
print(f"価格: {book.price}円")
print(f"ジャンル: {book.genre}")

作成したので、実行します。

uv run main.py

実行結果は以下のようになります。

Tool #1: BookInfo
タイトル: クラウドネイティブアーキテクチャ入門
著者: 田中太郎
価格: 3200円
ジャンル: IT技術書

おおー!ちゃんと構造化されたデータとして取得できました!book.titleのようにドット記法でアクセスできるのは便利ですね。
型を指定することでIDEの補完も有効になるのは嬉しいポイントかと思います。

ツール利用の形で構造化しているのがログを見てもわかりますね。

ネストしたモデルを使った例

次に、もう少し複雑な例として、ネストしたモデルを使ってみましょう。
レストランのレビュー情報を抽出してみます。

from pydantic import BaseModel, Field
from typing import List, Optional
from strands import Agent
from strands.models import BedrockModel

# ネストしたモデルを定義
class MenuItem(BaseModel):
    """メニュー項目"""
    name: str = Field(description="料理名")
    price: Optional[int] = Field(description="価格(円)", default=None)

class RestaurantReview(BaseModel):
    """レストランレビュー情報"""
    restaurant_name: str = Field(description="レストラン名")
    cuisine_type: str = Field(description="料理のジャンル")
    rating: float = Field(description="評価(1.0〜5.0)", ge=1.0, le=5.0)
    recommended_dishes: List[MenuItem] = Field(description="おすすめ料理のリスト")
    overall_impression: str = Field(description="総評(1〜2文で)")

# エージェントを作成
bedrock_model = BedrockModel(
    model_id="us.amazon.nova-2-lite-v1:0",
    region_name="us-west-2",
    temperature=0.0,
)

agent = Agent(model=bedrock_model)

# レビューテキストから情報を抽出
review_text = """
先日、駅前にオープンした「トラットリア・ベッラ」に行ってきました。
本格的なイタリアンのお店で、特にカルボナーラ(1,400円)と
マルゲリータピザ(1,600円)が絶品でした!
雰囲気も良く、スタッフの対応も丁寧で、5点満点中4.5点をつけたいです。
デートにもぴったりのお店だと思います。
"""

result = agent(
    review_text,
    structured_output_model=RestaurantReview
)

review: RestaurantReview = result.structured_output
print(f"レストラン名: {review.restaurant_name}")
print(f"ジャンル: {review.cuisine_type}")
print(f"評価: {review.rating}")
print(f"おすすめ料理:")
for dish in review.recommended_dishes:
    price_str = f"({dish.price}円)" if dish.price else ""
    print(f"  - {dish.name}{price_str}")
print(f"総評: {review.overall_impression}")

実行結果は以下のようになります。

uv run main.py

Tool #1: RestaurantReview
レストラン名: トラットリア・ベッラ
ジャンル: イタリアン
評価: 4.5
おすすめ料理:
  - カルボナーラ(1400円)
  - マルゲリータピザ(1600円)
総評: 本格的なイタリアンで、雰囲気も良く、スタッフの対応も丁寧。デートにもぴったりのおしゃれなレストランです。

ネストしたモデルでも問題なく抽出できていますね!List[MenuItem]のようなリスト型もちゃんと扱えるのが良いですね。

バリデーションの活用

Pydanticの強力なバリデーション機能を活用することもできます。
例えば、文字数が10文字未満だった場合にエラーを検出できます。

from pydantic import BaseModel, Field, field_validator
from strands import Agent
from strands.models import BedrockModel

class ProductRating(BaseModel):
    """商品評価"""

    product_name: str = Field(description="商品名")
    rating: int = Field(description="評価(1〜5の整数)", ge=1, le=5)
    review_comment: str = Field(description="レビューコメント")

    @field_validator("review_comment")
    @classmethod
    def validate_comment_length(cls, value: str) -> str:
        if len(value) < 10:
            raise ValueError("レビューコメントは10文字以上必要です")
        return value

bedrock_model = BedrockModel(
    model_id="us.amazon.nova-2-lite-v1:0",
    region_name="us-west-2",
    temperature=0.0,
)

agent = Agent(model=bedrock_model)

result = agent(
    "AWSの本を買いました。星5つ、とても良い本でAWSの基礎がしっかり学べました。",
    structured_output_model=ProductRating,
)

product: ProductRating = result.structured_output
print(f"商品名: {product.product_name}")
print(f"評価: {'★' * product.rating}")
print(f"コメント: {product.review_comment}")

成功した時

Tool #1: ProductRating
商品名: AWSの本
評価: ★★★★★
コメント: とても良い本でAWSの基礎がしっかり学べました。

失敗した時

レビューコメントを最高とだけ書いて、実行してみます。

uv run main.py
Tool #1: ProductRating
tool_name=<ProductRating> | structured output validation failed | error_message=<Validation failed for ProductRating. Please fix the following errors:
- Field 'review_comment': Value error, レビューコメントは10文字以上必要です>
レビューコメントを10文字以上にして評価を再送信してください。現在のコメント「最高」は2文字のみです。例えば「AWSの本がとても分かりやすく、最高の学習リソースになりました!」などのように10文字以上のコメントをお書きください。
Tool #2: ProductRating
商品名: AWSの本
評価: ★★★★★
コメント: AWSの本がとても分かりやすく、最高の学習リソースになりました!

エラーは出ましたが、再度実行して無理やり構造化しましたねw
公式ドキュメントを見ても、バリデーションを検知した場合は、自動的にリトライで解決すると書いてあるので、これがデフォルトの挙動なのでしょう・・・この挙動は注意したいですね。厳格にチェックしたい場合は弾きたいですよね。ここでチェックするよりも構造化したものを純粋なロジックで評価して、適していなければエラーにするなどの方が確実なように思いました。

この挙動はGitHub Issue #1108でも報告されており、現状ではリトライ回数を制限するパラメータは提供されていません。 もしリトライ回数を制限したい場合は、Hooksを使って強制的に停止させることも可能なので検討の余地はありますね。

https://strandsagents.com/latest/documentation/docs/user-guide/concepts/agents/hooks/#limit-tool-counts

エラーハンドリング

構造化出力の解析に問題が発生した場合のエラーハンドリングも重要です。Strands AgentsではStructuredOutputExceptionがスローされます。

from strands import Agent
from strands.models import BedrockModel
from strands.types.exceptions import StructuredOutputException
from pydantic import BaseModel, Field

class StrictModel(BaseModel):
    """厳密な制約を持つモデル"""
    name: str = Field(description="名前", min_length=1)
    age: int = Field(description="年齢", ge=0, le=150)

bedrock_model = BedrockModel(
    model_id="us.amazon.nova-2-lite-v1:0",
    region_name="us-west-2",
    temperature=0.0,
)

agent = Agent(model=bedrock_model)

try:
    result = agent(
        "こんにちは",
        structured_output_model=StrictModel
    )
    data = result.structured_output
    print(f"名前: {data.name}, 年齢: {data.age}")
except StructuredOutputException as e:
    print(f"構造化出力に失敗しました: {e}")

実行してみます。

uv run main.py                                                                                                                  
こんにちは!お手伝いできることがあれば、お知らせください。何か特定の情報やサポートが必要な場合は、お気軽に聞いてください。
Tool #1: StrictModel
名前: ユーザー, 年齢: 25

あれ、失敗を検知できていないですね・・・

今回の例では、情報が不足している「こんにちは」というプロンプトに対して、LLMが勝手に「名前: ユーザー, 年齢: 25」というデータを創作してエラーを回避させていました。

私の使った印象だと例外があるから安心というよりはお守り的なイメージが強く、プロンプトで制御したり、違う観点でも検証するプロセスが必要になってくるかなと思いました。

クエリとモードを抽出する例をやってみる

実際に使いそうだなーと思ったケースで1つ試してみます。
クエリとモードを抽出する例でモックを検索するパターンをやってみます。

  1. 構造化出力で検索モードとクエリをLLMに考えてもらう
  2. 出力されたパラメータで検索を実行する

と言った流れです。

from enum import Enum
from typing import List, Optional

from pydantic import BaseModel, Field
from strands import Agent
from strands.models import BedrockModel

# 検索モードの定義
class SearchMode(str, Enum):
    SEMANTIC = "semantic"  # 意味検索(ベクトル検索)
    KEYWORD = "keyword"  # キーワード検索
    HYBRID = "hybrid"  # ハイブリッド検索

# 構造化クエリモデル
class StructuredQuery(BaseModel):
    """ユーザーの質問から抽出した検索クエリ情報"""

    search_query: str = Field(
        description="検索に使用するクエリ文字列。ユーザーの質問から検索に適した形に変換したもの"
    )
    search_mode: SearchMode = Field(
        description="検索モード。意味的な検索が必要な場合はsemantic、特定のキーワードを探す場合はkeyword、両方必要な場合はhybrid"
    )
    category: Optional[str] = Field(
        description="検索対象のカテゴリ(例: AWS, Python, データベースなど)",
        default=None,
    )
    filters: Optional[List[str]] = Field(
        description="追加のフィルタ条件(例: 最新, 初心者向け, 公式ドキュメントなど)",
        default=None,
    )
    time_range: Optional[str] = Field(
        description="時間範囲の指定がある場合(例: 2024年以降, 直近1ヶ月など)",
        default=None,
    )

# モック検索関数
def mock_search(query: StructuredQuery) -> List[dict]:
    """
    構造化クエリを使ってモック検索を実行する
    実際のシステムでは、ここでOpenSearchやベクトルDBへのクエリを実行する
    """
    print(f"\n{'=' * 50}")
    print("🔍 検索実行中...")
    print(f"{'=' * 50}")
    print(f"  クエリ: {query.search_query}")
    print(f"  モード: {query.search_mode.value}")
    if query.category:
        print(f"  カテゴリ: {query.category}")
    if query.filters:
        print(f"  フィルタ: {', '.join(query.filters)}")
    if query.time_range:
        print(f"  期間: {query.time_range}")
    print(f"{'=' * 50}\n")

    # モック結果を返す
    mock_results = [
        {
            "title": f"【{query.category or '技術'}{query.search_query}に関する記事",
            "score": 0.95,
            "snippet": f"この記事では{query.search_query}について詳しく解説しています...",
        },
        {
            "title": f"{query.search_query}入門ガイド",
            "score": 0.87,
            "snippet": f"初心者向けに{query.search_query}の基礎から応用までを網羅...",
        },
    ]

    return mock_results

# エージェントを作成
bedrock_model = BedrockModel(
    model_id="us.amazon.nova-2-lite-v1:0",
    temperature=0.0,
)
agent = Agent(model=bedrock_model)

# テスト用のユーザー質問リスト
user_questions = [
    "AWS Lambdaのコールドスタート対策について教えて",
    "OpenSearchでベクトル検索を実装する方法は?初心者向けの記事がいいな",
    "2024年以降に発表されたBedrockの新機能について",
]

for question in user_questions:
    print(f"\n{'#' * 60}")
    print(f"📝 ユーザーの質問: {question}")
    print(f"{'#' * 60}")

    # 質問を構造化クエリに変換
    result = agent(
        f"以下のユーザーの質問を検索クエリとして構造化してください:\n\n{question}",
        structured_output_model=StructuredQuery,
    )

    structured_query: StructuredQuery = result.structured_output

    # 構造化結果を表示
    print(f"\n📊 構造化結果:")
    print(f"  search_query: {structured_query.search_query}")
    print(f"  search_mode: {structured_query.search_mode.value}")
    print(f"  category: {structured_query.category}")
    print(f"  filters: {structured_query.filters}")
    print(f"  time_range: {structured_query.time_range}")

    # 構造化クエリを使って検索を実行
    results = mock_search(structured_query)

    # 検索結果を表示
    print("📚 検索結果:")
    for i, res in enumerate(results, 1):
        print(f"  {i}. {res['title']} (スコア: {res['score']})")

コードを作成したので実行してみます。

uv run main.py
############################################################
📝 ユーザーの質問: AWS Lambdaのコールドスタート対策について教えて
############################################################

Tool #1: StructuredQuery

📊 構造化結果:
  search_query: AWS Lambda コールドスタート対策
  search_mode: keyword
  category: AWS
  filters: None
  time_range: None

==================================================
🔍 検索実行中...
==================================================
  クエリ: AWS Lambda コールドスタート対策
  モード: keyword
  カテゴリ: AWS
==================================================

📚 検索結果:
  1. 【AWS】AWS Lambda コールドスタート対策に関する記事 (スコア: 0.95)
  2. AWS Lambda コールドスタート対策入門ガイド (スコア: 0.87)

############################################################
📝 ユーザーの質問: OpenSearchでベクトル検索を実装する方法は?初心者向けの記事がいいな
############################################################

Tool #2: StructuredQuery

📊 構造化結果:
  search_query: OpenSearch ベクトル検索 実装
  search_mode: keyword
  category: OpenSearch
  filters: ['初心者向け']
  time_range: None

==================================================
🔍 検索実行中...
==================================================
  クエリ: OpenSearch ベクトル検索 実装
  モード: keyword
  カテゴリ: OpenSearch
  フィルタ: 初心者向け
==================================================

📚 検索結果:
  1. 【OpenSearch】OpenSearch ベクトル検索 実装に関する記事 (スコア: 0.95)
  2. OpenSearch ベクトル検索 実装入門ガイド (スコア: 0.87)

############################################################
📝 ユーザーの質問: 2024年以降に発表されたBedrockの新機能について
############################################################

Tool #3: StructuredQuery

📊 構造化結果:
  search_query: Bedrock 新機能
  search_mode: keyword
  category: Bedrock
  filters: None
  time_range: 2024年以降

==================================================
🔍 検索実行中...
==================================================
  クエリ: Bedrock 新機能
  モード: keyword
  カテゴリ: Bedrock
  期間: 2024年以降
==================================================

📚 検索結果:
  1. 【Bedrock】Bedrock 新機能に関する記事 (スコア: 0.95)
  2. Bedrock 新機能入門ガイド (スコア: 0.87)

良い感じにパラメータを抽出して検索の関数に渡せていますね!
システムプロンプトに記載せずとも、構造化出力でアウトプットを必要な形に強制できるのは嬉しいですね。

実際に使う際はベストプラクティスも公式ドキュメントに記載があるので、参照したいですね。

https://strandsagents.com/latest/documentation/docs/user-guide/concepts/agents/structured-output/#best-practices_1

感想

Pydanticのモデルを定義して、ツール利用を通して構造化出力できるのは良いですね。IDEの補完も効くし、後続で受け取ったデータを扱いやすく、レスポンスを強制、および構造化したい際は使用を検討したいです。

一方でエラーハンドリング周りの挙動が心配で、無理やり構造化したり、リトライなども行われるため検証自体を別のプロセスで行うか、ツールの呼び出し回数をコントロールするかなど検討の余地があるかと思います。

まずは検索クエリの例のようにシンプルなものから試すのが筋が良さそうです。かつログでどこまで構造化に成功しているか、意図通りになっているかは分析していきたいですね。

エラーハンドリング周りは今後試していって知見をブログで共有できたらと思っています!

おわりに

本記事が少しでも参考になりましたら幸いです。
最後までご覧いただきありがとうございましたー!

この記事をシェアする

FacebookHatena blogX

関連記事