ABEJA Tech Blog

中の人の興味のある情報を発信していきます

OpenAI Chat Completion、Gemini、ClaudeのSDKインターフェースを揃えようとしてハマった

TL; DR

以下のAPIを全部同じインターフェースで使えるように挑戦した時にハマったポイントについて書いた

  • Chat Completion API (OpenAI、Azure OpenAI)
  • Gemini API (Google AI、VertexAI)
  • Claude API (Anthropic、Amazon Bedrock)

みんなちょっとずつ違ってて意外と大変だよ。

はじめに

こんにちは、データサイエンスグループの坂元です。以前にこんな記事を書いて各方面からコメントを頂き、良くも悪くもみんなLangChainに対して色々考えがあるのだなぁと思いました(ちなみに私自身はLangChainについては中立的な立場のつもりです)。

tech-blog.abeja.asia

上記の記事で紹介した個人開発ライブラリのlangrilaは地味に更新を続けており、今ではOpenAIのChat CompletionだけではなくGeminiとClaudeも一部の機能をサポートしています。

github.com

APIベースの各種LLMのクライアントSDKリファレンスを見ていると、モデルの違いや利用するプラットフォームによって微妙にインターフェースが異なることが分かります。langrilaではその差分を(複雑にならないように気を付けつつ)なるべく吸収して、対応しているものならどのクライアント、どのプラットフォームで使っても基本同じように利用出来るようにしていきたいと思っています。直近でGeminiとClaudeもサポートしたのですが、その過程で色々ハマったのでこの記事でハマったポイントについて書いていきます。なお、本記事は2024年7月時点の情報に基づいて執筆しています。

まず何でそんなことしてるの?

一言で言うと趣味です。

  • 中身の挙動を理解して使いたい。理解しにくいものはなるべく避けたい
  • 細かいカスタマイズが出来るような柔軟性も欲しい
  • コードを管理しやすくしたい

という気持ちがあり、LangChainやDifyといった便利なツールを参考にしつつも自分で必要な機能に絞ったライブラリを作っている、というのが背景にあります。他にも似たようなことを考えている人がいるかもしれないので、そんな方にこの記事が参考になればいいなと思っています。

実装方針

OpenAI Chat Completion、Gemini、Claudeの各クライアントの大きな違いを見ていきます。各クライアントのリファレンスを読んでいると、異なる部分は大きく

  1. 入力・出力メッセージとその中身の違い
  2. Function Calling(ツール呼び出し)のやり方の違い

の2点で、それぞれで各クライアントに共通する部分(例えば、メッセージならroleとコンテンツから構成されるという概念的な部分)と、独自の部分(例えば、roleに入る値が違う等)があることが分かります。

インターフェースを統一するにはこれらの違いを相互に変換出来る必要がありますが、OpenAI Chat CompletionとGemini、OpenAI Chat CompletionとClaudeの変換処理…、のように組み合わせで個別に変換処理を実装するのはあまり得策ではありません(対応するクライアントを増やすほど大変になるので)。そこで、各クライアントに共通する部分を抽象化して、この抽象化した部分と各クライアント向けの変換処理を実装することで、異なる仕様を相互に行き来することを考えます。つまり、共通言語としてユニバーサルなメッセージクラスを実装し、OpenAI Chat Completionのメッセージ→ユニバーサルメッセージ→Geminiのメッセージ(逆も同様)という変換を考えることにします。

Function Callingについても同様に、ツールの定義時にはname、description、typeといったような共通の属性があるので、そこを抽象化して各クライアント向けに変換することを考えます。

この方針で実装しようとしてハマったポイントを書いていきます。

※ツール呼び出しのこと惰性でFunction Callingと呼び続けています。ご容赦ください。

ハマったポイント

Geminiを使うためのGoogle AIとVertexAIのSDKは似てるけどちょっと違う

同じ名前のクラスでも違うインターフェース

OpenAI Chat Completionは公式SDKでAzureの機能もサポートしています。ClaudeでもAnthropicがBedrockやVertexAIをSDKでサポートしています。一方でGeminiはGoogle Generative AIとVertexAIでそもそも使うライブラリが異なります。いうても同じGeminiだし同じようにいけるやろと思っていましたがそんなことありませんでした。Geminiのメッセージは基本的にはContentPartPartに格納される各種メッセージクラスという構成です。 Google AIの場合は

from google.ai.generativelanguage import Content, Part

