2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

900件のテストが緑でも、本番では壊れる──LLM製プロダクトの品質保証で学んだこと

2
Last updated at Posted at 2026-08-31

TL;DR

  • 実装もテストもLLMに書かせ、生成とは別系統のLLMにレビューさせる体制で、Discordに常駐するチャットボットを作り、社外の利用者がいる環境まで出しました
  • テストは MVP時点367件 → 実環境への初投入時805件 → 現在1000件。それでも8月下旬、935件が緑の状態で不具合が4つ出ました(3つは本番で、1つはその修正のレビューで)
  • 4つとも、テストが見ていない側で壊れました。fakeが本番より寛容だった/監視値が嘘をついた/「0件」と「取得できなかった」を同じ値で返した/終了処理がシグナルに繋がっていなかった
  • 効いたのはテストを増やすことではなく、本番の契約をテストに通すこと、失敗を失敗として型に出すこと、レビューを収束するまで回すこと
  • 環境: Python 3.12 / pytest 8 / discord.py / Amazon Bedrock。コードレビューは codex-cli 0.147.0

観測日: 2026-06-24 〜 2026-08-26


はじめに

株式会社七夕研究所では、社内のソフトウェア開発のかなりの部分をLLMに任せています。

補完機能を使って人間がコードを書く、という程度の話ではありません。要求と設計方針は人間が決めますが、実装コードもテストコードもLLMが書きます。そこへ生成とは別系統のLLMをレビュアーとして当て、指摘を受けて直します。人間に残るのは、何を作るか、何を保証するか、その提案を採るか、テストとレビューが足りているか、実環境で起きた現象をどう読むか、出していいか。だいたいこの6つです。

2026年の夏、この方式でDiscordに常駐するチャット型のLLMボットを作りました。複数人の会話に同席して応答し、会話ログを溜め、区切りごとに退避します。6月下旬に社内のドッグフーディングから始め、7月には社外の利用者がいる環境にも展開しました。

8月下旬の時点で、pytestが収集するテストは935件。CIは緑で、別LLMによるコードレビューも複数巡していました。

それでも本番では普通に壊れました。

以下は、個別の機能やアルゴリズムの話ではなく、LLMが大量にコードを書く体制でソフトウェア品質をどう見るべきだったか、という話です。

私たちが採用した開発プロセス

このプロダクトでは、最初から品質保証の工程をかなり重くしました。「LLMが速く書けるのだから速く作ろう」で走ると、後で自分たちが何を保証したのか分からなくなるからです。

要求
 ↓
機能仕様
 ↓
詳細仕様
 ↓
TDD
 ↓
実装
 ↓
CI(lint + pytest)
 ↓
別LLMによるコードレビュー
 ↓
指摘への comply-or-explain
 ↓
ドッグフーディング
 ↓
実環境投入

仕様は3層に分けました。REQ(何を保証したいか)、FUNC(どの機能で満たすか)、DET(実装可能なレベルでどの振る舞いを保証するか)。テストは原則としてDETから起こします。実装した後で通しやすいテストを足すのではなく、先に期待挙動を固定する順序です。

レビュアーLLMの指摘には、comply(受け入れて直す)か explain(直さない理由を書く)のどちらかを必ず返します。黙って無視できないようにしておくと、後から「なぜこの指摘を捨てたのか」を読み返せます。

ここまでやれば、かなり堅そうに見えます。実際、コードだけを見れば堅くなりました。

問題はここからです。

1. 本番にだけ存在した契約

運用中に、観測用のイベントを1種類追加しました。区切り処理がいつ、どの部屋で走ったかを events.jsonl に残すためのものです。

単体テストは通りました。ところが本番では、そのイベントが1件も記録されませんでした。しかもエラーになりません。静かに0件でした。

原因は、テストで使っていたfakeと本番実装の契約の差です。

# テスト用
class FakeEventLog:
    def record(self, payload):
        self.events.append(payload)

fakeは渡されたものをそのまま溜めるので、payloadに何が生えているかを問いません。一方、本番の EventLog は次のように書きます。

class EventLog:
    def record(self, payload) -> None:
        try:
            d = payload.to_dict()
            ...
        except Exception:
            logger.exception("EventLog record failed ...")

新しく追加したpayloadに to_dict() がありませんでした。そして record() は、観測の失敗でアプリ本体を落とさないために例外を握りつぶす設計です。この2つが噛み合って、テストは全部緑、本番は全件欠落、例外は誰も見ないログの中、という状態になりました。

以降、payloadを実際の EventLog に渡し、実際のファイル形式に落ち、実際の集計処理まで通るテストを足しました。ここで得た教訓を一行にすると、テストダブルは本番より便利であってはいけない、になります。

便利であること自体が悪いのではありません。本番で要求される契約を省略してはいけない、というだけです。

