OpenAIのAPIレスポンスに自作スキーマを設定できるようになった😄（structured outputsの話）

# 概要
openAIのAPI、ここ数カ月で色々変わってきていますね。
モデル`gpt-4o`が登場しておよそ3ヶ月。
モデル`gpt-4o-mini`が登場してからおよそ1カ月。
そして、モデル`gpt-4o-2024-08-06`が先日リリースされました。

またこのモデルが登場したことと合わせて、
[2024.08.06のリリース(v1.40.0)](https://github.com/openai/openai-python/releases/tag/v1.40.0)で、APIの機能として**Structured Outputs**がリリースされました。

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

この記事では、**Structured Outputs**について、理解を進めていきます。

## Structured Outputsとは
ここから記事の内容を読み進めていきます。記事の英文を一部お借りしながら、どのような機能か理解していきます。

#### 現状
JSONモードを使ったレスポンスのJSON化が既にリリースされています。
この機能によってレスポンスがJSONであることは保証されていますが、こちら（開発者）側でスキーマの指定まではできませんでした。

JSONモードに関して確認したい方は、以下記事などご覧ください。

https://note.com/peisuke/n/n13de59ad4e8e

#### 開発背景🏗️
> **非構造化入力から構造化データを生成することは、現代のアプリケーションにおけるAIの主要なユースケースの一つです。**
開発者は長い間、LLMのこの分野での制限を克服するために、オープンソースのツールやプロンプト、リクエストの再試行を通じて、**モデル出力がシステムと相互運用可能な状態にするため、必要なフォーマットに一致するように努めてきました。**

`プロンプトの再試行`という部分について、業務でopenAIのAPIを扱われた方々は特に身に覚えがあるのではないでしょうか。
私は業務で触った経験はないですが、個人開発レベルでも感じることありました。

> Structured Outputsは、OpenAIのモデルを開発者が提供するスキーマに一致させることによって、この問題を解決し、複雑なスキーマをより理解するようにモデルを訓練します。
複雑なJSONスキーマに基づく評価において、新しいモデル「gpt-4o-2024-08-06」は、構造化出力（Structured Outputs）において満点の100％を獲得しました。これに対して、「gpt-4-0613」は40％未満のスコアにとどまりました。

このスコアの算出方法は分かっていないのですが、モデル`gpt-4o-2024-08-06`は、構造化出力において、最高の精度となった、といった話が記載されていました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/316cdf2e-acd3-8834-a4b5-fc481b4d3f10.png)
（冒頭の記事内から引用）


## APIのレスポンスの違いを見ていこう
記事のコード（1次方程式を解くタスク）を借ります。
Structured Outputsを用いた結果を見る前に、これまで（Structured Outputs導入前）のレスポンスを再確認して、比較します。

##### Structured Outputsを使う方法
記事では、2つ記載されていました。
1. Function calling
1. A new option for the response_format parameter
今回は、Pythonのコードを使い、後者を使って話を進めていきます。
またコードを動かす前に、openaiのバージョンについて`v1.40.0`にアップデートする必要があります。以下のコマンドなどでアップデートをします。
```bash
pip install -U openai
```
##### モデル「gpt-4o-2024-08-06」について
記事と同じモデル`gpt-4o-2024-08-06`を使いますが、
`gpt-4o-2024-05-13`からさらに安くなってました。入力が50%、出力で33%のコストセーブになるそうです。
> By switching to the new gpt-4o-2024-08-06, developers save 50% on inputs ($2.50/1M input tokens) and 33% on outputs ($10.00/1M output tokens) compared to gpt-4o-2024-05-13.

### どんなパターンで動作確認？
以下3つの方法でレスポンスを確認してみました。
1. Promptのみ
1. JSONモード使用
1. スキーマ指定（Structured Outputs）

#### Promptのみ
上述したグラフでいうと、`Prompting Alone`に該当するやり方だと思います。
これだけですと、レスポンスを扱うのは大変ですよね。
```python
from dotenv import load_dotenv
load_dotenv(dotenv_path='.env')
from pydantic import BaseModel
from openai import OpenAI

client = OpenAI()

completion = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "You are a helpful math tutor."},
        {"role": "user", "content": "solve 8x + 31 = 2"},
    ]
)

message = completion.choices[0].message.content
print(message)
```
レスポンス
```text
To solve the equation \(8x + 31 = 2\), we need to isolate the variable \(x\). Here is a step-by-step solution:

1. Subtract 31 from both sides of the equation to get rid of the constant term on the left side:
    
    \[
    8x + 31 - 31 = 2 - 31
    \]
    
    Simplifying, we have:
    
    \[
    8x = -29
    \]
    
2. Now, divide both sides by 8 to solve for \(x\):
    
    \[
    x = \frac{-29}{8}
    \]
    

So, the solution is \(x = -\frac{29}{8}\).
```

#### JSONモード使用
出力がJSON形式になるように`response_format`を加えます。
```python
from dotenv import load_dotenv
load_dotenv(dotenv_path='.env')
from pydantic import BaseModel
from openai import OpenAI

client = OpenAI()

completion = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        # jsonで結果を返してほしい、とプロンプトに含める（含めないとエラーになる）
        {"role": "system", "content": "You are a helpful math tutor. output result by using json"}, 
        {"role": "user", "content": "solve 8x + 31 = 2"},
    ],
    response_format={ "type": "json_object" } # json形式の指定
)

message = completion.choices[0].message.content
print(message)
```
レスポンス
```json
{
  "equation": "8x + 31 = 2",
  "solution": {
    "x": -3.625
  },
  "steps": [
    {
      "step": "Subtract 31 from both sides.",
      "equation": "8x = 2 - 31"
    },
    {
      "step": "Simplify the right side.",
      "equation": "8x = -29"
    },
    {
      "step": "Divide both sides by 8.",
      "equation": "x = -29 / 8"
    },
    {
      "step": "Simplify the fraction.",
      "equation": "x = -3.625"
    }
  ]
}
```
これだけでも十分ありがたいとは思います。。。
続いて、スキーマ指定（Structured Outputs）の形を確認します。

