一覧に戻る
    プロトタイプに区切りをつける:PRコメント生成ツールを「初期バージョン」にするまで
    開発ラボ
    PRこの記事には広告が含まれています

    プロトタイプに区切りをつける:PRコメント生成ツールを「初期バージョン」にするまで

    24 分で読める

    前回の記事では、「沈黙の質を上げる」を合言葉に、PR コメント生成ツール(レシピジェネレーター)の品質を、プロンプトとコードの両面から詰めていった話を書きました。

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

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

    Tech
    開発ラボ

    grounding の追加、チェックリスト上限と禁止カテゴリ、動詞の縛りなどを経て、出力はかなりまともになった一方、最後に残ったのが「トークン消費」でした。

    この記事は、そのトークン削減から始まって、レポート内容の詰め直し、体裁の改善まで進めて、最終的に「これでプロトタイプというより初期バージョン完成でいい」と判断するまでの記録です。

    開発ラボ的に言えば、ツールを「作るフェーズ」から「使ってもらうフェーズ」に移すところまで。ループはまだ終わっていませんが、誰のループにするかという区切り方があった、という話でもあります。

    トークン削減の3施策

    前回の記事の末尾で挙げていた施策は3つでした。

    • 施策B: prompt caching を効かせる
    • 施策C: consolidation から diff を外す
    • 施策A: generation の diff を matched_files に絞り込む

    これらは別 Issue にまとめて、1本の PR で実装しました。

    施策B:prompt caching に乗せる

    gpt-5 系モデルには、先頭の一致する長いプレフィックスをキャッシュして割引する仕様があります(閾値はモデルによります)。このツールの detection は、7 パターン分の playbook を含めた長いプロンプトを送っているので、本来ここがキャッシュに乗りやすい。

    ところが、初期実装では playbook の説明(数百行)を user prompt 側に入れていました。user prompt の末尾に PR diff が来るので、呼び出しごとに user prompt 全体が変わる。system prompt だけが固定でも、キャッシュヒット条件の「先頭が完全一致する十分な長さ」を満たしきれないケースがありました。

    本来、静的な部分は system prompt に寄せて、変動する PR diff だけを user prompt に置くのが自然です。これを直しました。

    • system prompt: 7 パターン分の playbook を init 時に {patterns_description} で埋め込み、コール間で完全不変にする
    • user prompt: PR diff のみ

    ここで小さいトレードオフがあって、PatternDetector を作るときにメモリ上の system prompt 文字列が長くなります。でも、ランタイムで毎回送信されるトークンは減る。今回はコスト最適化を優先しました。テストでは、複数回の呼び出しで system prompt が完全一致することを検証する項目を追加しています(キャッシュ条件を壊す変更が入ったら CI で気づけるように)。

    施策C:consolidation から diff を外す

    consolidation は、各パターンで個別に生成されたレシピを統合して、最終レポートにする段です。ここに PR の diff を渡していたんですが、よく考えると個別のレシピにはすでに具体的な識別子が入っている。consolidation が参照するのはレシピ本文であって、diff そのものではない。

    なので、consolidate() の引数から diff_text を落としました。これは破壊的変更になるので、呼び出し側と全テストを同時に更新しています。

    ここは迷いませんでした。「レシピを統合するのに diff が要るか」を改めて考えると、単に「念のため」で渡していただけで、実際の統合ロジックには寄与していなかった。念のためで渡してある引数は、あとで消す候補という経験則が、今回も当たった格好です。

    施策A:generation の diff を絞り込む

    一番大きいのがここです。generation はパターンごとに呼ばれるので、パターン数だけ diff が送信される。5 パターンヒットする PR だと、diff が 5 回送られる。diff が大きいほど線形にトークンが膨らみます。

    ただ、各パターンで実際に関係するファイルは diff 全体の一部のはずです。detection 段階で matched_files を返しているので、そこに含まれるファイルの hunks だけを送れば良い、という発想です。

    ここで1つ小さい設計判断をしました: 同一ディレクトリの隣接ファイルも含めるようにしました。たとえば「呼び出し元の網羅」(B1)で services/user_service.py がマッチしたとき、実際の呼び出し元は api/user_controller.py など別ディレクトリにあるかもしれません。純粋に matched_files だけで絞ると、呼び出し元のファイルが落ちてしまう。

    「じゃあ全部送った方が安全では?」という話にもなるんですが、ここで思い出したのは前回整理した動詞の話でした。generator はチェックリストを作るだけで、実際に呼び出し元を列挙するのは開発者です。絞り込みで diff に入らなかったファイルがあっても、その呼び出し元こそチェックリストに「列挙した」と書かれていて、開発者が手元で確認する。つまり、チェックリストの質を落とさずに diff を減らせる、という判断でした。

    フォールバックとして、絞り込み結果が空になった場合はフル diff に戻す実装にしています(警告ログを出す)。ここは安全側に倒しました。

    3施策合わせての結果

    実行してみたら、generation の各パターンで diff サイズが 73〜78% 削減 されました。

    パターン絞り込み後削減率
    B1約 1000 行218 行78%
    B7約 1000 行251 行75%
    B6約 1000 行263 行74%
    B3約 1000 行263 行74%

    トータルのトークン消費も、以前の6〜7割程度に落ちました。consolidation の prompt_tokens は数万単位から 3,000 台に落ちています。

    prompt caching のヒットもログで確認できる範囲では順調で、一番重い部分が安くなりました。

    削減したら、品質の問題が再浮上した

    「よし、これで動かせる」と思って、改めて出てきたチェックリストをじっくり読んでみたんです。そしたら、また違和感が出てきました。

    トークンは減ったのに、見ている項目の質が気になる。前回は「ハルシネーション」と「コードレビュー寄り」を潰した。今回は違う種類の問題でした。

    気になった点は3つ。

    1. 項目の複雑化

    5 項目中 1 項目が、「変更の説明+影響先の調査+プロンプトログ経路+アラート影響」と4つの観点を詰め込んだ長文になっていました。1項目で「やった/やってない」の二値判定がしづらい。

    これは、項目上限6に収めようとして、複数観点を1項目に圧縮してしまった副作用のように見えました。仕様を守った結果、仕様が悪さをしている。

    2. 過剰一般化

    「dev/staging/production で列挙」という項目が出ていたんですが、このツール自体には環境分離がありません。diff にも環境の話は出てこない。LLM が「一般的に気にすべきリスク」を挙げてしまっていて、PR 固有の根拠がない。

    前回、ハルシネーション対策で grounding をかけたので、存在しない識別子は出にくくなっています。でも今回のは「存在しない仕組みを前提にした確認項目」。識別子がない一般論だから、grounding を素通りしていました。

    3. プレースホルダの未置換

    項目に <関数名> と、山括弧のプレースホルダが残ったまま出力されていました。本来なら具体名(extract_diff_file_list など)を埋めてほしい。これは出力のフォーマット指示が甘かった、というわかりやすい問題でした。

    4施策で対応

    これも別 Issue に切り出して、順に対応しました。

    • 施策A: consolidation プロンプトで「1項目1確認事項」を明記し、複合項目を禁止
    • 施策B: チェックリスト上限を 6 → 5 に下げる
    • 施策C: 一般論フィルタを consolidation 側にも追加
    • 施策D: プレースホルダ禁止ルールを追加

    施策B は「項目数を減らすと、代わりに圧縮が起きないか?」という逆向きの懸念があって、他への影響がやや読みにくい。なので、低リスクな A+D から着手して、B は最後に回しました。効きの見えない施策を最後に回すのは、複数施策を組む時の自分の習慣です。前の施策の効果が先に観測できる。

    もう一点、上限を下げることと、圧縮を禁止することは、方向が違うという整理がここで頭の中でクリアになりました。圧縮(1項目に詰め込む)ではなく、削除(弱い項目を落とす)の方向に寄せたかった。施策A(複合項目禁止)は「圧縮するな」、施策B(上限5)は「その代わりに多少削っていい」。この2つをセットにすることで、「詰め込み圧縮」ではなく「弱い項目の削除」が選ばれる設計にしたかった。

    結果と、残った未解決の問題

    修正後の実行では、

    • 項目の複雑化 → ✅ 解消(5 項目すべてが 1 観点に絞られた)
    • プレースホルダ未置換 → ✅ 解消(CHECKLIST_MAX_ITEMS など具体識別子が入った)
    • 過剰一般化 → △ 部分解消

    「dev/staging/production」のような、diff にない話は出なくなりました。

    ただ、前回の記事でも触れたメタ PR 固有のハルシネーションは残っていました。プロンプト自体を編集する PR に対してツールを走らせると、LLM が「下流の検出ルール」「CI ジョブの環境差分に基づくチェック生成」など、このツールの外側にあるはずの参照先を作ってしまう。

    これ、前回の記事でも「キリがない問題」として保留にしました。根本は、プロンプトを書くことと、プロンプトで扱われる PR の対象になることが、同じリポジトリで起きていることです。構造の問題。

    ここで判断を迫られました。「このメタ PR ハルシネーションを完全に潰してから区切るか」「このレベルで『初期バージョン』として運用を始めるか」。

    後者にしました。理由は3つ。

    1. メタ PR ハルシネーションが顔を出すのは、このツール自身のプロンプトを変える PR のときだけ。通常のコード変更 PR では再現していない
    2. 完全に潰すには構造(プロンプト編集の PR を別リポジトリに切り出す等)を変える必要があり、影響が大きい
    3. 初期バージョンとして他の人に使ってもらってから、実 PR でのフィードバックを集める方が、次の改善の優先度づけが健全にできる

    3 つめが一番効きました。自分一人で詰め続けていると、机上の最適化に寄っていきます。「自分の PR で違和感が出たので対策した」の積み重ねだと、他の開発者の PR では出ない違和感まで拾ってしまうかもしれない。

    仕上げ:レポートの体裁

    最後に残ったのが体裁の問題でした。チェックリストの情報は正しくなった。でも、読みにくいんです。

    - [ ] recipe_consolidator.py の CHECKLIST_MAX_ITEMS(5)と
      cap_checklist_items を参照する呼出元をリポジトリ内で列挙した
      (例: git grep -n "CHECKLIST_MAX_ITEMS\|cap_checklist_items")
      — 影響先: PR コメント生成パイプライン / recipe_cli / CI ジョブ等

    1行の中に「変更対象 + 影響先 + 調査コマンド」が詰まっていて、スキャンしづらい。チェックリストって、見出しをスッと目で追って「あ、これ確認した」とチェックを入れていくものだと思うんですが、この長さだと1項目読むのに時間がかかる。

    「見出し行 + サブ行」の2段構成にすることにしました。

    - [ ] `CHECKLIST_MAX_ITEMS` / `cap_checklist_items` の呼出元を列挙した
      - 影響先: PR コメント生成パイプライン / recipe_cli / CI ジョブ
      - 調査例: `git grep -n "CHECKLIST_MAX_ITEMS\|cap_checklist_items"`

    見出し行だけ読めば趣旨が掴める。影響先と調査例はサブ行にぶら下げる。識別子はバックティックで囲んで、地の文と区別できるようにする。

    プロンプト側で、このフォーマットと「識別子をバックティックで囲む」ルールを明記しました。

    コード側のバグを踏む

    ここで小さいバグを踏みました。チェックリストの項目数を制限する cap_checklist_items() は、親の - [ ] 行だけを数えて drop する実装だったんです。サブ行は catch-all で通過していました。

    親が drop されたあと、所属先を失ったサブ行だけが残って孤立する、という不思議な状態が起きます。

    修正は dropping_subitems フラグを追加して、「親が drop されたあとに続くインデント行はまとめて drop する」形にしました。

    if _CHECKLIST_ITEM_RE.match(line):
        if item_count < max_items:
            result.append(line)
            item_count += 1
            dropping_subitems = False
        else:
            dropped += 1
            dropping_subitems = True
        continue
    if dropping_subitems and line.startswith((" ", "\t")):
        continue
    result.append(line)

    書きながら、「これ、2段構成にしたから出たバグだな」と思いました。1段構成のときには、このバグは存在できなかった。

    学びとしては、フォーマットを変えるなら、そのフォーマットを扱うコードも一緒に見直す。当たり前なんですが、「プロンプトだけ書き換えれば済むかな」と思い込んでいた自分を、ここで軌道修正した格好です。

    途中の小さなトラブル

    このタイミングで、なぜか Docker Desktop が応答しなくなって、ローカルで pytest が走らない時間帯がありました。docker versiondocker ps も無応答。Docker Desktop のプロセスは起動しているのに、CLI が通らない。原因は特定できないまま、Engine Starting みたいな状態でずっと止まっていました。

    ここはロジックトレースで正しさを確認して、先にコミットまで進めることにしました。「テストは Docker Engine が戻ってから実行する」という割り切り。完璧を狙うと止まる場面なので、止まらない順路を選ぶ判断です。Docker が復活したあとでテストが通り、Jenkins 側のマージ後実行でも期待通りの出力が確認できたので、結果的には問題ありませんでした。

    こういう時に、手順の完璧さと、進捗の実質とを分けて考える癖は、昔インフラをやっていた時期から身についた感覚かもしれません。

    「ここで区切る」という判断

    2段構成化したレポートが期待通りに出たのを見て、自分の中で「これはもう、プロトタイプというより初期バージョンとして出していい」と思いました。

    区切りの判断は、絶対的な基準があるわけではありません。ただ、自分が何を根拠に「ここで初期バージョンとする」と決めたか、言語化しておきます。

    1. 盲点検出の core 機能が、前回の沈黙の質改善で安定した
    2. トークン消費が、日常運用に耐えるレベルまで落ちた
    3. レポートの体裁が、他の開発者に見せられる形になった
    4. 残っているメタ PR ハルシネーションは通常 PR では再現しない特殊ケース
    5. 次の改善の良い材料は「他の人が使った実 PR のフィードバック」であって、自分一人で机上で詰めることではない

    5 つめが一番効きました。

    プロトタイプと初期バージョンの違いは、自分にとって「一人で詰めるか、他の人も巻き込んで詰めるか」の違いだったと整理しています。ここまでは、自分一人でプロンプトと向き合って、自分の PR で試して、自分で違和感を拾ってきた。ここから先は、他の人の PR で使ってもらって、他の人の視点で違和感を拾ってもらう。同じ「詰める」でも、使う道具(誰の視点で、どんな PR で、何を違和感として言語化するか)がまったく変わります。

    前回の記事で書いた「1 周回すたびに次の違和感が見えてくる」のは変わりません。ループは終わらない。ただ、誰のループにするかという区切り方がありました。

    全体を振り返って

    認知軸(B1〜B7)というアイデアから始まって、grounding の追加、チェックリストの制約、動詞の縛り、トークン削減3施策、項目品質の改善、プレースホルダ禁止、そして2段構成フォーマット。PR の数で言うと、主要なものだけで8本ほどになりました。

    一発で完成するものではなくて、使ってみて出た違和感を拾って、プロンプトとコードの両方で潰していく。これを続けるうちに、途中から「完成した」とは思わなくなりました。常に1つ次の違和感が控えている。それはもう、そういうものとして付き合うしかない。

    ただ、「一旦ここで止める」という判断は、完成したかどうかとは別のものです。自分一人で詰めるフェーズから、他の人に使ってもらうフェーズに移す判断。完成と区切りは、別々に考えていい。

    もう1つ、このプロジェクトをやりながら感じていたのが、LLM を組み込むツールを作ると、自分の頭の中の概念が書きながら固まっていく、という性質です。前回の記事では「調査とレビューの違い」「空の種類の違い」が言語化された、と書きました。今回だと:

    • 「圧縮」と「削除」は別方向(上限に合わせて1項目に詰め込むのと、弱い項目を落とすのは、出力の性質が逆になる)
    • 「存在しない識別子」と「存在しない仕組みを前提にした項目」は別(grounding の射程が違う)
    • 「一人で詰める」と「使ってもらって詰める」は別(フィードバックの性質がまったく違う)

    こういう区別が、コードを書きながら1つずつクリアになっていく。もともと曖昧にしていた概念を、実装の都合で言語化させられる。ものづくりに内省が巻き込まれる、という感覚が、今回もずっとありました。

    次のフェーズと、シリーズの区切り

    ここから先は、運用フィードバックが中心になります。他の人の PR でこのツールを走らせたとき、どんな違和感が出るか。チェックリストが役に立ったのか、スルーされたのか。メタ PR ハルシネーションが通常 PR でも顔を出すのか、出さないのか。

    実運用からしか出てこない情報なので、ここで一度区切って、それを待ちます。

    次に来そうな課題として、いま気配があるのは:

    • チェックリストが「確認した」と完了するまでの平均時間はどれくらいか
    • 項目ごとに「これは役に立った」「これは空振りだった」のシグナルを、開発者から集められるか
    • そもそも「投稿された PR コメントが読まれているか」をどう測るか

    ここまで来ると、もう LLM の話ではなくて、ツールが現場に根付くかどうかの話です。別の種類の難しさがある。でも、そこを観測できる体制を作らないと、初期バージョンから先に進めません。

    Phase 0(動かす) → Phase 1(沈黙の質) → Phase 2(トークン削減と仕上げ)と続いてきたこのシリーズは、ここで一旦区切りです。レシピジェネレーターは、プロトタイプから初期バージョンになりました。使いながら育てるフェーズが、ここから始まります。

    区切る判断と、完成の判断は別物。今回の一番の学びは、多分そこでした。

    参考:出荷と区切りの判断について

    「完成」ではなく「信頼して届ける」ことを主題にした古典で、パイプラインと出荷判断の考え方を体系的に整理してくれる1冊です。今回の記事では個人ツールの話として書きましたが、同じ種類の判断は、もっと大きなスケールでも繰り返し立ち現れます。

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

    Coffee cup

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

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

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

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

    対話の時間について