0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルLLMをフロントに、難しいタスクをOpenRouter経由でQwen/Sonnetに委譲する

0
Last updated at Posted at 2026-08-11

本記事は個人環境での検証に基づく個人的な備忘録です。設定値やパスは一般化して記載しているため、実際の環境に読み替えてください。また技術的な確認は Sonnet 5 と、記事の執筆は Opus 5 と共に進めています。

0. はじめに

前回の記事では、Ollama の tools capability を実機で調べ、ローカル LLM をエージェント用途で選ぶ順序は「tools capability → ネイティブ context 長 → 日本語性能」だという結論に至りました。その結果選んだのが nemotron-3-nano:4b をベースにした 4B モデルです。

実際に Hermes Agent 上で動かしてみると、ツール呼び出しの機構そのものは問題なく動きました。しかし運用してみると、4B というサイズに起因する課題が 2 方向で出てきます。

  • 難しい推論タスクでは力不足 — 複雑な調査・比較になると指示追従が不安定
  • 日本語出力そのものが不安定 — 固有名詞が崩れ、ファイル名を誤記し、存在しないファイルにまで言及する

かといって「全部クラウド API に投げる」ではローカル LLM を使う意味がなくなります。本記事では OpenRouter を導入し、普段の会話はローカルの Nemotron、難しいタスクだけクラウドの Qwen に逃がすというハイブリッド構成を作ります。最後に、クラウドを使う以上避けて通れない「入力データを学習に使われたくない」という設定もします。

検証環境

  • OS: Windows 11 Pro / WSL2 (Ubuntu 26.04 LTS)
  • GPU: NVIDIA GeForce RTX 5070 Ti (VRAM 16GB)
  • Ollama: v0.32.4(Windows ネイティブ)
  • Hermes Agent: v0.19.0
  • 使用モデル: nemotron-ja:4b(ローカル) / qwen/qwen3-235b-a22b-thinking-2507・anthropic/claude-sonnet-4.5(OpenRouter 経由)

1. なぜ Nemotron だけでは足りないのか

前提として、Hermes Agent は MINIMUM_CONTEXT_LENGTH = 64_000 をハードコードで要求します(agent/model_metadata.py)。ツールスキーマとシステムプロンプトが固定のプレフィックスを占めるためです。この条件と tools capability の 2 つで候補を絞った結果が Nemotron でした。

ツール実行そのものは正しく動きます。問題はその先です。「作業ディレクトリのファイル一覧を教えて」と尋ねただけの単純なやり取りで、次のようなことが起きました。

  • ファイル名を誤記した — .claude/ を .clavide/、hermes-agent-security-md.md を hermes-agent-security.md と書いた
  • 件数が合わない — 箇条書きには 10 件しか並べていないのに、末尾で「合計 11 file」と締めた
  • 作業ディレクトリを取り違えた — 実際は Windows 側をマウントした /mnt/c/... 配下なのに、/home/<user>/Developer/... という WSL 側のパスとして答えた

いずれもツールの戻り値には正しく入っていた情報です。search_files は 11 件のファイルパスを過不足なく返しており、壊れたのはそれを日本語で要約する段階でした。固有名詞も同様で、自己紹介をさせると「Nous Research」が「ナオール」になります。

この切り分けは重要です。エージェントの骨格(ツールを選び、実行し、結果を受け取る部分)は動いているので、残るのは生成品質の問題ということになります。

モデルを大きくすれば解決しますが、VRAM 16GB の制約下でローカルに載る範囲には限界があります。実際に一段大きいモデルへの載せ替えも検討しましたが(下のコラム参照)、Hermes のコンテキスト要件を満たすものは見つかりませんでした。

そこで方針を変え、ローカルモデルを主力に据えたまま、必要なときだけクラウドのモデルを併用するハイブリッド構成に進みます。


コラム: Qwen2.5-14B 系を検討して不採用にした話

Qwen2.5-14B-Instruct と Qwen2.5-Coder-14B-Instruct は日本語もコードも扱える有力な候補で、Ollama のモデルページには「128K コンテキスト対応」と書かれています。ただし前回記事で見たとおり、Ollama は num_ctx がモデルのネイティブ上限を超えると警告なくクランプするため、ページの表記ではなく GGUF に焼き込まれた値を確認しました。

9GB のモデルを丸ごと pull する必要はありません。GGUF はファイル先頭にメタデータを持つので、マニフェストから本体 blob のダイジェストを引き、HTTP Range リクエストで先頭 20MB だけ取得すれば読み取れます。

# ① マニフェストから application/vnd.ollama.image.model の digest を取得
curl -s "https://registry.ollama.ai/v2/library/qwen2.5/manifests/14b" \
  -H "Accept: application/vnd.oci.image.manifest.v1+json"

