tk3.biz
ブログ一覧に戻る Back to Blog

ローカルLLMをdev-orchestraのコードレビュアーに追加してみた ローカルLLMをdev-orchestraのコードレビュアーに追加してみた

AIローカルLLMdev-orchestraコードレビューQwenClaude Code

はじめに

前回、Ryzen AI 搭載のミニPCに ローカルLLM環境(llama.cpp + Vulkan + Qwen3.6-35B-A3B)を作りました。 生成 20 t/s 前後で、対話には十分使える速さです。

せっかく手元にモデルがあるので、コードレビューに使えないかと考えました。 使っているのは dev-orchestra という Claude Code のプラグインで、 複数の AI に独立してコードレビューさせる仕組みを持っています。

既定では Claude と Codex の 2 枚。ここに 3 枚目としてローカルの Qwen を足します。

動機は 3 つ。

  • 課金されない — 何度レビューさせてもタダ
  • コードが手元から出ない — 外に出せないものにも使える
  • 独立した 3 つ目の意見 — 2 つが一致したときの「本当に正しいのか」に対する別の目

結論から言うと動きました。ただし素直には繋がらず、壁が 1 つありました。

壁: プロバイダは API ではなく CLI をラップしていた

dev-orchestra に「レビュアーを追加する」機能はあります。

dev_orchestra.py reviewer add --provider <名前> --role general

ただし --provider に指定できるのは claude / codex / mock の 3 つだけ。 ローカルLLMは OpenAI 互換 API を出しているので、 「エンドポイントを設定すれば繋がるだろう」と踏んでいたのですが、 アダプタの実装を読んで違うと分かりました。

def build_command(self, mode, resolved, cwd, extra_args, options) -> List[str]:
    ...

プロバイダが返すのは コマンドの配列です。 そして実行側はこうなっていました。

proc = subprocess.Popen(
    list(command), cwd=cwd,
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, ...
)

プロンプトは stdin で渡され、答えは stdout から読まれます。 つまりプロバイダは「CLI をラップするもの」であって、API を叩く口はありません。

dev-orchestraの構成図。dev-orchestraから claude / codex / localllm の3つのCLIに分岐し、localllmだけ local-llm.cmd を経由して llama-server のOpenAI互換APIに繋がる。アダプタはstdinでプロンプトを渡しstdoutを読む設計で、APIを直接叩く口が無い

解決: 薄い CLI を 1 枚挟む

本体に手を入れるのは筋が悪いので、プロンプトを受けて答えを返すだけの CLI を作りました。

prompt = sys.stdin.read()
answer = complete(prompt, port, ...)   # localhost:8080 に投げるだけ
sys.stdout.write(answer)

標準ライブラリだけで書いてあります(urllib.request で十分でした)。 サーバが落ちていたら自動で起動して待つようにもしています。 レビューは無人で走るので、ここで止まると困るためです。

--version に応答させるのも忘れずに。dev-orchestra は CLI の存在確認にこれを使います。

バッチファイルは ASCII で書く

Windows なので .cmd のラッパーを置いたのですが、最初こうなりました。

'。' is not recognized as an internal or external command,
operable program or batch file.

REM に日本語コメントを書いたのが原因です。 cmd.exe は UTF-8 のバイト列を OEM コードページで読むので、 コメントが壊れてコマンドとして解釈されます。動きはしますが毎回エラーが出ます。

バッチファイルは ASCII のみで書くのが無難でした。

アダプタを書く

あとは CLI をラップするアダプタを書くだけです。

class LocalLLMProvider(Provider):
    name = "localllm"
    executable = r"C:\Projects\local-llm\bin\local-llm.cmd"

    def build_command(self, mode, resolved, cwd, extra_args, options):
        if mode == MODE_IMPLEMENT:
            raise ModelResolutionError(
                "localllm cannot implement: it has no file access."
            )
        ...

implement モードを明示的に拒否しています。 この CLI はファイルを読み書きできないので、うっかり実装役に割り当てると 「成功したのに何も変わっていない」という、いちばん気づきにくい失敗をします。 できないことは、できないと言わせておくのが安全です。

登録はプロバイダのレジストリに 1 行足すだけでした。

register(localllm.LocalLLMProvider.name, localllm.build_provider)

