AI

【AIからの回答の構造を指定する】OpenAIのStructured Outputsを試してみる。

ChatGPTなどの大規模言語モデルに質問をすると通常は文章で回答が返ってきます。

これはAPIを使用した場合も同様です。
以下はOpenAIのAPIを使用してワンピースの主人公、仲間、敵の名前を回答させるためのコードです。

from openai import OpenAI

client = OpenAI(api_key=<OpenAIから取得したAPIキー>)

completion = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {
            "role": "system",
            "content": "与えられた作品の主人公、仲間、敵を教えてください。",
        },
        {"role": "user", "content": "ワンピース"},
    ],
)
message = completion.choices[0].message.content
print(message)

 

回答(messageの内容をprintしたもの)は以下のとおりです。

「ワンピース」の主人公はモンキー・D・ルフィです。彼は海賊王を目指す若き海賊で、「麦わらの一味」というクルーを率いています。仲間には、ロロノア・ゾロ(剣士)、ナミ(航海士)、ウソップ(狙撃手)、サンジ(料理人)、トニートニー・チョッパー(医者)、ニコ・ロビン(考古学者)、フランキー(船大工)、ブルック(音楽家)、ジンベエ(操舵手)などがいます。 敵としては、海賊界の強敵や世界政府、海軍の要人たちが挙げられます。主な敵には、七武海や四皇(例:シャンクス、カイドウ、ビッグ・マム)、さらに王下七武海(例:クロコダイル、ドフラミンゴ)や海軍大将(例:青雉、赤犬、黄猿)など、多種多様な強敵が存在します。各エピソードごとにルフィたちの前に立ちはだかる敵が変わっていきます。

わかりやすくて良いのですが、もしプログラムでこの結果から味方や敵のリストを抽出する必要がある場合、このような自由な文章であることがかえってアダになります。

プログラムで情報を抽出できるような回答結果を得るためにはどうすれば良いでしょうか?

なお、OpenAIのバージョンは1.44.1を使用しています。OpeAIのAPIキーはOpenAIのアカウントを作成した後、以下にアクセスして「Create new secret key」によりAPIキーを取得してください。

https://platform.openai.com/account/api-keys

また本記事ではワンピースを知らないと回答結果を見ても面白くないので、万が一、ワンピースを読んだことない方は予習しておいてくださいw(まあ1巻だけ読んでもわからないですが・・)

 

プロンプトで回答の構造を指定する。

以下のようにプロンプトに構造を指定すると、その指定した構造で回答を出力してくれます。

from openai import OpenAI

client = OpenAI(api_key=<OpenAIから取得したAPIキー>)

system_content = """
        与えられた作品の主人公、仲間、敵を教えてください。
        回答は無駄な説明を省いて、以下のような形式で出力してください。
         主人公:AAA
         仲間: BBB ,CCC ,DDD ,EEE
         敵: FFF ,GGG ,HHH ,III
        """
completion = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": system_content},
        {"role": "user", "content": "ワンピース"},
    ],
)
message = completion.choices[0].message.content
print(message)

 

回答(messageの内容をprintしたもの)は以下のとおりです。

主人公: モンキー・D・ルフィ
仲間: ロロノア・ゾロ, ナミ, ウソップ, サンジ, トニートニー・チョッパー, ニコ・ロビン, フランキー, ブルック, ジンベエ
敵: 海軍(モンキー・D・ガープ, サカズキ, 黄猿, 藤虎, つる), クロコダイル, ドフラミンゴ, カタクリ, ビッグ・マム, カイドウ, 黒ひげ(マーシャル・D・ティーチ)

この回答ならば、プログラムで情報を抽出できそうです。

ただしこの方法には以下のような欠点があります。

  • 構造がさらに複雑な場合にはプロンプトでの例示も複雑になります。
  • 複雑になればなるほど、常にに例示した通りに回答を出力してくれる保証はありません。

Structured Outputsにより構造を指定

そのような問題を解決すべく、2024年8月6日、OpenAI はStructured Outputs という新機能を公開しました。
この機能を使用すると正確かつ安定的に出力を構造化できるようになります。

以下、Structured Outputsを使用したコードになります。

from openai import OpenAI
from pydantic import BaseModel

client = OpenAI(api_key=<OpenAIから取得したAPIキー>)

