🦙

OllamaのJSONモードと思考機能の相互作用

に公開

OllamaのPythonライブラリが提供するchat関数は、formatパラメータを通じてモデルの出力形式を制御する機能を備えています。本記事では、このパラメータがどのように機能し、思考(thinking)機能とどう相互作用するのかの調査結果をまとめたものです。

OllamaのPythonライブラリからOllamaサーバー内部のllama.cppまでの処理フローを追跡します。

https://github.com/ollama/ollama-python

https://github.com/ollama/ollama

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スキーマオブジェクト)のみを受け付けます。

ただしJsonSchemaValueDict[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文法へ変換されます。

https://zenn.dev/7shi/articles/c8c631bb8f31de

生成された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.cppllama_grammar_apply_impl関数で、以下の3段階の処理が行われます:

  1. 各トークンを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);
  1. 文法ルールに対してトークンを検証 (llama-grammar.cpp:1163):
const auto rejects = llama_grammar_reject_candidates(grammar.rules, grammar.stacks, candidates_grammar);
  1. 無効なトークンの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の出力を誘導的に制約するアーキテクチャとなっています。

  1. JSONモードは思考分離機能と排他的です。 これはGBNF文法の技術的制約に起因するものであり、両機能を併用したい場合は、JSONスキーマ内に思考内容を格納するフィールドを明示的に定義するか、リクエストを2段階に分けるアプローチが必要です。

  2. formatパラメータはクライアントからサーバーへ透過的に伝達されます。 クライアント側では型検証のみが行われ、実際の処理はすべてサーバー側で実行されます。

この調査により、クライアントライブラリの型安全性から、サーバー内部の2段階アーキテクチャ、そしてGBNFによる出力制御に至るまで、Ollamaエコシステム全体のformatパラメータに関する包括的な理解が得られました。

関連記事

構造化出力の効率的な利用方法を探るため、複数のLLMを対象に5つの異なる指示形式を比較します。

https://zenn.dev/7shi/articles/20250704-structured-output

GitHubで編集を提案

Discussion