OpenAI の Structured Outputs を試す
使用可能なモデル
使い方 | 使用可能なモデル |
---|---|
Function Calling |
gpt-4-0613 / gpt-3.5-turbo-0613 以降のツールをサポートする全てのモデル |
response_format パラメータ |
gpt-4o-2024-08-06 / gpt-4o-mini-2024-07-18
|
なお、2024/08/08時点では、AzureOpenAIはPlayGroundのみと思われる。
公式ドキュメントに従ってやってみる。Colaboratoryで。
事前準備
!pip install openai
!pip freeze | grep -i openai
openai==1.40.1
from google.colab import userdata
import os
os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')
Structured Output
Pythonで。
from pydantic import BaseModel
from openai import OpenAI
client = OpenAI()
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "イベント情報を抽出して。日本語で。"},
{"role": "user", "content": "アリスとボブは金曜日に科学フェアに行きます。"},
],
response_format=CalendarEvent,
)
event = completion.choices[0].message.parsed
print(event)
OpenAI().beta.chat.completions.parse
ってのがStructured Outputで出力するためのメソッドで、記述の通りベータっぽい。で、出力時のスキーマをPydandicクラスで定義、それをresponse_format
で渡すと、そのスキーマにあわせて出力される。
name='科学フェア' date='金曜日' participants=['アリス', 'ボブ']
結果は辞書で取り出せる
event.dict()
{'name': '科学フェア', 'date': '金曜日', 'participants': ['アリス', 'ボブ']}
いくつかの例が上げられている
CoT
CoTによる数学の計算
from pydantic import BaseModel
from openai import OpenAI
from pprint import pprint
client = OpenAI()
class Step(BaseModel):
explanation: str
output: str
class MathReasoning(BaseModel):
steps: list[Step]
final_answer: str
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "あなたは親切な数学の家庭教師です。ステップバイステップで、ユーザが問題を解くのを導いてください。"},
{"role": "user", "content": "8x + 7 = -23 はどうやって解けばいいの?"}
],
response_format=MathReasoning,
)
math_reasoning = completion.choices[0].message.parsed
pprint(math_reasoning.dict())
{'final_answer': 'x = -\\frac{15}{4}',
'steps': [{'explanation': 'まず、8x + 7 = -23 の方程式を解くためには、x '
'の項を片側に、定数項を反対側に分ける必要があります。これを行うために、等式の両側から7を引きます。',
'output': '8x + 7 - 7 = -23 - 7'},
{'explanation': '7を引くことで、方程式は8x = -23 - 7になります。',
'output': '8x = -30'},
{'explanation': '次に、xを解くために、両側を8で割ります。',
'output': '8x / 8 = -30 / 8'},
{'explanation': '左側はxを残し、右側は分数になります。この段階で、分数の単純化が可能です。',
'output': 'x = -30 / 8'},
{'explanation': '分数 -30/8 は約分できます。 30と8の最大公約数4を使って約分します。',
'output': 'x = -15 / 4'},
{'explanation': 'したがって、方程式 8x + 7 = -23 の解は x = -\\frac{15}{4} である。',
'output': 'x = -\\frac{15}{4}'}]}
構造化データの抽出
論文を構造化データとして抽出。論文データを持ってくるのが面倒だったので、参考程度に。やってることは冒頭の例と基本的に同じ。
from pydantic import BaseModel
from openai import OpenAI
from pprint import pprint
client = OpenAI()
class ResearchPaperExtraction(BaseModel):
title: str
authors: list[str]
abstract: str
keywords: list[str]
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "あなたは構造化データ抽出のエキスパートである。研究論文から構造化されていないテキストを与えられ、それを与えられた構造に変換しなければならない。"},
{"role": "user", "content": "[ここに論文のテキストを入れる]"}
],
response_format=ResearchPaperExtraction,
)
research_paper = completion.choices[0].message.parsed
pprint(research_paper.dict())
UIの生成
ユーザー情報を入力するフォームのUIを生成する。なるほど、Pydanticのこういう使い方はぜんぜん頭になかった。
from enum import Enum
from typing import List
from pydantic import BaseModel
from openai import OpenAI
from pprint import pprint
client = OpenAI()
class UIType(str, Enum):
div = "div"
button = "button"
header = "header"
section = "section"
field = "field"
form = "form"
class Attribute(BaseModel):
name: str
value: str
class UI(BaseModel):
type: UIType
label: str
children: List["UI"]
attributes: List[Attribute]
UI.model_rebuild() # これは再帰型を有効にするために必要
class Response(BaseModel):
ui: UI
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "あなたは日本語UIジェネレーターAIです。ユーザーの入力をUIに変換します。"},
{"role": "user", "content": "ユーザーのプロフィールを入力するフォームを作成して。"}
],
response_format=Response,
)
ui = completion.choices[0].message.parsed
pprint(ui.dict())
{'ui': {'attributes': [{'name': 'method', 'value': 'post'},
{'name': 'action', 'value': '/submit-profile'}],
'children': [{'attributes': [{'name': 'type', 'value': 'text'},
{'name': 'placeholder',
'value': '名前を入力してください'}],
'children': [],
'label': '名前',
'type': <UIType.field: 'field'>},
{'attributes': [{'name': 'type', 'value': 'email'},
{'name': 'placeholder',
'value': 'メールアドレスを入力してください'}],
'children': [],
'label': 'メールアドレス',
'type': <UIType.field: 'field'>},
{'attributes': [{'name': 'type', 'value': 'tel'},
{'name': 'placeholder',
'value': '電話番号を入力してください'}],
'children': [],
'label': '電話番号',
'type': <UIType.field: 'field'>},
{'attributes': [{'name': 'type', 'value': 'text'},
{'name': 'placeholder',
'value': '住所を入力してください'}],
'children': [],
'label': '住所',
'type': <UIType.field: 'field'>},
{'attributes': [{'name': 'type', 'value': 'submit'}],
'children': [],
'label': '送信',
'type': <UIType.button: 'button'>}],
'label': 'ユーザープロフィールフォーム',
'type': <UIType.form: 'form'>}}
モデレーション
入力内容のモデレーション
from enum import Enum
from typing import Optional
from pydantic import BaseModel
from openai import OpenAI
from pprint import pprint
client = OpenAI()
class Category(str, Enum):
violence = "violence"
sexual = "sexual"
self_harm = "self_harm"
class ContentCompliance(BaseModel):
is_violating: bool
category: Optional[Category]
explanation_if_violating: Optional[str]
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "ユーザーの入力が特定のガイドラインに違反しているかどうかを判断し、違反している場合はそれを説明してください。"},
{"role": "user", "content": "面接の準備はどうすればいいでしょうか?"}
],
response_format=ContentCompliance,
)
compliance = completion.choices[0].message.parsed
pprint(compliance.dict())
{'category': None, 'explanation_if_violating': None, 'is_violating': False}
JSONモードとの違い
- Structured Outputは指定されたスキーマに必ず従って出力される。JSONモードはJSONで出力するがスキーマは保証されない。
- Structured Outputは、現時点で最新のgpt-4o、gpt-4o-mini 以降のモデルでしか使えない
- Function Callingで使う場合、JSONモードは常に有効になるが、Structured Outputは関数定義に
strict: true
が定義された場合のみ - Parallel Function Callingは、JSONモードではサポート、Structured Outputではサポートされていない
Structured Outputs の使い分け
Structured Outputsは2つの使い方がある
- function callingで使う
-
response_format
パラメータで使う
どちらを使うべきか?はざっくり以下とまとめられていた
- モデルをシステム内のツール、関数、データなどに接続する場合は、function callingを使用する。
- モデルがユーザーに応答するときの出力を構造化したいのであれば、構造化されたresponse_formatを使うべき
JSONモードだとフォーマットは破綻しないがスキーマが期待通りにはならない、function callingの場合は本来的にはツールとの連携がメインの目的だが、スキーマを指定できるので、これを使って出力制御だけに使う、みたいなやり方があったのが、単に出力制御が目的だけならresponse_format
パラメータを使えばいいという感じ。
Function Callingで使う場合はこちら
単に関数定義に"strict": True
をつけるだけっぽい。
tools = [
{
"type": "function",
"function": {
"name": "get_delivery_date",
"description": "顧客の注文の配達日を取得する。例えば、顧客から「私の荷物はどこですか」と尋ねられたときなど、配達日を知る必要がある場合に呼ぶ。",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "顧客の注文ID",
},
},
"required": ["order_id"],
"additionalProperties": False,
},
},
"strict": True,
}
]
SDKの場合はresponse_format
でスキーマを指定すると、内部で"strict": True
を付与してる模様。
シンプルに使えて良い。
ドキュメントには他にもいろいろ書いてあるので、じっくり読んでみたいと思う。
やればやるほど、Pydanticをきちんとやっておく必要性を痛感している。前にGetting Startedやってみただけ、あとは雰囲気で使ってるだけ・・・改めてやってみるつもり。
そういえばストリーミングを確認していなかった。
from typing import List
from pydantic import BaseModel
from openai import OpenAI
class EntitiesModel(BaseModel):
attributes: List[str]
colors: List[str]
animals: List[str]
client = OpenAI()
with client.beta.chat.completions.stream(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "入力テキストからエンティティを抽出して。"},
{
"role": "user",
"content": "茶色いキツネは青い瞳の怠け者の犬を飛び越える。",
},
],
response_format=EntitiesModel,
) as stream:
for event in stream:
if event.type == "content.delta":
if event.parsed is not None:
# パースされたデータをJSONで出力
print("パースされた差分コンテンツ:", event.parsed)
elif event.type == "content.done":
print("コンテンツ生成終了")
elif event.type == "error":
print("ストリームでエラーが発生しました:", event.error)
final_completion = stream.get_final_completion()
print("最終結果:")
pprint(final_completion.choices[0].message.parsed.model_dump())
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {'attributes': []}
パースされた差分コンテンツ: {'attributes': []}
パースされた差分コンテンツ: {'attributes': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': ['キツネ']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': ['キツネ']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': ['キツネ', '犬']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': ['キツネ', '犬']}
コンテンツ生成終了
最終結果:
{'animals': ['キツネ', '犬'], 'attributes': [], 'colors': ['茶色', '青']}
チャットのレスポンスに感情を追加した出力フォーマットを指定してストリーミングを試してみた。
from typing import List
from pydantic import BaseModel
from openai import OpenAI
from enum import Enum
class Sentiment(str, Enum):
happy = "happy"
angry = "angry"
sad = "sad"
fun = "fun"
class ResponseWithSentiment(BaseModel):
message: List[str]
sentiment: Sentiment
client = OpenAI()
with client.beta.chat.completions.stream(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "あなたは親切な日本語のアシスタントです。"},
{
"role": "user",
"content": "おはよう。。。昨日は競馬ボロ負けしちゃったよ。。。。",
},
],
response_format=ResponseWithSentiment,
) as stream:
for event in stream:
if event.type == "content.delta":
if event.parsed is not None:
# パースされたデータをJSONで出力
print("パースされた差分コンテンツ:", event.parsed)
elif event.type == "content.done":
print("コンテンツ終了")
elif event.type == "error":
print("ストリームでエラーが発生しました:", event.error)
final_completion = stream.get_final_completion()
print("最終結果:")
pprint(final_completion.choices[0].message.parsed.model_dump())
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。'], 'sentiment': 'sad'}
コンテンツ終了
最終結果:
{'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。'],
'sentiment': <Sentiment.sad: 'sad'>}
文字列をストリーミングで扱いたい場合は、フォーマット定義する際にリストで指定するのが良さそう。文字列で定義するとこうなる。
(snip)
class ResponseWithSentiment(BaseModel):
message: str
sentiment: Sentiment
(snip)
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。', 'sentiment': 'sad'}
コンテンツ終了
最終結果:
{'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。',
'sentiment': <Sentiment.sad: 'sad'>}
ストリームの意味があまりない感じになる。
余談
OpenAI SDKだと上記の通りストリーミングでもvalidなJSONオブジェクトになるんだけども、LiteLLMで試してみたら、ただのJSON「文字列」のストリーミング、つまりストリーミング途中のレスポンスはvalid-JSONではないので、ストリーミングの意味がない。