プラグインキャッシュに置くので、更新すると消える

アダプタの置き場所はここです。

%USERPROFILE%\.claude\plugins\cache\dev-orchestra\dev-orchestra\0.8.0\
  scripts\orchestrator\providers\

名前のとおり キャッシュなので、プラグインを更新すると消えます。 パスにバージョン番号も入っています。

なので、原本は自分のプロジェクト側に置き、 流し直せば復旧する導入スクリプトを用意しました。

pwsh -File integrations\dev-orchestra\install.ps1            # 導入
pwsh -File integrations\dev-orchestra\install.ps1 -Status    # 状態確認
pwsh -File integrations\dev-orchestra\install.ps1 -Uninstall # 撤去

レビュアーの登録自体は %APPDATA%\dev-orchestra\config.yaml にあるので消えません。 アダプタだけ消えて「unknown provider」になる、という壊れ方をします。

こういう「更新すると消える場所に置かざるを得ない」改造は、 復旧手順をセットで用意しておかないと、半年後の自分が確実に困ります。

【追記】この節は 0.8.0 時点の話です。 0.9.0 で公式の仕組みが入り、プラグインを改変する必要はなくなりました。 いま同じことをするなら、この方法は使わないでください。 経緯は記事末尾の「追記: 公式の仕組みが入った」に書きました。

動くか試す

登録できました。

1. claude-general     claude   opus               latest   general
2. codex-general      codex    recommended-coding latest   general
3. localllm-qwen      localllm qwen3.6-35b-a3b    latest   general

レビュアー3枚体制の流れ。凍結した差分から claude-general、codex-general、localllm-qwen の3つに並列で渡され、結果を統合してから人がトリアージする

ただし、登録できたことと使えることは別です。 dev-orchestra はレビュー結果を決まった形式で要求します。

## Finding
- Severity: critical | high | medium | low
- File: <path>
- Line: <number, range, or n/a>
- Category: <correctness | security | performance | tests | …>
- Problem: ...

そして、形式を守れなかったレビューは unparsed として「失敗」扱いになります。 ドキュメントにこう書いてありました。

読めないレポートは、コードが問題ないことの証拠にはならない。 それを問題なしとして扱うのは、レビューツールにとって最悪の壊れ方である。

35B のローカルモデルがこの形式を守れるかは、やってみないと分かりません。

バグを仕込んで試す

わざと 2 つ欠陥を入れた diff を食わせました。

 def average_price(items):
-    if not items:
-        return 0
-    return sum(i.price for i in items) / len(items)
+    return sum(i.price for i in items) / len(items)
+
+def top_n(items, n):
+    ordered = sorted(items, key=lambda i: i.price, reverse=True)
+    return ordered[:n+1]
  • 空リストのガードを消した → ZeroDivisionError
  • [:n+1] のオフバイワン → n 件のはずが n+1 件返る

結果です。

仕込んだ欠陥検出重大度
ガード削除による ZeroDivisionError検出critical
ordered[:n+1] のオフバイワン検出high

2 つとも見つけました。 形式も完全に守られていて、そのままパースできる状態です。 所要 29 秒。

ノイズは多め

ただし、低優先度の指摘が 3 件ぶら下がってきました。 そのうち 1 件がこれです。

  • Severity: low
  • Category: performance
  • Problem: 空チェックを外したことで、空リストでも除算が走るようになった。
  • Impact: 性能影響は無視できるが、論理的に誤った挙動が主な問題。

critical で挙げたのと同じ行を、今度は performance として挙げ直しています。 自分で「性能影響は無視できる」と書いているとおり、これは実質的な重複です。

商用モデルに比べると、こういう水増しは目立ちます。 dev-orchestra は重複をある程度まとめてくれますが、 言い回しが違う重複は人が潰すしかありません。

レビュアーを増やすと、その分トリアージの手間も増える。 3 枚目を足すというのは、そういうトレードオフでもありました。

おまけ: 「CLI が入っていない」の犯人は PATH だった

作業中、doctor がこう言い出しました。

Problems
  - Orchestrator: claude CLI is not installed
  - Architect: claude CLI is not installed
  ...

