Closed9

LLMの構造化出力ライブラリ「Instructor」を試す

kun432kun432

2024/10のTechnology RaderでTrialになってた。

https://www.thoughtworks.com/radar/languages-and-frameworks/summary/instructor

以前からちょいちょい見かけてはいたのだけど、今はStructured Outputがあったり、LLMフレームワークにも出力制御する機能はあるので、あえて使うほどの意味を感じていなかった。

個人的な印象として、Technology Radarの評価はあくまでも一社の主張にすぎないし、新しいものについてはやや慎重過ぎないかなという気がしないではないが、過去見てきた限り、内容については一定の共感する部分もある。

ということで、改めてInstructorについても見てみようと思う。

kun432kun432

GitHubレポジトリ

https://github.com/instructor-ai/instructor

シンプルな構造化出力のための最も人気のあるライブラリ、Instructor

Instructorは大規模言語モデル(LLM)の構造化出力を扱うための最も人気のあるPythonライブラリで、月間60万以上のダウンロード数を誇ります。Pydanticの上に構築されたこのライブラリは、検証、再試行、ストリーミング応答を管理するためのシンプルで透過的、そしてユーザーフレンドリーなAPIを提供します。LLMのワークフローを強化するために、コミュニティで最も選ばれているPydanticをぜひご利用ください!

主な特徴

  • レスポンスモデル: LLM出力の構造を定義するPydanticモデルの指定
  • 再試行管理: リクエストの再試行回数を簡単に設定できます。
  • 検証: Pydantic の検証機能により、LLM レスポンスが期待通りのものであることを確認します。
  • ストリーミングのサポート: リストやパーシャルレスポンスを簡単に扱うことができます。
  • 柔軟なバックエンド: OpenAI 以外のさまざまな LLM プロバイダーとシームレスに統合できます。
  • 多くの言語でのサポートPythonTypeScriptRubyGoElixir など多くの言語をサポートしています。
kun432kun432

ドキュメントのほうがもうちょっと詳しい概要説明がある

https://python.useinstructor.com/

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などをサポートしています。

kun432kun432

Getting Started

https://python.useinstructor.com/#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
kun432kun432

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
  }
}

kun432kun432

ストリーミング。ストリーミングの結果は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ではないと思う。

ここで変わってるみたい。

https://github.com/instructor-ai/instructor/commit/fadeb47a5888bb35261394324675d74eaf205d4b#diff-d800b87917e3b35ecad4323a534c51d14a735b2b9ca4d3a3e0b3d5206543055f

kun432kun432

これ以外にもいくつかの機能がある。

まずプロンプトテンプレート。Jinjaを使ったテンプレートが使える。

https://python.useinstructor.com/concepts/templating/

プロンプトを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

実際には送信されているので、ログに残さないとかそういう用途向けかな。

kun432kun432

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_attemptsmessagesを使って取得できる。確認のために、バリデーションを必ず失敗させるようにした例。

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"のようなことができる。

https://python.useinstructor.com/concepts/reask_validation/

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

詳しくは以下

https://python.useinstructor.com/concepts/reask_validation/

kun432kun432

まとめ

構造化出力だけかなと思ってたけど、それ以外にもプロンプトテンプレートやリトライなどの機能もあって、必要十分なものという気がした。Pydanticがコンセプトのコアになっているのも良いと思う。

しかし、これFunction Callingリリース時点からすでにあるんだよね。自分は当時RAGのことばかり考えててLangChainやLlamaIndexばかり見てたので、こういうのを見てなかったのだよなぁ。。。。

で、LangChainやLlamaIndexなどのフレームワークをすでに使っている場合には似たような機能はあるので不要かもしれない。ただ、モデルプロバイダーのSDK中心でやりたい、複雑なフレームワークは使いたくない、というニーズには合うと思う。OpenAIだと今ならStructured Outputもあるんだけど、それでも定義したとおりに出力してくれない場合がある、みたいなのを見かけたので、リトライとバリデーションが組み込まれているだけで、十分に使う価値があると思う。

主要なプロバイダはカバーできているし、また、Python以外の複数の言語にも対応しているというところは主要フレームワークにはないところだと思う。ドキュメントもかなりボリュームがあって、サンプルも豊富なので、サポートもしっかりしていると感じた。

このスクラップは1ヶ月前にクローズされました