OllamaのJSONモードと思考機能の相互作用
OllamaのPythonライブラリが提供するchat
関数は、format
パラメータを通じてモデルの出力形式を制御する機能を備えています。本記事では、このパラメータがどのように機能し、思考(thinking)機能とどう相互作用するのかの調査結果をまとめたものです。
OllamaのPythonライブラリからOllamaサーバー内部のllama.cppまでの処理フローを追跡します。
JSONモードと思考機能の排他性
調査における最も重要な発見は、format='json'
とthink=True
が技術的な制約により同時に機能しない、排他的な関係にあるという点です。
JSON出力が指定されると、GBNF文法に従ってトークン生成の確率分布そのものを制御し、文法的に無効なトークンの生成確率を実質的にゼロ(-INFINITY)にすることで、出力形式を保証します。これは<think>
のようなJSON外のタグを許可しないため、モデルは思考タグを生成すること自体ができません。
実験結果に見る挙動の違い
この排他的な関係は、実際のレスポンスに明確に現れます。
format
を指定せずthink=True
のみを使用した場合、思考プロセスは正しく分離されます。
curl -X POST http://localhost:11434/api/generate -d '{
"model": "qwen3:4b", "think": true, "prompt": "日本の首都はどこですか?"
}'
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:04.4680973Z","response":"","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:04.5046244Z","response":"","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:04.5208294Z","response":"","thinking":"Okay","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:04.5369929Z","response":"","thinking":",","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:04.5680974Z","response":"","thinking":" the","done":false}
(中略)
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.6245094Z","response":"","thinking":" simple","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.6424321Z","response":"","thinking":" and","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.6603707Z","response":"","thinking":" accurate","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.6782082Z","response":"","thinking":".\n","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.6961351Z","response":"","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.7139707Z","response":"","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.7315473Z","response":"日本の","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.7496859Z","response":"首都","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.7666905Z","response":"は","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.7852244Z","response":"**","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.8033094Z","response":"東","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.8291408Z","response":"京","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.8469255Z","response":"**","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.8660335Z","response":"です","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:19:09.8844849Z","response":"。","done":false}
(以下略)
一方、format='json'
とthink=True
を併用した場合、直接回答が始まり、thinking
フィールドは現れません。
curl -X POST http://localhost:11434/api/generate -d '{
"model": "qwen3:4b", "format": "json", "think": true, "prompt": "日本の首都はどこですか?"
}'
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.7103131Z","response":"{\n","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.7292524Z","response":"{\n ","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.7459582Z","response":" \"","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.7627667Z","response":"answer","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.7795919Z","response":"\":","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.7962283Z","response":" \"","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.8130185Z","response":"日本の","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.8290173Z","response":"首都","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.8596342Z","response":"は","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.8771681Z","response":"東","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.8939219Z","response":"京都","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.910949Z","response":"です","done":false}
{"model":"qwen3:4b","created_at":"2025-07-01T04:28:47.9277607Z","response":"。\",\n","done":false}
(以下略)
思考の分離はOllamaサーバー側で行われます。thinking/parser.go:57-74
func (s *Parser) AddContent(content string) (string, string) {
s.acc.WriteString(content)
var thinkingSb, remainingSb strings.Builder
var thinking, remaining string
keepLooping := true
// we loop because we might pass through multiple parsing states in a single
// call to addContent, and we want to make sure callers don't have to wait for
// data that's already unambiguous
for keepLooping {
thinking, remaining, keepLooping = eat(s)
thinkingSb.WriteString(thinking)
remainingSb.WriteString(remaining)
}
return thinkingSb.String(), remainingSb.String()
}
ここで呼び出されているeat
は生のテキストストリームからタグを抜き出す実装です。JSONの構造を解析して値の中に埋め込まれたタグを抽出することは想定されていません。
代替手法
思考プロセスの代替として、スキーマでreasoning
などのフィールドを持たせる方法が考えられます。
{
"reasoning": "思考プロセス...",
"answer": "東京"
}
この場合、スキーマでのフィールドの定義順が結果に影響を及ぼすと考えられます。reasoning
フィールドを先に定義すれば、answer
フィールドの前に理由を思考することになります。reasoning
フィールドが回答の後に定義されると、直感的に与えられた回答に対して後付けで説明を生成することになります。
別の方法として、リクエストを2段階に分ける方法があります。最初のリクエストではformat
を指定せずに自由に生成させて、次のリクエストでその結果をJSON形式に書き換えます。
format
パラメータの基本仕様
クライアント側におけるformat
パラメータの型は、ollama/_types.py:154で以下のように定義されています。
class BaseGenerateRequest(BaseStreamableRequest):
options: Optional[Union[Mapping[str, Any], Options]] = None
'Options to use for the request.'
format: Optional[Union[Literal['', 'json'], JsonSchemaValue]] = None # 154行目
'Format of the response.'
keep_alive: Optional[Union[float, str]] = None
'Keep model alive for the specified duration.'
BaseStreamableRequest
の継承関係をたどればPydanticのBaseModel
に行き着きます。Pydanticは型アノテーションを検証するため、format
パラメータはNone
または空文字列、JSON出力を強制する'json'
、そしてより複雑な構造を定義するためのJsonSchemaValue
(JSONスキーマオブジェクト)のみを受け付けます。
ただしJsonSchemaValue
はDict[str, Any]
型の辞書として定義されているだけなので、JSONスキーマとしての妥当性は検証されません。
- 例:
{'invalid': 'schema'}
→ 受け入れられる(辞書型のため)
クライアントからサーバーへの処理フロー
format
パラメータがどのように処理されるかを、クライアント側とサーバー側に分けて見ていきましょう。
1. クライアント側処理:透過的なパラメータ送信
Pythonライブラリでは、format
パラメータは特別な変換を受けることなく、そのままリクエストに組み込まれます。ollama/_client.py:346-355
json=ChatRequest(
model=model,
messages=list(_copy_messages(messages)),
tools=list(_copy_tools(tools)),
stream=stream,
think=think,
format=format,
options=options,
keep_alive=keep_alive,
).model_dump(exclude_none=True),
2. サーバー側処理:GBNF文法への変換
Ollamaサーバーは、メインプロセスとrunner
プロセスから成る2段階HTTPリクエストアーキテクチャを採用しており、APIリクエストの受付と実際のLLM推論を分離しています。
クライアントからformat
パラメータを受け取ったメインサーバーは、server/routes.go:1539のハンドラでリクエストが処理されます。
if err := r.Completion(c.Request.Context(), llm.CompletionRequest{
Prompt: prompt,
Images: images,
Format: req.Format, // 1539行目
Options: opts,
}, func(r llm.CompletionResponse) {
llm/server.go:739-759でその値を解釈します。
if len(req.Format) > 0 {
switch string(req.Format) {
case `null`, `""`:
// Field was set, but "missing" a value. We accept
// these as "not set".
break
case `"json"`:
req.Grammar = grammarJSON
default:
if req.Format[0] != '{' {
return fmt.Errorf("invalid format: %q; expected \"json\" or a valid JSON Schema object", req.Format)
}
// User provided a JSON schema
g := llama.SchemaToGrammar(req.Format)
if g == nil {
return fmt.Errorf("invalid JSON schema in format")
}
req.Grammar = string(g)
}
}
-
format
が'json'
の場合、定義済みのJSON用GBNF文法(grammarJSON
)が適用されます。 - JSONスキーマが指定された場合は、
llama.SchemaToGrammar
関数によって動的にGBNF文法へ変換されます。
生成されたGBNF文法はrunner
プロセスrunner/llamarunner/runner.go:572のサンプリングパラメータに設定され、LLMの出力を直接的かつ厳密に制御します。
// Extract options from the CompletionRequest
samplingParams := llama.SamplingParams{
TopK: req.Options.TopK,
TopP: req.Options.TopP,
MinP: req.Options.MinP,
TypicalP: req.Options.TypicalP,
Temp: req.Options.Temperature,
RepeatLastN: req.Options.RepeatLastN,
PenaltyRepeat: req.Options.RepeatPenalty,
PenaltyFreq: req.Options.FrequencyPenalty,
PenaltyPresent: req.Options.PresencePenalty,
Seed: uint32(req.Options.Seed),
Grammar: req.Grammar, // 572行目
}
runnerプロセスでのGrammar処理の詳細
runnerに渡されたGrammarパラメータは、以下のようにLLMの出力を制約します。
1. サンプリングコンテキストへの受け渡し
llama/llama.go:550-554でGrammar文字列がC++層に渡されます:
grammar := C.CString(params.Grammar)
defer C.free(unsafe.Pointer(grammar))
cparams.grammar = grammar
context := &SamplingContext{c: C.common_sampler_cinit(model.c, &cparams)}
2. トークンサンプリング時の文法適用
llama/llama.cpp/common/sampling.cpp:346-348,367,377で、各トークン生成時に文法チェックが行われます:
sampling.cpp:346-347行目 - grammar_firstモードの場合、文法を最初に適用
if (grammar_first) {
llama_sampler_apply(grmr, &cur_p);
}
sampling.cpp:367行目 - トークンが文法に適合するかチェック
const bool is_valid = single_token_data_array.data[0].logit != -INFINITY;
sampling.cpp:377行目 - 無効な場合の再サンプリング
llama_sampler_apply(grmr, &cur_p);
3. 文法による制約の実装
llama/llama.cpp/src/llama-grammar.cppのllama_grammar_apply_impl
関数で、以下の3段階の処理が行われます:
- 各トークンをUTF-8文字列にデコード (llama-grammar.cpp:1145-1147):
const std::string piece = grammar.o_vocab ?
grammar.o_vocab->token_to_piece(id) :
grammar.vocab->token_to_piece(id);
- 文法ルールに対してトークンを検証 (llama-grammar.cpp:1163):
const auto rejects = llama_grammar_reject_candidates(grammar.rules, grammar.stacks, candidates_grammar);
- 無効なトークンのlogitを負の無限大に設定 (llama-grammar.cpp:1164-1166):
for (const auto & reject : rejects) {
cur_p->data[reject.index].logit = -INFINITY;
}
この仕組みにより、GBNF文法に違反するトークンは生成不可能となり、JSONモードでは<think>
タグのような非JSON構造が出力されることはありません。
結論
一連の動作は、Pydanticによる型安全性を確保し、クライアントからサーバーへパラメータを透過的に渡し、GBNF文法を用いてLLMの出力を誘導的に制約するアーキテクチャとなっています。
-
JSONモードは思考分離機能と排他的です。 これはGBNF文法の技術的制約に起因するものであり、両機能を併用したい場合は、JSONスキーマ内に思考内容を格納するフィールドを明示的に定義するか、リクエストを2段階に分けるアプローチが必要です。
-
format
パラメータはクライアントからサーバーへ透過的に伝達されます。 クライアント側では型検証のみが行われ、実際の処理はすべてサーバー側で実行されます。
この調査により、クライアントライブラリの型安全性から、サーバー内部の2段階アーキテクチャ、そしてGBNFによる出力制御に至るまで、Ollamaエコシステム全体のformat
パラメータに関する包括的な理解が得られました。
関連記事
構造化出力の効率的な利用方法を探るため、複数のLLMを対象に5つの異なる指示形式を比較します。
Discussion