OpenAI の SDK をそのまま Ollama に向けて、response_format に JSON Schema を渡し、モデルの出力を JSON に固定する。よくある使い方です。ところが reasoning_effort に "none" を添えた瞬間、返ってくるのが JSON ではなく英語の文章になり、受け取った側の json.loads() が落ちることがあります。
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
原因はあなたのスキーマでも SDK でもありません。Ollama サーバの版です。reasoning_effort:"none" は Ollama の内部で「思考(thinking)を切る」指定に変換され、古いサーバではこのとき response_format の制約が最後まで適用されません。同じスクリプトが、サーバの版を上げるだけで通ります。
結論:サーバを 0.31.2 以上に上げる
直すのはクライアントではなくサーバです。コードは変更しません。
docker rm -f ollama # 稼働中の旧コンテナを止めて削除する(名前は docker ps で確認)
docker run -d --name ollama \
-v ollama:/root/.ollama \
-p 127.0.0.1:11434:11434 \
ollama/ollama:0.31.2
この docker run は、既存コンテナが名前付き volume ollama を使い、追加の設定を持たない構成を前提にした例です。GPU 指定(--gpus)や別のマウント、環境変数(-e)を使っているなら、先に docker inspect ollama で今の設定を控え、それらを新しい docker run に引き継いでください(引き継がないと、モデルの置き場所や GPU 設定を失います)。Docker Compose で動かしているなら、docker rm -f はせず、image: のタグを ollama/ollama:0.31.2 に書き換えて作り直すのが安全です。
-p にホスト側 IP を書かないと Docker は全インターフェース(0.0.0.0)へ公開します。ローカルで使うだけなら 127.0.0.1: を付けて loopback に閉じてください。モデルは名前付き volume ollama に残るので、同じ volume を使う限り再ダウンロードは要りません。作り直したら curl http://127.0.0.1:11434/api/version で版を確かめます。
再現するコード
OpenAI SDK を Ollama の /v1 に向け、response_format(JSON Schema)と reasoning_effort:"none" を同時に送るだけで再現します。スキーマに反する出力をモデルに促すため、プロンプトでは「JSON を使うな」と指示しました。response_format が守られていればスキーマが勝ち、無視されればプロンプトが勝つ、という切り分けです。
import json
from openai import OpenAI
SCHEMA = {
"type": "object",
"properties": {
"answer": {"type": "string", "enum": ["ok"]},
"count": {"type": "integer", "enum": [7]},
},
"required": ["answer", "count"],
"additionalProperties": False,
}
PROMPT = (
"Do not use JSON. Answer as one friendly English sentence. "
"The answer value should be ok and the count should be 7."
)
client = OpenAI(base_url="http://127.0.0.1:11434/v1", api_key="ollama")
resp = client.chat.completions.create(
model="qwen3.5:9b",
messages=[{"role": "user", "content": PROMPT}],
response_format={
"type": "json_schema",
"json_schema": {"name": "answer", "schema": SCHEMA},
},
reasoning_effort="none",
temperature=0,
max_tokens=64,
)
content = resp.choices[0].message.content
print("raw content:", repr(content))
data = json.loads(content)
assert data == {"answer": "ok", "count": 7}, data
print("FORMAT_OK", data)
ollama/ollama:0.31.1 のサーバに対して実行すると、content には自然文が入り、次の行の json.loads() が JSONDecodeError を送出して終了コード 1 になります。ollama/ollama:0.31.2 に向ければ、同じスクリプトが {"answer": "ok", "count": 7} を返して終了コード 0 で終わります。api_key は Ollama では検証されないので、任意の文字列で構いません。
なぜ /v1 でも起きるのか:翻訳層を通ると /api/chat と同じ要求になる
Ollama の /v1/chat/completions は、OpenAI 形式のリクエストを Ollama ネイティブの /api/chat の形へ翻訳してから処理します。この翻訳で、あなたが送った2つのパラメータは次のように読み替えられます(0.31.1 の openai/openai.go で確認しました)。
response_format: {"type": "json_schema", ...}→ ネイティブのformat(JSON Schema による出力制約)。json_schemaの中で読まれるのはschemaフィールドだけで、nameは無視されます。reasoning_effort: "none"→ ネイティブのthink。"none"のときだけthink=falseになり、"low"/"medium"/"high"/"max"は thinking を有効にする側へ回ります。
ここで注意したいのは、Ollama の /v1 が reasoning_effort に受理する値が、openai/openai.go の中で high / medium / low / max / none の5つに限られている点です。このうち "none" だけが think=false(思考を明示的に切る)に対応し、ほかの4つは thinking を有効にする側へ回ります。範囲外の値を送ると 400 で弾かれます。
thinking モデルから速く JSON を取り出したくて reasoning_effort:"none" を付けた——その指定こそが、翻訳を通ると think=false になり、古いサーバで response_format を無視させる引き金です。これは、ネイティブ API で format と think=false を同時に送ったときに起きる不具合と、入口が違うだけの同一事象です。ネイティブ API 側で踏んだ場合の詳しい機構は、姉妹記事「Ollama で think=false を送ると format が無視される」で扱っています。
版の境界
errfix の検証ハーネスで実測した2点です。モデルもスクリプトも固定し、入れ替えたのはサーバの版だけです。
| Ollama サーバ | response_format + reasoning_effort:"none" | 返ってくる content |
|---|---|---|
ollama/ollama:0.31.1 | スキーマが無視される | 自然文 |
ollama/ollama:0.31.2 | スキーマどおり | { "answer": "ok", "count": 7 } |
境界は 0.31.1 と 0.31.2 のあいだにあります。修正は Ollama の server/routes.go に入った PR #15901 で、v0.31.1…v0.31.2 の差分に入っています。v0.31.2 のリリースノートの次の1行が、この修正を指しています。
Fixed structured output for thinking models when thinking is disabled
翻訳層の openai/openai.go ではなく、その下流でスキーマ制約をいつ適用するかを決める server/routes.go の変更なので、/api/chat と /v1 の両方が同じ版で直ります。この不具合は、手元では出ないのに古い推論サーバに向けたときだけ出ます。OpenAI SDK(openai パッケージ)の版を上げても直りませんし、pip freeze にも痕跡が残りません。Docker Compose でタグを固定したまま共有している推論サーバだけが落ちる、という形になりやすいところです。
temperature:0 で測っており、0.31.1 が返した自然文はネイティブ API で同じ条件を送ったときと同一の文字列でした。あらゆる入力・あらゆる環境で同じ文字列が返るという意味ではありません。判定に使うのは文面の一致ではなく、JSON としてパースでき、スキーマに合うかどうかです。
切り分け
contentが空文字で返る:これは版境界の不具合とは別です。thinking を有効にしたままmax_tokens(ネイティブのnum_predict)を絞ると、思考が生成枠を使い切って本文に一文字も到達せず、finish_reasonがlengthで返ります。reasoning_effort:"none"を外して thinking を許した場合に起きやすく、0.31.2 に上げても枠が足りなければ続きます。見るべきは版ではなく生成枠です。reasoning_effort:"none"を外したら JSON が返るようになった:これは翻訳層の写像から筋の通った挙動です。think=falseになるのは"none"のときだけなので、"none"以外の値(またはreasoning_effort自体を送らない)にすれば thinking が有効になり、古いサーバでもresponse_formatが適用される側に回ります。ただし thinking のぶん応答は遅く(CPU 推論では姉妹記事のように十数分かかる例もあります)、生成枠も食います。速さのために思考を切りたいなら、回避策で粘るよりサーバの版を上げるのが確実です。response_formatを渡していないのに JSON が返らない:それは構造化出力ではなくプロンプトの問題で、この記事の対象外です。まずresponse_formatを渡しているか、json_schemaの中にschemaが入っているかを確認してください。reasoning_effortが 400 で弾かれる:Ollama が受理しない値(空文字や別の語など)を送っています。受理されるのはhigh/medium/low/max/noneの5つです。- ネイティブの
/api/chatを直接叩いている:response_format/reasoning_effortではなくformat/thinkを送っているなら、入口は違っても同じサーバ側の不具合です。姉妹記事「Ollama でthink=falseを送るとformatが無視される」を参照してください。直し方は同じくサーバの版上げです。
検証環境
ollama/ollama:0.31.1(再現)/ollama/ollama:0.31.2(消滅)- モデル
qwen3.5:9b、GPU 無し(CPU 推論) - Python クライアント
openai==2.45.0 - リクエスト
POST /v1/chat/completions、stream: false、response_format(json_schema)+reasoning_effort:"none"、temperature:0、max_tokens:64
再現から修正までは errfix の検証ハーネスが機械的に確認しています。再現時にシグネチャが出ること、修正後に消えること、終了コードが 1 から 0 に変わることの3点です。翻訳層の写像(response_format→format、reasoning_effort:"none"→think=false)は 0.31.1 の openai/openai.go を、上流の版・PR・機構は GitHub の issue・PR・タグ差分・ソースで裏を取っています。