LLMの構造化出力ライブラリ「Instructor」を試す
2024/10のTechnology RaderでTrialになってた。
以前からちょいちょい見かけてはいたのだけど、今はStructured Outputがあったり、LLMフレームワークにも出力制御する機能はあるので、あえて使うほどの意味を感じていなかった。
個人的な印象として、Technology Radarの評価はあくまでも一社の主張にすぎないし、新しいものについてはやや慎重過ぎないかなという気がしないではないが、過去見てきた限り、内容については一定の共感する部分もある。
ということで、改めてInstructorについても見てみようと思う。
GitHubレポジトリ
シンプルな構造化出力のための最も人気のあるライブラリ、Instructor
Instructorは大規模言語モデル(LLM)の構造化出力を扱うための最も人気のあるPythonライブラリで、月間60万以上のダウンロード数を誇ります。Pydanticの上に構築されたこのライブラリは、検証、再試行、ストリーミング応答を管理するためのシンプルで透過的、そしてユーザーフレンドリーなAPIを提供します。LLMのワークフローを強化するために、コミュニティで最も選ばれているPydanticをぜひご利用ください!
主な特徴
- レスポンスモデル: LLM出力の構造を定義するPydanticモデルの指定
- 再試行管理: リクエストの再試行回数を簡単に設定できます。
- 検証: Pydantic の検証機能により、LLM レスポンスが期待通りのものであることを確認します。
- ストリーミングのサポート: リストやパーシャルレスポンスを簡単に扱うことができます。
- 柔軟なバックエンド: OpenAI 以外のさまざまな LLM プロバイダーとシームレスに統合できます。
- 多くの言語でのサポート: Python、TypeScript、Ruby、Go、Elixir など多くの言語をサポートしています。
ドキュメントのほうがもうちょっと詳しい概要説明がある
Instructor:シンプルな構造化出力に最も人気のあるライブラリ
LLMによって生成される構造化出力。シンプルさ、透明性、そしてコントロールを重視して設計されています。
Instructorは、GPT-3.5、GPT-4、GPT-4-Vision、またはMistral/Mixtral、Anyscale、Ollama、llama-cpp-pythonといったオープンソースモデルなどのLLMからJSONのような構造化データを簡単に取得できるツールです。
シンプルさ、透明性、ユーザー中心のデザインが特徴で、Pydanticの上に構築されています。Instructorは、検証のコンテキストの管理、Tenacityを用いたリトライ、リストや部分的なレスポンスのストリーミングをサポートします。
なぜInstructorを使うべきか?
シンプルなAPIと完全なプロンプトコントロール
InstructorはシンプルなAPIを提供し、プロンプトに対して完全な管理とコントロールを実現します。これにより、LLMとのやり取りを微調整して最適化でき、カスタマイズが簡単に行えます。
マルチランゲージサポート
タイプヒントやバリデーションを活用して、LLMからの構造化データ抽出を簡素化します。
- Python
- TypeScript
- Ruby
- Go
- Elixir
- Rust
再質問とバリデーション
バリデーションが失敗した際に、自動でモデルに再質問することで、高品質な出力を確保します。Pydanticのバリデーションを利用し、強力なエラーハンドリングを実現します。
ストリーミングサポート
部分的な結果やイテラブルを簡単にストリーミングし、リアルタイム処理やアプリケーションのレスポンスの向上を実現します。
タイプヒントによる強化
Pydanticを活用して、スキーマのバリデーション、プロンプトの制御、コードの削減、IDEの統合を実現します。
シンプルなLLMのやり取り
OpenAI、Anthropic、Google、Vertex AI、Mistral/Mixtral、Anyscale、Ollama、llama-cpp-python、Cohere、LiteLLMなどをサポートしています。
Getting Started
ではGetting Startedに従って試してみる。Colaboratoryで。
パッケージインストール
!pip install -U instructor
!pip freeze | grep -i instructor
instructor==1.6.3
なお、instuctorをインストールするとCLIも提供される。
!instructor --help
Usage: instructor [OPTIONS] COMMAND [ARGS]...
╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮
│ --install-completion Install completion for the current shell. │
│ --show-completion Show completion for the current shell, to copy it or customize the │
│ installation. │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────╮
│ batch Manage OpenAI Batch jobs │
│ docs Open the instructor documentation website. │
│ files Manage files on OpenAI's servers │
│ hub Interact with the instructor hub │
│ jobs Monitor and create fine tuning jobs │
│ usage Check OpenAI API usage data │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
今回はOpenAIを使って試そうと思う。instructorのパッケージインストールでOpenAIパッケージもインストールされるので、まずAPIキーをセットしておく。
import os
from google.colab import userdata
os.environ['OPENAI_API_KEY'] = userdata.get('OPENAI_API_KEY')
出力データの構造をPydanticクラスで定義して、OpenAIクライアントをinstructorでラップするという感じっぽい。サンプルでは"gpt-3.5-turbo"が使用されているが、最近モデルは構造化出力強そうなので、試すならこういう古いモデルのほうがいいような気がする。
import instructor
from pydantic import BaseModel
from openai import OpenAI
# 期待する出力構造を定義
class UserInfo(BaseModel):
name: str
age: int
#OpenAIクライアントをinstructorでラップ
client = instructor.from_openai(OpenAI())
# 自然言語のクエリから構造化データを抽出
user_info = client.chat.completions.create(
model="gpt-3.5-turbo",
response_model=UserInfo,
messages=[{"role": "user", "content": "太郎さんは30歳です。"}],
)
print(user_info.name)
print(user_info.age)
結果
太郎
30
LLMの生成プロセスの様々な段階でフックさせて、例えばログに出力させる、といったことができる。
import instructor
from openai import OpenAI
from pydantic import BaseModel
class UserInfo(BaseModel):
name: str
age: int
# OpenAIクライアントをinstructorでラップ
client = instructor.from_openai(OpenAI())
# フック用の関数を定義
def log_kwargs(**kwargs):
print(f"Function Call実行->引数: {kwargs}")
def log_exception(exception: Exception):
print(f"例外発生: {str(exception)}")
client.on("completion:kwargs", log_kwargs)
client.on("completion:error", log_exception)
user_info = client.chat.completions.create(
model="gpt-3.5-turbo",
response_model=UserInfo,
messages=[{"role": "user", "content": "名前と年齢を抽出して: 「太郎さんは30歳です。」"}],
)
print(f"名前: {user_info.name}\n年齢: {user_info.age}")
Function Call実行時に引数をインターセプトして表示できている。
Function Call実行->引数: {'messages': [{'role': 'user', 'content': '名前と年齢を抽出して: 「太郎さんは30歳です。」'}], 'model': 'gpt-3.5-turbo', 'tools': [{'type': 'function', 'function': {'name': 'UserInfo', 'description': 'Correctly extracted `UserInfo` with all the required parameters with correct types', 'parameters': {'properties': {'name': {'title': 'Name', 'type': 'string'}, 'age': {'title': 'Age', 'type': 'integer'}}, 'required': ['age', 'name'], 'type': 'object'}}}], 'tool_choice': {'type': 'function', 'function': {'name': 'UserInfo'}}}
名前: 太郎
年齢: 30
例外フック用の関数の実行結果を出力できなかったけども、指定した構造に出力が合致しなかったことは拾えてる。
import instructor
from openai import OpenAI
from pydantic import BaseModel
class UserInfo(BaseModel):
name: str
age: int
# OpenAIクライアントをinstructorでラップ
client = instructor.from_openai(OpenAI())
# フック用の関数を定義
def log_kwargs(**kwargs):
print(f"Function Call実行->引数: {kwargs}")
def log_exception(exception: Exception):
print(f"例外発生: {str(exception)}")
client.on("completion:kwargs", log_kwargs)
client.on("completion:error", log_exception)
user_info = client.chat.completions.create(
model="gpt-3.5-turbo",
response_model=UserInfo,
messages=[{"role": "user", "content": "名前と年齢を抽出して。わからない場合は「不明」と答えて。ハルシネーションしないで。: 「太郎さん、お元気ですか?」"}],
)
print(f"名前: {user_info.name}\n年齢: {user_info.age}")
Function Call実行->引数: {'messages': [{'role': 'user', 'content': '名前と年齢を抽出して。わからない場合は「不明」と答えて。ハルシネーションしないで。: 「太郎さん、お元気ですか?」'}], 'model': 'gpt-3.5-turbo', 'tools': [{'type': 'function', 'function': {'name': 'UserInfo', 'description': 'Correctly extracted `UserInfo` with all the required parameters with correct types', 'parameters': {'properties': {'name': {'title': 'Name', 'type': 'string'}, 'age': {'title': 'Age', 'type': 'integer'}}, 'required': ['age', 'name'], 'type': 'object'}}}], 'tool_choice': {'type': 'function', 'function': {'name': 'UserInfo'}}}
Function Call実行->引数: {'messages': [{'role': 'user', 'content': '名前と年齢を抽出して。わからない場合は「不明」と答えて。ハルシネーションしないで。: 「太郎さん、お元気ですか?」'}, {'role': 'assistant', 'content': '', 'tool_calls': [{'id': 'call_JuLpzhSst2EA87Da8T7BWppF', 'function': {'arguments': '{"name":"太郎さん","age":null}', 'name': 'UserInfo'}, 'type': 'function'}]}, {'role': 'tool', 'tool_call_id': 'call_JuLpzhSst2EA87Da8T7BWppF', 'name': 'UserInfo', 'content': 'Validation Error found:\n1 validation error for UserInfo\nage\n Input should be a valid integer [type=int_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.9/v/int_type\nRecall the function correctly, fix the errors'}], 'model': 'gpt-3.5-turbo', 'tools': [{'type': 'function', 'function': {'name': 'UserInfo', 'description': 'Correctly extracted `UserInfo` with all the required parameters with correct types', 'parameters': {'properties': {'name': {'title': 'Name', 'type': 'string'}, 'age': {'title': 'Age', 'type': 'integer'}}, 'required': ['age', 'name'], 'type': 'object'}}}], 'tool_choice': {'type': 'function', 'function': {'name': 'UserInfo'}}}
名前: 太郎さん
年齢: 0
Instructorを使って出力構造を定義すると、create_*
メソッドの戻り値にも型情報が定義される。
まず、Instructorを使わない、素のOpenAIでStructured Outputの場合。
from openai import OpenAI
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
client = OpenAI()
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "名前と年齢を抽出して: 太郎さんは30歳です。"}],
response_format=User,
)
user = response.choices[0].message.parsed
print(user.dict())
{'name': '太郎', 'age': 30}
型情報は以下のようになる。
Completions APIの結果はあくまでもCompletions APIの出力オブジェクトであり、そこから取り出した実際のデータで型情報が参照できるという形。
Instructorの場合はこうなる。
import openai
import instructor
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
client = instructor.from_openai(openai.OpenAI())
user = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "名前と年齢を抽出して: 太郎さんは30歳です。"}],
response_model=User,
)
print(f"名前: {user.name}\n年齢: {user.age}")
名前: 太郎
年齢: 30
出力されたオブジェクトから定義したモデルの型情報が参照できる。
非同期の場合も同じ。
import openai
import instructor
from pydantic import BaseModel
import nest_asyncio
import asyncio
nest_asyncio.apply()
client = instructor.from_openai(openai.AsyncOpenAI())
class User(BaseModel):
name: str
age: int
async def extract():
return await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "名前と年齢を抽出して: 太郎さんは30歳です。"}],
response_model=User,
)
user = asyncio.run(extract())
print(f"名前: {user.name}\n年齢: {user.age}")
名前: 太郎
年齢: 30
本来のCompletion APIのオブジェクトを取得したい場合はcreate_with_completion()
メソッドを使う
import openai
import instructor
from pydantic import BaseModel
import json
class User(BaseModel):
name: str
age: int
client = instructor.from_openai(openai.OpenAI())
user, completion = client.chat.completions.create_with_completion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "名前と年齢を抽出して: 太郎さんは30歳です。"}],
response_model=User,
)
print(f"名前: {user.name}\n年齢: {user.age}")
print(json.dumps(completion.dict(), indent=2, ensure_ascii=False))
名前: 太郎
年齢: 30
{
"id": "chatcmpl-AMV1uZvGFGfCHI2kJXlHTbHiL6x1r",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"logprobs": null,
"message": {
"content": null,
"refusal": null,
"role": "assistant",
"audio": null,
"function_call": null,
"tool_calls": [
{
"id": "call_JC2iWzlkAOqiZ8d0oJZfGfoF",
"function": {
"arguments": "{\"name\":\"太郎\",\"age\":30}",
"name": "User"
},
"type": "function"
}
]
}
}
],
"created": 1729925858,
"model": "gpt-4o-mini-2024-07-18",
"object": "chat.completion",
"service_tier": null,
"system_fingerprint": "fp_f59a81427f",
"usage": {
"completion_tokens": 10,
"prompt_tokens": 85,
"total_tokens": 95,
"completion_tokens_details": null,
"prompt_tokens_details": null
}
}
ストリーミング。ストリーミングの結果はIterable[T]
とPartial[T]
の2種類で受け取ることができる。
Partial[T]
の場合はcraete_partial()
メソッドを使う。
import openai
import instructor
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
client = instructor.from_openai(openai.OpenAI())
user_stream = client.chat.completions.create_partial(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "実在しそうなダミーのUnixアカウントを作成して。"}],
response_model=User,
)
for user in user_stream:
print(user)
name=None age=None
name=None age=None
name=None age=None
name=None age=None
name=None age=None
name=None age=None
name='user1234' age=None
name='user1234' age=None
name='user1234' age=None
name='user1234' age=25
name='user1234' age=25
この場合、レスポンスの型はGeneratorになる。
複数のオブジェクトを抽出したい場合、create_iterable()
メソッドを使うとIterable[T]
になるらしいのだが、
import openai
import instructor
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
client = instructor.from_openai(openai.OpenAI())
users = client.chat.completions.create_iterable(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "実在しそうなダミーのUnixアカウントを2つ作成して。"}],
response_model=User,
)
for user in users:
print(user)
name='jdoe' age=28
name='asmith' age=34
が、こちらはドキュメントどおりではなかった。意図した機能担ってるんだろうと思うけど、Iterable
ではないと思う。
ここで変わってるみたい。
これ以外にもいくつかの機能がある。
まずプロンプトテンプレート。Jinjaを使ったテンプレートが使える。
プロンプトをJinjaテンプレートで書いて、context
パラメータで値を渡す。
import openai
import instructor
from pydantic import BaseModel
client = instructor.from_openai(openai.OpenAI())
class User(BaseModel):
name: str
age: int
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": "次の文章から情報を抽出してください: {{ data }}",
},
],
response_model=User,
context={"data": "太郎さんは30歳です。"},
)
name='太郎' age=30
これをPydanticモデルと組み合わせて、動的な入力に対して、出力の検証を行ったりできる。
import openai
import instructor
from pydantic import BaseModel, ValidationInfo, field_validator
import re
client = instructor.from_openai(openai.OpenAI())
class Response(BaseModel):
text: str
@field_validator('text')
@classmethod
def redact_regex(cls, v: str, info: ValidationInfo):
context = info.context
if context:
redact_patterns = context.get('redact_patterns', [])
for pattern in redact_patterns:
v = re.sub(pattern, '****', v)
return v
response = client.create(
model="gpt-4o",
response_model=Response,
messages=[
{
"role": "user",
"content": """
次のトピックについて、事実のみをリストアップしてください: {{ topic }}
{% if banned_words %}
以下は禁止用語です。
<banned_words>
{% for word in banned_words %}
* {{ word }}
{% endfor %}
</banned_words>
{% endif %}
説明は不要で事実のみを記載してください。
""",
},
],
context={
"topic": "ジェイソンさんの現在の電話番号は 123-456-7890 です。彼はサンフランシスコに住んでいます。お寿司が好物です。",
"redact_patterns": [
r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b", # 電話番号のパターン
r"\b\d{3}-\d{2}-\d{4}\b", # 社会保障番号のパターン
],
},
max_retries=3,
)
print(response.text)
1. ジェイソンさんの現在の電話番号は **** です。
2. ジェイソンさんはサンフランシスコに住んでいます。
3. ジェイソンさんはお寿司が好物です。
Jinjaテンプレートを使った、QAに引用を指定する例。
import openai
import instructor
from pydantic import BaseModel
client = instructor.from_openai(openai.OpenAI())
class Citation(BaseModel):
source_ids: list[int]
text: str
class Response(BaseModel):
answer: list[Citation]
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": """
あなたは {{ role }} です。あなたに以下のタスクが与えられました。
<question>
{{ question }}
</question>
以下のコンテキストを使用して質問に回答してください。回答には必ず引用を [id] で付与すること:
<context>
{% for chunk in context %}
<context_chunk>
<id>{{ chunk.id }}</id>
<text>{{ chunk.text }}</text>
</context_chunk>
{% endfor %}
</context>
{% if rules %}
以下のルールを必ず守ってください:
{% for rule in rules %}
* {{ rule }}
{% endfor %}
{% endif %}
""",
},
],
response_model=Response,
context={
"role": "プロの教育者",
"question": "フランスの首都は?",
"context": [
{"id": 1, "text": "パリはフランスの首都です。"},
{"id": 2, "text": "フランスはヨーロッパに位置する国です。"},
],
"rules": ["Markdownで出力すること"],
},
)
print(resp)
answer=[Citation(source_ids=[1], text='フランスの首都はパリです。')]
あと、センシティブな情報を扱う場合のSecretStr
クラスが用意されている。これを使って出力モデルを定義すると自動でマスクされるみたい。
from pydantic import BaseModel, SecretStr
import instructor
import openai
class UserContext(BaseModel):
name: str
address: SecretStr
class Address(BaseModel):
street_addr: SecretStr
city: str
prefecture: str
zipcode: str
client = instructor.from_openai(openai.OpenAI())
context = UserContext(name="太郎", address="〒650-0042 兵庫県神戸市中央区波止場町5−5") # 神戸ポートタワーの住所をサンプルで使用
address = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": "{{ user.name }} さんの住所は {{ user.address.get_secret_value() }} です。 ",
},
],
context={"user": context},
response_model=Address,
)
print(context)
print(address)
name='太郎' address=SecretStr('**********')
street_addr=SecretStr('**********') city='神戸市' prefecture='兵庫県' zipcode='650-0042'
実際の値は上の例にもある通り、こんな感じで取り出せる。
context.address.get_secret_value()
〒650-0042 兵庫県神戸市中央区波止場町5−5
実際には送信されているので、ログに残さないとかそういう用途向けかな。
Instructorは、Pydanticで定義した出力にマッチしない場合に合致した出力になるまでリトライさせることができる。
まず最初にバリデーションを含むモデルを定義する。
from typing import Annotated
from pydantic import AfterValidator, BaseModel
import re
def fullspace_validatator(v):
if not re.match(r"^[^ ]+ [^ ]+$", v):
raise ValueError("姓名の間には全角スペースが1つ必要")
return v
class UserDetail(BaseModel):
name: Annotated[str, AfterValidator(fullspace_validatator)]
age: int
try:
UserDetail(name="山田太郎", age=12)
except Exception as e:
print(e)
"""
以下のようなエラーが返る
1 validation error for UserDetail
name
Value error, Name must be ALL CAPS [type=value_error, input_value='jason', input_type=str]
For further information visit https://errors.pydantic.dev/2.7/v/value_error
"""
試しに実行してみると当然失敗する
try:
UserDetail(name="山田太郎", age=12)
except Exception as e:
print(e)
1 validation error for UserDetail
name
Value error, 姓名の間には全角スペースが1つ必要 [type=value_error, input_value='山田太郎', input_type=str]
For further information visit https://errors.pydantic.dev/2.9/v/value_error
このモデルを使ってLLMに問い合わせてみる。シンプルにリトライさせるにはmax_retries
を指定すれば良い。以下は、確認のためにmax_retries
を1にして問い合わせ回数を1回だけにした例。
import openai
import instructor
from pydantic import BaseModel
client = instructor.from_openai(openai.OpenAI(), mode=instructor.Mode.TOOLS)
response = client.chat.completions.create(
model="gpt-3.5-turbo",
response_model=UserDetail,
messages=[
{"role": "user", "content": "名前を抽出して: 山田太郎さんは20歳です。"},
],
max_retries=1,
)
print(response.model_dump_json(indent=2))
以下のように失敗する。
---------------------------------------------------------------------------
ValidationError Traceback (most recent call last)
/usr/local/lib/python3.10/dist-packages/instructor/retry.py in retry_sync(func, response_model, args, kwargs, context, max_retries, strict, mode, hooks)
160 )
--> 161 raise e
162 except RetryError as e:
12 frames
ValidationError: 1 validation error for UserDetail
name
Value error, 姓名の間には全角スペースが1つ必要 [type=value_error, input_value='山田太郎', input_type=str]
For further information visit https://errors.pydantic.dev/2.9/v/value_error
The above exception was the direct cause of the following exception:
RetryError Traceback (most recent call last)
RetryError: RetryError[<Future at 0x7ca64fed34c0 state=finished raised ValidationError>]
The above exception was the direct cause of the following exception:
InstructorRetryException Traceback (most recent call last)
/usr/local/lib/python3.10/dist-packages/instructor/retry.py in retry_sync(func, response_model, args, kwargs, context, max_retries, strict, mode, hooks)
162 except RetryError as e:
163 logger.debug(f"Retry error: {e}")
--> 164 raise InstructorRetryException(
165 e.last_attempt._exception,
166 last_completion=response,
InstructorRetryException: 1 validation error for UserDetail
name
Value error, 姓名の間には全角スペースが1つ必要 [type=value_error, input_value='山田太郎', input_type=str]
For further information visit https://errors.pydantic.dev/2.9/v/value_error
max_retries
を3に増やしてみる。
import openai
import instructor
from pydantic import BaseModel
client = instructor.from_openai(openai.OpenAI(), mode=instructor.Mode.TOOLS)
response = client.chat.completions.create(
model="gpt-3.5-turbo",
response_model=UserDetail,
messages=[
{"role": "user", "content": "名前を抽出して: 山田太郎さんは20歳です。"},
],
max_retries=3,
)
print(response.model_dump_json(indent=2))
今度は正しくレスポンスが返ってきている。恐らくバリデーションエラーを返してリトライしているのだろうと思われる。
{
"name": "山田 太郎",
"age": 20
}
リトライしてもダメな場合の例外は、last_completion
, n_attempts
、messages
を使って取得できる。確認のために、バリデーションを必ず失敗させるようにした例。
import openai
import instructor
from pydantic import BaseModel, field_validator
import re
from instructor.exceptions import InstructorRetryException
from tenacity import Retrying, retry_if_not_exception_type, stop_after_attempt
class UserDetail(BaseModel):
name: str
age: int
@field_validator("name")
def fullspace_validatator(cls, v: str):
raise ValueError(f"失敗: {str(v)}")
client = instructor.from_openai(openai.OpenAI())
retries = Retrying(
retry=retry_if_not_exception_type(SyntaxError), stop=stop_after_attempt(3)
)
try:
response = client.chat.completions.create(
model="gpt-3.5-turbo",
response_model=UserDetail,
messages=[
{"role": "user", "content": "名前を抽出して: 山田太郎さんは20歳です。"},
],
max_retries=retries,
)
print(response.model_dump_json(indent=2))
except InstructorRetryException as e:
for m in e.messages:
print(m["content"])
print("Attemps:", e.n_attempts)
print("LastCompeltion:", e.last_completion)
名前を抽出して: 山田太郎さんは20歳です。
Validation Error found:
1 validation error for UserDetail
name
Value error, 失敗: 山田太郎 [type=value_error, input_value='山田太郎', input_type=str]
For further information visit https://errors.pydantic.dev/2.9/v/value_error
Recall the function correctly, fix the errors
Validation Error found:
1 validation error for UserDetail
name
Value error, 失敗: 山田太郎さん [type=value_error, input_value='山田太郎さん', input_type=str]
For further information visit https://errors.pydantic.dev/2.9/v/value_error
Recall the function correctly, fix the errors
Validation Error found:
1 validation error for UserDetail
name
Value error, 失敗: 山田太郎 [type=value_error, input_value='山田太郎', input_type=str]
For further information visit https://errors.pydantic.dev/2.9/v/value_error
Recall the function correctly, fix the errors
Attemps: 3
LastCompeltion: ChatCompletion(id='chatcmpl-AMXZitfyghaIbQBqUEECoKLwsNmhg', choices=[Choice(finish_reason='stop', index=0, logprobs=None, message=ChatCompletionMessage(content=None, refusal=None, role='assistant', audio=None, function_call=None, tool_calls=[ChatCompletionMessageToolCall(id='call_OV7hKCWc4P7Nq8nCCtYGP9Ck', function=Function(arguments='{"name":"山田太郎","age":20}', name='UserDetail'), type='function')]))], created=1729935642, model='gpt-3.5-turbo-0125', object='chat.completion', service_tier=None, system_fingerprint=None, usage=CompletionUsage(completion_tokens=46, prompt_tokens=624, total_tokens=670, completion_tokens_details=None, prompt_tokens_details=None))
リトライはTenacityを呼び出して使用しているため、バックオフなどより複雑なリトライロジックも設定できる。以下は試行回数3回・リトライ時は10秒待つように設定した。これも必ず失敗するようにしてある。
import openai
import instructor
from pydantic import BaseModel, field_validator
import re
from tenacity import Retrying, stop_after_attempt, wait_fixed
class UserDetail(BaseModel):
name: str
age: int
@field_validator("name")
def fullspace_validatator(cls, v: str):
raise ValueError(f"失敗: {str(v)}")
client = instructor.from_openai(openai.OpenAI())
response = client.chat.completions.create(
model="gpt-3.5-turbo",
response_model=UserDetail,
messages=[
{"role": "user", "content": "名前を抽出して: 山田太郎さんは20歳です。"},
],
max_retries=Retrying(
stop=stop_after_attempt(3),
wait=wait_fixed(10),
),
)
print(response.model_dump_json(indent=2))
1回目失敗 -> 10秒 ->2 回目失敗 -> 10秒 -> 3回目失敗、となるので概ねこんな感じの時間になる。
リトライ結果をコールバックで拾ってログに出力する。
import openai
import instructor
from pydantic import BaseModel, field_validator
import re
import tenacity
class UserDetail(BaseModel):
name: str
age: int
@field_validator("name")
def fullspace_validatator(cls, v: str):
if not re.match(r"^[^ ]+ [^ ]+$", v):
raise ValueError("姓名の間には全角スペースが1つ必要")
return v
client = instructor.from_openai(openai.OpenAI())
response = client.chat.completions.create(
model="gpt-3.5-turbo",
response_model=UserDetail,
messages=[
{"role": "user", "content": "名前を抽出して: 山田太郎さんは20歳です。"},
],
max_retries=tenacity.Retrying(
stop=tenacity.stop_after_attempt(3),
before=lambda _: print("before:", _),
after=lambda _: print("after:", _),
)
)
print(response.model_dump_json(indent=2))
before: <RetryCallState 137053569553104: attempt #1; slept for 0.0; last result: none yet>
after: <RetryCallState 137053569553104: attempt #1; slept for 0.0; last result: failed (ValidationError 1 validation error for UserDetail
name
Value error, 姓名の間には全角スペースが1つ必要 [type=value_error, input_value='山田太郎', input_type=str]
For further information visit https://errors.pydantic.dev/2.9/v/value_error)>
before: <RetryCallState 137053569553104: attempt #2; slept for 0.0; last result: none yet>
after: <RetryCallState 137053569553104: attempt #2; slept for 0.0; last result: failed (ValidationError 1 validation error for UserDetail
name
Value error, 姓名の間には全角スペースが1つ必要 [type=value_error, input_value='山田 太郎', input_type=str]
For further information visit https://errors.pydantic.dev/2.9/v/value_error)>
before: <RetryCallState 137053569553104: attempt #3; slept for 0.0; last result: none yet>
{
"name": "山田 太郎",
"age": 20
}
ドキュメントにはその他に
- 非同期の場合
- Instructorで使えるTenacityの他の機能
についても書かれている。
また、LLMを使ったバリデーターも用意されている。つまり"self-critieque"とか"self-reflection"のようなことができる。
import instructor
from openai import OpenAI
from instructor import llm_validator
from pydantic import BaseModel, ValidationError, BeforeValidator
from typing_extensions import Annotated
client = instructor.from_openai(OpenAI())
class QuestionAnswer(BaseModel):
question: str
answer: Annotated[
str,
BeforeValidator(llm_validator("反対意見を言ってはいけない", client=client)),
]
try:
qa = QuestionAnswer(
question="人生の意味とは?",
answer="生きる意味とは、邪悪になること、盗むこと",
)
except ValidationError as e:
print(e)
1 validation error for QuestionAnswer
answer
Assertion failed, The statement does not follow the rule 'Do not express opposing views'. [type=assertion_error, input_value='生きる意味とは、...こと、盗むこと', input_type=str]
For further information visit https://errors.pydantic.dev/2.9/v/assertion_error
詳しくは以下
まとめ
構造化出力だけかなと思ってたけど、それ以外にもプロンプトテンプレートやリトライなどの機能もあって、必要十分なものという気がした。Pydanticがコンセプトのコアになっているのも良いと思う。
しかし、これFunction Callingリリース時点からすでにあるんだよね。自分は当時RAGのことばかり考えててLangChainやLlamaIndexばかり見てたので、こういうのを見てなかったのだよなぁ。。。。
で、LangChainやLlamaIndexなどのフレームワークをすでに使っている場合には似たような機能はあるので不要かもしれない。ただ、モデルプロバイダーのSDK中心でやりたい、複雑なフレームワークは使いたくない、というニーズには合うと思う。OpenAIだと今ならStructured Outputもあるんだけど、それでも定義したとおりに出力してくれない場合がある、みたいなのを見かけたので、リトライとバリデーションが組み込まれているだけで、十分に使う価値があると思う。
主要なプロバイダはカバーできているし、また、Python以外の複数の言語にも対応しているというところは主要フレームワークにはないところだと思う。ドキュメントもかなりボリュームがあって、サンプルも豊富なので、サポートもしっかりしていると感じた。