🍣

OpenAI APIを使ってLLMの出力を構造化する方法

に公開

はじめに

こんにちは、株式会社STAR AIでデータサイエンティスト兼人事をしている本田です。

LLM(大規模言語モデル)を活用したサービスやプロジェクトを考える上で、モデルの出力をどのように制御するかは非常に重要なポイントです。
何も指示を与えずに質問などを入力すると、LLMは自由記述の形式で回答を返してきます。これでは、情報の整理や再利用が難しく、ビジネスでの活用には不向きです。もちろん、プロンプトエンジニアリングによってある程度の出力制御は可能ですが、ノウハウが必要であったり、質問文などの入力が変動することで指示がうまく通らないこともあります。結果として、毎回同じ形式での出力を安定して得るのは難しいというのが現状です。
そこで今回は、OpenAIのAPIを活用して、LLMの出力を構造化データとして制御・取得する方法をご紹介します。
プロンプトの工夫だけに頼らず、より確実に出力フォーマットを固定するテクニックです。

使用する環境・バージョン

  • python 3.11
  • openai 1.74.0
  • python-dotenv 1.1.0 (オプション)

前提

Pythonでの環境構築が可能であり、すでにOpenAIの設定ページからAPIキーを取得済みの前提で進めます。
APIキーの取得方法について以下リンクを参考にしてみてください。
OpenAIのAPIキー取得方法|2024年7月最新版|料金体系や注意事項

環境設定

ライブラリのインストール

まずは、OpenAI APIを使用するためにopenaiとdotenv(オプション)をインストールします。
dotenvは必須ではありませんが、活用することでAPIキーを直接コードに記載する必要がなくなり、誤ってGitHubに公開してしまうなどのセキュリティ上のリスクを減らすことができます。(.gitignoreへの記載は必要)

pip install openai python-dotenv

APIキーの設定

作業ディレクトリに.envファイルを作成し、APIキーを書き込みます。

.env
OPENAI_API_KEY={YOUR API KEY}

これでPythonからOpenAI APIをを使用する準備は完了です。

ChatGPTの出力を制御する

ケース1:ニュース記事から筆者の感情を推定し、理由を構造化して出力する

このケースでは、架空のニュース記事をもとに、筆者の感情を「ポジティブ」「ネガティブ」「ニュートラル」のいずれかに分類し、その理由までを構造化されたデータとして出力させる方法をご紹介します。

まずは、ChatGPTに生成させた架空の記事を使用します。
※なお、この記事はあくまで検証用に作成したものであり、筆者自身の考えを反映したものではありません(笑)


タイトル
技術革新の芽を摘む“大手の論理”:加速するスタートアップ買収の裏側

本文
また一つ、有望なスタートアップが大手IT企業に飲み込まれた。先日発表された、X社による急成長中の生成AIスタートアップY社の買収は、表向きには「技術の融合」や「スケールアップによる社会貢献」といった美辞麗句で彩られている。しかしその裏には、業界にとって深刻な影響を及ぼしかねない“支配の構造”が透けて見える。

本来、スタートアップとは、大手がなし得ない自由な発想とスピードで技術革新を生み出す存在であり、業界全体に健全な緊張感と競争をもたらす重要なプレイヤーだ。しかし近年、資本力を背景にこうした企業を次々と取り込んでいく大手企業の動きは、単なる成長戦略ではなく、“市場支配”を目的とした囲い込みに他ならない。

特に懸念されるのは、買収後にスタートアップの独自性が失われるケースだ。Y社もまた、買収後はX社のプラットフォームへの統合が進められ、かつての大胆な製品開発のスピリットが薄れていくことが予想される。これでは、単なる「開発チームの買収」に過ぎず、真のイノベーションは遠ざかるばかりだ。

さらに、業界全体にもネガティブな影響が広がる。資金調達の選択肢が「独立したまま成長する」道から「いずれ大手に買収される」前提へと変わってしまえば、起業家の意識も保守的になる。自由で独創的なアイデアが生まれにくくなり、業界の停滞を招く恐れすらある。

買収自体を全否定するものではない。しかし、本当に今必要なのは、資本による吸収ではなく、対等な技術連携やオープンなイノベーションの促進ではないだろうか。企業の論理が、技術の未来を狭めていないか。今こそ、私たちは問い直す必要がある。


通常の応答

まずは特別な工夫をせずに、GPT-4oを使ってそのまま感情分析を行わせてみます。

import openai
from dotenv import load_dotenv
import os

# .envに記載したAPIキーを環境変数に埋め込み、openaiのライブラリに渡す
load_dotenv()
openai.api_key = os.getenv("OPENAI_API_KEY")

# 用意した架空の記事の読み込み
with open("input.txt", "r") as f:
    input_text = f.read()