VertexAIの場合は

from vertexai.generative_models import Content, Part

のようにimportします。これらのクラスは名前こそ同じですが、インターフェースは異なります。例えば、テキストメッセージを初期化する場合、Google AIでは

Content(role="user", parts=[Part(text=text)])

ですが、VertexAIでは

Content(role="user", parts=[Part.from_text(text=text)])

となります。微妙に違いますね。この程度ならまぁ良いのですが、実装を進めて行くとVertexAIにはfunction callのメッセージのPartを生成するメソッドが無いことに気付きます。

VertexAIのSDKにはfunction_callのメッセージを作るメソッドが無い

Google AIであれば

Part(
    function_call=FunctionCall(
        name=name,
        args=args,
    )
)

という感じでツール呼び出しのメッセージを作ることが出来るのですが、VertexAIではPart.from_text()と同じノリで使えるPart.from_function_call()というメソッドはありません(少なくとも執筆時点では)。ユニバーサルメッセージからfunction_callのメッセージを作れないとFunction Calling後に会話が継続出来なくなってしまうので問題になり得ます。そこで、SDKのソースコードを少し辿ると以下のように実装出来ることが分かりました。

from google.cloud.aiplatform_v1beta1.types import tool as gapic_tool_types
from google.cloud.aiplatform_v1beta1.types import content as gapic_content_types


function_call = gapic_tool_types.FunctionCall(
                    name=name,
                    args=args,
                )

Part._from_gapic(
            raw_part=gapic_content_types.Part(
                function_call=function_call,
            )
        )

今度はgapic_content_types.Partとかいうまた違うPartが出てきた…という感じでなかなか混乱します。

他にも、VertexAIの方はContentをシリアライズするためのメソッドとしてto_dict()があるのですが、Google AIの方には無く、

def to_dict(content: Content) -> dict[str, Any]:
    return json.loads(Content.to_json(content))

のように書くとシリアライズ可能なdictに変換出来るようです。

Function Callingの仕様がバラバラ

そもそも知らないうちに仕様が変わっていた

functionからtoolに変わったタイミングでここの仕様も変わってたのを見落としてただけなのかなと思います。OpenAI Chat CompletionのFunction Callingは昔は

  1. userのroleでプロンプトを入れてfunction callingを実行する
  2. tool_callのレスポンスが返ってきて、LLMが作った引数で関数を実行する
  3. functionのroleで関数の実行結果のメッセージを適当に作る
  4. ステップ3のメッセージとプロンプトを併せて入力する
  5. 関数の実行結果を踏まえた回答が返って来る

という感じだったかな?と記憶していたのですが、最近OpenAIのFunction Callingのページを見たら仕様が変わっていました。

platform.openai.com

どうやら今は

  1. 同じ
  2. 同じ
  3. toolというroleで関数の実行結果をtool_callの数だけ別々に送信する(tool_call_idを含める)
  4. 関数の実行結果を踏まえた回答がassistantから返って来る

という仕様に変わっているようです。Function Callingを単発で実行するだけならあまり気にしなくていいのですが、Function Callingを使いつつ会話を継続するには上記の3以降のステップを進める必要があります。

まだ古い方のやり方でも一応動きはするのですが、どこかのタイミングで廃止になるかもしれないですね。

ツールの実行結果の入れ方が違う

その上でのハマりポイントなのですが、前述のようにOpenAI Chat Completionではツールの実行結果は別々のメッセージで送信する必要があります。この仕様、GeminiやClaudeとは異なっていて、GeminiやClaudeのFunction Callingでは関数の実行結果を一つのメッセージにまとめて送信します(なおGeminiはOpenAIと同じように別々のメッセージとして入力しても認識はしてくれるっぽいですが、推奨パターンかどうかは不明です)。

実際にtoolを呼び出した際のプロンプトをOpenAI Chat CompletionとClaudeで比較してみます(ツールの呼び出しと実行結果の部分だけ抜粋します)。

OpenAI Chat Completionの場合

