💡

OpenAI APIの構造化出力機能:実践編(📒GoogleColabノートブック付):

2024/08/07に公開

はじめに

こんにちは!今回は、OpenAI APIの新機能である「構造化出力」について、初心者の方にも分かりやすく解説していきます。この機能を使うことで、AIモデルの出力を特定のJSON形式に強制することができ、より信頼性の高いアプリケーションが開発できるようになります。

それでは、さっそく詳しく見ていきましょう!

構造化出力とは

構造化出力とは、OpenAIのAIモデルが生成する出力を、開発者が指定したJSONスキーマに厳密に従わせる機能です。これにより、モデルの出力を予測可能で一貫性のあるものにすることができます。

主な特徴

  • 開発者が定義したJSONスキーマに完全に準拠した出力を生成
  • 複雑なスキーマにも対応可能
  • 高い信頼性(ベンチマークテストで100%のスコアを達成)

構造化出力の使用方法

構造化出力は、主に2つの方法で使用できます:

  1. 関数呼び出し(Function Calling)
  2. レスポンスフォーマット(Response Format)

それぞれの使用方法を見ていきましょう。

関数呼び出しによる構造化出力

関数呼び出しを使用する場合、toolsパラメータ内でstrict: trueを設定します。

!pip install openai
# OpenAI APIをインポート
from google.colab import userdata
from enum import Enum
from typing import Union
from pydantic import BaseModel
from openai import OpenAI

# APIキーを設定(実際の使用時は環境変数などで安全に管理してください)
client = OpenAI(api_key=userdata.get('OPENAI_API_KEY'))

class Table(str, Enum):
    orders = "orders"
    customers = "customers"
    products = "products"

class Column(str, Enum):
    id = "id"
    status = "status"
    expected_delivery_date = "expected_delivery_date"
    delivered_at = "delivered_at"
    shipped_at = "shipped_at"
    ordered_at = "ordered_at"
    canceled_at = "canceled_at"

class Operator(str, Enum):
    eq = "="
    gt = ">"
    lt = "<"
    le = "<="
    ge = ">="
    ne = "!="

class OrderBy(str, Enum):
    asc = "asc"
    desc = "desc"

class DynamicValue(BaseModel):
    column_name: str

class Condition(BaseModel):
    column: str
    operator: Operator
    value: Union[str, int, DynamicValue]

class Query(BaseModel):
    table_name: Table
    columns: list[Column]
    conditions: list[Condition]
    order_by: OrderBy

# 関数呼び出しを使用した構造化出力の例
response = client.chat.completions.create(
    model="gpt-4-0613",  # 適切なモデルを指定
    messages=[
        {"role": "system", "content": "あなたは有能なアシスタントです。現在の日付は2024年8月6日です。ユーザーがクエリ関数を呼び出して探しているデータを取得するのを手伝います。"},
        {"role": "user", "content": "去年の5月の注文で、履行されたが期日通りに配達されなかったものをすべて探してください"}
    ],
    tools=[{
        "type": "function",
        "function": {
            "name": "query",
            "description": "Execute a query.",
            "parameters": Query.schema()
        }
    }]
)

# 結果を表示
print(response.choices[0].message)
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.text import Text
from rich.json import JSON
import json

# 結果の文字列(実際の出力に合わせて調整してください)
result_str = '''ChatCompletionMessage(content=None, refusal=None, role='assistant', function_call=None, tool_calls=[ChatCompletionMessageToolCall(id='call_aOQTDzuJEZJlNgEUzdhIWuIJ', function=Function(arguments='{\n  "table_name": "orders",\n  "columns": ["id", "status", "expected_delivery_date", "delivered_at"],\n  "conditions": [\n    {\n      "column": "status",\n      "operator": "=",\n      "value": "fulfilled"\n    },\n    {\n      "column": "ordered_at",\n      "operator": ">=",\n      "value": "2023-05-01"\n    },\n    {\n      "column": "ordered_at",\n      "operator": "<=",\n      "value": "2023-05-31"\n    },\n    {\n      "column": "expected_delivery_date",\n      "operator": "<",\n      "value": {\n        "column_name": "delivered_at"\n      }\n    }\n  ],\n  "order_by": "asc"\n}', name='query'), type='function')])'''

# JSON部分を抽出
json_start = result_str.index('{')
json_end = result_str.rindex('}') + 1
json_str = result_str[json_start:json_end]

# JSONをパース
data = json.loads(json_str)

console = Console()

# メインパネルの作成
main_panel = Panel(
    Text("注文クエリ結果", style="bold magenta"),
    expand=False,
    border_style="cyan"
)

