InstructorとPydanticでLLMから構造化データを抽出:JSONパースの悪夢からの解放

AI tutorial - IT technology blog
AI tutorial - IT technology blog

深夜2時の本番環境の悪夢

時刻は午前2時14分。ナイトスタンドの上でスマートフォンが震え、Sentryのアラートが鳴り響いています。そのエラーメッセージは、すべてのAIエンジニアを震え上がらせるものでした。 json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

複雑な法的文書を要約する私たちのアプリケーションは、数週間にわたって順調に稼働していました。しかし、前触れもなくLLMが「気を利かせる」ことにしたのです。生のJSONオブジェクトを返す代わりに、レスポンスの冒頭に 「もちろんです!ご要望の構造化データはこちらです:」 という一文を付け加え、JSONをMarkdownのコードブロック(“`)で囲んでしまいました。

それらのバッククォートを取り除くために設計された正規表現ベースのパーサーは、予期しない改行一つで正常に動作しなくなりました。ダウンストリームের サービスは、オブジェクトの代わりに壊れた文字列を受け取り、パイプライン全体が崩壊したのです。

これが、本番環境向けのAIアプリを構築する上での現実です。LLMは非決定論的なテキスト補完エンジンであり、信頼できるAPIエンドポイントではありません。もしあなたのコードが json.loads(response.choices[0].message.content) に依存しているなら、それは安定したシステムを構築しているとは言えません。「砂上の楼閣」を築いているのと同じです。

根本原因:なぜLLMはコードを壊すのか

基本的に、LLMはPythonやTypeScriptのような意味での「型」を理解していません。モデルに 「JSONのみを返して」 と懇願しても、id を期待しているところで user_id のようなフィールド名を捏造(ハルシネーション)する可能性があります。必要なカンマを飛ばしたり、パーサーを壊すような会話の埋め草を追加したりすることもあります。

GPT-4oやGemini 1.5 Proのようなモデルで「JSONモード」が有効になっていても、依然として3つの大きなハードルに直面します。

  • スキーマのドリフト(Schema Drift): モデルが突然、文字列のリストをカンマ区切りの単一の文字列に変更してしまうことがあります。
  • ロジックの失敗: JSONの構文としては正しくても、ユーザーの年齢が -15 歳だったり、開始日が終了日より後だったりするなど、論理的にあり得ないデータが含まれることがあります。
  • リトライループ: モデルが失敗したとき、try-exceptブロックの巨大で乱雑なループを書くことなく、何が間違っていたのかを正確に伝え、修正を求める方法が必要です。

解決策の比較

より良い標準的な手法に落ち着く前に、私は一般的な解決策を一通り試しました。

1. 手動の正規表現とJSONパース

これは、最初の { と最後の } を見つける関数を書くような手法です。メンテナンスが非常に困難です。プロンプトを微調整するたびにパーサーが壊れるリスクがあります。脆弱で、見栄えも悪く、数十もの機能にスケールさせることは不可能です。

2. LangChainのOutput Parsers

LangChainには組み込みのパーサーがありますが、ブラックボックスのように感じられることがよくあります。かなりのオーバーヘッドが発生し、環境のサイズが数百メガバイトも増加する可能性があります。巨大なフレームワークの重みを必要とせず、構造化データだけが必要な場合には、過剰な選択です。

3. 現代の標準:Instructor

Instructorは、Pydanticを活用したLLMクライアント(OpenAI、Anthropic、Gemini)向けの軽量なラッパーです。LLMを単なるテキスト生成器として扱うのではなく、Pydanticクラスに値を投入する関数として扱います。プロンプト作成、バリデーション、そして決定的な点として、問題が発生した際のリプロンプト(再プロンプト)を自動で処理してくれます。

より良い方法:Instructorの実装

私はすべての本番パイプラインをこのアプローチに移行しました。安定性は劇的に向上しました。脆弱なパースを、堅牢で型安全なセットアップに置き換える方法は以下の通りです。

ステップ 1:インストール

instructorpydantic が必要です。この例ではOpenAIを使用しますが、Instructorはほぼすべての主要なプロバイダーで動作します。

pip install instructor pydantic openai

ステップ 2:データスキーマの定義

正しいキーが返ってくることを祈るのはやめましょう。それらをPydanticクラスとして定義します。このクラスが、データ構造의 唯一の真実のソース(Single Source of Truth)になります。

from pydantic import BaseModel, Field, field_validator
from typing import List

class UserDetail(BaseModel):
    name: str
    age: int = Field(..., description="ユーザーの年齢(歳)")
    email: str
    interests: List[str]

    @field_validator("age")
    @classmethod
    def must_be_positive(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("年齢は正の整数である必要があります")
        return v

ステップ 3:クライアントの初期化と抽出

Instructorは標準のクライアントをラップし、 response_model パラメータを追加します。ここでバリデーションが行われます。

import instructor
from openai import OpenAI

# パッチを適用したクライアントの初期化
client = instructor.from_openai(OpenAI(api_key="your_api_key"))

# 構造化データをPydanticモデルに直接抽出
user = client.chat.completions.create(
    model="gpt-4o",
    response_model=UserDetail,
    messages=[
        {"role": "user", "content": "抽出:私の名前はJason、28歳です。メールは[email protected]で、趣味はコーディングとハイキングです。"}
    ]
)

print(f"名前: {user.name}, 年齢: {user.age}")
# 出力: 名前: Jason, 年齢: 28

これが優れている理由:自動リトライ

Instructorの真の力は、単なる最初の抽出だけではありません。 max_retries 機能にあります。LLMが無効な年齢(-5など)や不正な形式のメールを返した場合、Pydanticはバリデーションエラーを投げます。Instructorはそのエラーをキャッチし、LLMに送り返して次のように伝えます:「あなたは-5を提供しましたが、年齢は正の値である必要があります。修正してください。」

user = client.chat.completions.create(
    model="gpt-4o",
    response_model=UserDetail,
    max_retries=3,
    messages=[
        {"role": "user", "content": "-10歳のBobの情報を抽出して..."}
    ]
)

本番環境では、このシンプルなループにより、パースの失敗率を10%から0.1%未満に抑えることができます。アプリケーションはクラッシュする代わりに、リアルタイムで自己修復します。

本番環境での実践的なヒント

いくつかのコアパイプラインを移行した結果、信頼性を最大化するためのいくつかの戦略を見つけました。

1. フィールドの説明(Field Descriptions)を活用する

Pydanticの Field に記述する description は、実際には指示の一部としてLLMに渡されます。モデルが特定のフィールドで苦戦している場合は、メインのプロンプトを書き直すだけでなく、フィールド自体に明確な説明を追加してください。

2. Enumを活用する

フィールドが ['high', 'medium', 'low'] のような特定の値のみを受け入れるべき場合は、Pythonの Enum を使用します。InstructorはLLMにそれらの特定のオプションから選択させるため、後で文字列をクリーンアップする必要がなくなります。

3. 複雑なネストを処理する

Instructorはネストされたモデルを難なく処理します。注文のリストを抽出する必要があり、各注文にアイテムのリストが含まれ、各アイテムにSKUと価格がある場合でも、単にクラスを定義するだけです。ツールがマッピングを処理してくれます。

最後に

response.split("\n") で奮闘する日々は終わりました。プロフェッショナルなAIアプリケーションを構築するのであれば、LLMの出力を単なる文字列として扱うことはできません。InstructorとPydanticを使用することで、データの整合性の負担を脆弱な正規表現パターンから、堅牢で型安全なバリデーションレイヤーへと移すことができます。

私のプロジェクトをこのパターンに移行して以来、午前2時の「JSONDecodeError」アラートは姿を消しました。コードはよりクリーンになり、テストは容易になり、アプリケーションはエンドユーザーにとって大幅に信頼性の高いものになりました。

Share: