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

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

AIコードレビューdev-orchestraローカルLLMPowerShell

はじめに

前に、ローカルLLMを dev-orchestra の 3 枚目のレビュアーとして組み込みました。 そのとき「次は実際の開発で回してみる」と書いたので、その回収です。

題材は自分のコードです。この一連の作業で書いたスクリプト類、 13 ファイル・1418 行。自分で書いて自分で動かしているだけで、 一度も他人のレビューを受けていません。

結果、11 件の実在するバグが出ました。 そのうち 3 件は、自力ではまず気づけなかったものです。

3 枚の内訳

レビュアーは互いの指摘が見えない状態で、同じ差分を並列に読みます。 出てきた指摘は私が全件トリアージしました。

各レビュアーの指摘6件がトリアージをどれだけ生き残ったか。claude opusは採用6件、codex gpt-6-solは採用4件と重複2件、localllm qwenは採用1件と却下5件

レビュアー提起採用却下重複
claude opus6600
codex gpt-6-sol6402
localllm qwen3.6-35B-A3B6150

商用 2 枚は空振りゼロ。 codex の 2 件は claude と同じ箇所を別の言葉で書いたもので、 中身としては正しい指摘でした。

ローカルは 6 件中 1 件。 1 回のデータなので断定はしませんが、 中身を見ると傾向ははっきりしていました。後述します。

自力では気づけなかった 3 件

1. PowerShell の switch 内の continue はループに戻らない

対話モードで clear と打つと履歴をリセットする、という実装です。

while ($true) {
    $line = Read-Host
    switch ($line) {
        'clear' { $messages.Clear(); continue }   # ← ここ
    }
    $messages.Add(@{ role = 'user'; content = $line })   # 到達してしまう
    ...
}

switch の中の continue は、その switch を抜けるだけで、 外側の while には戻りません。結果、履歴をリセットした直後に “clear” という文字列がそのままモデルに送られて、律儀に回答されていました。

指摘が言語仕様の話だったので、実際に再現させました。

[1] モデルに送信: msg1
[2] clear を検出 → continue
[2] モデルに送信: clear    ← 指摘どおり
[3] モデルに送信: msg3

:repl ラベルを付けて continue repl にして直りました。 動いているように見えていたので、たぶん一生気づきませんでした。

2. ストリーミングで HTTP ステータスを見ていない

SSE を読む処理が、data: で始まる行だけを拾っていました。

エラー応答には data: 行がありません。JSON のエラーオブジェクトが返るだけです。 つまり**ループが一度も回らずに抜け、何も表示されないまま「成功」**します。

これが刺さったのは、指摘された当日にこの失敗を踏んでいたからです。 ローカルLLMにレビューさせようとして 400 が返ったとき、 出たメッセージは「HTTP Error 400: Bad Request」だけでした。 本当の原因(コンテキスト超過)はサーバが JSON で返していたのに、 こちらが読み捨てていたわけです。

修正後はこうなりました。

修正前: リクエストに失敗しました: HTTP Error 400: Bad Request
修正後: サーバがエラーを返しました: Context size has been exceeded.

3. レビュー結果を集計するスクリプト自体が壊れていた

これが一番こたえました。

3 枚の打率を記録するために、consolidated.json を読んで レビュアーごとに集計するスクリプトを書いていました。 未トリアージの回を混ぜると数字が狂うので、警告を出す作りにしてあります。

untriaged = [r for r in rows if r["status"] == "untriaged"]

dev-orchestra が実際に入れる値は needs-triage でした。 untriaged という文字列はどこにも存在しません。

警告は永久に出ず、未トリアージの回を黙って記録し続けるところでした。 打率を測るための道具が、測定を歪める側に回っていたことになります。

しかもこれは、そのレビュー自体の出力を見れば確認できるバグでした。 consolidated.md に Triage: needs-triage と書いてあります。

ローカル LLM の却下 5 件

却下の中身を見ると、商用との差がはっきりしました。

自己矛盾している指摘(security, HIGH)

subprocess.Popen が shell=True でないのは良いが、スクリプトのパスが ハードコードされている。このパスが攻撃者に書き込み可能ならコードを注入できる。

自分で「ハードコードされている」と認めたうえで「もし動的なら危険」と論じています。 具体的な失敗経路がありません。

設計どおりの挙動を欠陥として挙げる(MEDIUM)

main が stdin を全部メモリに読む。巨大な入力だとメモリ問題や タイムアウトを起こす可能性がある。

プロンプトを受け取る CLI なので、そういう設計です。

トレードオフを欠陥と呼ぶ(LOW, performance)

