見出し画像

OpenRouterを使ってStructured Outputの処理を共通化する方法

Open Routerは複数のAIモデルにアクセスできる統一インターフェースを提供する革新的なプラットフォームです。

GPT, Claude, Geminiはもちろん、最近話題のDeepSeek V3などにも同じPythonコードで利用できます。

今回はStructured Outputを使った処理を共通化する方法についてまとめます。
(GPTとGeminiで成功しています)

結論

以下のようにすれば共通化できます。

import openai
from pydantic import BaseModel, Field

# どのモデルを使う場合でも、クライアントの宣言方法は同じ
client = openai.OpenAI(api_key="OPEN_ROUTER_API_KEY", base_url="https://openrouter.ai/api/v1")
MODEL_NAME = "google/gemini-flash-1.5"

# レスポンスフォーマットの宣言
class ChatResponse(BaseModel):
    A_thought_process: list[str] = Field(
        ..., description="Please output details of your thought process before responding. Up to 7 steps. In English."
    )
    B_message: str = Field(..., description="The content of the response. Please select the most appropriate language to use.")

# プロンプトの宣言
system_prompt = """
Your task is to answer questions from users.
Please output thought_process before message.
"""
user_prompt = """
日本国内の電柱の数をフェルミ推定してください。
"""

# レスポンスの取得
response = client.chat.completions.create(
    model=MODEL_NAME,
    messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}],
    response_format={"type": "json_schema", "json_schema": {"name": response_format.__name__, "schema": ChatResponse.model_json_schema()}},
)

# レスポンスをパースしてオブジェクト化
parsed_response = ChatResponse.model_validate_json(response.choices[0].message.content)

# 出力
print(parsed_response.B_message)

openaiでStructured Outputを使っている方は、上記のコードで以下の疑問を持つと思います。

  • なぜ `client.beta.chat.completions.parse`を使わないのか?

  • レスポンスフォーマットの変数についているA_やB_はなんのためにあるのか?

一つずつ解説していきます。

なぜ `client.beta.chat.completions.parse`を使わないのか?

OpenAI公式では、Structured Outputは以下のように行うことが推奨されています。

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": "Extract the event information."},
        {"role": "user", "content": "Alice and Bob are going to a science fair on Friday."},
    ],
    response_format=CalendarEvent,
)

event = completion.choices[0].message.parsed

しかし2025年1月現在、OpenRouter経由でOpenAI以外のモデルを使う場合、parse関数だと大体エラーが発生します
parse関数を使っても成功することがあるので謎ですが、基本的にはjson_schemaで指定する方法が安定して動作します。

また、pydanticを使った可読性の高いコーディングは続けたいため、以下のようにしています。

response_format={"type": "json_schema", "json_schema": {"name": response_format.__name__, "schema": ChatResponse.model_json_schema()}}

こうすることで、可読性を維持したまま処理を共通化できます。

レスポンスフォーマットの変数についているA_やB_はなんのためにあるのか?

Geminiを使ってStructured Outputを行うと、json_schemaで指示した通りの出力順にならず、なぜかアルファベット順で出力されます。

これはissuesでも報告されており、これはCoTなどの最終的な出力のまえに推論をさせたい場合などに大きな問題となります。

そのため、A_やB_といった頭文字をつけることで、意図した通りの出力順を保証しています。

おまけ

私はStructured Outputを多用するので、ラッパー関数も作ってみました。
良ければ参考にしてください。

from typing import TypeVar

import openai
from pydantic import BaseModel

T = TypeVar("T", bound=BaseModel)

def chat_completion_using_structured_output(
    messages: list[dict],
    response_format: type[T],
    model: str,
    temperature: float = 0.0,
    top_p: float = 1.0,
) -> T:
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        response_format={"type": "json_schema", "json_schema": {"name": response_format.__name__, "schema": response_format.model_json_schema()}},
        temperature=temperature,
        top_p=top_p,
    )
    return response_format.model_validate_json(response.choices[0].message.content)

いいなと思ったら応援しよう!