1
1

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をdev-orchestraのコードレビュアーに追加してみた

1
Posted at

この記事は tk3.biz のブログ からの転載です。

はじめに

前回、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% への読み書きをパッケージ内へリダイレクトします。

見る側 実際に読み書きされる場所
PowerShell C:\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 枚体制を回してみて、ローカル分がどれくらい当たるのか(そして外すのか)を見ていく予定です。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?