LLMは非常に高速にmockやfakeを作ります。テストを増やす限界費用が下がった結果、大量のテストに守られている感覚だけが先に増える。LLM開発では、この問題はむしろ増えるだろうと思っています。

2. 監視値が「保存しました」と嘘をつく

同じ調査の中で、状態の永続化について persisted = true / false をイベントに載せることにしました。何かがおかしくなったとき、メモリ上では処理されたがディスクへ保存できなかった、を後から判別するためです。

ところがレビューで、保存に失敗しても persisted=true が記録される経路が見つかりました。保存関数が OSError をログに出して握りつぶす設計なので、呼び出し側は例外を受け取りません。戻り値も見ていなかったので、このフラグは常に true でした。ディスク満杯や権限エラーという、まさに診断したい場面でだけ嘘をつく監視値です。

アプリケーションが壊れるだけなら、壊れたことには気づけます。監視情報が「正常に保存しました」と言い出すと、障害解析そのものが狂います。

直し方は素直で、保存系の関数を bool 返しの契約に統一しました。例外を投げない(=処理を継続する)契約は保ったまま、成否だけは呼び出し側へ返します。

ok = save_state()
if not ok:
    logger.error(...)
# checkpoint イベントには persisted=ok を載せる

回帰テストは2件。書き込み失敗で False が返ること、そして例外を投げずに False を返す保存失敗が persisted=false として記録されること。後者がないと、同じ穴がまた開きます。

この件以降、品質の見方が少し変わりました。以前は「本体の正しさ」に「監視」を足していました。いまは、振る舞いの正しさと、その振る舞いについての記録の正しさを、同じ「正しさ」の下に並べています。ログが出ているかではなく、ログが現実について正しいことを述べているか。そこまでが保証対象です。

3. 「0件」と「取得できなかった」を同じ値で返していた

Web検索ツールでも問題が出ました。ある時期から、本番環境で検索結果がほぼ常に0件になりました。

コードは動いています。HTTPも成立しています。手元の回線からは同じコードで結果が返ります。調べると、サーバの出口IPがDuckDuckGo側でbot判定されていて、HTTP 202 とともに anomaly ページが返っていました。htmlとliteの両エンドポイントで再現したので、コードではなく出口IPの問題です。

旧実装は、これを「検索結果0件」として扱っていました。上位から見れば「検索したが何も見つからなかった」ようにしか見えません。実際には「検索サービスが利用できなかった」わけで、値としては同じ [] でも意味が違います。

そこで状態を分けました。

SearchResult
├─ Success
│   ├─ results > 0
│   └─ results == 0   ← 正常な「見つからなかった」
└─ BackendFailure     ← 検索基盤が使えなかった

bot判定ページを検出したら SearchBackendError を投げます。ついでに、鍵が無いときに黙って別バックエンドへ降格する挙動もやめました。明示指定なら鍵不在で失敗させます。暗黙の降格は品質劣化を隠すからです。移行先で {"message": "Not enough credits"} が HTTP 200 で返ってくるAPIにも出会ったので、これも0件と混同しないようにしました。

これはLLM固有の問題ではありません。ただ、LLMアプリケーションでは危険度が上がります。下流のLLMは「検索結果が0件でした」と渡されれば、それを前提にもっともらしい文章を書けてしまうからです。従来ならエラー画面になっていたものが、LLMを挟むと自然言語として取り繕われる。失敗を失敗として表現する型設計は、そのぶん重くなります。

なお検索バックエンド探しは、記事の主題からは外れますが消耗しました。Google Custom Search JSON API は発行した鍵で 403、Programmable Search の「ウェブ全体を検索」は2026-01-20以降の新規作成では作れず、Brave も2026-02に無料枠を廃止。結局 Serper に落ち着いています。

4. 終了処理はコードにあったが、実機では走っていなかった

仕様上、プロセス終了時には状態をflushするはずでした。コードにも終了処理はありますし、テストもあります。

実機でコンテナを再起動し、状態ファイルの更新時刻を見たら、4部屋すべて再起動前と同じでした。1つは1か月以上前の日付のまま。終了時flushは一度も走っていません。

docker stopcompose up --force-recreate はSIGTERMを送りますが、Pythonの既定ハンドラはプロセスを即終了させます。クライアントの run() の後片付け(close())に到達しないので、そこにぶら下げていたflushも走らない。それだけの話でした。

修正は setup_hook でSIGTERMを close() に繋ぐこと。SIGINTは触っていません。discord.py が KeyboardInterrupt 経路で既に graceful に閉じるので、二重に登録すると開発時のCtrl-Cの挙動が変わります。シグナルを扱えない環境では、警告を出して起動は続けます。

