在庫APIを10商品ぶん叩いて、在庫が5個未満のものだけ抜き出し、その一覧をSlackに投げる。エージェントにやらせると、10回ぶんのJSONレスポンスが丸ごとモデルのコンテキストを通過する。使うのは「available_units」の数値だけなのに、だ。しかも1往復ごとにモデルの推論が走るので、レイテンシも課金も往復回数に比例して膨らむ。
この構造的なムダに対して、OpenAIが7月9日にResponses APIへ入れた回答が Programmatic Tool Calling である。ひとことで言うと、モデルにツールを直接呼ばせるのをやめて、ツールを呼ぶJavaScriptを書かせる。
「ツールを呼ぶ」から「ツールを呼ぶコードを書く」へ
従来のfunction callingは、モデルが get_inventory(sku="A") という関数呼び出しを1個出力し、アプリ側が実行して結果をモデルに返す、というピンポンの繰り返しだった。10商品なら最低10往復。
Programmatic Tool Callingでは、モデルはこう書く。
const skus = ["A", "B", "C", /* ... */];
const results = await Promise.all(skus.map(s => get_inventory({ sku: s })));
const low = results.filter(r => r.available_units < 5);
そしてこのプログラムがOpenAI側のランタイムで走る。Promise.all による並列呼び出しも、filter による絞り込みも、ループも条件分岐も、すべてモデルの外側で完結する。モデルのコンテキストに戻ってくるのは最終結果だけだ。往復もトークンも、扱うデータ量ではなく「タスクの段数」で決まるようになる。
OpenAIのAPIチェンジログでは、同日に explicit prompt caching controls、persisted reasoning、max reasoning effort、Pro mode がまとめて追加されたと記載されている。GPT-5.6ファミリー(Sol / Terra / Luna)の投入と同じタイミングで、エージェント実行まわりの土台が一段更新された形になる。
有効化は2行、ただし output_schema が肝
設定はツール定義に足すだけだ。ホストツールとして programmatic_tool_calling を並べ、各ツールに「誰が呼んでいいか」を書く。
tools = [
{
"type": "function",
"name": "get_inventory",
"allowed_callers": ["programmatic"],
"output_schema": {
"type": "object",
"properties": {
"sku": {"type": "string"},
"available_units": {"type": "number"},
},
"required": ["sku", "available_units"],
},
},
{"type": "programmatic_tool_calling"},
]
allowed_callers の指定は3通りある。
| 指定 | 挙動 |
|---|---|
省略 または ["direct"]
|
モデルが直接呼ぶ従来どおりの動作 |
["programmatic"] |
生成されたプログラム内からのみ呼べる |
["direct", "programmatic"] |
両方 |
見落としやすいのが output_schema だ。従来のfunction callingで必須なのは入力側の parameters だけで、戻り値のスキーマは書かなくても動いた。人間が読む前提のJSONが返ってくればモデルが解釈してくれるからだ。しかしプログラムを書かせるとなると話が変わる。モデルは r.available_units というプロパティアクセスを実行前に書き切らなければならず、フィールド名が違えば undefined を掴んで静かに壊れる。ドキュメントが output_schema を「生成されたJavaScriptから信頼してフィールドを参照できるようにするもの」と位置づけているのはそのためで、ここは事実上の必須項目と考えたほうがいい。
呼べるツールは function / custom / mcp / apply_patch と、ローカル・ホスト両方の shell、code_interpreter。既存のMCPサーバーがそのままプログラムから叩ける点は実務上おいしい。
「コードが動く」と言われて期待するものは、たぶん動かない
ここは冷静に読む必要がある。プログラムが走るのは毎回まっさらな隔離V8ランタイムで、トップレベル await は使えるが、それ以外はかなり削られている。ドキュメントが明示的に「無い」と書いているものを並べると、Node.js、パッケージインストール、直接のネットワークアクセス、汎用ファイルシステム、サブプロセス実行、console、そして実行間の状態保持。
つまり axios も fs も console.log も無い。外の世界と触れる唯一の口は、こちらが allowed_callers で許可したツールだけだ。これは制約であると同時に設計そのもので、ネットワークもファイルシステムも無いV8はサンドボックス脱出の面積が小さく、データがモデルの重みやログを経由しない。OpenAIがこの機能をZDR(Zero Data Retention)互換と言えるのは、この「何も無さ」の裏返しである。
デバッグ体験は素直に厳しくなる。console が無いので、プログラム内で何が起きたかを覗くには、ログ相当のことをするツールを自分で1個生やすくらいしか手が無い。レスポンスの output 配列には生成されたJavaScriptを含む program アイテム、プログラムが発行した function_call(caller.caller_id が親プログラムの call_id と一致する)、最終結果の program_output が並ぶので、事故ったときはまず program の中身を読むことになる。なお関数結果を返すときは caller フィールドを改変せずそのまま返す必要がある。サービス側がどのプログラムを再開すべきか判定できなくなるからだ。
63%と98.7%、同じ「コードモード」で桁が違う理由
この発想自体はOpenAIの発明ではない。Anthropicは2025年11月のCode execution with MCPで同じ方向を提示していて、そこでの数字は150,000トークンが2,000トークンになる、98.7%削減だった。一方でOpenAI側が今回示しているのは、あるケースで直接ツール呼び出し比 63.5%のトークン削減・50.1%の往復削減という水準で、桁が違う。
差を「どちらかが盛っている」で片付けるとポイントを外す。両者は解いている問題が微妙にずれている。Anthropicの記事が主眼に置くのはツール定義そのものの肥大化で、数百個のツール定義を先読みさせる代わりに、エージェントがファイルシステムを探索して必要な定義だけ読む(progressive disclosure)。98.7%はそこが効いた数字だ。対してOpenAIのProgrammatic Tool Callingが潰しているのは主に中間結果の往復で、ツール定義は最初からリクエストに載っている。ちなみにOpenAIは定義肥大化の側にはTool Search(gpt-5.4以降)という別の機能を当てていて、役割分担が分かれている。
自分の実装でどちらの効果が出るかは、ボトルネックがどっちかで決まる。ツールが5個しかなくて1回の呼び出しが巨大なJSONを返すなら、効くのはProgrammatic Tool Callingのほう。逆にツールが200個あって各レスポンスは小さいなら、先に見るべきはTool Searchだ。数字を鵜呑みにして「コードモードにすれば9割減る」と見積もると、まず外す。
入れるべきか
導入コストは低い。既存のfunction定義に output_schema を足して allowed_callers を付けるだけで、プロンプトの書き換えはほぼ要らない。段階移行もしやすく、まずは ["direct", "programmatic"] にしておいてモデルの判断に任せ、ログを見てから絞ればいい。
一方で、これはアプリ側のオーケストレーション責務をモデルに寄せる変更でもある。今までPythonで書いていた「並列に叩いて絞り込んで整形する」部分が、モデルが毎回生成するJavaScriptに置き換わる。決定的でない中間層が1枚増えるわけで、金額計算や権限判定のような外したくないロジックをそこへ流し込むのは筋が悪い。生成コードに任せるのはあくまでデータの取得・絞り込み・集約までにして、判断そのものは自前のツール側に閉じ込めておく。この線引きだけ最初に決めておけば、コストとレイテンシの改善はかなり素直に取りにいけるはずだ。