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

自分が書いた1418行を3つのAIにレビューさせたら、実バグが11件出た I Had Three AIs Review 1418 Lines I Wrote, and They Found 11 Real Bugs

はじめに

前に、ローカル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 が収まるなら不採用」と判断しましたが、 その前提が長いコンテキストでも成り立つのかは、まだ測っていません。

Introduction

A while ago, I added a local LLM as the third reviewer in dev-orchestra. I wrote then that “next I’ll run it on real development”, so this is the follow-up.

The subject is my own code: the scripts I wrote during this series of work, 13 files, 1418 lines. I wrote them and I run them, and that’s it. They had never been reviewed by anyone else.

The result: 11 real bugs. Three of them were ones I would almost certainly never have found on my own.

The Three Reviewers’ Results

The reviewers read the same diff in parallel, without seeing each other’s findings. I triaged every finding that came out.

How many of each reviewer's 6 findings survived triage. claude opus had 6 accepted, codex gpt-6-sol had 4 accepted and 2 duplicates, and localllm qwen had 1 accepted and 5 rejected

ReviewerRaisedAcceptedRejectedDuplicate
claude opus6600
codex gpt-6-sol6402
localllm qwen3.6-35B-A3B6150

The two commercial reviewers had zero misses. codex’s 2 duplicates described the same spots as claude in different words, and were correct findings in substance.

The local one got 1 out of 6. It’s one round of data, so I won’t draw firm conclusions, but looking at the content, the pattern was clear. More on that below.

Three I Couldn’t Have Found on My Own

1. continue Inside a PowerShell switch Doesn’t Go Back to the Loop

This is the code that resets the history when you type clear in interactive mode.

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

continue inside a switch only exits that switch; it doesn’t go back to the outer while. As a result, right after resetting the history, the string “clear” was sent to the model as is, and it dutifully answered.

Since the finding was about language semantics, I actually reproduced it.

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

Adding a :repl label and using continue repl fixed it. It looked like it was working, so I probably would never have noticed.

2. Streaming Didn’t Check the HTTP Status

The code that reads SSE only picked up lines starting with data: .

Error responses have no data: lines. They just return a JSON error object. So the loop exits without running even once, and it “succeeds” with nothing displayed.

This one hit home because I had run into this exact failure on the day it was flagged. When I tried to have the local LLM do a review and got a 400, the only message was “HTTP Error 400: Bad Request”. The server was returning the real cause (context exceeded) as JSON, and my side was throwing it away.

After the fix, it looks like this:

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

3. The Script That Tallies Review Results Was Itself Broken

This one stung the most.

To record the three reviewers’ hit rates, I had written a script that reads consolidated.json and tallies results per reviewer. Mixing in untriaged rounds would throw the numbers off, so it was built to show a warning.

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

The value dev-orchestra actually writes was needs-triage. The string untriaged doesn’t exist anywhere.

The warning would never have appeared, and it would have kept silently recording untriaged rounds. The tool for measuring hit rates had ended up distorting the measurement.

And this was a bug you could confirm just by looking at that very review’s output. consolidated.md says Triage: needs-triage.

The Local LLM’s 5 Rejections

Looking at what got rejected made the gap with the commercial models clear.

A finding that contradicts itself (security, HIGH)

It’s good that subprocess.Popen doesn’t use shell=True, but the script path is hardcoded. If this path were writable by an attacker, code could be injected.

It admits the path is “hardcoded” and then argues “it would be dangerous if it were dynamic”. There’s no concrete path to failure.

Raising designed behavior as a defect (MEDIUM)

main reads all of stdin into memory. A huge input could cause memory problems or timeouts.

It’s a CLI that receives a prompt, so that’s by design.

Calling a trade-off a defect (LOW, performance)

--sleep-idle-seconds is set to 43200. When idle, the model is unloaded, so the next request pays a cold-start penalty.

That’s a trade-off I chose on purpose in the previous post.

