浮世絵壁紙の分類パイプラインにツールを 1 本足した翌朝、前の晩まで通っていた呼び出しが finishReason: MALFORMED_FUNCTION_CALL で返ってきておりました。足したのは色の分布を返すだけの小さなツールで、既存の宣言には指一本触れておりません。
最初に疑ったのは出力トークンの上限でした。次に、スキーマと自然言語の指示の衝突です。どちらも外れました——上限を倍にしても、指示文を空にしても、同じところで関数呼び出しが平文へ化けます。
行き着いたのは、足したツールの説明文でも引数の型でもなく、そのツールが持っていたプロパティの名前 でした。ひと文字の接尾辞を足すだけで、その晩のうちに止まりました。同じ袋小路に入っている方のために、絞り込みの手順と、私が引いた線引きを書き残します。
返ってきていたのは、エラーではなく平文でした
MALFORMED_FUNCTION_CALL は HTTP のエラーではありません。ステータスは 200 で返り、レスポンスの中身だけが壊れています。ここが切り分けを遅らせました。
実際に返ってきていたのは、こういう形です。
{
"candidates" : [
{
"content" : {
"parts" : [
{ "text" : "print(default_api.classify_palette(in= \" 新着/2026-09/ukiyoe-0431.jpg \" ))" }
],
"role" : "model"
},
"finishReason" : "MALFORMED_FUNCTION_CALL"
}
]
}
parts に functionCall がありません。代わりに、関数呼び出しを文字で書いたもの が text として入っています。モデルはツールを呼ぼうとして、呼び出しの形を組み立てきれず、途中で文章の側へ落ちたのです。
大事なのは、落ちたのが classify_palette だけではなかったことでした。同じターンで呼ばれるはずだった別の 2 本も、そろって平文になっていました。1 本の宣言の問題が、そのターン全体を巻き込みます。
壊れているのが 1 本なのに、症状は全部に出ます。だから「最後に足したツール」から疑うと外れます。
出力上限とスキーマ衝突は、先に外せます
名前を疑う前に、よく知られた 2 つの原因を落としておきます。どちらも数分で外せますので、順番を飛ばさないほうが結局は早く済みます。
上限を疑うとき
maxOutputTokens を十分に大きくして、同じ入力をもう一度投げます。上限が原因なら、ここで STOP に戻ります。戻らなければ上限は無関係です。思考予算を使うモデルでは、思考の分も同じ枠から引かれますので、余裕を持たせた値で試します。
指示文を疑うとき
システム指示とユーザー入力から、出力の形を縛る文(JSON で返せ、コードブロックで包め、といった類い)を一時的に外します。構造化出力とツール宣言の同時指定は、組み合わせによっては噛み合いません。この層の話は responseSchema の enum 指定なのに違う文字列が返る — Gemini API で起きる原因と回避策 に書いた内容と地続きです。
私はこの 2 つを外した時点で、原因が宣言そのものにあると腹をくくりました。ここから先が、この記事の本題です。
犯人は二分探索で 4 回に絞れます
宣言を 1 本ずつ外して試す方法は、9 本あれば最悪 9 回かかります。半分に割って試せば 4 回です。私は後者を使いました。
# tool_bisect.py — どのツール宣言が平文化を誘発しているかを絞り込みます。
# 実行前に、失敗が安定して再現する入力を 1 つ用意しておきます(たまに出る状態では絞れません)。
import os
from google import genai
from google.genai import types
from my_tools import ALL_DECLARATIONS # list[types.FunctionDeclaration]
client = genai.Client( api_key = os.environ.get( "GEMINI_API_KEY" , "YOUR_API_KEY" ))
MODEL = "gemini-3.8-flash"
PROMPT = "新着の壁紙を 1 枚分類して、結果をカタログへ書き戻してください。"
def reproduces (subset):
"""subset だけを渡したときに、関数呼び出しが平文へ化けるかどうかを返します。"""
resp = client.models.generate_content(
model = MODEL ,
contents = PROMPT ,
config = types.GenerateContentConfig(
tools = [types.Tool( function_declarations = subset)],
# 上限を切り分けから外すため、十分に大きい値を明示しておきます。
max_output_tokens = 4096 ,
),
)
return str (resp.candidates[ 0 ].finish_reason).endswith( "MALFORMED_FUNCTION_CALL" )
def bisect (decls):
"""再現する側だけを残して、半分ずつ落としていきます。"""
calls = 0
while len (decls) > 1 :
half = len (decls) // 2
left, right = decls[:half], decls[half:]
calls += 1
if reproduces(left):
decls = left
continue
calls += 1
if reproduces(right):
decls = right
continue
print ( "片側だけでは再現しません。2 本の組み合わせを疑ってください。" )
break
print ( "呼び出し回数:" , calls)
return decls
if __name__ == "__main__" :
suspects = bisect( list ( ALL_DECLARATIONS ))
print ( "犯人候補:" , [d.name for d in suspects])
このスクリプトが前提にしているのは、失敗が毎回再現すること だけです。たまにしか出ない状態のまま走らせると、再現しなかった側を無実と判定して、犯人を捨ててしまいます。私は先に同じ入力を 5 回投げて、5 回とも平文になることを確かめてから回しました。
片側でも反対側でも再現しないときは、単独犯ではありません。その場合は 2 本ずつの組み合わせに切り替えます。宣言の総数が少ないうちなら、総当たりでも現実的な回数で終わります。
書き換えたのは、たった一つのプロパティ名でした
絞り込みが止まったのは classify_palette でした。引数はファイルパスを 1 つ取るだけです。その引数の名前が in でした。
{
"name" : "classify_palette" ,
"parameters" : {
"type" : "object" ,
"properties" : {
"in" : { "type" : "string" , "description" : "分類する画像のパス" }
},
"required" : [ "in" ]
}
}
in を in_ に変えて、同じ入力をもう一度投げました。finishReason は STOP に戻り、3 本のツールはすべて本来の functionCall として返ってきました。説明文も型も required も、ほかは一文字も触っておりません。
手元で確かめるだけなら、宣言を 2 通り用意して差し替えるだけで足ります。
# 名前だけを変えた 2 通りを用意して、finishReason の違いを見ます。
BROKEN = { "name" : "probe" , "parameters" : { "type" : "object" , "properties" : { "in" : { "type" : "string" }}}}
FIXED = { "name" : "probe" , "parameters" : { "type" : "object" , "properties" : { "in_" : { "type" : "string" }}}}
print ( list ( BROKEN [ "parameters" ][ "properties" ]), list ( FIXED [ "parameters" ][ "properties" ]))
なぜ名前ひとつで、そのターン全体が壊れるのか
ここから先は、公式ドキュメントに書かれた説明ではありません。同じ症状を二分探索で追った報告 と、gemini-cli 側の関連 Issue で述べられている観察です。私が確かめられたのは症状と再現条件までで、内部の仕組みは推測の域を出ません。そのうえで、いちばん筋が通ると感じた説明を書きます。
モデルはツール宣言を、JSON スキーマのままではなく、関数の署名 として読み直しているようなのです。classify_palette(in="…") という平文が返ってきたことが、その傍証になります。Python では in は予約語ですので、この署名は言語として成り立ちません。成り立たない署名が一つ混ざると、そのターンで組み立てられる呼び出しがまとめて崩れる——症状が 3 本すべてに出たことと、これは噛み合います。
だとすると、線引きは単純になります。呼び出しの形に化けて現れるものは、呼び出しの形で書けない名前を嫌います。
この見立てが正しいかどうかは、正直なところ私には確かめようがありません。ただ、正しくなかったとしても、予約語を避けて困ることは何もないのです。ですから私は、原因の解明を待たずに名前の側を直す道を選びました。
スキーマに置かないと決めた語
その晩のうちに、置かないと決めた語を書き出しました。予約語だけでは足りません。関数の第一引数や組み込み名として読まれうる語も、同じ棚に入れています。
置かない名前 理由 私が使っている代わり
in / from / class / is / not / lambda / globalPython の予約語。署名として書けません source / origin / category / is_active
self / cls第一引数として解釈される余地があります target / owner
args / kwargs可変長引数の記法と紛れます options / extra
type / id / input / list / dict組み込み名。壊れはしませんが、読み替えの揺れを呼びます kind / item_id / payload
先頭が数字の名前・ハイフン入りの名前 識別子として成立しません アンダースコア区切りへ寄せます
in_ のような末尾アンダースコアは、Python 側で予約語を避けるときの古くからの書き方です。読み手にも意図が伝わりますので、私は新しい語を発明するよりこちらを推奨します。ただし外部 API のレスポンスをそのままスキーマに写している場合は、名前を変えた時点で写像がずれます。その場合は、宣言の側だけ改名して、実装の入口で元の名前へ戻すのがいちばん被害が小さく済みました。
宣言の手前で落とす小さなリンタ
一度直しても、次にツールを足す人(半年後の私を含みます)が同じ名前を置けば、また同じ朝が来ます。ですので、宣言を Gemini へ渡す前に落とす検査を入れました。
# schema_lint.py — ツール宣言を渡す前に、危ない名前を見つけて止めます。
# 起動時と CI の両方から assert_clean() を呼びます。
import keyword
RESERVED = set (keyword.kwlist) | set (keyword.softkwlist) | {
"self" , "cls" , "args" , "kwargs" , "type" , "id" , "input" , "list" , "dict" , "object" ,
}
class SchemaNameError ( ValueError ):
"""ツール宣言のプロパティ名が予約語と衝突したときに送出します。"""
def walk (schema, path):
"""properties・items・$defs を再帰的に辿り、見つけた名前と経路を返します。"""
if not isinstance (schema, dict ):
return
for name, child in (schema.get( "properties" ) or {}).items():
yield name, path + [name]
yield from walk(child, path + [name])
items = schema.get( "items" )
if items is not None :
yield from walk(items, path + [ "[]" ])
for key in ( "$defs" , "definitions" ):
for name, child in (schema.get(key) or {}).items():
yield from walk(child, path + [key, name])
def lint (declarations):
"""違反を (ツール名, 経路, 名前) の形で列挙します。"""
found = []
for decl in declarations:
for prop, path in walk(decl.get( "parameters" ) or {}, []):
if prop in RESERVED :
found.append((decl[ "name" ], "." .join(path), prop))
return found
def assert_clean (declarations):
"""違反が 1 件でもあれば、その場で止めます。"""
found = lint(declarations)
if found:
lines = [ " {} : {} が予約語 {} です" .format(t, p, n) for t, p, n in found]
raise SchemaNameError( "ツール宣言に使えない名前があります \n " + " \n " .join(lines))
if __name__ == "__main__" :
sample = [{
"name" : "search_catalog" ,
"parameters" : {
"type" : "object" ,
"properties" : { "in" : { "type" : "string" }, "limit" : { "type" : "integer" }},
},
}]
try :
assert_clean(sample)
except SchemaNameError as err:
print (err)
起動時にも呼んでいるのは、CI を通った後に設定ファイルからツールを足せる作りにしているためです。検査が走る場所を 1 か所に絞ると、その 1 か所を通らない経路が必ず生まれます。エラーを握り潰さず、その場で起動を止める設計にしている理由は、Gemini API のエラーを status で見分ける — 本番で止めない実装 で書いた方針と同じです。本番で静かに壊れるより、起動時にうるさく落ちるほうが安く済みます。
落とし穴
ネストの底まで辿らないと、半分しか見えません
最上位の properties だけを見る検査では、配列要素の中や入れ子のオブジェクトを取りこぼします。私は最初にそれをやって、2 日後に同じ症状で戻ってきました。items と $defs を辿るようにしてから、再発しておりません。
自動生成しているときは、むしろ安全です
Pydantic やデータクラスからスキーマを起こしている場合、Python 側で in というフィールドは書けませんので、この問題は起きにくくなります。危ないのは、外部仕様に合わせて JSON スキーマを手書きしている箇所です。混在しているコードベースでは、手書きの側だけを重点的に見ます。
名前の変換を挟んでいると、見える名前と渡る名前がずれます
camelCase と snake_case の変換をシリアライズ時に挟んでいると、リポジトリ上の名前と実際に渡る名前が一致しません。検査は、変換を通した後のスキーマに対して掛けます。私はここで一度、緑のまま壊れているという最悪の状態を作りました。
予約語なのは名前だけで、値は関係ありません
enum に "in" という値が入っていても症状は出ませんでした。直すべきはキーであって、値ではありません。値まで一括置換すると、今度は分類結果そのものが変わってしまいます。
次のアクション
まずは手元の宣言を 1 つ選んで、schema_lint.py の RESERVED に照らしてみてください。1 件も出なければ、この記事の内容は当面お守りとして置いておけば十分です。1 件でも出たなら、その名前は次にツールを足した日に効いてきます。
私は個人開発で複数のアプリとサイトを回しておりますので、朝いちばんに壊れているものを見つける時間がいちばん高くつきます。だからこそ、原因を突き止めたその日のうちに、次の自分が同じ道を辿らずに済む仕掛けを 1 つだけ置くようにしております。
最後までお読みいただき、ありがとうございました。同じ症状で夜を溶かしている方の、ひと晩分の近道になれば幸いです。