一覧に戻る
    「また同じ警告か」をどう避けるか:PRコメント生成ツールの品質を、沈黙の質から問い直す
    開発ラボ
    PRこの記事には広告が含まれています

    「また同じ警告か」をどう避けるか:PRコメント生成ツールの品質を、沈黙の質から問い直す

    28 分で読める

    前回の記事で、影響範囲調査レシピを生成するツール(以下、レシピジェネレーター)の Phase0 が動いたところまで書きました。

    「ハルシネーション対策」を実装する前に、設計変更で消えていた──Phase 0 検証で起きた3つのピボット

    「ハルシネーション対策」を実装する前に、設計変更で消えていた──Phase 0 検証で起きた3つのピボット

    Tech
    開発ラボ

    「動いた」と書きましたが、正直、本当に動いたのは「コードが落ちずに PR コメントが投稿された」というレベルの話です。実際に自分の PR で使ってみると、出てくるコメントの品質に、いくつも違和感を抱く瞬間がありました。

    この記事は、そこから「沈黙の質を上げる」を合言葉に、プロンプトとコードの両面で品質を詰めていった試行錯誤の記録です。まだ道半ばですが、LLM を PR レビュー補助に使うときにハマりやすい罠が、かなりきれいに浮かび上がってきました。

    前提:このツールは何をするものか

    簡単に振り返ると、このツールは PR の diff を読んで、レビュアーが見落としがちな「認知の盲点」(B1〜B7 の 7 パターン) を検出し、確認用のチェックリストを PR コメントに投稿します。

    処理の流れはこうです。

    detection・generation・consolidation はそれぞれ Azure OpenAI を呼んでいて、要は LLM を 3 段使って「どこに注意を向けるべきか」のチェックリストを絞り込む構造になっています。

    技術的には動きました。でも、出てくるコメントが読むに耐えるかどうかは、また別の話です。

    最初の違和感:「形骸化」の兆候

    ある PR に対して投稿されたコメントを読んでいて、ふと手が止まりました。要約するとこういう内容です。

    - 外部契約: PR メタデータが外部 LLM に送信される経路が有効化され、機密情報・個人情報漏洩や外部送信ポリシー違反のリスク
    - 外部仕様依存: プロンプト形式変更が downstream のパーサやテストを壊すリスク

    - [ ] 法務・セキュリティの承認を取得した
    - [ ] 外部クライアントや他リポジトリでの参照を確認した
    - [ ] テストでカバーされているケースに過信していないことを確認した

    7 項目のチェックリスト。読んだ瞬間に、まずいな、と思いました。

    気になったのは 4 つ。

    1. 「外部契約: LLM への送信リスク」はこのツール自身の設計前提。このツールは本来そのために LLM を使っているのに、全 PR で「法務承認を取れ」と出続ければ、レビュアーは「また同じ警告か」と読み飛ばすようになります。
    2. 「外部仕様依存」は playbook に存在しない盲点名。LLM が名前を創作しています。「downstream のパーサを壊す」も、該当するパーサは実在しません。典型的なハルシネーション。
    3. チェックリストが 7 項目(仕様は 4〜6)。
    4. 「過信しない」「慎重に」など、どの PR でも言える自己暗示が混ざっている。

    ここで改めて気づいたんですが、ノイズと本物の洞察は対称じゃないんですよね。ノイズを一度出すたびに、「このツールのコメントは斜め読みでいい」という学習がレビュアーに蓄積されていく。そうなると、本当に重要な指摘を出したときにも、もう読まれません。

    PR コメント系ツールの本当の勘所は、沈黙している時が一番信頼される、ということなんだと思います。

    施策1:grounding(根拠)を強制する

    最初に手を付けたのは、ハルシネーションへの対策です。

    「なんとなく B3 っぽい」のような曖昧な検出と、「存在しない盲点名の創作」を止めたい。そのために、検出結果に grounding(根拠)の要求 をかけることにしました。

    ルールはシンプルです。以下の両方を満たさない検出は drop します。

    1. matched_files に含まれるパスがすべて diff に実在する
    2. reasoning に diff 由来の具体的な識別子(ファイル basename、関数名、変数名など)が含まれている

    プロンプト側にも同じルールを書いておき、コード側でもフィルタする二段構えにしました。

    def _is_grounded(self, match: PatternMatch, diff_files: set[str], diff_text_lower: str) -> tuple[bool, str]:
        # matched_files の妥当性
        invalid = [f for f in match.matched_files if f not in diff_files]
        if invalid:
            return False, f"matched_files not in diff: {invalid}"
    
        reasoning = (match.reasoning or "").strip()
        if not reasoning:
            return False, "empty reasoning"
    
        # reasoning にファイル basename が含まれるか
        basenames = {f.rsplit("/", 1)[-1].lower() for f in diff_files}
        reason_lower = reasoning.lower()
        if any(b and b in reason_lower for b in basenames):
            return True, "file basename referenced"
    
        # reasoning に含まれる識別子が diff 本文に出現するか
        tokens = {t.lower() for t in _IDENTIFIER_RE.findall(reasoning) if len(t) >= 3}
        for token in tokens:
            if token in diff_text_lower:
                return True, f"identifier {token} referenced"
    
        return False, "no concrete diff anchor"

    実装して、次の実行。結果は「該当パターンなし」でした。

    3 件検出されていたものが、全部 drop されていました。

    ログを確認すると、LLM は確かに B6 (high), B1 (medium), B7 (low) を検出していました。でも全部 grounding フィルタを通らなかった。

    プロンプトログの JSON と実装を突き合わせてデバッグした結果、原因は CRLF 改行 でした。Jenkins 上で実行されるため、diff テキストに \r\n が混ざっていて、matched_files の照合でパスの末尾に \r が残ってしまい、"src/foo.py\r" != "src/foo.py" で弾かれていた。

    学び:フィルタは「正しい沈黙」か「過剰フィルタ」かを区別する

    静かになったレポートを見て「きれいになった」と喜びたくなるんですが、ここで一度立ち止まるのが大事でした。

    • LLM が検出 0 件を返した(正しい沈黙)
    • LLM は検出したが grounding で drop(過剰フィルタ)
    • API エラー・JSON パース失敗で事故的に 0(壊れた静けさ)

    この 3 つを混ぜない。ツールの状態を自分が把握できていないとチューニングができないので、フィルタ入口と drop 理由を両方 INFO ログに出すようにしました。

    logger.info(
        "Grounding filter input: candidates=%d diff_files=%d",
        len(matches), len(diff_files)
    )
    # ...
    logger.info(
        "Detection result dropped (ungrounded) pattern=%s reason=%s",
        match.pattern_id, reason
    )

    施策2:チェックリストの上限と禁止カテゴリ

    grounding で創作は減りましたが、今度はチェックリストが 11 項目に膨らむ PR が出ました。集約プロンプトで「4〜6 項目」と書いているのに、守られていない。

    ここは2段構えで対処しました。

    プロンプト側:禁止カテゴリを具体的に列挙する。

    - **チェックリストは合計 6 項目以内**。7 項目以上になる場合は、
      PR 固有性の弱い項目から順に削除する
    - **PR 固有性のない汎用項目は出力しない**。次のような項目は禁止:
      - 「テストを実行して全件 pass することを確認した」「ドキュメントを更新した」
        など、どの PR でも成立する汎用確認
      - 「過信しない」「慎重にレビュー」「注意する」など、
        具体的な確認行動を含まない自己暗示
      - 法務承認・組織ポリシー適合・コンプライアンス承認など、
        ツールや開発者個人では判定できないマクロ観点
      - 「他リポジトリでの参照」「外部クライアント」など、
        diff から根拠が確認できない推測

    コード側:プロンプトが守られなかった場合の安全網として、最後に cap_checklist_items() でハードリミットをかける。

    def cap_checklist_items(content: str, max_items: int) -> str:
        """チェックリスト見出し以降の - [ ] 項目を max_items 件で切り詰める。"""
        # チェックリスト見出しを探す
        heading_match = re.search(r"^##\s*チェックリスト\s*$", content, re.MULTILINE)
        if not heading_match:
            return content
        # ... 見出し以降の項目をカウントし、超過分を削除

    プロンプトだけに頼らない、コードで最後のガードをする。これ、LLM を組み込んだツールを書くときにかなり効く原則だと感じています。プロンプトは「この形で出してほしい」というお願いで、コードは「この形でしか通さない」という契約。両方ないと、品質がモデルのその日の機嫌に左右されます。

    施策3:JSONパース失敗の真犯人

    しばらく運用していて、ある日また「該当パターンなし」が出ました。でもログを見ると様子がおかしい。

    WARNING - 検出レスポンスのパースに失敗しました:
      Expecting ',' delimiter: line 20 column 116 (char 1284)

    JSON のパース失敗で drop しています。保存してあったプロンプトログを確認すると、reasoning フィールドの中に、こんな文字列が入っていました。

    "reasoning": "recipe_generator.py のプロンプトフォーマットが `"pr={pr_number}:{pr_title}:..."

    見えますか? reasoning 文字列の中に、バックティックで囲まれたコードスニペットがあり、その中に " が含まれている。LLM が JSON 文字列内で " をエスケープし忘れて、JSON の文字列境界を破綻させていました。

    これは LLM 生成の JSON ではあるあるの失敗です。対策として、システムプロンプトに JSON 記述ルールを追加しました。

    ## JSON 文字列の記述ルール(厳守)
    - **`reasoning` の中に `"` を含めない**。
      コード例や文字列リテラルを示したい場合はバックティック(\` ... \`)
      や日本語引用符「...」を使う
    - **`reasoning` の中に改行(`\n`)やバックスラッシュを含めない**。
      1 行の自然文で簡潔に
    - JSON としてそのまま `json.loads()` に通せる文字列だけを生成する。
      エスケープ忘れは後段で即 drop になる

    合わせて、max_completion_tokens を 3000 → 8000 に上げました。gpt-5 系は内部の推論トークンも完了枠を消費するため、3000 だと推論で使い果たして空返却になったり、JSON が途中で切れるケースがあったからです。

    ここもモデル固有の性質に引きずられた話で、「いつもの OpenAI 感覚」では踏む罠でした。

    施策4:沈黙の質を上げる

    grounding が効きすぎると、コメントが「該当パターンなし」の 1 行だけになる PR が出てきます。これはこれで情報が少なすぎる。

    ただ、「何か書け」とプロンプトに言うと、今度は「盲点はないが〜にも注意」みたいな 尾ひれ が付いて、ノイズが戻ってきます。

    考えた末に、「検出 0 件」専用のプロンプトに分岐する ことにしました。

    ## 結果: 該当する盲点は検出されませんでした
    
    ### この PR の変更概要
    diff を 1〜2 文で要約する。PR 固有のファイル名・関数名・識別子を含めて具体的に記述する。
    
    ### 判定の補足
    なぜ B1〜B7 のどのパターンにも該当しなかったか、1〜2 文で説明する。

    制約は厳しくしました。

    • 2 セクションのみ、各 1〜2 文
    • 「慎重にレビュー」「過信しない」などの汎用注意は禁止
    • 「盲点はないが〜に注意すべき」と新たな観点を滲ませることを禁止(ここでは沈黙の説明に徹する)

    さらに、API エラーや JSON パース失敗のときは、このプロンプトに分岐しない。事故ケースで LLM にごまかし要約を作らせるのは危険だからです。そういうときは「該当パターンなし」という素朴な表示に戻します。

    def _build_no_match_output(outcome, diff_text, ...) -> str:
        """検出 0 件時の出力を構築する。成功ケースは LLM サマリ、事故ケースは簡易メッセージ。"""
        if not outcome.successful:
            logger.info("検出処理に失敗したため no-match サマリをスキップします")
            return "該当パターンなし\n"
        # ... LLM で沈黙の説明を生成

    この「成功と失敗を区別する」ために、detection の戻り値を list, usage のタプルから DetectionOutcome(matches, usage, successful) という dataclass に変えました。空配列には 2 種類ある(検出できなかった / 検出に失敗した)という区別を、型で表現した形です。

    施策5:動詞を縛る

    ここまでの改善で、コメントのノイズはかなり減りました。ただ、出てくるチェックリストを読み返していて、次の違和感が来ました。

    - [ ] chmod 777 が必要か・770 で代替可能か確認した
    - [ ] archive の保存先・閲覧範囲が適切か確認した
    - [ ] savellmcall_log にマスクの仕組みがあるか確認した

    これ、「影響範囲調査」じゃなくて「コードレビュー」なんですよね。

    • 悪い: 「chmod 777 が必要か確認した」(author が説明する)
    • 良い: 「PROMPTLOGDIR の権限変更により、コンテナ外から書込み可能になる範囲を確認した」(影響の追跡)

    動詞が決定的でした。

    • 「列挙した」「参照箇所を全て確認した」「影響を受ける下流を検証した」 → impact analysis(トレース)
    • 「〜が必要か確認した」「〜が妥当か確認した」「〜が適切か確認した」 → design review(正当性の問い直し)

    このツールの目的は前者です。後者はレビュアー自身が PR を読んで判断すべきことで、ツールが列挙しても意味がない(それどころか、author と reviewer の間でやるべき対話を肩代わりしてしまう)。

    プロンプトでこう縛りました。

    ### 使うべき動詞(トレース系)
    - **列挙した** / **参照箇所を全て確認した** / **影響先を検証した**
      / **下流に与える変化を確認した** / **呼び出し元をたどった**
      / **消費側を洗い出した**
    
    ### 使ってはいけない動詞・形式(コードレビュー系)
    - 「〜が必要か確認した」「〜が妥当か確認した」「〜が適切か確認した」
    - 「〜の実装をレビューした」「〜の欠如を確認した」
    
    ### 禁止する項目カテゴリ
    - **設計判断の正当化要求**: 「なぜこの値なのか」「代替手段を検討したか」
    - **組織ポリシー適合**: 「監査要件に適合するか」「法務承認が取れているか」
    - **実装の欠如指摘**: 「マスキング機構があるか」
      (※これらは影響調査ではなく実装改善提案)

    次の実行では、かなりまっとうなチェックリストが出るようになりました。

    - [ ] テンプレートを読み込むジェネレータ・消費する下流(CIレポート集約、
    ドキュメント自動生成ツール等)を呼び出し元ごとに列挙した・消費側を洗い出した

    動詞が「列挙した」「洗い出した」になっている。「変更された対象」と「その影響先」の両方が書かれている。求めていた形です。

    予期しなかった罠:LLMの自己参照

    このルールを入れた直後に、ひとつ面白い失敗に遭遇しました。

    プロンプトのテンプレート(.md ファイル)を編集する PR に対してツールを走らせたら、チェックリストに chmod 777 の話 が出てきました。

    でも、その PR は chmod 777 なんて一切触っていません。

    プロンプトログを見て、原因がわかりました。僕はさっきのルールに、こんな例文を書いていたんです。

    - 悪い例: `- [ ] chmod 777 が必要か確認した`(設計の正当性を問う)
    - 良い例: `- [ ] PROMPT_LOG_DIR の権限変更(chmod 777)により、
      コンテナ外から書込み可能になる範囲を確認した`

    LLM は、この例文を「PR の実変更」だと誤認していました

    • 通常の PR: diff が実コード → プロンプト内の例文と区別できる
    • プロンプト編集 PR: diff そのものがプロンプトテキスト → 例文が diff に入り込む

    meta-PR 固有の罠。自分で自分のプロンプトを書き換えるツールを作ると、こういう自己参照的な状況が発生します。

    ここで手が止まって、一瞬笑いました。AI ツールを開発するときの LLM は、「自分が今扱っているテキストが何のテキストか」を区別しない。区別してくれると思い込んでいた自分の前提が、ここで崩れた瞬間でした。

    対策はまだ未完です。いまのところ思いつくのは、

    • 悪い例/良い例をコードフェンスで囲って「これは説明用の例示」と明示する
    • 「diff が .md ファイルで、その中に『悪い例:』『良い例:』のマーカーを含む場合、それらは illustration であり実変更ではない」とプロンプトに書く

    のどちらかですが、これ自体がまた新しい例文として diff に混ざる可能性があり、キリがない問題です。この手の自己参照は、根本的には「プロンプトを書くこと」と「プロンプトで扱われる PR の対象になること」を同じリポジトリでやっているから起きるので、構造を見直したほうがいい気もしています。

    ここまでの全体像

    試行錯誤のループをまとめると、こんな流れでした。

    1 周回すたびに、次の違和感が見えてきます。これ、ドキュメントを書いて終わる種類の仕事じゃないなと実感しました。プロンプトとコードの両方を、使いながら育てていく仕事です。

    残している課題:トークン消費

    品質はかなり上がってきたんですが、最後に残ったのが トークン消費 です。

    diff は現状、以下で送られています。

    • detection(1回)
    • generation(N回 = パターン数)
    • consolidation(1回)
    • nomatchsummary(0 or 1回)

    PR が 3 パターンヒットすると diff が 5 回、5 パターンで 7 回送信される。大きな diff ほど線形で無駄が膨らみます。

    次に試したい施策はいくつかあります。

    • prompt caching を効かせる:gpt-5 系は先頭 1024 tokens 以上が完全一致するプロンプトをキャッシュして大幅割引してくれる。静的な playbook を system prompt 側に寄せればキャッシュヒットする
    • consolidation から diff を外す:consolidation は個別 recipe を統合する役割で、recipe 内に具体識別子は既に入っているはず
    • generation を matched hunks のみに絞る:pattern が関係するファイルの hunks だけ渡す

    ここは別 issue に切り出して、次のセッションで取り組みます。

    振り返り:LLMを組み込むときに効いた原則

    今回の品質改善を通して、自分の中で整理された原則がいくつかありました。

    1. プロンプトとコード、両方で縛る

    プロンプトは「こう出してほしい」というお願いで、コードは「こうでしか通さない」という契約。両方ないと、品質がモデルのその日の機嫌に左右されます。

    2. 「空」には種類がある

    検出 0 件と、検出に失敗して 0 件は、まったく別の状態です。型で区別してしまうのが一番確実でした。

    3. 沈黙は価値

    ノイズを出すたびに「斜め読みでいい」がレビュアーに学習される。PR コメント系ツールは、静かにしているときが一番信頼される

    4. 動詞が設計を決める

    「列挙する」と「妥当か確認する」では、ツールの役割がまったく違う。プロンプトで動詞を縛るだけで、出力の性格がガラッと変わります。

    5. 自分が作っているものを、自分に使ってみる

    プロンプト編集 PR でプロンプトが自己参照的に暴発する、みたいな罠は、使ってみないと絶対に見えません。

    参考:基盤モデルを組み込むアプリケーション設計

    今回扱った grounding、プロンプトとコードの二段構え、トークン経済といった論点は、基盤モデルを活用したアプリケーション開発で共通して立ち現れるものです。もう少し体系立てて押さえたい方には、以下が補助線になります。


    ここまで書いてきて改めて思うのは、LLM を組み込んだツールの品質って、「LLM の性能」ではなく「LLM との付き合い方の設計」 で決まるんだな、ということでした。プロンプトエンジニアリングというより、プロンプト運用設計、と呼びたい感覚に近いです。

    そして、それを詰めていくプロセス自体が、「自分は何を検出してほしくて、何を検出してほしくないのか」「そもそも、このツールは何のためにあるのか」を言語化する作業になっていました。コードを書きながら、自分の頭の中の「調査とレビューの違い」みたいな、ぼやっとしていた概念が輪郭を持ち始める。ものづくりと内省は、思っていた以上にくっついています。

    トークン削減は、品質が落ちないことを前提にしないといけないので、これまでより慎重に進めることになりそうです。続きは次の記事で。

    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について