ここで効いたのは、コードの中に終了処理が存在することと、本番環境で終了処理が実行されることは別の性質だ、という当たり前の区別でした。確認手段も素朴で、コンテナを止めて、ファイルの更新時刻を見て、起動して、また見る。それだけです。ドッグフーディングの価値をいちばん強く感じたのはこの件でした。

5. LLMレビューは1回で終わる工程ではなかった

生成担当とは別に、コードレビュー担当のLLMを使っています。これはかなり有効です。ただ、実装 → AIレビュー → 修正 → 完成、にはなりませんでした。

いちばん長かった変更では、レビューが6巡しました。応答モデルのティアを上げる、という変更です。モデルIDの定数を数行いじるだけのはずでした。実際に起きたのはこうです。

Sonnet は遅い
 → 60秒のタイムアウトでは足りない
 → タイムアウトを延ばす
 → wait_for はキャンセル完了を待つので投稿まで巻き込む
 → shield で手放す
 → 手放したタスクが資源を食う(孤児タスク)
 → 上限管理が要る
 → 上限の追跡から漏れる経路がある

面白かったのは、5巡連続で「前の巡で入れた修正そのもの」がP1として返ってきたことです。3巡目は新機能が本番で無言になる経路、4巡目は通常メッセージ側にだけ上限を入れ忘れていた件、5巡目は外側のタイムアウト経由で孤児タスクが追跡から漏れる件。6巡目でP1が消えました。

途中からは「今回入れた修正が新しい穴を作っていないか」という観点を明示して再レビューさせています。それで実際に見つかりました。

5巡目の修正では、入れた修正を一度戻してテストが本当に落ちることを確認しました。回帰テストが偽陽性でないことの確認です。LLMは通るテストを書くのがうまいので、落ちることの確認は人間側で意識して挟んだほうがいいと思っています。

この経験で、LLMレビュアーの位置づけが変わりました。正解を一度返してくれる相手ではありません。反証を安く何度も試せる相手です。収束するまで回す、その回数込みで工程だと考えたほうが実態に合っています。

6. 差分レビューが「問題なし」を返したとき

同じ変更で、もうひとつ気づいたことがあります。

差分だけを対象にしたレビューは、この変更を clean と判定しました。ところが観点を指定してコード全体との相互作用を調べさせると、P1が複数出ました。しかもP1はすべて、差分そのものではなく「差分によって前提が崩れる既存コード」の側にありました。

つまり次の2つは別のレビューです。

  • 差分レビュー: 今回変更したコードに局所的な問題がないか
  • 影響レビュー: この変更で既存の前提・不変条件・運用が崩れないか

人間のレビューでも同じことは起きますが、LLMではレビュー範囲を明示的に設計できるぶん、意識して使い分けたほうが得です。ちなみに codex review --base <branch> はカスタムプロンプトと併用できないので、観点を指定した深掘りは codex exec --sandbox read-only 側で回しています。

テストと同じで、レビューの件数ではなく、そのレビューが何を観測できるのかを見るべきでした。

7. テスト件数の推移

では900件のテストに意味がなかったのか、というと、そんなことはありません。テストがなければ、この変更速度で運用するのはかなり怖い。見つけた不具合はその都度、回帰テストへ落としています。

実際の推移を出します。pytest --collect-only で各時点のコミットを実測した件数です。

時点 日付 収集件数
MVP(初回のライブ応答が通った日) 2026-06-27 367
社外の利用者がいる環境への初投入 2026-07-13 805
現在 2026-08-26 1000
2026-06-27  ███████                 367  MVP
2026-07-13  ████████████████        805  実環境へ初投入
2026-08-26  ████████████████████   1000  現在

表題の「900件」は、8月下旬に不具合が出た時点のことです。実測では935件でした。そこから現在までに65件増えていますが、内訳はこうなっています。

変更 増分
名簿の項目追加(通常の機能開発) +7
検索の「0件と失敗」分離(第3節) +26
状態永続化とSIGTERM(第2・4節) +23
ダッシュボードの表示追加 +9

2日で増えた49件が、本番で見つけた2つの事故の対応分です。全部が純粋な回帰テストではなく、検索バックエンドを差し替えた分の新規テストも入っています。それでも、テストは製品が成熟したから増えたのではなく、本番で殴られるたびに増えました。増え方が止まらないうちは、まだ外側に世界が残っているのだと思います。

問題は、テストが900件あることと品質が高いことが同じではない、という一点です。テストが保証するのは、そのテストが観測している世界についてだけです。

実世界
┌────────────────────┐
│                    │
│   ┌──────────┐     │
│   │ Tests    │     │
│   └──────────┘     │
│                    │
└────────────────────┘

テストを増やすと内側の面積は広がります。それでも外側には、本番OS、コンテナのライフサイクル、外部サービス、ネットワーク、実データ、実際の権限、人間の操作、デプロイ手順、設定ファイル、時間、障害時の挙動が残ります。今回壊れた4つは、全部この外側にありました。