[
  {
    "content": null,
    "role": "assistant",
    "function_call": null,
    "tool_calls": [
      {
        "id": "call_m5TSf0D62dfdOmtspb4sKU9Q",
        "function": {
          "arguments": "{\"power\": true}",
          "name": "power_disco_ball"
        },
        "type": "function"
      },
      {
        "id": "call_ErDHDNZSi02sn8FbWvb4OMd7",
        "function": {
          "arguments": "{\"energetic\": true, \"loud\": true, \"bpm\": 180}",
          "name": "start_music"
        },
        "type": "function"
      },
      {
        "id": "call_XOJiwx0cunuTwV7b5PFa3CIG",
        "function": {
          "arguments": "{\"brightness\": 0.5}",
          "name": "dim_lights"
        },
        "type": "function"
      }
    ]
  },
  {
    "role": "tool",
    "tool_call_id": "call_m5TSf0D62dfdOmtspb4sKU9Q",
    "name": "power_disco_ball",
    "content": "Disco ball is spinning!"
  },
  {
    "role": "tool",
    "tool_call_id": "call_ErDHDNZSi02sn8FbWvb4OMd7",
    "name": "start_music",
    "content": "Starting music! energetic=True loud=True, bpm=180"
  },
  {
    "role": "tool",
    "tool_call_id": "call_XOJiwx0cunuTwV7b5PFa3CIG",
    "name": "dim_lights",
    "content": "Lights are now set to 0.5"
  }
]

ツールの実行結果はroleがtoolのメッセージとしてそれぞれ独立しています。

Claudeの場合

[
  {
    "role": "assistant",
    "content": [
      {
        "text": "Certainly! I'd be happy to help turn this place into a full-on party using the available tools. To create the ultimate party atmosphere, we'll need to use all three functions available to us. Let's set up the disco ball, start some energetic music, and adjust the lighting. Here's how we'll do it:",
        "type": "text"
      },
      {
        "id": "toolu_01Hojw1YhKppyLVf6xDS6N7H",
        "type": "tool_use",
        "input": {
          "power": true
        },
        "name": "power_disco_ball"
      },
      {
        "id": "toolu_01Ba3SEtR7JsYd32wBVcC86j",
        "type": "tool_use",
        "input": {
          "energetic": true,
          "loud": true,
          "bpm": 180
        },
        "name": "start_music"
      },
      {
        "id": "toolu_012PW4cA4AXVMDsJqCDCFy1C",
        "type": "tool_use",
        "input": {
          "brightness": 0.6
        },
        "name": "dim_lights"
      }
    ]
  },
  {
    "role": "user",
    "content": [
      {
        "tool_use_id": "toolu_01Hojw1YhKppyLVf6xDS6N7H",
        "type": "tool_result",
        "content": "Disco ball is spinning!"
      },
      {
        "tool_use_id": "toolu_01Ba3SEtR7JsYd32wBVcC86j",
        "type": "tool_result",
        "content": "Starting music! energetic=True loud=True, bpm=180"
      },
      {
        "tool_use_id": "toolu_012PW4cA4AXVMDsJqCDCFy1C",
        "type": "tool_result",
        "content": "Lights are now set to 0.6"
      }
    ]
  }
]

tool_resultはroleがuserのメッセージのcontentにまとめられています。

Claudeだけさらに少し特殊

Claudeの仕様はもう少し特殊です。先ほどの例を見て気付いた方もいると思いますが、Claudeは関数呼び出し時にテキストを出力してくることがあります。この点もOpenAI Chat CompletionやGeminiとは異なる仕様です。

docs.anthropic.com

また、Claudeはツールを呼び出して会話履歴にツール呼び出しのメッセージが入ると、その後の会話でツールを実行するか否かに関わらずツールの定義を与え続ける必要があるようです(OpenAI Chat CompletionとGeminiは途中からツールを与えなくても会話を継続出来ます)。

インターフェースの統一

他にも細かい所で時間を溶かしたりしたのですが、それについては後述するとして、上記2点についてlangrilaを使ってどんな感じで実装したかを見ていきます。

github.com

メッセージの統一

サンプルnotebookはこちら

例えばこういうプロンプトを入力した場合

prompt = "こんにちは"

これをまずユニバーサルメッセージに変換します。

from langrila.openai import OpenAIMessage

universal_message = OpenAIMessage.to_universal_message(role="user", message=prompt)
universal_message

>>> Message(role='user', content=[TextContent(text='こんにちは')], name=None)

to_universal_message()はOpenAIMessageの親クラスのメソッドなので他のクライアントメッセージクラスからも利用出来ます。次にこれをOpenAIのメッセージ形式になるように変換します。

OpenAIMessage.to_client_message(universal_message)

>>> {'role': 'user',
 'content': [{'type': 'text', 'text': 'Please describe this picture.'}],
 'name': None}