class Character(BaseModel):
    title: str
    main_character: str
    buddies: list[str]
    enemies: list[str]

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {
            "role": "system",
            "content": "与えられた作品の主人公、仲間、敵を出力してください。",
        },
        {"role": "user", "content": "ワンピース"},
    ],
    response_format=Character,
)
character = completion.choices[0].message.parsed
print(character)

 

ポイントはpydanticライブラリのBaseModelを継承したクラス(Character)を作成して、そのフィールドで構造を指定する事です。

変数characterは指定した型(Character)となっており、この例での回答(characterの内容をprintしたもの)は以下のようになります。

title=’ワンピース’ main_character=’モンキー・D・ルフィ’ buddies=[‘ロロノア・ゾロ’, ‘ナミ’, ‘ウソップ’, ‘サンジ’, ‘トニートニー・チョッパー’, ‘ニコ・ロビン’, ‘フランキー’, ‘ブルック’, ‘ジンベエ’] enemies=[‘黒ひげ’, ‘赤犬’, ‘ビッグ・マム’, ‘カイドウ’, ‘ドフラミンゴ’, ‘シャーロット・クラッカー’, ‘エネル’, ‘クロコダイル’, ‘アーロン’, ‘バギー’]

指定したクラスに結果が格納されるため構造は確実なものとなり、プログラムからも情報を確実に抽出することができます。

私自身、驚きなのは、Characterクラスのフィールドの変数名から類推して情報を格納している事です。titleというフィールドに対してはプロンプトでは何の指示も与えてませんが、作品名が格納されています。

試しに以下のようにCharacterクラスにauthorを追加します。

class Character(BaseModel):
    title: str
    author: str
    main_character: str
    buddies: list[str]
    enemies: list[str]

 

結果、以下のようにauthorに作者名が格納されます。

title=’ワンピース’ author=’尾田栄一郎’ main_character=’モンキー・D・ルフィ’ buddies=[‘ロロノア・ゾロ’, ‘ナミ’, ‘ウソップ’, ‘サンジ’, ‘トニートニー・チョッパー’, ‘ニコ・ロビン’, ‘フランキー’, ‘ブルック’, ‘ジンベエ’] enemies=[‘シャンクス’, ‘ドンキホーテ・ドフラミンゴ’, ‘バギー’, ‘シャーロット・リンリン’, ‘カイドウ’, ‘黒ひげ’]

上記の例では、title,authorと誤解のしようのない名称だったので問題ないですが、変数名だけでは、わかりにくい例をもあるかもしれません。

そのような場合のために以下のようにFieldクラスを使用して詳細に格納する条件を指定する事もできます。

from pydantic import Field
class Character(BaseModel):
    title: str = Field(..., description="作品のタイトル")
    author: str =  Field(..., description="作者")   
    main_character: str = Field(..., description="作品の主人公")
    buddies: list[str] = Field(
        ..., description="主人公の仲間, 重要な順に最大10個まで"
    )
    enemies: list[str] = Field(..., description="主人公の敵, 重要な順に最大10個まで")

 

Characterクラスを上のものに差し替えて実行すると結果は以下のようになります。

title=’ワンピース’ author=’尾田栄一郎’ main_character=’モンキー・D・ルフィ’ buddies=[‘ロロノア・ゾロ’, ‘ナミ’, ‘ウソップ’, ‘サンジ’, ‘トニートニー・チョッパー’, ‘ニコ・ロビン’, ‘フランキー’, ‘ブルック’, ‘ジンベエ’] enemies=[‘アルビダ’, ‘バギー’, ‘クロ’, ‘アーロン’, ‘スモーカー’, ‘ワポル’, ‘サー・クロコダイル’, ‘エネル’, ‘ロブ・ルッチ’, ‘ドンキホーテ・ドフラミンゴ’]

この例では、元々、それほど悪くない結果だったのであまり違いがわかりませんが、Fieldを指定した方がより確実な結果が得られるので指定しておいた方が良いでしょう。(順番は重要な順というよりも登場順のような気もしますが、まあいいでしょう・・)

構造にマッチしないプロンプトの場合はどうなる?

ここでは、構造にマッチしないようなプロンプトを入力した場合にどうなるかを試してみます。

ワンピースのように敵と味方がいるような作品はわかりやすいですが、そうでない作品だとどうなるでしょうか?