4 つのロールとレビュアー 1 枚が死んでいる、と。 しばらく「claude CLI を入れ直す必要があるのか」と考えていたのですが、 違いました。claude を入れ直して PATH が変わったのに、 シェルが古い PATH を持ったままだっただけです。

$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','Machine') + ';' +
            [System.Environment]::GetEnvironmentVariable('PATH','User')

読み直したら No problems found. になりました。

CLI を入れ直したあとに「入っていない」と言われたら、まず PATH の鮮度を疑う。 ツールを疑う前に自分のシェルを疑うべきでした。

向き不向き

役割可否理由
reviewer使える変更差分はプロンプトで渡されるので、リポジトリを読めなくても成立する
architect / implementer / review_fixer使えないファイルを読み書きできない

claude や codex はエージェント型の CLI で、関連ファイルを自分で追って調べられます。 対してこのローカル LLM は、渡されたプロンプトしか見えません。 「この関数の呼び出し元も見に行く」類の指摘はできない、ということです。

なので商用モデルの置き換えにはなりません。3 つ目の独立した目として足す、 という位置づけが実態に合っています。

追記: 公式の仕組みが入った

(2026-09-25 追記)

記事を書いた翌日、dev-orchestra 0.9.0 が出て、予想どおりアダプタが消えました。

Problems
  - reviewers[2]: unknown provider 'localllm'
  - Reviewer localllm-qwen: unknown provider 'localllm'

壊れ方も予想どおりでした。config.yaml にレビュアーの登録は残っているのに、 それが指すアダプタだけが無い。設定が片方だけ生き残るので、 更新した時点では気づけません。

ただし、今回は復旧スクリプトを流す必要がありませんでした。 0.9.0 でユーザー独自プロバイダの公式サポートが入っていたからです。

プラグインを改変しなくてよくなった

置き場所は設定ディレクトリの下です。

<設定ディレクトリ>\providers\*.py

config.yaml と同じ場所なので、プラグインを更新しても消えません。 アダプタと、それを参照する設定が、同じ寿命になりました。

こちらの変更は import 1 行だけでした。

-from .base import Provider, ResolvedModel, ...
+from orchestrator.providers.base import Provider, ResolvedModel, ...

パッケージの外から読み込まれるので、相対 import が使えません。 ドキュメントにも「built-in をコピーするなら、その 1 行を変えること」と書かれていました。

doctor が由来まで表示してくれるようになったのも助かります。

User providers
  Directory: ...\dev-orchestra\providers
  Code in this directory is imported at startup, from outside the plugin.
  Imported: localllm  <- ...\providers\localllm.py

Local LLM (llama.cpp) (localllm)
  Source: user module ...\providers\localllm.py

「このアダプタはサードパーティで、ここから来ている」と一目で分かります。

本題: インストーラを PowerShell で書いたのが間違いだった

移行自体は簡単だったのですが、ここで今回いちばん厄介な罠を踏みました。

新しい置き場所にアダプタをコピーしたのに、doctor がこう言い続けます。

User providers
  Directory: C:\Users\...\AppData\Roaming\dev-orchestra\providers (not present; nothing imported)

PowerShell では存在するのに、Python からは存在しない。

PowerShell:  Test-Path  -> True    (localllm.py もある)
Python:      os.path.isdir -> False

同じパス文字列です。しかも同じディレクトリの config.yaml は dev-orchestra がちゃんと読めている。

犯人は Microsoft Store 版 Python のファイルシステム仮想化でした。 Store アプリは %APPDATA% への読み書きをパッケージ内へリダイレクトします。

見る側実際に読み書きされる場所
PowerShellC:\Users\<user>\AppData\Roaming\dev-orchestra\
Store 版 Python%LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.13_…\LocalCache\Roaming\dev-orchestra\

証拠は、同じ config.yaml のサイズが見る側で違うことでした。

PowerShell から : 0 バイト
Python から     : 705 バイト

たちが悪いのは、パス文字列では見抜けないことです。 os.environ['APPDATA'] は普通の C:\Users\...\AppData\Roaming を返します。 差が出るのはパスではなく、ファイル操作そのものの方でした。

なので「Python に正しいパスを聞けば安全だろう」という回避策も効きません。 実際それを実装して、同じ場所に書いて、同じように失敗しました。