# ② その blob の先頭だけ取得(-L は必須。実体は R2 へ 307 リダイレクトされる)
curl -sL -r 0-20971519 \
  "https://registry.ollama.ai/v2/library/qwen2.5/blobs/sha256:2049f5674b1e..." \
  -o qwen25_14b_head.bin
GGUF メタデータのパーサ(Python)
import struct, sys

def read_str(data, off):
    (n,) = struct.unpack_from('<Q', data, off); off += 8
    return data[off:off+n].decode('utf-8', errors='replace'), off + n

TYPE_SIZES = {0:1, 1:1, 2:2, 3:2, 4:4, 5:4, 6:4, 7:1, 10:8, 11:8, 12:8}

def skip_value(data, off, vtype):
    if vtype == 8:                      # string
        _, off = read_str(data, off)
        return off
    if vtype == 9:                      # array
        (atype,) = struct.unpack_from('<I', data, off); off += 4
        (alen,)  = struct.unpack_from('<Q', data, off); off += 8
        for _ in range(alen):
            off = skip_value(data, off, atype)
        return off
    return off + TYPE_SIZES[vtype]

with open(sys.argv[1], 'rb') as f:
    data = f.read()

assert data[0:4] == b'GGUF'
version,      = struct.unpack_from('<I', data, 4)
tensor_count, = struct.unpack_from('<Q', data, 8)
kv_count,     = struct.unpack_from('<Q', data, 16)
off = 24
print(f"version={version} tensors={tensor_count} kv={kv_count}")

KEYS = ('context_length', 'rope', 'block_count', 'general.architecture', 'general.name')
for _ in range(kv_count):
    key, off = read_str(data, off)
    (vtype,) = struct.unpack_from('<I', data, off); off += 4
    if any(k in key for k in KEYS):
        # 文字列・整数・浮動小数だけ読めれば十分
        if vtype == 8:
            val, off = read_str(data, off)
        elif vtype in (4, 5):
            (val,) = struct.unpack_from('<I' if vtype == 4 else '<i', data, off); off += 4
        elif vtype == 6:
            (val,) = struct.unpack_from('<f', data, off); off += 4
        else:
            val, off = '(skipped)', skip_value(data, off, vtype)
        print(f"  {key} = {val}")
    else:
        off = skip_value(data, off, vtype)

トークナイザの語彙が巨大な配列として含まれるため、先頭 2MB では途中でバッファが尽きます。20MB 取得すれば 34 個の KV をすべて読み切れました。

結果

モデル モデルページの表記 実測値 判定
qwen2.5:14b-instruct 128K 32768(GGUF) ✗ 要件未達
qwen2.5-coder:14b-instruct (記載なし) 32768(GGUF) ✗ 要件未達
nemotron-3-nano:4b (記載なし) 262144(/api/tags) ✓

両モデルとも qwen2.context_length = 32768 で、rope.scaling.* 系のキーは存在しませんでした。Qwen 公式が言う 128K は YaRN を手動で有効化した場合の数値であり、Ollama が配布しているこの GGUF はその設定を含んでいません。

前回記事で確認したクランプ挙動を踏まえると、PARAMETER num_ctx 65536 を焼き込んでも実効値は 32768 に留まり、Hermes の 64,000 要件を満たせないことになります(この点は GGUF の実測値からの帰結であり、実際にモデルを作成して計測したわけではありません)。VRAM 16GB という制約も踏まえ、ローカルで一段大きいモデルに載せ替える案はここで見送りました。


2. OpenRouter を導入する

2.1 OpenRouter とは

OpenRouter は、複数ベンダー(Anthropic、Qwen/Alibaba、Google など)のモデルを、単一のエンドポイントと単一の API キーで呼び分けられる中継サービスです。OpenAI 互換の /v1/chat/completions 形式なので、既存のクライアントをほぼそのまま使えます。

2.2 直接 API を叩く場合と比べたメリット・デメリット

OpenRouter が勝っている点

  • 1 つの API キーでマルチモデル切り替え — モデル名を anthropic/claude-sonnet-4.5 から qwen/qwen3-235b-a22b-thinking-2507 に書き換えるだけで、コードもキーも変えずに別モデルを試せます
  • 自動フォールバック(障害耐性) — あるモデルの主提供元が混雑・ダウンした場合、AWS Bedrock や GCP Vertex など別のバックエンド経由で呼び出しを継続します。デフォルトで有効で、明示設定は不要です(allow_fallbacks: false で無効化可能)
  • 使いすぎの物理的な防止 — プリペイド方式のため、バグで無限ループが発生してもチャージ残高が尽きた時点で確実に止まります
  • プロバイダー横断のルーティング設定 — sort / only / ignore / order / require_parameters / data_collection といった制御が使えます(4 章で data_collection を実際に使います)

