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を提供するときに悩ましいのが、ざっくり言うと次の両立です。
- APIキーをクライアントに置きたくない(SaaSとしては当たり前)
- でも、ツール実行(例:ローカルにあるRAG参照等)をサーバーに全部寄せると重い/遅い/コスト高
- さらにストリーミング中にツールが走ると、サーバー側で「待ち」が発生しがち
そこで、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 の持ち回し(図解)
実装方針としてはこれだけです。
- GeminiからFunctionCallを抽出するときに、thought_signatureがあれば Base64にして一緒に渡す
- 次のリクエストでFunctionCallを履歴に復元するときに、Base64を bytesに戻して thought_signature に戻す
これを入れてから、「たまに壊れる」系が目に見えて減りました。逆に言うと、これが無いと検証が本当に辛いです。
切断型ループで“会話履歴”をどう扱うか(内部メッセージ)
切断型だと「ツール呼び出し」や「ツール結果」はUIに見せたくない一方、次ターンの文脈には必要です。
そこで model_function_call / function_result を内部メッセージとして履歴に残しつつ、表示や検索からは除外する、という形に寄せています。
まとめ:HTTP切断型の良いところ / 悪いところ
良いところ
- サーバーは「中継・認証・課金」に集中できる(スケールしやすい)
- ツール実行をクライアントに寄せられる(コスト・自由度)
- “待ち”を設計で回避できる(長時間ツール、ローカル依存処理)
悪いところ(設計コスト)
- 会話履歴の表現(特にツール呼び出し/結果)を丁寧に作らないと破綻する
- Gemini / OpenAI で微妙に要件が違う(FunctionCall履歴、thought_signatureなど)
- クライアント側の実装が増える(max_iterations、再実行、冪等性など)
最後に
QUTRIT(Windows版)を公開したので、興味がある方は触ってみてください。
「この方式、こうするともっと堅くなる」「このログ出すとデバッグ楽」などのフィードバックも大歓迎です。