0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeのトークンが溶けるので、機械作業だけローカルLLMに投げるMCPサーバーを作った

0
Last updated at Posted at 2026-09-12

「これ、Claudeにやらせる必要ある?」

Claude Code を使っていると、ある時点で気づきます。

トークンを一番食っているのは、難しい作業ではない

docstring を4つ足す。型注釈をつける。コメントを日本語に直す。lint を潰す。

こういう作業でも Claude は毎回ファイルを全部読み、読んだ本文はそのままコンテキストに積み上がります。判断は一切要らないのに、一番高いモデルに全文を読ませている。

一方、手元には Ollama があります。qwen2.5-coder:7b は 4.7GB、8GB VRAM に丸ごと載る。docstring を書く程度なら十分やれます。

じゃあ そこだけ投げればいいのでは。そう思って作ったのがこれです。

Claudeを置き換えるのではない

先に誤解を潰しておくと、ANTHROPIC_BASE_URL を Ollama に向けて Claude Code 全体をローカルで動かす話とは別物です。あれは Claude を取り除く。ここでやりたいのは Claude を残したまま、単純作業だけ外に出すことです。

  あなた ──▶ Claude Code            ← 判断・計画・レビュー
              │
              │  パスと指示だけ      (小さい)
              ▼
        MCPサーバー ──▶ Ollama ──▶ qwen2.5-coder
              │             │
              │             └── 読む・grepする・編集する を全部ローカルで
              │
              ▼  検証ゲート
              │
              │  レシート: a.py +37/-0, gate=pass   (小さい)
              ▼
        Claude Code

Claude が受け取るのは「何をどれだけ変えたか」のレシートだけ。コードは一行も返しません。

核心は一行で言える

委譲は、処理を一段増やすのではなく、本文を追い出すものでなければならない。

だからツールは パスと指示 しか受け取りません。ファイルの中身は絶対に受け取らない。

地味に見えて、成否を分ける一点です。

❌ Claudeがファイルを読む → 内容を指示文に貼ってローカルに投げる
   → トークンは既に払い終わっている。節約ゼロ。単に遅くなっただけ

✅ Claudeは「src/agent.py の全関数にdocstringを付けて」とパスだけ渡す
   → 読むのはローカルモデル。Claudeは本文を一度も見ない

なので CLAUDE.md に書くルールの一行目は、機能説明ではなくこうなります。

先にファイルを Read しないこと。

でも7Bは平気で嘘をつく

ここが本題です。小さいモデルにコードを書かせると、壊れたものを自信満々で返してきます。複数箇所の置換で括弧を1つ落とす、くらいは普通にやる。それを作業ツリーに残したら、この仕組みは「安いけど信用できない機能」になって終わりです。

なので原則はこうしました。決定的なゲートを通るまで、成功とは報告しない。

  1. 最初の書き込み前に、元のファイル内容をメモリにスナップショット
  2. 編集後、構文チェック + プロジェクト固有のチェック(ruff, pytest, tsc --noEmit)を実行
  3. 落ちたらローカルで1回だけリトライ
  4. それでも落ちたら バイト単位でロールバックして ESCALATE を返す

Claude が ESCALATE を受け取ったときの意味はひとつだけです。「作業ツリーは無傷。これは自分でやれ」

実測でも 7B は docstring の複数箇所編集に失敗しました。が、これは設計の失敗ではなく設計が働いた証拠です。壊れた出力はゲートに止められ、ファイルは元通りになり、Claude に正直に突き返された。

fast (7b) deep (30b)
docstring 編集 ゲート失敗 → ロールバック 適用成功 +37/−0
時間 39秒 61秒(ウォーム26秒)

RTX 5050 Laptop (8GB VRAM) + Ryzen 7 260 + 32GB RAM

調査と単一箇所は fast、複数箇所に及ぶ編集は最初から tier="deep" が結論です。

日本語環境で普通に壊れていた話

日本語環境で動かすと、英語環境では絶対に踏まない地雷が出ます。以下、全部実際に壊れていたものです。

① cp932 でサブプロセスが即死する

subprocess.run(..., text=True) はロケールのエンコーディングでデコードします。日本語 Windows では cp932。ところが ruff も pytest も、コードページに関係なく UTF-8 を吐く

結果、日本語を含むエラー出力の最初の1バイトで UnicodeDecodeError。しかもゲート内部で起きるので、「チェックが失敗する」ではなく委譲そのものが落ちます

② BOM で構文チェックが必ず落ちる

メモ帳が保存する BOM 付き UTF-8 を str にデコードしてから ast.parse に渡すと、先頭の U+FEFF が問答無用で SyntaxError。つまり 正しい編集が、ファイルの保存形式のせいでロールバックされていた。原因がモデルではなくエディタなので、ログでは分かりません。