--sleep-idle-seconds を 43200 に設定している。アイドルだとモデルが降りるので、 次のリクエストでコールドスタートの penalty がある。

それは前回の記事で 意図して選んだトレードオフです。

共通するのは「〜な可能性がある」で終わっていることです。 商用 2 枚は逆でした。switch の continue がどう振る舞うか、 needs-triage という文字列が実際に入るか、確かめられる形で書いてきます。

採用した 1 件(エラー応答の本文を握り潰している件)は、まっとうな指摘でした。 重大度を CRITICAL としたのは過大でしたが、欠陥自体は実在します。

3 枚とも見逃した欠陥

一方で、全員が見逃したものがあります。しかも実害が大きいものでした。

修正後に掃除スクリプトをドライランで流したら、こう出ました。

[2] Lemonade Server を停止
  [dry-run] 停止対象: llama-server (PID 89960)

このスクリプトは第1回で 旧環境を掃除するために書いたものです。当時 llama-server は Lemonade が起動するバックエンドで、止めるのが正しい対象でした。

いま llama-server は、新環境そのものです。

3枚とも見逃した欠陥。旧環境ではllama-serverはLemonadeのバックエンドで止めるのが正しかったが、Lemonade撤去後は新環境そのものを指すようになった。コードは一行も変わっていない

$procs = Get-Process | Where-Object { $_.Name -match 'LemonadeServer|llama-server|ryzenai' }

-Execute していたら、掃除スクリプトが新環境を殺していました。

見逃されたのは当然とも言えます。コードは一行も変わっていません。 変わったのは「その名前が何を指すか」という、リポジトリの外側の事実です。 差分を読むレビューに、そこまで求めるのは酷でしょう。

静的に読むだけでは見つからないものがある、という当たり前の話でした。 実際に動かす価値は、レビューを 3 枚に増やしても消えません。

費用対効果

正直に書くと、面倒ではあります。

  • 差分を絞る必要があった。記事や画像まで含めると 5535 行になり、無駄が多い
  • 18 件を全部読んで採否を決めるのに、それなりの時間がかかる
  • ローカル分は 5 件の却下を読む手間が増えるだけだった

それでも、11 件の実バグと引き換えなら安いと思いました。 とくに switch/continue と集計スクリプトの件は、 自分では発見の見込みが薄いものです。

3 枚目のローカルについては、**現時点では「無料の保険」**という位置づけです。 打率は低いですが、課金されないので却下の手間だけが原価です。 ただし数回まわして傾向が変わらなければ、外すことも考えます。

まとめ

  • 自分のコード 1418 行に、実バグが 11 件あった
  • 自力では気づけないものが 3 件。言語仕様、静かに失敗する経路、 そして測定する道具自体のバグ
  • ローカル LLM は 6 件中 1 件。却下分は**「〜な可能性がある」で終わる**推測が多い
  • 3 枚とも見逃した欠陥もある。 コードが変わらないまま、 名前の指す先が変わったケース。ドライランで見つかった

レビューを増やせば安心、という話ではありませんでした。 読ませるのと動かすのは別で、両方要ります。

次は、コンテキスト長を 16K から 64K に広げた件を掘ります。 第2回で 「KV キャッシュ量子化は f16 が収まるなら不採用」と判断しましたが、 その前提が長いコンテキストでも成り立つのかは、まだ測っていません。

関連記事 Related Posts

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

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

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

自宅PCで動かしているQwenを、複数モデルで独立レビューするdev-orchestraの3枚目のレビュアーとして組み込みました。プロバイダがAPIではなくCLIをラップする設計だったので、薄いCLIを1枚挟んで繋いだ話です。翌日の公式対応と、Store版Pythonのパス罠についても追記しました。 自宅PCで動かしているQwenを、複数モデルで独立レビューするdev-orchestraの3枚目のレビュアーとして組み込みました。プロバイダがAPIではなくCLIをラップする設計だったので、薄いCLIを1枚挟んで繋いだ話です。翌日の公式対応と、Store版Pythonのパス罠についても追記しました。

thinking を切っていいのか測ったら、採点器のほうが2回壊れていた thinking を切っていいのか測ったら、採点器のほうが2回壊れていた

ローカルLLMのCLIでthinkingを既定で切っていました。速度だけ見て決めた判断の裏を取るため正答率を測ったら、同率でした。ただしそこに至るまでに、自分で書いた採点スクリプトのバグを2回踏んでいます。 ローカルLLMのCLIでthinkingを既定で切っていました。速度だけ見て決めた判断の裏を取るため正答率を測ったら、同率でした。ただしそこに至るまでに、自分で書いた採点スクリプトのバグを2回踏んでいます。