このような変換をGemini on Google AI、Gemini on VertexAI、Claudeで実装すれば、これらのモデルがユニバーサルメッセージという共通言語でお互いのメッセージをやり取りすることが出来ます。会話履歴をユニバーサルメッセージの形式で保持しておけば会話履歴を共有出来るので、

# OpenAI Chat Completion API
prompt = "Do you know Rude in Final Fantasy VII?"
response = chat_openai.run(prompt)
print(response.message.content[0].text)

>>> Yes, Rude is a character from the popular video game "Final Fantasy VII," which was developed by Square Enix (formerly Square). He is a member of the Turks, a covert group that works for the Shinra Electric Power Company. Rude is known for his stoic demeanor, loyalty to his colleagues, and his distinctive appearance, which includes a bald head and sunglasses. He often works alongside his partner, Reno, and the two share a close bond. Despite being an antagonist, Rude is portrayed with a sense of honor and professionalism, making him a memorable character in the game.


# Gemini API
prompt = "What does he think about Tifa?"
response = gemini.run(prompt)
print(response.message.content[0].text)

>>> Rude doesn't explicitly express his thoughts about Tifa in the game. However, we can infer some things based on his actions and the overall narrative:

* **Professional Detachment:** As a member of the Turks, Rude is trained to be professional and objective. He likely sees Tifa as a target or an obstacle in his missions, rather than someone he has personal feelings about.
* **Respect for Strength:** Rude is a skilled fighter himself, and he likely respects Tifa's strength and fighting abilities. He might even see her as a worthy opponent.
* **Potential Interest:** While not explicitly stated, some fans speculate that Rude might have a subtle interest in Tifa, based on his occasional glances and the way he interacts with her. However, this is purely speculative and not supported by any concrete evidence in the game.

Ultimately, Rude's thoughts about Tifa remain largely unknown. He is a complex character with a stoic personality, and his true feelings are rarely revealed.

といった具合でOpenAI Chat Completion、Gemini、ClaudeのAPIをミックスして会話を継続することが出来ます。

Function Callingの統一

サンプルnotebookはこちら

Function Callingについても同様に、ツールの名前や説明、変数の定義を共通の形式で受け付けるようにし、そこを起点にしてそれぞれのクライアント向けに形式を変換します。Geminiだけ、Function Declarationという関数宣言部分とToolという宣言した関数をツールとして渡すクラスが分かれているのでやや注意です(そしてここのインターフェースもGoogle AIとVertexAIで異なります)。

例えば、

from langrila import ToolConfig, ToolParameter, ToolProperty

tool_configs = [
    ToolConfig(
        name="power_disco_ball",
        description="Powers the spinning disco ball.",
        parameters=ToolParameter(
            properties=[
                ToolProperty(
                    name="power",
                    type="boolean",
                    description="Boolean to spin disco ball.",
                ),
            ],
            required=["power"],
        ),
    ),
    ToolConfig(
        name="start_music",
        description="Play some music matching the specified parameters.",
        parameters=ToolParameter(
            properties=[
                ToolProperty(
                    name="energetic",
                    type="boolean",
                    description="Whether the music is energetic or not.",
                ),
                ToolProperty(
                    name="loud", type="boolean", description="Whether the music is loud or not."
                ),
                ToolProperty(
                    name="bpm",
                    type="number",
                    description="The beats per minute of the music.",
                    enum=[60, 180],
                ),
            ],
            required=["energetic", "loud", "bpm"],
        ),
    ),
    ToolConfig(
        name="dim_lights",
        description="Dim the lights.",
        parameters=ToolParameter(
            properties=[
                ToolProperty(
                    name="brightness",
                    type="number",
                    description="The brightness of the lights, 0.0 is off, 1.0 is full.",
                ),
            ],
            required=["brightness"],
        ),
    ),
]

という形でツールを定義し(pydanticモデルで定義することも出来ます)、これを各クライアント向けのToolConfigで変換すると見慣れた形式になります。

from langrila.openai import OpenAIToolConfig

converted_configs = OpenAIToolConfig.from_universal_configs(tool_configs)
converted_configs[0].format()

>>> {'type': 'function',
 'function': {'name': 'power_disco_ball',
  'description': 'Powers the spinning disco ball.',
  'parameters': {'type': 'object',
   'properties': {'power': {'type': 'boolean',
     'description': 'Boolean to spin disco ball.'}},
   'required': ['power']}}}