直接契約が勝っている点

  • チャージ手数料がかからない — OpenRouter はクレジット購入時に 5.5%(最低 $0.80)の手数料がかかります
  • 新機能・ベータ機能の提供が早い(一般論。新モデルの初出しや独自機能は提供元が最速です)

上記の 5.5% は推論の API コールごとに課金される手数料ではなく、残高をチャージするときだけ発生する一度きりのコストです。OpenRouter の FAQ は "We pass through the pricing of the underlying providers without any markup" と明記しており、推論単価そのものは直接契約と同じです。

プロンプトキャッシュについては、OpenRouter 経由でも Anthropic モデルに対して同じ 90% 割引(キャッシュヒット分は 0.1 倍課金)が適用されることを公式ドキュメントで確認しました。

2.3 今回の選択

今回やりたいのは「ローカルの Nemotron を主力に据えつつ、難しいタスクだけをクラウドに逃がす」ハイブリッド運用です。そしてこの段階では、どのクラウドモデルが最適かまだ決まっていません。Qwen も Anthropic も試したいし、より良いモデルが出れば乗り換えたいところです。

使うモデルが 1 つに固まっているなら直接契約で手数料を避ける意味がありますが、複数モデルを切り替えながら試したい今回の状況では、キーを増やさずモデル名の書き換えだけで済む利点がそのまま効きます。加えて実験段階では暴走による課金事故が怖いので、プリペイドで上限が物理的に決まる点も決め手になりました。

導入後の構成は次のようになります。

起点はユーザーの指示ひとつですが、そこから先の経路が 3 つに分岐します(実線が通常経路、点線が条件付きの経路)。推論エンジンの配置が Windows ネイティブ・WSL2・クラウドの 3 箇所に分かれている点、そして WSL2 から Windows 側の Ollama へは localhost:11434 で、逆に Windows のブラウザから WSL2 のダッシュボードへは localhost:9119 で到達できる双方向の関係が、この構成の土台になっています。

2.4 導入手順

  1. OpenRouter で API キーを発行し、クレジットをチャージします(従量課金です。ローカルモデルと違って無料ではありません)
  2. ~/.hermes/.env の OPENROUTER_API_KEY を設定します

利用できるモデル ID は https://openrouter.ai/api/v1/models から取得できます。今回関係するものを抜粋します。

用途 モデル ID
Anthropic Sonnet 系 anthropic/claude-sonnet-4.5
Anthropic Haiku 系(軽量) anthropic/claude-haiku-4.5
Qwen 大型(推論強化版) qwen/qwen3-235b-a22b-thinking-2507
Qwen 大型(通常版) qwen/qwen3-235b-a22b-2507
Qwen コード特化 qwen/qwen-2.5-coder-32b-instruct

3. モデルを切り替える 3 つのアプローチ

OpenRouter を繋いだだけでは「いつどのモデルを使うか」は決まりません。Hermes Agent には用途の異なる 3 つの切り替え方が用意されています。

3.1 アプローチの比較

アプローチ 設定箇所 切り替わるタイミング 向いている用途
① デフォルトモデル指定 model.default / model.provider(hermes model のピッカー、hermes chat -m) 常時(会話全体) 主力モデルそのものを変えたい
② サブエージェントへの委任 delegation.model / delegation.provider delegate_task が呼ばれた時だけ 難タスクだけクラウドに逃がしたい
③ フォールバック fallback_model プライマリがエラーを返した時だけ 障害時の保険

① は全部クラウドに移すことになり、ローカル LLM を使う意味が薄れます。そして③は、名前から「難しい時に賢いモデルへ切り替わる仕組み」と読めてしまいますが、そうではありません。

fallback_model の発動条件はレート制限(429)・過負荷(529)・サービスエラー(503)・接続失敗であり、タスクの内容や難易度は一切見ていません。あくまで障害時の保険です。

「難しいタスクだけクラウドへ」を実現するのは②だけ、というのが本記事の要点です。

3.2 実際の設定

②と③を併用し、役割を分けます。委譲先は Qwen、障害時のフォールバックは Anthropic としました。

# ~/.hermes/config.yaml
delegation:
  max_iterations: 50
  model: "qwen/qwen3-235b-a22b-thinking-2507"
  provider: "openrouter"

fallback_model:
  provider: openrouter
  model: anthropic/claude-sonnet-4.5

model.default は nemotron-ja:4b のままです。つまりユーザーと直接会話する親エージェントはローカルで動き続け、delegate_task で切り出された子だけがクラウドに出ていきます。

