0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

“HTTP切断型” Function Callingを作った話(Gemini x ChatGPT)

0
Posted at

2人のAI(ChatGPT / Gemini)とグループチャットできるアプリを作ったのですが、ユーザーの会話履歴などをローカル保存しプライバシーを保護できるようにデスクトップのアプリにしたため、 Cloud Run との繋ぎこみに苦労した話を解説します。


背景・前置き

QUTRITでは、2つのモードがあります。

  • BYOK: Bring Your Own Key - アプリ利用者が自身のAPIキーを使うモード
  • SaaS: APIキーがなくても手軽に使えるモード

BYOKではローカル→直接API接続になっています。一方でSaaSモードは、Cloud Runを経由させ、開発者である私のAPIキーを付与した上でGeminiやChatGPTに接続する、という仕組みにしています。

このSaaSモードの「ローカル → Cloud Run(Proxy)→ API接続」の実装に苦労した、というお話です。

TL;DR(結論)

LLMがツールを呼びたくなった瞬間に、サーバーはツール呼び出し情報だけ返してHTTPを切断
クライアントがローカルでツールを実行し、結果を添えて再リクエスト。これを最大回数まで繰り返して最終回答を生成します。

シーケンス図(HTTP切断型 Function Calling)

まずは全体像です。

背景:なぜ「HTTP切断型」なのか

SaaSでLLMを提供するときに悩ましいのが、ざっくり言うと次の両立です。

  1. APIキーをクライアントに置きたくない(SaaSとしては当たり前)
  2. でも、ツール実行(例:ローカルにあるRAG参照等)をサーバーに全部寄せると重い/遅い/コスト高
  3. さらにストリーミング中にツールが走ると、サーバー側で「待ち」が発生しがち

そこで、Cloud Runは「LLM呼び出しの中継・認証・課金/制限」に集中させ、ツール実行はクライアントで行い、ツールが必要になったら一旦接続を切って再接続する方式にしました。
(Cloud Runのタイムアウトやサーバー負荷の“待ち”を、設計で回避できます)

Proxyが担う責務:認証・制限・中継

Proxyは「ただの中継」に見えて、実際は以下をまとめて引き受けます。

1) 認証

AuthorizationヘッダーのBearerトークンを検証します。

2) プラン/上限の取得とトークン計算

ユーザーのプランに応じて上限を適用し、トークン使用量も計測します。

3) tools定義の中継(切断型ループ前提)

切断→再接続を跨いでも同じtools定義を維持し、LLM側のTool Callingが安定するようにします。

実装のキモ①:最初のtool_callで切ると“1個しか取れない”

最初は「function callを検出した瞬間に切断」していました。すると、同一ターンで複数ツールを呼ばれた時に1つ目しか取れない問題が出ます。

対策は単純で、ストリーム全体を走査して全tool_callsを収集してから返す。そして返したら切断します。

実装のキモ②:Geminiは“FunctionCallを履歴に入れてから”FunctionResponseを送る

Geminiは「ツール結果だけ渡す」と理解してくれないケースがあり、
モデルが出したFunctionCall自体も履歴に追加した上で、FunctionResponseを送る必要があります。

HTTP切断型と相性が良いのは、Proxyは「ツールを呼べ」のイベントだけ返し、クライアント側がツールを走らせ、次のリクエストで (FunctionCall + FunctionResponse) を揃えて渡せる点です。

実装のキモ③:OpenAI Responses APIは“function_call typeを入力contentに入れられない”

OpenAI側は、入力contentに許可されるtypeが限られ、function_callをそのまま履歴として入力できない制約があります。
切断型ループでは「過去のツール呼び出し情報(引数含む)」を履歴に復元する必要があるので、構造化テキストに変換して渡す方式にしました。

具体的には:

  • model_function_call(call_id/name/args)を、[Assistant called tools: ...] のようなテキストへ
  • function_result[Tool result for ...] のようなテキストへ
    という形で、会話の整合性を保ちます。

実装のキモ④:Geminiの thought_signature(ここが地獄)

ここは本当に苦労しました。

thought_signature とは

GeminiのTool Callingでは、FunctionCallに thought_signature という“署名(バイナリ)”が付いてくることがあります。
普段は気にする必要がなくGemini側で自動で処理してくれるのですが、HTTP切断型では「次のリクエストでFunctionCallを履歴に復元する」必要があるため、このthought_signatureを 落とさずに持ち回す必要が出てきます。

これを落とすと、体感として以下が起きやすいです。

  • ツール結果を受理しない/関係ない返答になる
  • たまに壊れる(再現しにくい)
  • “会話が混線したように見える”症状が出る

なぜ難しいか(JSONと相性が悪い)

  • thought_signature は bytes(バイナリ)
  • こちらはツール呼び出し情報を JSONで保存/転送したい
  • bytesはそのままだと JSON serializable ではない
  • よって、保存時に Base64化、復元時に Base64デコード が必要

thought_signature の持ち回し(図解)

実装方針としてはこれだけです。

  1. GeminiからFunctionCallを抽出するときに、thought_signatureがあれば Base64にして一緒に渡す
  2. 次のリクエストでFunctionCallを履歴に復元するときに、Base64を bytesに戻して thought_signature に戻す

これを入れてから、「たまに壊れる」系が目に見えて減りました。逆に言うと、これが無いと検証が本当に辛いです。

切断型ループで“会話履歴”をどう扱うか(内部メッセージ)

切断型だと「ツール呼び出し」や「ツール結果」はUIに見せたくない一方、次ターンの文脈には必要です。
そこで model_function_call / function_result を内部メッセージとして履歴に残しつつ、表示や検索からは除外する、という形に寄せています。

まとめ:HTTP切断型の良いところ / 悪いところ

良いところ

  • サーバーは「中継・認証・課金」に集中できる(スケールしやすい)
  • ツール実行をクライアントに寄せられる(コスト・自由度)
  • “待ち”を設計で回避できる(長時間ツール、ローカル依存処理)

悪いところ(設計コスト)

  • 会話履歴の表現(特にツール呼び出し/結果)を丁寧に作らないと破綻する
  • Gemini / OpenAI で微妙に要件が違う(FunctionCall履歴、thought_signatureなど)
  • クライアント側の実装が増える(max_iterations、再実行、冪等性など)

最後に

QUTRIT(Windows版)を公開したので、興味がある方は触ってみてください。
「この方式、こうするともっと堅くなる」「このログ出すとデバッグ楽」などのフィードバックも大歓迎です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?