# テーブル名とカラムの表示
table_info = Table(show_header=False, box=None)
table_info.add_row("テーブル名", data["table_name"])
table_info.add_row("カラム", ", ".join(data["columns"]))

# 条件の表示
conditions_table = Table(title="クエリ条件", show_header=True, header_style="bold green")
conditions_table.add_column("カラム", style="cyan")
conditions_table.add_column("演算子", style="magenta")
conditions_table.add_column("値", style="yellow")

for condition in data["conditions"]:
    value = condition["value"]["column_name"] if isinstance(condition["value"], dict) else condition["value"]
    conditions_table.add_row(condition["column"], condition["operator"], str(value))

# ソート順の表示
order_by = Text(f"ソート順: {data['order_by']}", style="bold blue")

# 全体の表示
console.print(main_panel)
console.print(table_info)
console.print(conditions_table)
console.print(order_by)

# 元のJSONの表示(オプション)
console.print(Panel(JSON(json_str), title="元のJSON", border_style="green"))

このコードでは、query_ordersという関数を定義し、その中でstrict: Trueを設定しています。これにより、AIモデルの出力が指定したスキーマに厳密に従うようになります。

レスポンスフォーマットによる構造化出力

レスポンスフォーマットを使用する場合、response_formatパラメータを指定します。

from pydantic import BaseModel
from openai import OpenAI

class Step(BaseModel):
    explanation: str
    output: str

class MathResponse(BaseModel):
    steps: list[Step]
    final_answer: str

client = OpenAI(api_key=userdata.get('OPENAI_API_KEY'))

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",  # 最新のモデルを指定
    messages=[
        {"role": "system", "content": "あなたは親切な数学の家庭教師です。"},
        {"role": "user", "content": "8x + 31 = 2 を解いてください。"},
    ],
    response_format=MathResponse,
)

message = completion.choices[0].message
if message.parsed:
    print("解答のステップ:")
    for step in message.parsed.steps:
        print(f"説明: {step.explanation}")
        print(f"計算: {step.output}")
        print("---")
    print(f"最終的な答え: {message.parsed.final_answer}")
else:
    print(f"拒否理由: {message.refusal}")
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from rich.table import Table
from rich.markdown import Markdown

console = Console()

# 結果データ
steps = [
    {"explanation": "Firstly, we'll try to isolate the term with 'x' on one side of the equation. Currently, we have the equation 8x + 31 = 2. We need to get rid of the constant term on the left side, which is +31.", "output": "8x + 31 = 2"},
    {"explanation": "Subtract 31 from both sides of the equation to eliminate the constant from the left side. This helps in isolating the term with 'x'.", "output": "8x + 31 - 31 = 2 - 31"},
    {"explanation": "Simplifying both sides of the equation after subtraction. The +31 and -31 on the left cancel each other out, leaving us with 8x. On the right, 2 - 31 gives us -29.", "output": "8x = -29"},
    {"explanation": "To solve for 'x', we need to isolate 'x' completely. Since 'x' is currently multiplied by 8, we'll divide both sides of the equation by 8 to solve for 'x'.", "output": "8x / 8 = -29 / 8"},
    {"explanation": "Simplifying the division gives us the value of 'x'.", "output": "x = -29/8"}
]
final_answer = "x = -\\frac{29}{8}"

# タイトルパネル
title_panel = Panel(
    Text("数式の解き方: 8x + 31 = 2", style="bold magenta"),
    border_style="cyan"
)

console.print(title_panel)

# ステップごとの表示
for i, step in enumerate(steps, 1):
    step_panel = Panel(
        Text(f"ステップ {i}", style="bold yellow"),
        border_style="green"
    )
    console.print(step_panel)

    explanation_md = Markdown(step["explanation"])
    console.print(explanation_md)

    equation_panel = Panel(
        Text(step["output"], style="bold cyan"),
        border_style="blue"
    )
    console.print(equation_panel)
    console.print()

# 最終答案の表示
final_answer_panel = Panel(
    Markdown(f"# 最終的な答え\n\n${final_answer}$"),
    border_style="red",
    title="解答",
    title_align="left"
)
console.print(final_answer_panel)

このコードでは、数学の問題の解法を構造化された形式で出力するようにAIモデルに指示しています。response_formatパラメータで出力のスキーマを定義しています。

安全性と制限事項

構造化出力機能は非常に強力ですが、いくつかの制限事項があります:

  1. 初回のリクエストは処理に時間がかかる場合があります(通常10秒以内、複雑なスキーマでは最大1分)。
  2. モデルが安全でないリクエストを拒否した場合、スキーマに従わない可能性があります。
  3. 出力がmax_tokensに達した場合、スキーマに完全に従えない可能性があります。
  4. 並列関数呼び出しとは互換性がありません。