delegation のモデルは config 側の固定値です。LLM 自身が呼び出しごとに「このサブタスクは A 社、あちらは B 社」と動的に選ぶことはできません。委譲先は全体で 1 つに固定されます。

3.3 実機検証: 本当に Qwen へ流れているか

設定しただけでは動作の確証になりません。delegate_task を明示的に呼ばせて、ログでルーティング先を確認しました。

run_agent - INFO - OpenAI client created (agent_init, shared=True)
    provider=openrouter base_url=https://openrouter.ai/api/v1
    model=qwen/qwen3-235b-a22b-thinking-2507

agent.turn_context - INFO - conversation turn: session=...
    model=qwen/qwen3-235b-a22b-thinking-2507 provider=openrouter
    platform=subagent history=0 msg='Rust vs Go for CLI tools'

platform=subagent の行で、親とは別のモデル・別のプロバイダーが使われていることが確認できます。TUI の /agents からも、サブエージェントが 1 体、22 秒で完了したことが見えました。

Last turn · finished 8:54:22 PM   d1 · 1 agent   caps d1/3 · qwen3-235b-a22b-thinking-2507×1
Timeline · 22s
  1 ● Rust vs Go for CLI tools

ここでもうひとつ、設計上重要な挙動が観測できました。上のログの history=0 です。サブエージェントは親の会話履歴をまったく引き継ぎません。

その結果、返ってきた Qwen のレポートは英語でした。親の Nemotron 側には日本語で応答する指示が入っていますが、それは子には渡らないためです。今回は親がそれに気づいて日本語へ訳し直してから提示しましたが、常にそうしてくれる保証はありません。

委譲先に前提を伝えられるのは delegate_task の context 引数だけです。出力言語、ファイルパス、プロジェクト構成など、必要な情報はすべて明示的に渡す必要があります。

なお、4B モデルは「難しいから委譲する」という判断自体があまり得意ではありませんでした。複雑な調査タスクでも delegate_task を使わずに自分でブラウザを開こうとすることがあり、確実に委譲させたい場合は「サブエージェントに委譲して」と明示するのが実用的です。


4. データの二次利用(モデル学習)を拒否する

クラウドを使う以上、送信した内容がログとして保存されたり学習に使われたりする可能性は気になります。OpenRouter にはこれを制御する data_collection があり、deny を指定するとデータを保持しうるプロバイダーへのルーティングを避けてくれます。

Hermes ではこれを provider_routing セクションで指定します。付属の cli-config.yaml.example に記載があり、sort / only / ignore / order / require_parameters / data_collection を同じブロックにまとめる形式です。

# ~/.hermes/config.yaml
provider_routing:
  data_collection: "deny"

設定を書く場所は provider_routing セクションです。似た名前の model.provider(openrouter などを指定するもの)とは役割が異なり、そちらに書いても読み込まれません。実際に値を拾っているのは次のコードです。

# cli.py
pr = CLI_CONFIG.get("provider_routing", {}) or {}
self._provider_sort = pr.get("sort")
self._providers_only = pr.get("only")
...
self._provider_data_collection = pr.get("data_collection")

この設定が委譲先にも効くかどうかも確認しました。tools/delegate_tool.py に、親から子へ明示的に引き継ぐ実装があります。

child_provider_data_collection = getattr(
    parent_agent, "provider_data_collection", None
)
...
provider_data_collection=child_provider_data_collection,

サブエージェント(=実際にクラウドへ出ていく側)にも同じポリシーが適用されることが、コード上で確認できました。

.env と config.yaml はプロセス起動時に一度だけ読み込まれます(load_dotenv())。systemd サービスとして常駐させている場合、ファイルを編集しただけでは反映されません。systemctl --user restart が必要です。


5. まとめ

ローカル LLM(無料・低レイテンシ・データが外に出ない)とクラウド LLM(高性能・従量課金)を、Hermes Agent の delegation を使って役割分担させました。

  • 通常の会話とツール実行 → ローカルの Nemotron
  • delegate_task で切り出された難しいタスク → OpenRouter 経由の Qwen
  • プライマリの障害時 → OpenRouter 経由の Anthropic

複数モデルを試している段階では、キーを 1 つに集約できる OpenRouter の利点が効きます。使うモデルが固まったら直接契約に移す、という判断もありえます。

残っている課題もあります。delegation の宛先は固定なので、タスクの性質に応じて「コードなら Coder モデル、調査なら thinking モデル」と動的に振り分けることは現状できません。また、委譲するかどうかの判断自体を 4B モデルに任せきるのは難しく、明示的な指示に頼っているのが実情です。


6. 参考リンク

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?