→ パーサに バイト列のまま渡せば直ります。CPython のトークナイザは BOM を剥がし、# -*- coding: cp932 -*- も解釈してくれます。

③ 日本語のエスカレーションが「成功」扱いされる

これが一番怖かったやつです。終了判定がこうなっていました。

if text.upper().startswith("ESCALATE"):

日本語で指示すれば、モデルは日本語で答えます

エスカレート: 対象のファイルが見つかりません

この行は条件に一致しません。つまり エスカレーションが「回答」として処理されていた。作業ツリーは何も変わっていないのに、レシートは成功を報告する。

追記:ここは公開後に直しました。 @Skillselion さんに、この問題の本質は日本語ではないと指摘していただきました。

日本語は一例にすぎませんでした。上の条件は「ESCALATE が見つからなければ成功」と読めます。つまり失敗マーカーの不在を、成功の証拠として使っていた

承知しました。ESCALATE します と一文前置きされただけで同じことが起きます。キーワードを日英両対応にしても、穴が狭くなるだけで向きは変わりません

大事なのは壊れ方がどちら側に倒れるかです。

誤判定したときの代償
失敗マーカーを探す(旧) 無傷または中途半端な作業ツリーが「成功」と報告される
成功マーカーを探す(新) 「自分でやれ」と突き返される。ツリーは確実に無傷

なので逆向きにしました。明示的な成功マーカーを読み取れたときだけ成功。それ以外は全部エスカレーション。 読み取れない返答には一度だけ言い直しを求め、それでもダメならエスカレーションです(キーワードを忘れただけのモデルはここで回収できるので、エスカレーション率が無駄に上がりません)。

指摘の後半 —— ステータスをモデルの自由文と同じチャンネルに乗せている限り、この手のズレは形を変えて出続ける —— もそのとおりで、finish ツールを追加しました。status="done" / status="escalate" という構造化された引数で終了を宣言させ、文章とプロトコルを同じ線に乗せない。「先に Read しないこと」と同じ、混ぜないことで守る発想です。

そしてこの修正の過程で、もっと危ないバグが出ました。「編集してから差し戻す」経路でロールバックが走っておらず、それなのにレシートには「作業ツリーは変更されていません」と書かれていた。Claude はその一行を信じて中途半端に書き換わったファイルの上に作業を重ねる。元の ③ より明確に悪いバグです。今は無条件にロールバックします。

④ 日本語は「4文字1トークン」ではない

削減量の計算が 文字数 / 4 でした。英語ソースの比率です。日本語はだいたい 1文字1トークン

効いたのは表示だけではありません。ツール結果の上限が「20,000文字」= 日本語では約20,000トークン。fast ティアの num_ctx は 16,384 です。つまり Ollama が黙って切り捨てて、モデルは全体を見ていないファイルを編集していました。

他に、macOS 由来の NFD ファイル名が Linux で「no such file」になる問題なども直しています。詳細はこちら。

なお ESCALATEAPPLIED といったステータストークンは意図的に翻訳していません。あれは文章ではなくプロトコルで、CLAUDE.md が「ESCALATE が返ったら作業ツリーは無傷」と書いている以上、訳した瞬間に全部壊れます。

使い方

git clone https://github.com/Rikiza89/ollama_mcp
cd ollama_mcp && uv venv && uv pip install -e .
ollama pull qwen2.5-coder:7b

claude mcp add -s user ollama-local -- /abs/path/to/.venv/bin/ollama-mcp

そして 一番飛ばされやすい工程が、CLAUDE.md に委譲ルールを書くことです。これが無いと Claude は自前の Edit を使い続けます。Claude の立場ではその方が速くて確実なので、使うなと明示する必要があります。コピペ用は こちら

正直な数字の見方

local_status を叩くと実測が出ます。

savings so far: {"calls": 41, "succeeded": 34, "escalated": 7,
                 "estimated_tokens_avoided": 118400, ...}

期待値は 委譲可能な作業で30〜60%、難しい思考では約0%。そして削減量より大事な指標が エスカレーション率です。

委譲の1/3以上がエスカレーションするなら、委譲する作業の種類を間違えている。

ローカルモデルが賢くなったから動くのではありません。タスクが狭く、結果が機械的に検査可能で、失敗したら必ず元に戻るから動きます。

そこさえ守れば、ローカルLLMは「おもちゃ」ではなく普通に使える下請けになります。


追記(2026-09-13)

公開後、@Skillselion さんから ③ について指摘をいただき、設計を修正しました(詳細は ③ に反映済みです)。終了判定の向きを逆にし、finish ツールでステータスを自由文から切り離し、エスカレーション時のロールバックを無条件にしました。テストは 128 → 143。

指摘のおかげで、記事に「一番怖いやつ」と書いたものより怖いバグが見つかりました。ありがとうございました。

0
0
2

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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?