このような変換をそれぞれのクライアント向けに実装し、あとはAPIからのレスポンスをユニバーサルメッセージに変換する処理を実装しておけば、ツール呼び出しやツールの実行結果を会話履歴にユニバーサルメッセージの形式で保持出来ます。こうすることでツールを実行しながらOpenAI Chat Completion、Gemini、Claudeミックスでマルチターンの会話が出来る状態になります(正確にはまだ他に細かい処理は必要なのですが、それは後述します)。

また、ユニバーサルメッセージではツールの実行結果を一つのメッセージにまとめておき、OpenAI APIを利用する場合だけ個々のtool resultのメッセージを分ける処理を入れました。

その他の細かいハマりポイント

ツールのcall_idの指定方法がバラバラ

OpenAI Chat Completionではcall_xxx、Claudeではtoolu_xxx、Geminiではcall_idがそもそもない、という感じで全部違います。ただ、こちらは実は適当な文字列でも大丈夫です。OpenAI APIでツール呼び出し&実行をして、call_の部分をtoolu_に置き換えればClaudeに入力出来ちゃいます。逆も同様です。OpenAI APIとClaudeのcall_idはどちらも24桁の文字列です。Geminiに関しては適当な24桁の文字列を生成してダミーのcall_idにすればOKです(強引だけどエラーにならないのでヨシッ!)。

tool_callの引数の型が違う

OpenAI APIはJSON文字列、GeminiとClaudeはdictionaryで受け付けます。

OpenAI本家で対応している機能がAzure OpenAIで未対応

最近OpenAI Chat CompletionではStream出力時のトークンを出力するstream_options機能が追加されています。これまではストリーム出力が終了した段階で全てのチャンクを結合した文字列でトークン数を自分で計算する必要があったのですが、この機能によって自分でトークン数を計算する必要がなくなりました。

platform.openai.com

ただしこれはOpenAI本家のAPIを使用した場合の話で、Azure OpenAIではまだ対応していないようです(少なくとも執筆時点では)。面倒なのは、stream_optionsを指定した場合としない場合で出力の終了判定が変わることです。指定した場合はchunkのchoicesが空になるのでそこで終了判定しますが、指定しない場合はチャンクがNone(stopで指定)になったタイミングが終了かと思います。

Claudeのストリーミング出力の仕様

OpenAI Chat CompletionとGeminiのストリーム出力は基本全部のチャンクが同じクラスのオブジェクトとして返ってきますが、Claudeは違います。RawMessageStartEventRawContentBlockStartEventRawContentBlockDeltaEventRawMessageStopEventRawMessageDeltaEventといったクラスに分かれていて、それぞれ持っている情報やテキスト情報へのアクセスの仕方が異なります。usageに至ってはRawMessageStartEventRawMessageDeltaEventに跨って存在しています。

docs.anthropic.com

よく分かってないこと

Function Calling利用時のトークン数計算

ツールを呼び出して、呼び出しのメッセージとツールの結果のメッセージをそれぞれ会話履歴に追加してレスポンスを生成させる時のトークン数の計算ってどうやるんでしょうか。ツール呼び出しのメッセージはトークン数計算にカウントされてないっぽいような雰囲気もあるのですが詳しいロジックはよくわかっていません。コスト削減の都合とかで事前にトークン数を計算して必要に応じて入力トークン数を調整したい(けど出来るだけコンテキスト残したいから入力するテキストを切り詰めたい)ことってあると思うんですが、ツールを呼び出した場合の正確な事前計算の方法がよくわかりません。この点は引き続き調査しようと思っています。

まとめ

そんなこんなで色んな所でハマりつつもlangrilaがパワーアップしました。汎用的なコードを書くには抽象化する必要がありますが、抽象化し過ぎるとかえって可読性が悪くなったりするのでバランスが難しいです。今後も引き続き、より便利&分かりやすいツールになるようにコーディングのスキルアップとライブラリの開発を継続していこうと思っています。(個人開発なのでペースはゆったりですが)

We Are Hiring!

ABEJAは、テクノロジーの社会実装に取り組んでいます。 技術はもちろん、技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方は、下記採用ページからエントリーください! (新卒の方やインターンシップのエントリーもお待ちしております!)

careers.abejainc.com

また、定期的にイベントも実施しているので興味があればぜひご参加ください!

abeja-innovation-meetup.connpass.com