自分が作った診断ツールに自分が騙された話 — CodeRouter v1.8.2 で doctor probe を thinking モデル対応にした記録
TL;DR: v1.8.1 で「note 流行モデル 3 つ実機検証して 2 つ詰んだ」と書いた翌日、深掘りしたら 3 つのうち 1 つ (Gemma 4 26B) は実は完全に動いていて、自分が作った doctor probe の偽陽性に騙されていた ことが分かった話。doctor の num_ctx / streaming probe が thinking モデルの reasoning トークン消費分を max_tokens=32 / 128 のバジェットに含めていなかったのが真因。v1.8.2 で probe バジェットを reasoning 検出付きの動的選択に変更、bundled registry で gemma4:* / qwen3.6:* に thinking: true を宣言、Gemma 4 26B は実機 OK 確定 (/v1/messages で "Hello." が 2 秒応答)、Qwen3.6 系の tool_calls [NEEDS TUNING] だけが真の課題として残る。
前回までのあらすじ
昨日 (v1.8.1) のリリース note 記事で、「note や HF で評判の高いローカル LLM を Ollama 経由で叩くと結構な確率で動かない」という話を書きました:
URL: https://note.com/zephel01/n/nf6c011519842
Qwen3.6:27b: 3 つの probe で NEEDS_TUNING (num_ctx silent cap / tool_calls 0 / streaming 0)
Qwopus3.5-9B: llama.cpp が qwen35 architecture 未対応で unable to load model 500 エラー
Gemma 4 26B: tool_calls [OK] で「逆転勝利」、ただし num_ctx と streaming は NEEDS_TUNING
v1.8.1 の最後に「実機 evidence first」原則を再確認した、と締めくくりました。
ところが翌日、Gemma 4 26B の num_ctx [NEEDS_TUNING] の正体を確かめるべく深掘りしたら、自分の判断が間違っていたことが判明しました。
検証環境
マシン: M3 Max 64GB unified memory
Ollama: 0.21.2
CodeRouter: v1.8.1 → v1.8.2
検証対象: gemma4:26b (18 GB GGUF / declared context 262144)
段 1: doctor の patch を当てても NEEDS_TUNING のまま
v1.8.1 の coderouter doctor --check-model ollama-gemma4-26b --apply で extra_body.options.num_ctx: 32768 と num_predict: 4096 を providers.yaml に書き戻し → 再実行。期待値は 6 probe 全 [OK]。
実際の結果:
[1/6] auth+basic-chat …… [OK]
[2/6] num_ctx ………………… [NEEDS TUNING]
canary missing even with num_ctx=32768 declared.
[3/6] tool_calls ………… [OK] ← !!
[4/6] thinking ………… [SKIP]
[5/6] reasoning-leak …… [OK]
[6/6] streaming ………… [NEEDS TUNING]
stream closed with finish_reason='length' after only 0 chars.
Apply: 1 target file(s).
All 2 patch(es) already applied — providers.yaml is up to date.「patch を当てても NEEDS_TUNING のまま」。しかも tool_calls は OK なのに、num_ctx と streaming だけが落ちる。tool_calls が動くなら基本動作はしているはずで、矛盾している。
この時点での仮説:
Ollama の OpenAI 互換層が extra_body.options.num_ctx を捨てている
GGUF 側の intrinsic context が declare より短い
Ollama 0.21.2 の Qwen3.6 / Gemma 4 系の chat template / tool 仕様が未成熟
段 2: curl 直叩きで Ollama の挙動を確認
仮説を切り分けるため、native API と OpenAI 互換 endpoint の両方に同じ短文プロンプトを投げる:
# (1) native /api/chat
curl -s http://localhost:11434/api/chat -d '{
"model": "gemma4:26b",
"messages": [{"role":"user","content":"Repeat exactly this token: ZEBRA-MOON-847"}],
"stream": false,
"options": {"num_ctx": 32768, "num_predict": 4096}
}' | jq -r '.message.content'
ZEBRA-MOON-847
# (2) /v1/chat/completions (OpenAI 互換)
curl -s http://localhost:11434/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model": "gemma4:26b",
"messages": [{"role":"user","content":"Repeat exactly this token: ZEBRA-MOON-847"}],
"options": {"num_ctx": 32768, "num_predict": 4096}
}' | jq -r '.choices[0].message.content // .'
ZEBRA-MOON-847
# (3) ollama show
ollama show gemma4:26b
context length 262144
...両方とも canary が返ってきた。さらに モデルは declared context 262K を持っている。仮説 1 / 2 は両方ハズレ。短文では何の問題もない。
段 3: doctor probe の中身を覗く
「同じ Ollama 経由で curl は通るのに doctor だけ NEEDS_TUNING」となれば、doctor の probe 自体がおかしい疑いが浮上。coderouter/doctor.py の _probe_num_ctx を読み返す:
_NUM_CTX_PROBE_PADDING_SENTENCE = (
"The quick brown fox jumps over the lazy dog near the river bank today. "
)
_NUM_CTX_PROBE_PADDING_REPEATS = 300
# ...
padding = _NUM_CTX_PROBE_PADDING_SENTENCE * _NUM_CTX_PROBE_PADDING_REPEATS
user_prompt = (
f"CANARY: {_NUM_CTX_PROBE_CANARY}\n\n"
+ padding
+ "\n\nQuestion: What exact canary token appeared at the very "
"beginning of this message?..."
)
body.update({
"model": provider.model,
"messages": [{"role": "user", "content": user_prompt}],
"max_tokens": 32, # ← ここ!
"temperature": 0,
})Probe の作りは「canary を冒頭に、5K トークンの filler 後ろに、最後にもう一度聞く」というよくあるパターン。max_tokens: 32 は canary (~5 tokens) の echo には十分なはず。
curl で同じ shape を再現してみる:
python3 -c "
import json
canary = 'CANARY: ZEBRA-MOON-847\n\n'
padding = 'The quick brown fox jumps over the lazy dog near the river bank today. ' * 300
question = '\n\nQuestion: What exact canary token appeared at the very beginning of this message? Reply with only the canary token itself, nothing else.'
body = {
'model': 'gemma4:26b',
'messages': [{'role':'user','content': canary + padding + question}],
'options': {'num_ctx': 32768, 'num_predict': 4096},
'max_tokens': 32,
'temperature': 0
}
print(json.dumps(body))
" > /tmp/probe.json
curl -s http://localhost:11434/v1/chat/completions \
-H 'Content-Type: application/json' \
-d @/tmp/probe.json | jq '.choices[0]'結果:
{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"reasoning": "The user is asking for a specific \"canary token\" that appeared at the very beginning of the provided text.\n\n * The text"
},
"finish_reason": "length"
}ようやく真因が見えた。
Gemma 4 は thinking モデルで reasoning フィールドに思考過程を吐く。doctor の max_tokens: 32 制約だと、思考トークンで 32 token を食い切って content が空のまま finish_reason='length' で打ち切られる。canary が echo されないのは num_ctx が短いからではなく、思考の途中で切れているからだった。
ollama show gemma4:26b で確認すると、Capabilities に thinking が確かに含まれていて、Gemma 4 はネイティブで thinking モデル設計だった。doctor の reasoning-leak probe ([5/6] [OK]) も「Gemma 4 は reasoning フィールドを emit している」と既に検出していた — 自分で作った probe の出力を読み込めていなかっただけ。
段 4: streaming probe も同じ症状
念のため streaming probe も確認。プロンプトは「Count from 1 to 30」、max_tokens: 128:
_STREAMING_PROBE_USER_PROMPT = (
"Count from 1 to 30, one number per line. Output only the numbers, nothing else."
)
# ...
body.update({
...
"max_tokens": 128, # ← ここも
"temperature": 0,
"stream": True,
})finish_reason='length' after only 0 chars の症状は num_ctx と全く同じ — 128 token の budget が思考トークンに食われて、count 出力が始まる前に length cap で切られていた。
つまり Gemma 4 の num_ctx [NEEDS_TUNING] も streaming [NEEDS_TUNING] も両方 doctor probe の偽陽性で、tuning ではなく probe 設計の問題だった。
段 5: end-to-end 動作確認 — Gemma 4 は実機で完全 OK
doctor の偽陽性が分かったので、Claude Code の Anthropic 互換 API 経路で直接叩いて確認:
# coderouter を起動
coderouter serve --port 8088 &
sleep 2
# Anthropic 互換 1 round-trip
curl -s -X POST http://localhost:8088/v1/messages \
-H 'Content-Type: application/json' \
-H 'anthropic-version: 2023-06-01' \
-H 'x-api-key: dummy' \
-d '{
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 200,
"messages": [{"role":"user","content":"Say hello in one word."}]
}' | jq '.content[0].text'結果:
"Hello."2 秒で返答。CodeRouter ログには:
try-provider provider=ollama-gemma4-26b stream=false
HTTP Request: POST http://localhost:11434/v1/chat/completions "HTTP/1.1 200 OK"
capability-degraded provider=ollama-gemma4-26b dropped=["reasoning"] reason=non-standard-field
provider-ok provider=ollama-gemma4-26breasoning フィールドは strip されて、Anthropic 形式の content だけが client に届いている。tool_calls 含めて end-to-end 完全動作 — Gemma 4 26B は実用 OK 確定。
ただ Claude Code 経由 (claude --print "...") で同じ質問を投げると、agent loop で複数 round-trip する関係で 30〜90 秒かかる。これは Gemma 4 の遅さではなく、Claude Code が「ツール使う?」「最終応答」と複数回問い合わせるため。daily driver には qwen2.5-coder:14b のほうが速いが、tool_calls native + 高品質が要るときは Gemma 4 がベスト選択。
v1.8.2 で何を変えたか
実機 evidence を反映した patch release として v1.8.2 を出しました。
1. doctor probe を thinking モデル対応に
# 旧 (v1.8.1):
"max_tokens": 32, # num_ctx probe
"max_tokens": 128, # streaming probe
# 新 (v1.8.2):
_NUM_CTX_PROBE_MAX_TOKENS_DEFAULT = 256
_NUM_CTX_PROBE_MAX_TOKENS_THINKING = 1024
_STREAMING_PROBE_MAX_TOKENS_DEFAULT = 512
_STREAMING_PROBE_MAX_TOKENS_THINKING = 1024
def _is_reasoning_model(provider, resolved):
"""provider declaration / registry の thinking / reasoning_passthrough
のいずれかが true なら reasoning モデル。
"""
if provider.capabilities.thinking is True: return True
if provider.capabilities.reasoning_passthrough is True: return True
if resolved.thinking is True: return True
if resolved.reasoning_passthrough is True: return True
return False
# probe 内:
max_tokens = (
_NUM_CTX_PROBE_MAX_TOKENS_THINKING
if _is_reasoning_model(provider, resolved)
else _NUM_CTX_PROBE_MAX_TOKENS_DEFAULT
)非 thinking モデルは natural stop で早期終了するので無駄消費なし、thinking モデルは reasoning trace + 答えが収まる headroom。
2. registry に thinking 宣言追加
bundled model-capabilities.yaml で:
- match: "gemma4:*"
kind: openai_compat
capabilities:
tools: true
thinking: true # ← v1.8.2 追加
- match: "qwen3.6:*"
kind: openai_compat
capabilities:
tools: true
thinking: true # ← v1.8.2 追加これで user の providers.yaml を触らなくても registry 経由で doctor が thinking バジェットを使うようになる。
3. troubleshooting.md §4-2 を整理
§4-2-A (Qwen3.6): v1.8.2 で num_ctx / streaming は偽陽性として除去、tool_calls [NEEDS TUNING] が真の課題として残ると整理
§4-2-C (Gemma 4): 「実機で完全動作確定 (v1.8.2)」に更新、interactive UX が重い注意も追加
§4-2-E 新設 (doctor probe 自体の限界): thinking モデル対応のメタな話、provider.capabilities.thinking / registry の thinking のいずれかが true なら自動で probe バジェット拡大
4. tests +3
test_num_ctx_max_tokens_bumped_for_thinking_provider_declaration: provider 宣言 → 1024
test_num_ctx_max_tokens_bumped_when_registry_says_thinking: registry 宣言 → 1024
test_streaming_max_tokens_bumped_for_thinking_provider: streaming probe も同経路
730 → 733 tests green。
振り返り — 「diagnostic ツール自身も diagnostic され続ける必要がある」
v1.8.1 article の最後で「実機 evidence first」原則を強調しました。今日の v1.8.2 はその一段上の発見:
実機 evidence を取る診断ツール自体も、定期的に診断される必要がある。
具体的には:
Probe の設計時前提が変わる: max_tokens=32 は v1.0-B の時代 (2026 春前) には妥当だった。non-thinking モデルしか考慮していない設計は、thinking モデル時代 (Gemma 4、Qwen3、gpt-oss、deepseek-r1) ですぐ古くなる。
「自分が作ったツールの出力」を疑うのは難しい: doctor が [NEEDS TUNING] と言ったら 99% それが正しいと無意識に思ってしまう。実機で curl 直叩きとの突き合わせがなかったら気づかなかった。
メタ probe の必要性: doctor 自身を doctor する仕組み (probe の偽陽性検出) があったほうがよい。具体的には reasoning-leak probe ([5/6]) の結果と num_ctx / streaming の結果の整合性をクロスチェックする probe... v2.0 のネタ。
今日の作業を一言で表すなら:
3 連敗どころか、自分の judge が偏っていた
Qwen3.6 系の tool_calls [NEEDS TUNING] (これは本物の課題) は引き続き対処継続、Gemma 4 は名誉回復、doctor は thinking モデル対応に修理完了。
v1.8.2 の入手方法
PyPI に出した v1.8.2:
# 既存ユーザー
uv tool upgrade coderouter-cli
# 新規ユーザー
uvx coderouter-cli serve --port 8088--apply 機能 (doctor の YAML パッチ自動書き戻し) を使うなら:
uv tool install --reinstall --force coderouter-cli --with ruamel.yamlまたは pip install 'coderouter-cli[doctor]'。
まとめ
「自分が作った診断ツールに自分が騙された」という珍しいタイプの実機検証 retrospective でした。v1.8.2 で 3 段の対策:
doctor probe を thinking モデル対応: max_tokens を _is_reasoning_model() で動的選択 (32→256/1024、128→512/1024)
registry に thinking: true 宣言: gemma4:* / qwen3.6:* に追加 (user は providers.yaml を触らなくて OK)
troubleshooting.md §4-2 を再整理: 偽陽性とそれ以外を分離、§4-2-E で doctor probe の限界を明文化
色々と疑うことは必要。関係ないところが影響を出していることがある。
これはエンジニアとして必要。
これで Gemma 4 26B は晴れて coding profile primary の有力候補に名誉回復、Qwen3.6 系は tool_calls の真の課題が残るので引き続き primary には推奨せず。
ローカル LLM 界隈の方の参考になれば幸いです。
質問・感想は GitHub Issues または X (@zephel01) までどうぞ。
(リリース日: 2026 年 4 月 26 日 / バージョン: v1.8.2)
いいなと思ったら応援しよう!
サーバー代とコーヒー代になります☕ 役に立ったら応援よろしくお願いします!