📝 この記事は forge.workstyle.tech に掲載した記事の転載です。
OpenAI互換の/v1/chat/completionsを使い、OpenAIのクラウドと自前のvLLMなどを切り替えています。ブラウザ操作が1回も実行されず、画面上ではモデルがツールを呼ばないように見えました。原因はreasoning_effortの扱いが接続先ごとに異なり、400の本文を読まずにツール非対応と判定していたことでした。
① クラウドは、ツールと設定の組み合わせを断っていた
最初の断りは、ツールとreasoning_effortを同時に送ったときでした。接続先はgpt-5.6-lunaです。返ってきたのは400で、本文にはこうありました。
Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.
アプリは本文のtoolとnot supportedを見て、「このモデルはツールに非対応」と覚えていました。以後、ツールそのものをリクエストから外します。
これが冒頭の誤判定です。ブラウザ操作の依頼をしても、ツールを渡していないので操作は1回も実行されません。モデルがツールを呼ばないように見えても、そもそも呼ぶ機会がありませんでした。
本文には直し方も書いてありました。ツールを使うなら/v1/responsesを使うか、reasoning_effortを'none'にする。アプリはその部分を読まず、ツールを捨てる判定を先にしていたのです。
直し方は、断りをツールの対応可否として覚える前に読むことでした。ツールを持ったままreasoning_effort: 'none'を付けて、1回やり直します。代わりに、このモデルがツールを使う回は考えるのを止めます。
速さだけを見れば、この変更には魅力がありました。reasoning_effort: 'none'にすると、会話は入力から発話の始まりまで4〜5秒だったものが2秒になりました。
② 自前のvLLMは、値そのものを断った
同じ設定をツール呼び出しの経路にも入れると、自前のvLLMから400が返りました。
Input should be 'low', 'medium' or 'high'
エラーの種類はliteral_errorです。欄が存在しないのではなく、noneという値を受け付けないという断りでした。
会話だけを試していると、ここで止まります。会話は速くなるので、うまくいったように見えました。ツール呼び出しの経路を試して初めて、接続先によって値の受け取り方が違うと分かりました。
この接続先では、値の拒否を見分けて、以後reasoning_effortを送りません。思考を切るために、代わりにchat_template_kwargs: { enable_thinking: false }を送ります。
実測値も並べてみました。
| 思考の切り方 | 所要時間 |
|---|---|
| 何もしない | 87秒 |
/no_think |
154秒 |
enable_thinking=false |
65秒 |
reasoning_effort none |
2秒 |
数字は端末内のモデルで測ったものです。方法ごとの差は大きく、設定名が似ているだけで動作まで揃うわけではありませんでした。
そのうえ、思考の出力欄も揃っていません。名前はreasoning、reasoning_content、thinkingの3つです。落とす側は3つを知っていたのに、読む側はreasoningしか見ていませんでした。
vLLMはreasoning_contentに入れます。自前のvLLMへ切り替えたとたん、分類は5回連続で「応答が空」になりました。モデルは思考を返していたのに、読む側がその欄を見ていなかったので、思考の量すら数えられなかったのです。
③ 中継サーバーは、逐次のときだけ空を返した
3つ目は、逐次応答の扱いでした。
stream: trueを無視して普通のJSONを返す接続先がありました。アプリはdata:で始まらない行を全部捨てます。結果として切れ端は0個でした。response.okなので例外も出ず、画面には何も出ません。
ここはcontent-typeを見て、逐次応答か普通のJSONかを判定するように直しました。
それで終わりではありません。ある中継サーバーの接続先は、逐次のときだけ200を94msで返し、中身は空でした。逐次でない呼び出しは400で断ります。空の応答には、エラー本文のような手掛かりがありません。
この接続先では、空を例外にします。思考の指定を外して1回やり直し、それ以降は送らないようにしました。指定を外して送り直すと、327msで正しいJSONが返りました。
接続先ごとの実測を表にした
| 接続先の挙動 | 送るもの |
|---|---|
| 普通(OpenAIなど) | reasoning_effort: 'none' |
値の一覧にnoneがない(vLLM、400で断る) |
chat_template_kwargs: { enable_thinking: false } |
| 逐次で空を返す(中継サーバー) | 何も送らない |
どれも実測で決めました。利用者には訊きません。接続先側の応答を見て、アプリが覚えます。
自分で入れた回帰
接続先ごとの違いを覚えさせる修正を入れたあと、今度は私が別の失敗を作りました。
回帰1:400の本文を広く読みすぎた
値の拒否を見分ける判定が、invalidという文字も見ていました。本文にinvalid_request_errorが入る400なら、どのエラーでも同じ扱いになります。
その結果、gpt-5.6-lunaを「reasoning_effortの欄が無い接続先」と覚えてしまいました。①で必要だったnone指定まで止まり、ブラウザ操作が丸ごと落ちました。
原因は、試験用のエラー文面を私が作っていたことです。実際の断りと少し違えば、判定のずれを見つけられません。記録に残した本文を、そのまま試験に使うようにしました。
回帰2:自前向けの設定をクラウドにも送った
声の会話では、思考を自動で切る設定を入れていました。その設定を公式のクラウドにも効かせたため、OpenAIにnoneを送るようになりました。
返事を作る依頼だけが毎回60秒止まり、500になりました。4回とも同じでした。OpenAIの稼働状況は「正常」でした。
コードには元から、「クラウドのプロバイダは同じ鍵を別の意味で使う」と書いてありました。接続先ごとの意味を見ずに、共通の設定として広げてしまったのは私です。自前の提供者だけに効かせるように直しました。
別件として、断られた欄を外してやり直す仕組みにも穴がありました。1回の発話につき、やり直しは1回だけです。temperatureとfrequency_penaltyの両方を断られると、1回目の発話が黙りました。
断られるたびに外してやり直すようにしました。8回で止めます。1文字でも返した後に落ちた場合は、やり直しません。
まとめ
- 400の本文を読み、実際の断りを試験に使う。
- 接続先ごとに
reasoning_effortの値の受け取り方を確認する。 - vLLMで
noneが拒否されたら、chat_template_kwargs: { enable_thinking: false }を使う。 - 逐次応答が空なら思考の指定を外して再試行し、その接続先では以後送らない。
- 接続先ごとの実測に合わせて設定を適用する。
関連記事
- https://forge.workstyle.tech/blog/jev-vs-local-models-routing-1000/
- https://forge.workstyle.tech/blog/h100-only-3b-for-conversation/
- https://forge.workstyle.tech/blog/free-llm-api-rate-limits-fallback/