# APIを介してChatGPTから出力を得る
completion = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "記事の内容から筆者の感情を予測してください。また、予測結果は、ポジティブ、ネガティブ、ニュートラルのいずれから選択してください。"},
        {"role": "user", "content": input_text}
    ]
)

output = completion.choices[0].message.content
print(output)

実行結果

筆者の感情はネガティブです。記事では、大手企業によるスタートアップの買収の問題点や懸念が強調されています。特に技術革新の自由が奪われ、業界全体へのネガティブな影響が広がることや、買収後の独自性の喪失について批判的な視点を持っています。

このように、感情の分類とその理由を含んだ回答は返ってきますが、自由記述形式であるため、そのままでは後続の処理で使用するデータとしては扱いにくいという課題があります。

構造化出力による応答の制御

次に、OpenAIのAPIにおける構造化出力(structured outputs)を使って、感情分析の結果を「形式が固定されたJSON形式」で取得する方法を見ていきます。

このアプローチを使えば、自由記述の曖昧な出力ではなく、あらかじめ定義したフォーマットに従ったデータを安定して得ることができます。
これにより、後続の処理や可視化、分析が圧倒的に容易になります。
具体的には以下のようなjson形式で構造化された出力を得ることを目指します。

{
  "sentiment": [
    {
      "output": "感情分析結果",
      "explanation": "推定理由"
    }
  ]
}

コードの全体像から

import openai
from dotenv import load_dotenv
import os
import json

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "記事の内容から筆者の感情を予測してください。また、予測結果は、ポジティブ、ネガティブ、ニュートラルのいずれから選択してください。"},
        {"role": "user", "content": input_text}
    ],
    tools=[
        {
            "type": "function",
            "function": {
                "name": "sentiment_analysis",
                "description": "記事から筆者の感情を分類し、その理由を説明する",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "sentiment": {
                            "type": "array",
                            "items": {
                                "type": "object",
                                "properties": {
                                    "output": {
                                        "type": "string",
                                        "enum": ["ポジティブ", "ネガティブ", "ニュートラル"]
                                    },
                                    "explanation": {
                                        "type": "string"
                                    }
                                },
                                "required": ["output", "explanation"]
                            }
                        }
                    },
                    "required": ["sentiment"]
                }
            }
        }
    ],
    tool_choice={"type": "function", "function": {"name": "sentiment_analysis"}}
)

print(json.loads(response.choices[0].message.tool_calls[0].function.arguments))
コードの解説
1. tools による構造化出力の定義
tools=[
    {
        "type": "function",
        "function": {
            "name": "sentiment_analysis",
            ...
        }
    }
],

tools パラメータを使うことで、「このような関数を呼び出すつもりで応答を返してね」とモデルに伝えることができます。
今回は sentiment_analysis という仮想的な関数を定義し、その返り値として 感情分類結果を構造化形式で出力するように設定しています。

2. parameters で出力形式を厳密に指定
"parameters": {
  "type": "object",
  "properties": {
    "sentiment": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "output": {
            "type": "string",
            "enum": ["ポジティブ", "ネガティブ", "ニュートラル"]
          },
          "explanation": {
            "type": "string"
          }
        },
        "required": ["output", "explanation"]
      }
    }
  },
  "required": ["sentiment"]
}

この部分で、出力すべきJSONの構造を厳密に定義しています。
特に以下の点がポイントです:

  • sentiment は配列形式のプロパティ(拡張性のある構造)
  • 各要素は output(感情の種類)と explanation(理由)の2つのキーを持つ
  • enum を使って出力可能な値を "ポジティブ", "ネガティブ", "ニュートラル" に限定

これにより、モデルは自由記述形式の文章ではなく、決められた形式でのみ応答するようになります。

3. tool_choice による関数実行の強制
tool_choice={"type": "function", "function": {"name": "sentiment_analysis"}}

tool_choice を明示的に指定することで、ChatGPTが必ず sentiment_analysis 関数を使用するように制御できます。
これを省略すると、通常のテキスト出力になってしまう可能性があるため、構造化出力を得たい場合は設定しておくのをおすすめします。

4. 出力結果の取得とパース
json.loads(response.choices[0].message.tool_calls[0].function.arguments)

tool_calls の中に、関数の引数としてモデルが出力したJSON文字列が格納されています。
これを json.loads() でパースすることで、Pythonオブジェクトとして扱えるようになります。

実行結果

{'sentiment':
 [{
    'output': 'ネガティブ',
    'explanation': 'この文章は大手企業によるスタートアップの買収についての懸念を強く表明しており、特にそのような買収が技術革新を妨げる可能性があると批判的に説明しています。『技術革新の芽を摘む』、『支配の構造』、『独自性が失われる』などの表現からも、筆者の強いネガティブな感情が伺えます。買収後にイノベーションが遠のくことや業界の停滞を危惧する内容が詳述されているため、全体的にネガティブなトーンです。'
    }]
    }

このように、予測された感情とその理由がJSON形式で返されるため、後段の機械的な処理に活用していくのに大変便利です。

