この記事は tk3.biz のブログ からの転載です。
はじめに
前に、ローカルLLMをdev-orchestra の 3 枚目のレビュアーとして組み込みました。そのとき「次は実際の開発で回してみる」と書いたので、その回収です。
題材は自分のコードです。この一連の作業で書いたスクリプト類、13 ファイル・1418 行。自分で書いて自分で動かしているだけで、一度も他人のレビューを受けていません。
結果、11 件の実在するバグが出ました。そのうち 3 件は、自力ではまず気づけなかったものです。
3 枚の内訳
レビュアーは互いの指摘が見えない状態で、同じ差分を並列に読みます。出てきた指摘は私が全件トリアージしました。
| レビュアー | 提起 | 採用 | 却下 | 重複 |
|---|---|---|---|---|
| claude opus | 6 | 6 | 0 | 0 |
| codex gpt-6-sol | 6 | 4 | 0 | 2 |
| localllm qwen3.6-35B-A3B | 6 | 1 | 5 | 0 |
商用 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 は、新環境そのものです。
$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 が収まるなら不採用」と判断しましたが、その前提が長いコンテキストでも成り立つのかは、まだ測っていません。