エージェントループにおける GLM 5.2 の Tool Call:「OpenAI 互換」が隠していること
目次
既存の OpenAI スタイルのエージェントループを GLM 5.2 に向けると、ほとんどはそのまま動く。tools を送れば tool_calls が返り、それを実行して結果を送り返す。だが、SDK のサンプルには出てこない挙動がひとつある。アシスタントが tool call と同じターンで 1 行のテキストを返してくるのだ。
{
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": "I'll look up both pieces of information for you at the same time!",
"tool_calls": [
{"id": "call_…", "type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}},
{"id": "call_…", "type": "function",
"function": {"name": "get_time", "arguments": "{\"city\":\"Tokyo\"}"}}
]
}
}]
}
主流の流儀は 2 つあり、両方を頭に入れておくと役に立つ。OpenAI 流では、function スキーマを送ると tool_calls が返り、各 call に対して tool_call_id で紐づけた tool メッセージで応答する。
resp = openai.chat.completions.create(model="…", tools=tools, tool_choice="auto", messages=messages)
# assistant.tool_calls → [{"id": "call_…", "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}}]
messages.append(resp.choices[0].message)
messages.append({"role": "tool", "tool_call_id": "call_…", "content": "18C, clear"})
Anthropic 流は形が違う。tool は input_schema を持ち、モデルは tool_use ブロックを出力し、tool_result ブロックで応答する。
resp = anthropic.messages.create(model="…", tools=tools, messages=messages)
# resp.content → [{"type": "tool_use", "id": "toolu_…", "name": "get_weather", "input": {"city": "Paris"}}]
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_…", "content": "18C, clear"}]})
GLM 5.2 は OpenAI 方言を話す。
OpenAI の契約では、finish_reason が tool_calls のとき message.content は null になる。多くのエージェントループはこれを前提にしている。「content か tool call か」で分岐させたり、content を最終回答としてログに記録したり、空であることをアサートしたりする。GLM は両方を同時に返してくるので、まずこの前提が崩れる。
ここで示した挙動は、glm-5.2 への実際の tool-calling リクエストから採取したものだ。比較対象として、同じタスクを gpt-5.5 と claude-opus-4-8 でも実行した。要点はこうだ。GLM 5.2 は OpenAI の API サーフェスを使うが、いくつかの軸では GPT より Claude に近い挙動をする。そして躓くのは、OpenAI 向けに作られたループのほうだ。
同じやり取りを 3 つの方法で
同じプロンプト、同じ 2 つのツール、3 つのモデルで試した結果。
GLM (glm-5.2) | OpenAI (gpt-5.5) | Anthropic (claude-opus-4-8) | |
|---|---|---|---|
| API 形式 | OpenAI chat-completions | OpenAI chat-completions | Anthropic messages |
| ツール呼び出しターンのテキスト | content に前置き(non-null) | content は null | tool_use の前に text ブロック |
| そのターンでの reasoning | 露出する:reasoning_content + reasoning_tokens | 隠れている。usage 内の reasoning_tokens のみ | 有効化すれば thinking ブロックとしてのみ |
| 並列ツール呼び出し | 可能、index 付き | 可能 | 可能、複数の tool_use ブロック |
| 完了シグナル | finish_reason: "tool_calls" | finish_reason: "tool_calls" | stop_reason: "tool_use" |
| ツール呼び出し id のプレフィックス | call_… | call_… | toolu_… |
ループが壊れるのはこの 2 行だ。ツール呼び出しターンに含まれるテキストと、そのターンに出てくる reasoning。残りは安心できるくらい退屈な内容だ。
テキストがツール呼び出しに同乗する
GLM 5.2 は tool_calls と一緒に短い assistant の content 前置きを日常的に出力し、finish_reason: "tool_calls" を返す。これはエラーではないし、たまに起きるものでもない。
同じターンを 3 つすべてから抜き出し、違いのある部分だけに絞ったものがこれだ。
// OpenAI gpt-5.5: content is null on a tool-call turn
"message": { "content": null,
"tool_calls": [ {/* get_weather */}, {/* get_time */} ] }
// GLM glm-5.2: content carries a preamble
"message": { "content": "I'll look up both pieces of information for you at the same time!",
"tool_calls": [ {/* get_weather */}, {/* get_time */} ] }
// Anthropic claude-opus-4-8: a text block sits before the tool_use blocks
"content": [ { "type": "text", "text": "I'll get both pieces of information for you." },
{ "type": "tool_use", /* get_weather */ },
{ "type": "tool_use", /* get_time */ } ]
OpenAI は content を null のままにし、GLM はそこを埋め、Anthropic は昔から text ブロックを置いてきた。つまり GLM は OpenAI のワイヤーフォーマットに、行動の前に語るという Anthropic の習慣を載せている。だから OpenAI 向けに書いたループが不意を突かれる。修正は小さいが、意識してやらなければならない。ツール呼び出しターンに content がないと決めつけるのをやめることだ。
resp = client.chat.completions.create(model="glm-5.2", messages=msgs, tools=tools)
msg = resp.choices[0].message
# GLM may return assistant text in the same turn as the tool calls.
if msg.content:
log.debug("preamble: %s", msg.content) # keep or drop, but don't assume it's empty
msgs.append(msg)
for call in msg.tool_calls:
result = dispatch(call.function.name, json.loads(call.function.arguments))
msgs.append({"role": "tool", "tool_call_id": call.id, "content": result})
ループが content を assistant の返答としてユーザーに表示しているなら、これからはツール呼び出しのたびに「ちょっと確認しますね」のような一文が出るようになる。それを出したいかどうかは自分で決めればいい。要は、その判断を自分で下すということだ。モデルの沈黙に任せて決まってしまうものではない。
思考を声に出すモデル
GLM 5.2 は推論モデルで、tool 呼び出しのときも推論を止めない。tool 呼び出しのターンには推論が一緒に乗ってくるが、GLM 5.2 はそれをテキストとして出す。非ストリーミングのレスポンスでは、token の集計を見ればそれがはっきり分かる。
"usage": {
"prompt_tokens": 224,
"completion_tokens": 68,
"completion_tokens_details": { "reasoning_tokens": 30 },
"total_tokens": 292
}
見えている出力は短い関数呼び出しが 2 つだけのリクエストなのに、completion のほぼ半分が推論だった。ここが 3 つのモデルの分かれ目になる。GLM 5.2 は推論を reasoning_content として、token 数とともに返す。OpenAI は usage で reasoning_tokens を課金するが、テキストは一切見せない。Anthropic は thinking ブロックとしてのみ見せるが、それも extended thinking をオンにしたときだけだ。デフォルトでは GLM 5.2 が 3 つの中で最も推論をさらけ出している。
ここから 2 つの帰結が出てくる。1 つはコストだ。tool 呼び出しのターンごとに推論 token の料金を払うことになり、エージェントのループは多くのターンを回す。この数字を動かすつまみが reasoning effort で、GLM 5.2: Reasoning Effort がコストのレバーになる で扱った。最終回答だけでなく、ターンごとに推論 token を数えること。
2 つめはストリーミングの順序だ。リクエストをストリーミングすると、GLM はまず推論を送り、次にプリアンブルのテキスト、最後に tool 呼び出しを送ってくる。
reasoning_content (many deltas)
content (a few deltas)
tool_calls (id + name, then arguments)
素の OpenAI chat completions に合わせて書いたパーサーは reasoning_content フィールドを知らないので、この冒頭のひとかたまりを黙って無視する。たいていは問題ない。問題になるのは、最初の content デルタをトリガーにして UI が「thinking…」状態を表示している場合だ。回線に最初に流れてくるのは推論であって content ではないので、インジケーターが切り替わらない。
GLM 5.2 の tool 呼び出し 1 ターンのコスト
挙動は話の半分でしかなく、もう半分は請求額だ。エージェントのループは同じターンを何度も繰り返す。固定のプレフィックス(約 2,000 token のシステムプロンプトと tool 定義)を置き、ユーザーメッセージを毎回変えて、ウォーム状態の 10 ターンを計測した結果がこれだ。
| warm な tool 呼び出し 1 ターンあたり | GLM glm-5.2 | OpenAI gpt-5.5 | Anthropic claude-opus-4-8 |
|---|---|---|---|
| コスト | $0.0009 | $0.0042 | $0.0051 |
| レイテンシ(中央値) | 6.6s | 1.9s | 3.1s |
| プロンプトのキャッシュ率 | ≈96% | ≈81% | ≈97% |
| 推論 token | ≈27 | 0 | 0 |
| cold → warm のコスト比 | 3.4× | 2.8× | 4.9× |
GLM 5.2 は安い。warm なターンあたりで GPT-5.5 のおよそ 4.5 分の 1、Opus の 5.4 分の 1 だ。一方で遅くもある。レイテンシは他の 2 つの 2〜3.5 倍で、これはこのタスクで他の 2 つが推論 token を使わなかったのに対し、GLM はターンごとに推論 token を使うからだ。これがトレードオフで、GLM はレイテンシと引き換えにコストを買っている。その動かしどころが reasoning effort だ。
ループの中でこれらが現実的なコストに収まるのは、キャッシュのおかげだ。システムプロンプトと tool 定義は毎ターンのプロンプトの大部分を占め、毎回同じなので、プレフィックスがキャッシュされればターンのコストは 2.8〜4.9 倍安くなる。キャッシュが効くかどうかは 2 つの要因で決まる。GLM と OpenAI はプレフィックスを自動でキャッシュするが、Anthropic は cache_control でマークしたものだけをキャッシュする。さらに GLM のキャッシュは温まるのが少し遅れるので、3 ステップのタスクは満額を払う一方で、30 ステップのタスクはキャッシュが効いた状態で走る。その仕組みは Open-Weight LLM のキャッシュ にまとめてある。
GLM 5.2 を選ぶ場面と、うまく動かすコツ
ここまでの話をまとめる。GLM 5.2 は先ほどの表で安くて遅いモデルであり、しかもターンごとに必ず推論する。この特性が、どこで活きるかを示している。
向いている場面は、コストが支配的で、ターンあたり数秒の遅延を許容できる長いマルチステップの agent ループだ。バックグラウンドで動くコーディング agent、CI やバッチ自動化、無人で走るジョブが当てはまる。遅さの原因である推論こそが、単純なルーティングではなく実際のコーディングや計画立案でも崩れない理由でもある。ウォームアップを過ぎるとキャッシュが効果を積み増す。30 ステップのタスクなら prefix が償却されて安く走るが、3 ステップだと全額を払ったうえに遅延だけ食らって何も得られないこともある。だから長いジョブには GLM 5.2 を選び、ターンあたり 6 秒が体感されるインタラクティブな単発の呼び出しには、より速いモデルを残しておく。
GLM 5.2 をうまく動かす方法。次の 5 つの習慣を押さえれば、OpenAI API の枠を離れずにループを GLM 対応にできる。
- tool-call のターンが
contentを持ちうるものとして扱う。空だと決めつけない。 - ワイヤ上には
reasoning_content、usageにはreasoning_tokensが来ると見込む。両方の分を確保し、reasoning-effort のつまみで品質とコストを調整する。 - streaming では、最初の content デルタを基準に UI の状態を切り替えない。推論が先に届くからだ。
tool_call_idはそのまま返す。中身は不透明なものとして扱い、パースも再生成もしない。- streaming の
argumentsは、呼び出しが閉じるまでindex単位で蓄積する。チャンク数を前提にしない。
防御しなくていいことが 2 つある。GLM は他と同じく index 付きで並列の tool call を出すし、往復は正常に閉じる。assistant のターンを追加し、各呼び出しごとに結果を載せた tool メッセージを 1 つずつ追加すれば、finish_reason: "stop" で終わる。そのついでに、キャッシュ可能な prefix をターンをまたいでバイト単位で安定させておく。system prompt と tool 定義はどのプロンプトでも大部分を占めるので、prefix が安定していれば GLM のキャッシュがウォームになった後にコストを肩代わりしてくれる。
特殊なことは何もない。「リクエストが成功する」と「agent ループが正しい」の差があるだけで、GLM ではその差はほぼ 2 つの思い込みに収まる。tool-call のターンは無言だという思い込みと、考えていないという思い込みだ。この 2 つを捨てて prefix を安定させれば、1 つのループで GLM、GPT、Claude を同じように回せる。そして遅延が最適化の対象でない限り、GLM がそれをわずかなコストでこなす。
注意事項
上記のコスト、レイテンシ、キャッシュの数値は、2026-06-30 に glm-5.2、gpt-5.5、claude-opus-4-8 を対象として、モデルごとに 10 回のウォームな tool-call ターンで測定したものだ。コストは報告された usage に基づき、レイテンシは実時間の中央値で、負荷や reasoning effort によって変動する。モデルの挙動や価格は変わっていくので、これらの数値はあくまで目安として扱い、依存する前に自分のトラフィックで測り直してほしい。