対処はインストーラを Python で書き直すことでした。

# 読む側と同じインタプリタに書かせる。
# そうすればリダイレクトがあってもなくても、必ず同じ場所になる。
shutil.copyfile(SOURCE, target)

パスを正しく計算しようとするのではなく、計算しなくて済むようにする。 こちらが正解でした。

別の Python(python.org 版など)に切り替えると、リダイレクトが無くなって 設定ごと「消えた」ように見えます。--status で警告を出すようにしました。

教訓

  • 「更新で消える場所に置くしかない」と感じる改造は、長生きしない。 今回は 1 日で公式対応が入り、自前の回避策は不要になりました。 同じ不便を感じている人が他にもいた、ということだと思います (こちらから要望を出したわけではなく、タイミングが重なっただけです)
  • パスが同じでも、同じ場所とは限らない。 Windows の Store アプリが絡むときは、 「どのプロセスが書いて、どのプロセスが読むのか」を先に確かめる

まとめ

  • dev-orchestra のプロバイダは API ではなく CLI をラップする設計。 OpenAI 互換 API があっても、そのままでは繋がらない
  • stdin → stdout の薄い CLI を 1 枚挟むだけで、本体に手を入れずに繋がった
  • 仕込んだバグ 2 件を両方検出し、要求された出力形式も守れた
  • ただし低優先度のノイズが多い。トリアージの手間は増える
  • 当初はプラグイン本体を改変するしかなかったが、 0.9.0 で公式のユーザープロバイダ機構が入り、改変は不要になった(追記参照)
  • パス文字列が同じでも、同じ場所とは限らない。 Store 版 Python の %APPDATA% リダイレクトで半日溶かした

課金されず、コードも外に出ない 3 枚目のレビュアーが手に入りました。 商用モデルほどの鋭さはありませんが、 「無料で何度でも回せる別の目」は、それはそれで使いどころがあります。

次は実際の開発でこの 3 枚体制を回してみて、 ローカル分がどれくらい当たるのか(そして外すのか)を見ていく予定です。

関連記事 Related Posts

自分が書いた1418行を3つのAIにレビューさせたら、実バグが11件出た 自分が書いた1418行を3つのAIにレビューさせたら、実バグが11件出た

Claude・Codex・ローカルQwenの3枚に、自分で書いて自分で動かしていたスクリプトをレビューさせました。3件は自力では気づけないもので、1件は「レビュー結果を集計するスクリプト自体」のバグでした。一方で、3枚とも見逃した欠陥もあります。 Claude・Codex・ローカルQwenの3枚に、自分で書いて自分で動かしていたスクリプトをレビューさせました。3件は自力では気づけないもので、1件は「レビュー結果を集計するスクリプト自体」のバグでした。一方で、3枚とも見逃した欠陥もあります。

機密コードをクラウドに出さずにAIでレビューする(ローカルLLM × dev-orchestra) 機密コードをクラウドに出さずにAIでレビューする(ローカルLLM × dev-orchestra)

社外に出せないコードを、クラウドのAIに送らずにレビューする方法をまとめました。ローカルLLMをdev-orchestraのレビュアーに登録し、機密のものはローカルだけで、公開してよいものはクラウドのAIと併用します。ローカルLLMのレビューの実力と、呼び出し方ひとつでコードが外に出てしまう落とし穴も書きます。 社外に出せないコードを、クラウドのAIに送らずにレビューする方法をまとめました。ローカルLLMをdev-orchestraのレビュアーに登録し、機密のものはローカルだけで、公開してよいものはクラウドのAIと併用します。ローカルLLMのレビューの実力と、呼び出し方ひとつでコードが外に出てしまう落とし穴も書きます。

Claude Codeの/insightsで自分の使い方を分析してもらってみた Claude Codeの/insightsで自分の使い方を分析してもらってみた

Claude Codeの/insightsコマンドで過去41セッションを分析してもらいました。うまくいっている使い方や詰まりポイント、自分の使い方の癖まで指摘されたレポートの中身を紹介します。 Claude Codeの/insightsコマンドで過去41セッションを分析してもらいました。うまくいっている使い方や詰まりポイント、自分の使い方の癖まで指摘されたレポートの中身を紹介します。