What they have in common is that they end with “it could …”. The two commercial reviewers were the opposite. How continue behaves in a switch, whether the string needs-triage is what actually gets written: they put it in a form you can verify.

The 1 I accepted (swallowing the body of error responses) was a sound finding. Rating it CRITICAL was overblown, but the defect itself is real.

A Defect All Three Missed

On the other hand, there was something everyone missed. And it was one with serious real-world impact.

After the fixes, I ran the cleanup script as a dry run, and it printed this:

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

I wrote this script in part 1 to clean up the old environment. Back then, llama-server was the backend that Lemonade launched, and stopping it was correct.

Now llama-server is the new environment itself.

A defect all three missed. In the old environment, llama-server was Lemonade's backend and stopping it was correct, but after Lemonade was removed it came to mean the new environment itself. Not a single line of code changed

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

If I had run it with -Execute, the cleanup script would have killed the new environment.

You could say it’s natural that it was missed. Not a single line of code changed. What changed was a fact outside the repository: “what that name refers to”. Expecting that much from a review that reads a diff would be asking too much.

It was the obvious lesson: some things can’t be found just by reading the code statically. Actually running things is still worth it, even with three reviewers.

Cost vs. Benefit

To be honest, it’s a hassle.

  • I had to narrow the diff. Including posts and images, it came to 5535 lines, with a lot of waste
  • Reading all 18 findings and deciding on each takes a fair amount of time
  • The local one only added the work of reading 5 rejections

Still, in exchange for 11 real bugs, I thought it was cheap. The switch/continue one and the tally script one in particular were things I had little chance of finding myself.

As for the third, local reviewer, for now I see it as “free insurance”. Its hit rate is low, but it costs nothing, so the only cost is the work of rejecting findings. If the pattern doesn’t change after a few more rounds, though, I’ll consider removing it.

Summary

  • My own 1418 lines of code had 11 real bugs
  • 3 I couldn’t have found on my own: language semantics, a path that fails silently, and a bug in the measuring tool itself
  • The local LLM got 1 out of 6. Most of the rejections were guesses that end with “it could …”
  • There was also a defect all three missed. The code didn’t change, but what a name refers to did. A dry run caught it

Adding more reviews doesn’t mean you can relax. Having it read and actually running it are different things, and you need both.

Next, I’ll dig into widening the context length from 16K to 64K. In part 2, I decided “no KV cache quantization if f16 fits”, but I haven’t yet measured whether that assumption still holds at long context lengths.

関連記事 Related Posts

thinking を切っていいのか測ったら、採点器のほうが2回壊れていた I Measured Whether Turning Off Thinking Was OK, and It Was My Grader That Broke Twice

ローカルLLMのCLIでthinkingを既定で切っていました。速度だけ見て決めた判断の裏を取るため正答率を測ったら、同率でした。ただしそこに至るまでに、自分で書いた採点スクリプトのバグを2回踏んでいます。 I had turned thinking off by default in my local LLM CLI. To back up a decision I'd made by looking only at speed, I measured accuracy, and it was the same. But on the way there, I hit bugs in the grading script I wrote myself twice.

ローカルLLMをターミナルから日常的に使えるようにした Making a Local LLM Part of My Daily Terminal Use

速くしただけでは使わない。PC起動後に`llm`と打つだけで答えが返る状態にするまでと、アイドル時に22.7GBを手放す仕組み、そして48GBを無駄に占有していたバグの話です。 Making it fast wasn't enough to get me to use it. How I got to the point where typing `llm` after booting the PC returns an answer, a way to give back 22.7GB when idle, and a bug that was needlessly taking up 48GB.

Windows に Coreutils を入れてみた I Installed Coreutils on Windows

winget で Microsoft の GNU Coreutils を Windows にインストールし、PowerShell から cat や rm などの Unix コマンドを使えるようにする手順を紹介します。 How to install Microsoft's GNU Coreutils on Windows with winget so you can use Unix commands such as cat and rm from PowerShell.