「ワンピース」から「サザエさん」に変更して試してみました。

    messages=[
        {
            "role": "system",
            "content": "与えられた作品の主人公、仲間、敵を出力してください。",
        },
        {"role": "user", "content": "サザエさん"},
    ],

 

結果は以下のようになりました。

title=’サザエさん’ author=’長谷川町子’ main_character=’磯野サザエ’ buddies=[‘磯野カツオ(弟)’, ‘磯野ワカメ(妹)’, ‘磯野波平(父)’, ‘磯野フネ(母)’, ‘フグ田マスオ(夫)’, ‘フグ田タラオ(息子)’, ‘タマ(飼い猫)’] enemies=[]

おお、敵がいない。平和な世界です。

しかし、試しにもう一回実行すると・・

title=’サザエさん’ author=’長谷川町子’ main_character=’フグ田サザエ’ buddies=[‘フグ田マスオ’, ‘フグ田タラオ’, ‘磯野カツオ’, ‘磯野ワカメ’, ‘磯野波平’, ‘磯野フネ’, ‘タマ(猫)’, ‘いささか先生’, ‘中島ヒロシ’, ‘花沢花子’] enemies=[‘意地の悪い隣のおばさん’, ‘他のいたずらっ子’]

なんか無理矢理、変な敵が追加されました。常に敵をブランクで返してくれるわけではなさそうです。

次に存在しない作品だとどうなるか試してみました。

以下のように作品名を存在しないものに変更して試してみました。(念のためググって存在しないことを確認済)

    messages=[
        {
            "role": "system",
            "content": "与えられた作品の主人公、仲間、敵を出力してください。",
        },
        {"role": "user", "content": "ドドの冒険"},
    ],

 

結果は以下のようになりました。

title=’ドドの冒険’ author=’不明’ main_character=’ドド’ buddies=[‘ポポ’, ‘ナヌ’] enemies=[‘マーシャル’, ‘ブラックバード’]

適当な名前が入ってますね・・(他の適当な作品名でも試しましたが、全部適当なキャラクター名を入れてきました・・)

そこで作品を知らない場合はブランクで返すように指示しました。

    messages=[
        {
            "role": "system",
            "content": "与えられた作品の主人公、仲間、敵を出力してください。作品を知らない場合は全てブランクで返してください。",
        },
        {"role": "user", "content": "ドドの冒険"},

 

今度はちゃんとブランクになりました。

title=’ドドの冒険’ author=” main_character=” buddies=[] enemies=[]

次にプロンプトにCharacterクラスのフィールドとは全く関係ないような指示を与えた場合はどうなるか試してみます。

    messages=[
        {
            "role": "system",
            "content": "あなたは数学者です。",
        },
        {"role": "user", "content": "3+4はいくつになりますか?"},
    ],

 

以下のような結果になりました。

title=’算数冒険記’ author=’山田太郎’ main_character=’算数大好きくん’ buddies=[‘計算マシーンくん’, ‘足し算博士’, ‘引き算ナビゲーター’] enemies=[‘数字を隠す妖怪’, ‘混乱の魔女’]

いや、適当すぎるだろw

本当は、completion.choices[0].messageからparsedが取得できない場合は、refusalに拒否メッセージが格納されるらしく、最後の取得の箇所はそれを考慮して以下のような書き方をするべきなのですが、拒否されるケースが再現できませんでした。

res = completion.choices[0].message
if res.parsed:
    print(res.parsed)
elif res.refusal:
    print(res.refusal)

 

公式文書では拒否する場合は「安全上の理由」という書き方だったので、センシティブな内容でも試してみましたが、通常の回答形式ならば「お答えできません」と答えるケースでも適当なタイトルやキャラクター名が出力されました。どのような場合に回答を拒否するようになるかは、今後、検証して行きたいと思います。

まとめ

以上、OpenAIのStructured Outputsを用いて回答を構造化しました。

さらに詳しく知りたい方は、以下の公式をご参照ください。

https://platform.openai.com/docs/guides/structured-outputs/introduction

本記事では非常に単純な例を用いて紹介しましたが、使いこなせば、実用的な用途に使用できるかと思います。(上の公式文書ではそのような例を記載されています)

当サイトも実用的な案を思いついたらさらに発展させた記事を書こうと思います。