テスト緑と、使えるは、別です。

8. 事故を修正で終わらせない

今回いちばん効いたのは、個別の不具合を直したことではなく、直すたびに次の形へ変換したことでした。

事故
 ↓
原因分析
 ↓
一般化
 ↓
不変条件 / ルール
 ↓
回帰テスト

fakeでは通った、なら、本番実装の契約を通る契約テストを足す。保存失敗を監視できなかった、なら、成功と失敗の観測値を明示的な契約にする。0件と障害を区別できなかった、なら、正常な空と基盤の失敗を型として分ける。終了処理が実環境では走らなかった、なら、プロセスのライフサイクル自体を受け入れ試験に入れる。

不具合を1個直して終わると、LLMはまた似た形のコードを高速に生成します。事故から得た知識を、次の生成物にも効く制約へ変換しないと割に合いません。

9. 下がったのは、コードを書くコストだけだった

このプロジェクトで最も変わった感覚を書きます。

以前は、実装そのものが大きなコストでした。LLMを強く使うと、コードとテストを作る限界費用が明確に下がります。すると、ボトルネックが判断側へ移ります。何を保証するのか。何をテストするのか。そのテストは本番を代表しているのか。どの失敗を許容するのか。レビュー指摘は正しいのか。修正に副作用はないのか。実環境の現象をどう解釈するのか。

コードを100行足すコストが十分に低くなると、その100行を足していいのかを判断するほうが高くつきます。LLMによって品質工学の仕事が減るのではなく、相対的な比重が上がった、というのが実感です。

10. いまの品質ゲート

現在は、おおむね次の層で品質を見ています。

  1. 仕様: 要求と保証範囲を明示する
  2. 静的解析・単体: lint と単体テスト
  3. 契約: fakeではなく実コンポーネント間の契約を確認する
  4. 結合: 本番と同じ経路を可能な範囲で踏む
  5. 独立レビュー: 生成とは別コンテキストで反証させる
  6. レビューの収束: 修正そのものを次のレビュー対象にする
  7. ドッグフーディング: 実際に人間が使う
  8. 本番の証跡: ログ・状態・外部副作用が想定どおりか確認する
  9. 回帰: 実際の障害をテストか不変条件へ戻す

LLM開発を本格化する前と比べて重くなったのは6から9です。1から5だけなら、以前からやっていました。

11. LLMが書いたコードは特別に低品質なのか

今回の経験からは、LLMが書いたコードは人間より品質が低い、とは言えません。比較実験をしていませんし、出た不具合はどれも人間が書いていても十分起きる種類のものです。fakeと本番の契約差も、握りつぶした例外も、SIGTERMの取りこぼしも、昔からある事故です。

違うのは速度のほうです。LLMは非常に速くコードを書き、非常に速くもっともらしいテストを書きます。人間が品質上の仮定を検討する速度より、ソフトウェアが複雑になる速度のほうが先に上がる。LLM時代のリスクは、AIがバグを書くことよりも、AIが人間の未検証の仮定を高速にコード化してしまうことだと思っています。

だから人間の仕事は、コードを書くことから、仮定を見つけて疑い、検証できる形にすることへ移っていきます。

まとめ

3層の仕様書、TDD、935件のテスト、CI、独立LLMによるコードレビュー、6巡の収束。ここまでやって本番で壊れました。

品質保証が失敗したというより、品質保証の対象がテストコードの外側まで広がった、という理解でいます。持ち帰ったのは次の点です。

  1. テスト数ではなく、本番のどの契約を観測しているかを見る
  2. テストダブルを本番より都合のいい存在にしない
  3. 監視値そのものの正しさをテストする
  4. 正常な空と障害を型として区別する
  5. 修正コードそのものを次のレビュー対象として疑う
  6. LLMレビューは正解を返す相手ではなく、安く反証を試せる相手として使う
  7. 本番で得た知識を回帰テストか不変条件へ戻す
  8. LLMが速くなるほど、人間の主戦場は品質判断へ移る

LLMでソフトウェアを作る速度はかなり上がりました。だからこそ次に要るのは、速くコードを書く方法よりも、速く作られた大量のコードを信用できるソフトウェアへ変換する方法だろうと思っています。

七夕研究所ではまだその方法を作っている途中です。研究的に新しいことをしているつもりはなく、既存の部品を運用に耐える形へ組み直しているだけです。少なくとも今のところ、AIが全部書いてくれるからテストもレビューも軽くできる、ではありませんでした。むしろ逆でした。


この記事は 七夕研究所 Qiita Organization の記事です。運用を始めたばかりで、開発と運用で実際に踏んだものを順次書いていきます。

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?