#### スキーマ指定（Structured Outputs）
```python
from dotenv import load_dotenv
load_dotenv(dotenv_path='.env')
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()

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "You are a helpful math tutor."},
        {"role": "user", "content": "solve 8x + 31 = 2"},
    ],
    response_format=MathResponse, # レスポンスフォーマットに、自作スキーマを設定
)

message = completion.choices[0].message
if message.parsed:
    print(message.parsed.steps)
    print(message.parsed.final_answer)
else:
    print(message.refusal)
```
ライブラリのコード（`client.beta.chat.completions.parse`）をVsCode上で追っていくと、pydanticのv2を用いて実現していることが分かります。[詳細はこちら！](https://platform.openai.com/docs/guides/structured-outputs)

レスポンス
`print(message.parsed.steps)`
```python
[Step(explanation='Start by isolating the term with the variable on one side. Subtract 31 from both sides of the equation to eliminate the constant on the left-hand side.', output='8x + 31 - 31 = 2 - 31'), Step(explanation='Simplify both sides. On the left, 31 - 31 cancels out, leaving just 8x. On the right, 2 - 31 simplifies to -29.', output='8x = -29'), Step(explanation='To solve for x, divide both sides by 8 to isolate x.', output='x = -29 / 8')]
```
`print(message.parsed.final_answer)`
```python
x = -29/8
```

**こちらで定義したクラス`Step`と`MathResponse`に沿った形でレスポンスが返ってくることを確認できました。**

**この後jsonに変換することも簡単です。**
クラス`MathResponse`は`BaseModel`を継承しているので、for文と`.dict()`を組み合わせればOKです。
```python
if message.parsed:
    steps_list_obj = message.parsed.steps
    steps_json = [step.dict() for step in steps_list_obj]
    format_steps_json = json.dumps(steps_json, indent=4, ensure_ascii=False)
    print(format_steps_json)
else:
    print(message.refusal)
```

`print(format_steps_json)`
```
[
    {
        "explanation": "Start by isolating the term with the variable on one side. Subtract 31 from both sides of the equation to eliminate the constant on the left-hand side.",
        "output": "8x + 31 - 31 = 2 - 31"
    },
    {
        "explanation": "Simplify both sides. On the left, 31 - 31 cancels out, leaving just 8x. On the right, 2 - 31 simplifies to -29.",
        "output": "8x = -29"
    },
    {
        "explanation": "To solve for x, divide both sides by 8 to isolate x.",
        "output": "x = -29 / 8"
    }
]
```

#### 動作確認まとめ📋
3つの方法について、簡単にまとめました。
（主観的な部分も含まれていると思いますが、概ねこんな感じでは？と思ってます！）

| 観点 | Promptのみ | JSONモードを使用 |スキーマ指定📌 |
|:-----------:|:------------:|:------------:|:------------:|
| **出力形体**       | △（不明）        | 〇（json）         | 〇（pythonオブジェクト）         |
| **各情報に紐づくキー名の一貫性（※1)**     | △ （プロンプト中の単語を用いたキー名となる場合もあり）     | △（プロンプト中の単語を用いたキー名となる場合もあり）       |◎（指定したスキーマのキー（フィールド）名）     
| **開発のしやすさ**     | △（出力形体が予測できない）      | 〇（キー名を使った処理をするのは難しいかもしれない）       |◎（pythonオブジェクトかつキー名固定）

※1：「説明」に紐づくキー名が`explanation`、「計算結果」に紐づくキー名が`result`というように、各情報がどういった変数名とマッピングするか、変数名が変動か固定なのか、といった観点の話です。

## Limitations and restrictions
記事の中に上記見出しで始まる項目がありました。完全に私目線ですが、使う前に特に理解おかないといけなさうな項目をピックアップしました。

##### 処理速度
> 新しいスキーマを使用する最初のAPI応答では追加の遅延が発生しますが、その後の応答はキャッシュの再利用により遅延なしで高速に処理されます。典型的なスキーマの処理には最初のリクエストで10秒未満かかりますが、より複雑なスキーマでは最大1分かかることがあります。

##### 想定したスキーマでレスポンスが返らない
> モデルが不適切なリクエストを拒否することを選択した場合、スキーマに従わない可能性があります。この場合、拒否のブール値がtrueに設定された返答メッセージが返されます。

> Structured Outputsはすべての種類のモデルのミスを防ぐわけではありません。たとえば、JSONオブジェクト内の値でミスをする可能性があります（例：数学的な方程式でステップを間違える）。開発者がミスを発見した場合、システムの指示に例を提供するか、タスクをよりシンプルなサブタスクに分割することを推奨します。

#### 並列機能との兼ね合い不可
> Structured Outputsは並列機能呼び出しと互換性がありません。並列機能呼び出しが生成された場合、提供されたスキーマと一致しない可能性があります。並列機能呼び出しを無効にするには、parallel_tool_calls: falseを設定してください。


## まとめ📋
今回は新しくリリースされた、**Structured Outputs**について簡単にみてきました。
記事の内容全部については触れられていないので、詳しい内容が気になる方は確認してみてください！

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

アプリケーションによって、JSONモードだけで事足りるのか、スキーマを定義して強固な形を作り上げるのか、異なるとは思いますが、**LLMの出力結果まで開発者が指定した形にできること自体凄いな～～**と感じました。

今後も継続的にリリース情報を追って、記事にできそうであれば、やってみようと思います！