ケース2: ニュース記事を関連する複数カテゴリに分類する

先程までは感情分析として、筆者の感情を「ポジティブ」「ネガティブ」「ニュートラル」のいずれか1つに分類することに試みましたが、今回は複数のカテゴリに分類することを考えます。

通常の応答

まずは先程までと同様に通常の応答を行わせてみます。

system_prompt = """記事の内容に最も関連するカテゴリを、次の中から選んでください。複数が関連する場合は、複数のカテゴリを選んでください。

【カテゴリ候補】
- 政治
- 経済
- 社会
- 国際
- 科学・技術
- 環境
- 教育
- 医療・健康
- ビジネス
- エンタメ
- スポーツ
- 文化・芸術
- IT・デジタル
- ライフスタイル"""

completion = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": input_text}
    ]
)

output = completion.choices[0].message.content
print(output)

実行結果

この記事に最も関連するカテゴリは以下の通りです:

- ビジネス
- IT・デジタル
- 経済

記事は、大手IT企業によるスタートアップの買収に関する問題を扱っており、ビジネス戦略、技術革新の動向、および経済的影響について述べられています。よってこれらのカテゴリが関連しています。

しっかりと関連するカテゴリを複数答えてくれいますが、こちらも不要な文章が含まれており、後段の処理に使用するには不向きです。

構造化出力による応答の制御

こちらも構造化出力(structured outputs)を使って、カテゴリだけを出力するように強制します。

# カテゴリの定義
categories = ["政治", "経済", "社会", "国際", "科学・技術", "環境", "教育", "医療・健康", "ビジネス", "エンタメ", "スポーツ", "文化・芸術", "IT・デジタル", "ライフスタイル"]

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "記事の内容に最も関連するカテゴリを選択してください。"},
        {"role": "user", "content": input_text}
    ],
    tools=[
        {
            "type": "function",
            "function": {
                "name": "article_classification",
                "description": "記事から内容に関連するカテゴリを1つ以上選択する",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "categories": {
                            "type": "array",
                            "items": {
                                "type": "string",
                                "enum": categories
                            },
                            "minItems": 1,
                            "uniqueItems": True
                        }
                    },
                    "required": ["categories"]
                }
            }
        }
    ],
    tool_choice={"type": "function", "function": {"name": "article_classification"}}
)

print(json.loads(response.choices[0].message.tool_calls[0].function.arguments))
カテゴリ分類コードにおける補足解説

前節で紹介した感情分析のコードと同様、このコードでも OpenAI API の tools を使って構造化出力を実現しています。ここでは、ニュース記事から関連するカテゴリを抽出するユースケースに合わせて、出力仕様を少し変更しています。

以下、カテゴリ分類に固有のポイントを見ていきましょう。

1. categories パラメータの構造
"categories": {
    "type": "array",
    "items": {
        "type": "string",
        "enum": categories
    },
    "minItems": 1,
    "uniqueItems": True
}

このパラメータは、記事に関連するカテゴリ(例:政治、経済、ビジネスなど)を1つ以上返すことを求めています。ポイントは以下の通りです:

  • 配列形式(type: "array")として複数カテゴリを返せる設計
  • 選択肢の制限(enum)により、意図しないカテゴリ名が出力されないよう制御
  • 最低1件の出力を強制(minItems: 1)
  • 重複の禁止(uniqueItems: True)

これにより、モデルは自由に文章で答えるのではなく、あらかじめ定義されたカテゴリから1つ以上選ぶように強制されます。

実行結果

{'categories': ['ビジネス', 'IT・デジタル', '科学・技術']}

これにより、モデルは決められた形式で複数回答を行えるようになります。

まとめ

構造化出力を活用することで、ChatGPTの出力を「人が読むための文章」から「システムで扱いやすいデータ」へと変換することができ、業務での自動化や効率化に大きく貢献します。
本記事で紹介した感情分析やカテゴリ分類はその一例ですが、他にも「FAQの自動分類」「アンケート自由記述のタグ付け」「プロダクトレビューの評価抽出」など、応用可能な場面は非常に広範囲にわたります。

LLMの可能性を最大限に引き出すためには、単に生成させるだけでなく、「どう扱うか」まで見据えることが重要です。今後も、こうした実践的な活用方法を引き続き発信していければと思っています!

最後に

STAR AIでは、LLMに限らず、機械学習モデルや統計的手法なども活用しながら、幅広いデータサイエンスの力で社会やビジネスの課題解決に取り組んでいます。
現在、特にプロジェクトリードやマネージャーとしてプロジェクトを推進していただける方を募集しています。
AI技術を実務に活かし、大手クライアントなどと共に社会的にインパクトのあるプロジェクトをリードしたいと考えている方、ぜひ一緒に挑戦しませんか?
ご興味のある方はこちらからお気軽にご連絡ください!

Discussion