ポイントは、署名が function call の part に紐づいて返ってくる という点です。モデルがツールを呼ぶと決めたとき、その functionCall part には、なぜそのツールを選んだのかという思考の文脈が署名として付随します。私たちがツールを実行して結果をモデルに戻すとき、この署名付きの part を会話履歴にそのまま含めて返さないと、モデルは「なぜ自分がそのツールを呼んだのか」という文脈を失った状態で次の推論を始めてしまいます。
差分はわずか数行ですが、model_content をそのまま積むことで parts[0] に含まれる thoughtSignature が次のリクエストへ運ばれます。引数を読みたいときは元 part を参照するだけにとどめ、決して parts を作り直さないのが鉄則です。
複数の part が返るときの落とし穴
もう一段、実戦で引っかかる点があります。思考モデルは 1 つの応答の中に、思考テキストの part、複数の function call の part を混在させて返すことがあります。並列ツール呼び出し(parallel function calling)を許可していると、parts 配列に複数の functionCall が並びます。
このとき、署名は すべての function call part のうち、原則として最初の part にまとめて付与される ことが多く、後続の part には付かないこともあります。だからこそ「最初の part だけ署名付きで再構築すればいい」という近道は危険です。どの part に署名が乗っているかはモデルの応答次第なので、part 単位で抜き出して並べ替える実装は署名の対応関係を壊しかねません。
# ❌ 並列 function call を自前で並べ替えると署名の対応が壊れるcalls = [p["functionCall"] for p in model_content["parts"] if "functionCall" in p]# この時点で thoughtSignature の所属情報は失われている
ストリーミング応答を使っている場合、署名はチャンクの途中ではなく、function call が確定する part が出そろったタイミングで届きます。私が最初にストリーミングを組んだとき、テキストチャンクだけを連結して捨てる実装にしていたため、署名どころか function call の part 自体を取りこぼしていました。
ストリーミングでは、各チャンクの parts を順に蓄積し、最終的に 1 つの content オブジェクトへ組み立て直す必要があります。このとき、thoughtSignature を含むキーをすべて保持したままマージするのがコツです。
# ✅ ストリーミングでも part をキーごと保持してマージmerged_parts = []for chunk in stream: for p in chunk["candidates"][0]["content"].get("parts", []): merged_parts.append(p) # text/functionCall/thoughtSignature をそのまま蓄積model_content = {"role": "model", "parts": merged_parts}contents.append(model_content)
テキストの結合だけに気を取られると、署名と function call を同時に失います。ストリーミングを採用する本番エージェントでは、ここを必ずテストで固めておくと安心です。
検証で役立ったのは、リクエスト直前に contents を JSON で書き出し、thoughtSignature というキーが function call の part に存在するかを毎ターン確認するチェックでした。
import jsondef assert_signature_preserved(contents): for c in contents: if c.get("role") != "model": continue for p in c.get("parts", []): if "functionCall" in p and "thoughtSignature" not in p: print("⚠️ signature missing on:", json.dumps(p["functionCall"])[:80])
長く動かすエージェントでは、会話履歴が際限なく伸びてコストとレイテンシが膨らみます。そこで古いターンを切り詰める処理を入れたくなるのですが、ここにも署名がらみの罠があります。私は最初、単純に「古い 6 ターンを削除する」というスライディングウィンドウを入れたところ、削除の境界がツール呼び出しの途中に来てしまい、function call の part だけ残って対応する functionResponse が消える、あるいはその逆が起きて、応答が不安定になりました。
署名は function call の part に乗っているため、切り詰めの単位を「ターン」ではなく「完結したツール往復のかたまり」に揃える必要があります。つまり、model の function call とそれに対応する user の functionResponse は、必ずセットで残すか、セットで消すかのどちらかにします。片方だけ残ると、署名と結果の整合が崩れてモデルが混乱します。
# ✅ ツール往復をセット単位で切り詰めるdef prune_history(contents, keep_recent_pairs=4): head = contents[:1] # 最初の user メッセージは残す rest = contents[1:] # functionCall を含む model content と直後の functionResponse をペアとして数える pairs = [] i = 0 while i < len(rest): c = rest[i] has_fc = c.get("role") == "model" and any("functionCall" in p for p in c.get("parts", [])) if has_fc and i + 1 < len(rest): pairs.append(rest[i:i+2]) # 署名付き model + functionResponse をセットで保持 i += 2 else: pairs.append([c]) i += 1 kept = [c for grp in pairs[-keep_recent_pairs:] for c in grp] return head + kept
本番では一時的なエラーやレート制限に備えてリトライを入れます。このとき注意したいのは、リトライ対象が「モデル呼び出し」なのか「会話全体の作り直し」なのかという区別です。私は当初、エラー時に会話を最初から組み立て直す素朴なリトライを書いていたのですが、組み立て直しの過程で part を再構築してしまい、せっかく保持していた署名を失っていました。
正しくは、リトライ時も contents 配列はそのまま使い回し、失敗した最後のリクエストだけを再送します。会話状態は不変オブジェクトとして扱い、エラーハンドリングのコードからは決して part を書き換えないようにします。
import timedef call_with_retry(contents, tools, max_attempts=3): for attempt in range(max_attempts): try: return call_model(contents, tools) # contents は一切加工しない except TransientError as e: if attempt == max_attempts - 1: raise time.sleep(2 ** attempt) # 指数バックオフ