これらの制限を念頭に置いて使用することが重要です。

実践的な例

では、構造化出力を使って実際にアプリケーションを作ってみましょう。ここでは、ユーザーの入力から To-Do リストを生成する簡単なアプリケーションを作ります。

from openai import OpenAI
from pydantic import BaseModel
from typing import List
import json
import os
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import box

# APIクライアントを初期化(APIキーは環境変数から取得するか、安全に管理してください)
client = OpenAI(api_key=userdata.get('OPENAI_API_KEY'))

console = Console()

class Task(BaseModel):
    description: str
    priority: str
    due_date: str

class TodoList(BaseModel):
    tasks: List[Task]

def generate_todo_list(user_input: str) -> TodoList:
    # OpenAI APIを呼び出して構造化された To-Do リストを生成
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-2024-08-06",
        messages=[
            {"role": "system", "content": "ユーザーの入力から To-Do リストを生成してください。"},
            {"role": "user", "content": user_input}
        ],
        response_format=TodoList
    )

    return completion.choices[0].message.parsed

def save_todo_list(todo_list: TodoList, filename: str = "todo_list.json"):
    with open(filename, "w", encoding="utf-8") as f:
        json.dump(todo_list.model_dump(), f, ensure_ascii=False, indent=2)

def load_todo_list(filename: str = "todo_list.json") -> TodoList:
    if os.path.exists(filename):
        with open(filename, "r", encoding="utf-8") as f:
            data = json.load(f)
        return TodoList.model_validate(data)
    return TodoList(tasks=[])

def print_todo_list(todo_list: TodoList):
    table = Table(title="現在の To-Do リスト", expand=True, box=box.ROUNDED)
    table.add_column("番号", style="cyan", no_wrap=True)
    table.add_column("タスク", style="magenta")
    table.add_column("優先度", style="green")
    table.add_column("期限", style="yellow")

    for i, task in enumerate(todo_list.tasks, 1):
        table.add_row(str(i), task.description, task.priority, task.due_date)

    console.print(Panel(table, expand=True))

# メインループ
def main():
    todo_list = load_todo_list()

    while True:
        print_todo_list(todo_list)

        user_input = console.input("\n[bold green]To-Do リストに追加したいタスクを教えてください[/bold green](終了する場合は 'q' を入力): ")

        if user_input.lower() == 'q':
            break

        new_tasks = generate_todo_list(user_input)
        todo_list.tasks.extend(new_tasks.tasks)

        save_todo_list(todo_list)

        console.print("\n[bold blue]新しいタスクが追加されました。[/bold blue]")

    console.print("[bold red]プログラムを終了します。To-Do リストは保存されました。[/bold red]")

if __name__ == "__main__":
    main()

このコードは以下のように動作します:

  1. ユーザーからの入力を受け取ります。
  2. OpenAI APIを使って、入力に基づいた構造化された To-Do リストを生成します。
  3. 生成されたリストを解析し、整形して表示します。

実行すると、例えば以下のような出力が得られます:

To-Do リストに追加したいタスクを教えてください: 週末に部屋の掃除をして、買い物に行って、友達と会う予定です。

生成された To-Do リスト:
- 部屋の掃除 (優先度: 高, 期限: 2024-08-10)
- 買い物 (優先度: 中, 期限: 2024-08-11)
- 友達との会合 (優先度: 低, 期限: 2024-08-11)

このように、構造化出力を使うことで、AIモデルの出力を簡単に処理可能な形式で取得できます。

まとめ

OpenAI APIの構造化出力機能は、AIモデルの出力を予測可能で一貫性のあるものにする強力なツールです。この機能を使うことで、開発者はより信頼性の高いアプリケーションを構築できるようになります。

主なポイントを振り返りましょう:

  1. 構造化出力は、AIモデルの出力を指定したJSONスキーマに従わせる機能です。
  2. 関数呼び出しとレスポンスフォーマットの2つの方法で使用できます。
  3. 高い信頼性を持ちますが、いくつかの制限事項があります。
  4. 実際のアプリケーション開発で非常に有用です。

この機能を活用して、より堅牢で信頼性の高いAIアプリケーションを開発してみてください!

以上で、OpenAI APIの構造化出力機能に関する解説を終わります。ご質問やコメントがありましたら、お気軽にお知らせください。Happy coding!

参考サイト

https://openai.com/index/introducing-structured-outputs-in-the-api/

📒ノートブック

https://colab.research.google.com/drive/1FMb1vwZWDab7eqrNrM84NmBGVl6aCGm5?usp=sharing

<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

Discussion