本記事は個人環境での検証と、公開されているドキュメントの読解に基づく個人的な整理です。技術的な確認と記事の執筆は Opus 5 と共に進めています。
はじめに
ローカル LLM で動かしている Hermes Agent を日本語対応させたい。既定の hermes3:8b は Llama 3.1 ベースで、公式サポート言語 8 つに日本語は入っていません。日本語の得意なモデルに替えるだけの話のはずでした。
有力候補は Gemma 3 でした。128K コンテキスト、140 以上の言語をサポート、12B なら VRAM 16GB に収まります。日本語性能は申し分ありません。
ところが使えませんでした。日本語性能ともパラメータ数とも無関係で、Ollama のモデルページに tools バッジが付いていないからです。
「日本語が得意なモデルを選べばよい」という前提が崩れ、Ollama の capability とは何を見ているのかを実機で追うことになりました。本記事はその記録です。辿り着いたのは次の 3 点です。
- capability の正体は、チャットテンプレートまたは内蔵実装の静的な検査結果である
-
RENDERER/PARSERを実行しているのはモデルではなく Ollama である - 公式ドキュメントに記載がない領域があり、実測でしか分からない挙動がある
対象読者は、ローカル LLM でエージェントを動かしていて、モデル選定で似た壁にぶつかった方を想定しています。
tools capability というバッジ
ollama.com のモデルページには、そのモデルが何をできるかを示すバッジが表示されます。tools / vision / thinking / embedding などです。
冒頭の Gemma 3 と、比較用に Mistral NeMo を並べると差がはっきりします。
| モデル | バッジ | コンテキスト | サイズ(12B) |
|---|---|---|---|
| gemma3 | Text, Image のみ | 128K | 8.1GB |
| mistral-nemo | tools | 1000K | 7.1GB |
同じページ形式でありながら、mistral-nemo には tools が付き、gemma3 には付きません。Gemma 3 が function calling 用の特殊トークンを持たず、Google 自身もプロンプトベースの関数呼び出しを案内しているためです。
エージェントはツール実行が動作の前提です。日本語がいくら得意でも、tools がなければ土俵に上がれません。モデルを探す段階からこのバッジでフィルタするのが確実です。
capability を判別する
ローカルに入れたモデルを確認する
手元のモデルの capability は API から取得できます。
curl -s http://localhost:11434/api/tags
出てくる capability 名を先に整理しておきます。
| capability | 意味 |
|---|---|
completion |
プロンプトからテキストを生成できる。基本の capability で、生成モデルなら必ず付く |
tools |
本記事の主題。ツール定義を受け取り、呼び出しを返せる |
thinking |
思考過程を本文と分けて返せる |
vision |
画像を入力として受け取れる |
embedding |
ベクトル埋め込み専用。この場合は completion が付かない |
整形すると次のようになります。
gemma3:270m family=gemma3 ctx=32768 caps=['completion']
hermes3:8b family=llama ctx=131072 caps=['completion', 'tools']
nemotron-3-nano:4b family=nemotron_h ctx=262144 caps=['completion', 'tools', 'thinking']
gemma3:270m は completion のみ、つまりテキスト生成しかできません。ちなみに 270m を選んだのは、この後の検証用に最小の非対応モデルが欲しかったためです(292MB)。
thinking にも触れておきます。これが付いたモデルは、思考過程を content とは別のフィールドに分けて返します。nemotron-3-nano に日本語で投げたときの実際の応答です(content は長いので省略)。
{"message":{"role":"assistant",
"content":"東京の現在の天気:\n- 温度 31℃\n- 天気状態: 晴れ …",
"reasoning":"The current weather in Tokyo is 31°C, clear skies (晴れ),
and humidity at 72%. I'll present this information clearly."}}
<think> タグのような形で本文に混ざらないので、クライアント側で除去する必要がありません。この分離を担当しているのも、後の章で見る PARSER です。
非対応モデルに tools を渡すとどうなるか
この挙動は Ollama 公式ドキュメントにも DeepWiki にも記載がありません。以下は筆者の実測結果です。
そもそも、なぜ tools を付けて投げるのでしょうか。
エージェントは「モデルに使わせたい道具の一覧」を、毎回のリクエストに JSON Schema として含めます。Hermes Agent も例外ではなく、こちらが何も指定しなくても、バンドルされているスキルのツール定義を毎リクエストに積んで送ります。つまりエージェントから使う限り tools 付きが常態で、これが通らなければ何もできません。
ここでは Ollama に直接リクエストを投げて、その挙動を切り分けます。
実験1: tools なし(対照実験)
curl -s -w "\n%{http_code}\n" -X POST http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" -d '{
"model": "gemma3:270m",
"stream": false,
"messages": [{"role": "user", "content": "Say hi in one word."}]
}'
// HTTP 200
{"choices":[{"message":{"role":"assistant","content":"Hello!\n"},"finish_reason":"stop"}]}
正常に応答します。モデル自体は生きています。
実験2: 同じモデルに tools を付ける
messages はそのままに、天気取得ツールの定義を tools として追加しただけのリクエストです。
curl -s -w "\n%{http_code}\n" -X POST http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" -d '{
"model": "gemma3:270m",
"stream": false,
"messages": [{"role": "user", "content": "What is the weather in Tokyo?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
}]
}'
// HTTP 400
{"error":{"message":"registry.ollama.ai/library/gemma3:270m does not support tools",
"type":"invalid_request_error","param":null,"code":null}}
実験3: ネイティブエンドポイントでも同じか
実験2 と同じリクエストボディを /api/chat に投げます。
curl -s -w "\n%{http_code}\n" -X POST http://localhost:11434/api/chat \
-H "Content-Type: application/json" -d '{ ... 実験2 と同じ ... }'
// HTTP 400
{"error":"registry.ollama.ai/library/gemma3:270m does not support tools"}
ツール定義を黙って無視するのではなく、リクエスト段階で拒否されます。無視される仕様だったら、エージェント側からは「なぜかツールを使ってくれないモデル」にしか見えません。プロンプトやツール定義を疑って時間を取られます。エラーで即座に落ちるほうが、原因を特定しやすい設計です。
対照実験を並べた意味もここにあります。tools を外せば 200 で応答するので、「モデルが壊れている」のではなく「tools だけが拒否されている」と切り分けられます。
細かい点を 2 つ。メッセージ本文は両エンドポイントで同一ですが、レスポンスの構造が違います。OpenAI 互換は type を持つ error オブジェクト、ネイティブは文字列ひとつです。クライアント側のエラーハンドリングで効いてきます。またモデル名は、ローカルのタグ名ではなく registry.ollama.ai/library/... という完全修飾で返ります。
Modelfile に現れる二つの実装方式
capability がどこから来るのかを追うため、Modelfile を見ます。
nemotron-3-nano の場合
ollama show --modelfile nemotron-3-nano:4b
出力の大半は NVIDIA Open Model License で、技術的な部分は 6 行しかありません。
FROM <blob>
TEMPLATE {{ .Prompt }}
RENDERER nemotron-3-nano
PARSER nemotron-3-nano
PARAMETER top_p 1
PARAMETER temperature 1
FROM の値はモデルの実体(重みファイル)への参照です。Ollama はダウンロードしたモデルを ~/.ollama/models/blobs/ にブロブとして置き、SHA256 ダイジェストで管理します。中身は GGUF 形式で、テンソル(重みの数値)とメタデータが並んだデータファイルです。この記事では以降「GGUF ブロブ」と呼びます。
そのうえで、TEMPLATE はプロンプトを素通しするだけで、代わりに RENDERER と PARSER という見慣れない行があります。
hermes3:8b の場合
hermes3:8b では、まったく違うものが出てきます。ollama show --template のほうが読みやすいので、そちらを引用します。
{{- if or .System .Tools }}<|im_start|>system
{{- if .Tools }}
You are a function calling AI model. You are provided with function signatures
within <tools></tools> XML tags. ...
{{- range .Tools }}
{"type": "function", "function": {{ .Function }}}
{{- end }} </tools> ...
{{- else if .System }}
{{ .System }}
{{- end }}<|im_end|>
Go の text/template です。ここに 4 つの仕事が全部書かれています。
-
{{- if .Tools }}— ツールが渡されたときだけ発動する分岐 -
You are a function calling AI model...— プロトコルをモデルに教える前置き -
{{- range .Tools }}— ツールスキーマをプロンプト本文のテキストとして展開 -
<tool_call></tool_call>— 出力フォーマットの指定
ツール定義は API の別チャネルではなく、プロンプトに埋め込まれるテキストです。Hermes Agent が最低 64,000 トークンのコンテキストを要求する「固定プレフィックス」の正体がこれです。
ツール結果を戻す経路も定義されています。
{{- else if eq .Role "tool" }}<|im_start|>user
<tool_response>
{{ .Content }}
</tool_response><|im_end|>
ツール結果が user ロールとして描画されている点に注目してください。モデルに「tool」という役割の概念はありません。OpenAI API の role: "tool" は API 層の抽象で、テンプレートがモデルの理解できる形に平坦化しています。
なお、このテンプレートの冒頭の分岐は排他です。if .Tools が成立すると .System は描画されません。Hermes Agent は常にツールスキーマを送るので、SOUL.md に書いた日本語指示が毎回落ちていることになります(テンプレートを読んで気づいた点で、実測はしていません)。hermes3:8b でシステムプロンプトが効かないと感じたら、ここを疑う価値があります。
テンプレート方式は中身が読めるので、こうした挙動を自分で追えるという利点があります。これは後で効いてきます。
ここまでで、capability の判定根拠がモデルによって違うことが分かります。テンプレート方式は .Tools 参照の有無、内蔵方式は PARSER 行の存在です。
非対応の gemma3:270m には、.Tools の分岐も .Role "tool" の分岐もありません。ツール定義を伝える場所も、結果を戻す経路も、そもそも存在しません。
RENDERER と PARSER を実行しているのは誰か
RENDERER nemotron-3-nano という行を見て、筆者は最初「モデル自身が再帰的に処理しているのか」と考えました。違います。実行しているのは Ollama で、モデルは何も実行していません。
実行ファイルの中に Go の型が入っている
Ollama の実行ファイルから文字列を抽出します。
strings ollama.exe | grep -i nemotron
*renderers.Nemotron3NanoRenderer
*parsers.Nemotron3NanoParser
!*parsers.Nemotron3NanoParserState
github.com/ollama/ollama/model/renderers/renderer.go
github.com/ollama/ollama/model/parsers/parsers.go
Nemotron3NanoRenderer と Nemotron3NanoParser は Go の構造体型で、ソースファイルのパスまで残っています。Ollama リポジトリのコードが実行ファイルに焼き込まれている直接の証拠です。名前で実装を引く仕組みも関数名として見つかります(renderers.rendererForName / parsers.ParserForName)。
モデルはそもそも実行できる形式ではない
file ollama.exe
file ~/.ollama/models/blobs/sha256-527db2cf...
ollama.exe → PE32+ executable for MS Windows, x86-64 (36.5 MB)
モデルのblob → data (2.8 GB)
ollama.exe は実行可能形式、モデルの実体は ただの data です。GGUF はテンソルとメタデータが並んだデータファイルであって、命令列を含みません。データは自分自身を処理できません。
時系列で考えると再帰はあり得ない
論理的にも成立しません。
RENDERER はモデルより前に動きます。 レンダラの出力がモデルの入力だからです。モデルがレンダリングを担当するなら、自分が動く前に動いていなければなりません。
PARSER はモデルより後に動きます。 モデルが出力し終えたトークン列を読むので、実行完了が前提です。
そしてモデルは tools[] という JSON 配列を一度も見ていません。モデルの目に入る時点で、すでに平坦なトークン列です。
名前が同じことが混乱の原因
RENDERER nemotron-3-nano の値は、モデルへの参照ではなく、どの Go 実装を使うかを選ぶ識別子です。
動画ファイルに例えると分かりやすいでしょう。コンテナには codec: h264 と書かれていますが、動画データが自分をデコードしているわけではありません。プレイヤー側のデコーダが、その名前で選ばれるだけです。名前がモデル名と一致しているのは、その書式がモデルファミリ専用だからにすぎません。
層で整理します。
| 層 | 実体 | 役割 |
|---|---|---|
① ollama.exe
|
Go プログラム | HTTP API、RENDERER、PARSER、スケジューラ |
② llama-server
|
推論エンジンのサブプロセス | トークン列 → トークン列 |
| ③ GGUF ブロブ | データ | 重み(テンソル) |
RENDERER と PARSER は①に住んでいます。モデルは③です。
二つの方式を図にすると、違いはこうなります。図の入口にある system は、エージェントがモデルの人格や方針を決めるために毎回渡すシステムプロンプトです。Hermes Agent の場合は ~/.hermes/SOUL.md の内容がこれにあたり、冒頭で触れた日本語指示もここに書きます。tools[](道具の一覧)、messages[](会話履歴)と並んで、リクエストを構成する 3 つ目の要素です。
差が出るのは ① 組み立てと ② 解析の 2 箇所だけで、入口・モデル推論・出口は同一です。そして①②を実行しているのはどちらも Ollama です。違いは、書式の定義が Modelfile のテキストとして外にあるか、ollama.exe に Go コードとして焼き込まれているか、それだけです。
なぜ内蔵実装が必要になったのか
テンプレートで済んでいたものを、なぜ Go コードに移したのか。
公式の設計文書は見つかりませんでしたが、Issue #7014(2024年9月)にテンプレート方式の限界が 3 点挙げられています。
- Content Loss — ツール呼び出しを抽出すると、周辺のモデル出力が捨てられる
- No Streaming — 応答が完全に揃うまで処理できず、逐次解析ができない
- Limited Format — JSON のみ対応で、XML などが扱えない
そして実行ファイルに含まれる関数名からは、実際にどんな処理をしているかが読み取れます。
壊れた出力の修復
parsers.repairGemma4MissingObjectClose
parsers.repairGemma4MissingStringDelimiter
parsers.repairGemma4SingleQuotedValues
parsers.quoteGemma4BareKeys
parsers.repairGLM46XML
閉じ括弧の欠落、シングルクォート、引用符のないキー。モデルは JSON を壊した形で出力するので、Ollama が修復しています。テンプレートは文字列を差し込むだけのエンジンで、復旧処理は書けません。
ストリーミング中の分断への対処
parsers.maybePartialTag
parsers.longestOverlap
parsers.Nemotron3NanoParserState
<tool_call> のようなタグが、ストリーミングのチャンク境界で <tool_c / all> に割れます。扱うにはステートマシンが要ります。型名に ...ParserState が並んでいるのはそのためです。テンプレートは状態を持てません。
JSON ですらない形式
parsers.parsePythonArgs
parsers.pythonLiteralToJSON
一部のモデル(Qwen3-Coder など)は、関数呼び出しを Python の構文で出力します。それを JSON に変換しています。
つまり内蔵化は一部のモデルを優遇しているのではなく、テンプレートの表現力が足りなくなった結果です。
代償もあります。テンプレートなら ollama show --template で全文を読め、前章の排他分岐のような挙動も自分で追えました。内蔵実装にはそれができません。中身を確認するには Ollama のソースを読むしかなくなります。
ツール結果はどう戻るのか
ここまでは往路の話でした。ツールを実行した結果は、そのままモデルに返却されるのでしょうか。
いいえ、ツール結果も必ず RENDERER を通ります。専用の関数があります。
renderers.writeToolResult ← ツール結果を書式化する関数
renderers.writeToolsSection ← ツール定義を書式化する関数
一方、入力方向を扱うパーサ関数は見つかりません。RENDERER と PARSER は非対称です。
| 入力方向(往路) | 出力方向(復路) | |
|---|---|---|
| 担当 | RENDERER | PARSER |
| 対象 | system / user / assistant / ツール結果 / tools[] | モデルが出力した生テキスト |
| 該当関数 |
writeToolsSection、writeToolResult
|
tool_calls 抽出、thinking 分離、JSON 修復 |
ツール結果はモデルへ渡す入力なので RENDERER の担当です。PARSER は一切関与しません。
「戻る」のではなく「もう一度送る」
図で subgraph を 2 つに分けたのには理由があります。Ollama は会話の状態を持ちません。
モデルがツール結果を待って止まっている状態は存在しません。ターン 1 の推論は tool_calls を出力した時点で終了しています。ターン 2 は、全履歴(user + assistant の tool_calls + tool 結果)とツール定義を組み立てて送る、まったく新しいリクエストです。
天気取得ツールのモックで往復させたところ、ターン 2 の prompt_tokens は 384 でした。差分ではなく全体が再トークン化されています。モデルから見れば、ターン 2 は「自分の過去の発言を含む、少し長い書き起こしを初めて読む」状態です。
だからエージェントのループが進むほどプロンプトが伸び続けます。Hermes Agent が MINIMUM_CONTEXT_LENGTH = 64_000 を要求し、履歴圧縮を持っているのはこのためです。ローカル LLM でコンテキスト長が効いてくる理由もここにあります。
モデルにより異なる実装
nemotron が特別扱いされているわけではありません。実行ファイルにはレンダラが約 15 種、パーサが 20 種前後入っています。
strings ollama.exe | grep -oE "renderers\.[A-Z][A-Za-z0-9_]+" | sort -u
抽出されたファミリ一覧
| ファミリ | RENDERER | PARSER |
|---|---|---|
| Gemma4 / FunctionGemma / GlmOcr | ✓ | ✓ |
| Qwen35 / Qwen3Coder / Qwen3VL | ✓ | ✓ |
| DeepSeek3 / GLM47 / Olmo3 / Olmo3Think | ✓ | ✓ |
| Laguna / LagunaV8 / Cogito / Cohere / LFM2 | ✓ | ✓ |
| Nemotron3Nano | ✓ | ✓ |
| Ornith | ✓ | — |
| GLM46 / Ministral / Qwen3 | — | ✓ |
加えて gpt-oss 用の harmony/harmonyparser.go、旧方式の受け皿として parsers.PassthroughParser があります。
この一覧は実行ファイルから抽出したシンボル名に基づく推定です。関数名と型名が具体的なので解釈の幅は小さいと考えていますが、確定させるなら Ollama のソース(model/renderers/、model/parsers/)を読むのが正確です。
内蔵実装を持つのは新しい世代(Gemma 4、Qwen 3.5、DeepSeek 3、Nemotron 3 など)ばかりで、llama3.1・hermes3・mistral・qwen2.5 はテンプレート方式のままです。「Modelfile のテンプレート文字列」から「コンパイル済み Go 実装」へ、新しいモデルから順に移行が進んでいるようです。
コラム: 他の推論サーバーはどうしているか
本節のみ、各プロジェクトの公式ドキュメントに基づく整理です。vLLM・SGLang・llama.cpp は筆者の手元で動作確認していません。他の章の実機検証とは性格が異なる点をご了承ください。
では「Ollama の内蔵化は特殊なのか」。比べると、そもそも設計思想が違います。
| Ollama | vLLM / SGLang | llama.cpp | |
|---|---|---|---|
| テンプレート言語 |
Go text/template または内蔵 Go 実装 |
Jinja2 | Jinja2(minja) |
| 書式定義の所在 |
Ollama 側 (Modelfile / バイナリ) |
モデル配布者 (tokenizer_config.json) |
モデル配布者 (GGUF 埋め込み) |
| パーサの選択 | 自動判定 | ユーザが明示指定 | テンプレート解析で自動 |
| 有効化 | 常時 |
--enable-auto-tool-choice が必要 |
--jinja が必要 |
| テンプレート上書き | 不可 |
--chat-template で可 |
--chat-template-file で可 |
| 自作パーサの追加 | 不可 |
--tool-parser-plugin で可 |
— |
書式定義を誰が持つか。 vLLM / SGLang / llama.cpp は、モデル配布者が書いた Jinja2 テンプレートをそのまま使います。Hugging Face の tokenizer_config.json にある chat_template がそれで、llama.cpp は GGUF に埋め込まれた同じものを読みます。新しいモデルが出ても推論サーバー側は何もしなくてよい構造です。
Ollama だけが独自系統です。先の hermes3:8b のテンプレートは Go の text/template 記法でした(Jinja2 なら {%- if tools %})。書式定義を Ollama 側で保守するため、新しいモデルへの対応が Ollama 自身のリリース作業になります。
パーサをどう選ぶか。 vLLM は起動時にユーザが明示指定します。ツール呼び出し自体がオプトインです。
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--enable-auto-tool-choice \
--tool-call-parser llama3_json \
--chat-template examples/tool_chat_template_llama3.1_json.jinja
llama.cpp は自動判定ですが方法が独特です。テンプレートにダミーデータを流し込んでレンダリングし、出力のどこにツールが現れるかを差分解析してパーサを導出します。Ollama がテンプレートからタグ文字列を抽出するのと同じ発想の、より踏み込んだ実装です。
なぜ違うのか(ここは筆者の解釈です)。想定利用者の差でしょう。vLLM / SGLang は本番サービング用で、運用者はパーサまで含めて全部を握りたい立場です。明示指定は手間ではなく「意図しない挙動が起きない」という価値になります。一方 Ollama は ollama run の一行で動くことが価値です。パーサを指定させた時点で、その価値が損なわれます。自動判定と上書き不可は、同じ思想の裏表です。
最後にひとつ。Ollama は内部で llama-server(llama.cpp)をサブプロセスとして動かしています。実行ファイルに llama-server process has terminated の文字列が含まれているのがその証拠です。つまり Ollama は、llama.cpp の Jinja テンプレート処理とツール呼び出し機構を使わずに素通りさせ、自前の Go レンダラで組み立てたプロンプトを平坦なテキストとして下位に渡しています。同じ推論エンジンの上に、別の書式レイヤーを重ねた構造です。
まとめ
モデル選定の指針
ローカル LLM をエージェント用途で選ぶなら、確認する順序があります。
- tools capability — 無ければ HTTP 400 で門前払い
-
ネイティブのコンテキスト長 — Ollama はネイティブを超える
num_ctxを警告なくクランプします。要求値が通ったつもりで、実際は切られています - 日本語性能
日本語性能から見ていたのが回り道でした。Gemma 3 が日本語に強くても Hermes Agent では使えない、という冒頭の話はここに帰着します。
最終的に採用したのは Nemotron 3 Nano 4B です。NVIDIA 公式の対応言語に日本語が含まれ、tools と thinking に対応し、ネイティブ 262K が 64K の要件を満たします。Mamba2 ハイブリッドで KV キャッシュが約 24KB/トークンと小さく、65536 トークンでも VRAM 消費は 4.42GB でした。詳細は別記事にします。
TEMPLATE の今後は不透明
調べていて気になったのは、この領域の情報が公開されていないことです。
-
公式 Modelfile リファレンスは
RENDERER/PARSERの存在自体を記載していません。ollama show --modelfileが実際に出力する命令が、公式リファレンスに載っていない状態です -
Issue #14560 では、親モデルから
RENDERER/PARSERが無条件に継承され、カスタムTEMPLATEが無視される問題が提起されています。未解決です - 同スレッドの「TEMPLATE は段階的に廃止されるのか」という質問に、メンテナからの回答はありません
- 0.20.0 では blob を直接指定する回避策も塞がれました(GGUF からアーキテクチャを自動判定して強制適用するようになったため)
保守性の評価は、どちらから見るかで反転します(以下は筆者の解釈です)。Go コードならユニットテストが書け、JSON 修復もストリーミング解析も実装できます。Ollama 側から見れば保守性はむしろ上がります。テンプレート文字列で同じことはできず、実際に排他分岐の事故も起きていました。
一方で、モデル対応が Ollama のリリースサイクルに結合し、利用者は書式を調整する手段を失いました。保守性が「落ちた」のではなく、保守の責任と自由度がユーザから Ollama 本体へ移った。これが実態に近いはずです。移動そのものについて、公の説明はありません。
参考リンク
- Ollama 公式 Modelfile リファレンス
- Ollama Docs: Tool calling
- Issue #7014: Better Tool Call parsing
- Issue #14560: Respect model-file overriding the RENDERER / PARSER with a custom TEMPLATE
- DeepWiki: Parsers and Renderers (ollama)
- vLLM: Tool Calling
- llama.cpp: docs/function-calling.md
- SGLang: Tool Parser
- Ollama: tools 対応モデルの検索