AI

LLM生成コードをCI/CDで自動検証する実装パターン

LLMを使ったコード生成は開発速度を引き上げてくれます。一方で、手動レビューが追いつかない状況では「動くけれど品質が怪しいコード」が本番環境に紛れ込むリスクも高まります。この記事では、LLM生成コードをCI/CDパイプラインの各ステージで自動検証する実装パターンと、見落としやすい落とし穴を整理します。

生成コードを本番に流す前に検証すべき3層

LLM生成コードに固有の問題は、「構文的には正しいが意味的に危ういコード」が多い点です。通常の開発者が書くコードとは失敗パターンが異なるため、検証の観点を整理しておくことが重要です。

検証すべき項目を大別すると、以下の3層になります。

  • 静的解析層:構文エラー・型の不整合・既知のセキュリティホール・コーディング規約違反
  • 動的テスト層:ユニットテスト・統合テスト・APIコントラクトの検証
  • 挙動・品質層:レイテンシ・エラーレート・ログの異常など、ステージング環境での観測値

これら3層を「ゲート」として順番に配置し、前のゲートを通過しなければ次に進めない構成にすることが重要です。CI/CDパイプラインを単なる実行環境ではなく、品質管理のフィルタとして設計し直すイメージです。

静的解析・セキュリティスキャンの活用

LLM生成コードだからといって特別なツールが必要なわけではなく、既存の静的解析ツールを「厳しめの設定」で動かすことが効果的です。

Lint・型チェック

  • ESLint / Biome(JavaScript/TypeScript):no-unused-varseqeqeq などの基本ルールに加え、import/no-cycle による依存関係の循環検出も有効にしておくと、LLMの生成ミスを拾いやすくなります
  • Ruff(Python):高速で、LLM特有の「未使用インポートが大量にある」パターンを効率よく検出できます
  • TypeScripttsc --strict --noEmit をCIに組み込むだけで、LLMが生成しがちな暗黙の any や型の取り違えを多数検出できます。Pythonなら mypypyright が同様の役割を果たします

セキュリティスキャン

LLM生成コードでは、ハードコードされたシークレット・SQLインジェクション・古い暗号アルゴリズムの使用といったセキュリティ問題が混入しやすい傾向があります。

ツール用途
SemgrepカスタムルールでLLM特有パターンも検出可能
Trivyコンテナイメージ・依存パッケージの脆弱性スキャン
detect-secretsシークレットの漏洩検出
BanditPythonコードのセキュリティ静的解析

Semgrepはカスタムルールが書けるため、次節で紹介するLLM特有パターンの検出にも応用できます。

LLM特有のバグパターンを検出するカスタムルール

LLM生成コードには繰り返し現れる「癖」があります。代表的なものを以下に挙げます。

  • 空のキャッチブロック:エラーを握りつぶすコードを生成しやすい
  • ページネーション未実装:一覧取得を全件フェッチで実装してしまう
  • 同期的なI/O:非同期が必要な箇所に同期処理が混入する
  • TODO/FIXMEコメントの放置:生成物にそのまま含まれていることがある

Semgrepで空のキャッチブロックを検出するカスタムルールの例です。

rules:
  - id: empty-catch-block
    patterns:
      - pattern: |
          try {
            ...
          } catch ($E) {
          }
    message: "空のcatchブロックはエラーを握りつぶす可能性があります"
    languages: [javascript, typescript]
    severity: WARNING

TODO/FIXMEの検出はgrepで十分です。CIスクリプトに以下を加えるだけで機能します。

if grep -rn "TODO\|FIXME\|HACK" --include="*.ts" src/; then
  echo "::warning::LLM生成コードにTODO/FIXMEが含まれています。レビューを確認してください。"
fi

カスタムルールはチームが遭遇した問題を起点に少しずつ育てていくのが現実的です。一度に多くのルールを導入すると誤検知が増え、開発者がアラートを無視するようになります。

ステージング環境での動的テストの工夫

静的解析を通過したコードは、次にステージング環境での動的テストにかけます。LLM生成コードに対して特に効果的な手法を2つ紹介します。

コントラクトテストを先に書く

LLMにコードを生成させる前に、APIの入出力仕様をコントラクトテスト(Pactなど)として定義しておきます。生成されたコードがコントラクトを満たすかをCIで自動検証することで、「動くが仕様から外れている」コードを検出できます。

ゴールデンファイルテスト

レスポンスの形状が重要なAPIでは、期待値をJSONファイルとして保存しておき、生成コードのレスポンスと差分を比較します。LLMがフィールド名を微妙に変えてしまうケースの検出に有効です。

actual=$(curl -s http://localhost:3000/api/users)
expected=$(cat tests/golden/users.json)
diff <(echo "$actual" | jq -S .) <(echo "$expected" | jq -S .) || {
  echo "APIレスポンスがゴールデンファイルと一致しません"
  exit 1
}

CI失敗をLLMへ戻すフィードバックループ

CIが失敗したとき、その情報をLLMに戻して再生成を促すフィードバックループを組むと、さらに効率化できます。基本的な流れは「CI失敗ログ → LLMへの再プロンプト → 修正コードの再コミット」です。

設計時に押さえておきたい点は以下の3つです。

  • 無限ループの防止:再試行回数に上限を設ける(3回程度が目安)
  • エスカレーションの設計:上限に達したら人間のレビュータスクとして起票する
  • ログの保存:繰り返し失敗するパターンをプロンプト改善にフィードバックする

CI失敗時のSlack通知にエラーの要約を含めるだけでも、レビュアーの負担を減らせます。

GitHub ActionsでLLM生成PRにのみ追加検証を適用する

最後に、GitHub ActionsでLLM生成PRを識別し、追加の検証ステップを適用するシンプルな実装例を示します。

name: LLM Code Validation

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  detect-llm-pr:
    runs-on: ubuntu-latest
    outputs:
      is_llm_generated: ${{ steps.check.outputs.result }}
    steps:
      - name: LLM生成PRの判定
        id: check
        run: |
          if [[ "${{ github.head_ref }}" == llm/* ]] || \
             echo '${{ toJson(github.event.pull_request.labels.*.name) }}' | grep -q '"llm-generated"'; then
            echo "result=true" >> "$GITHUB_OUTPUT"
          else
            echo "result=false" >> "$GITHUB_OUTPUT"
          fi

  static-analysis:
    needs: detect-llm-pr
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: 型チェック
        run: npx tsc --strict --noEmit
      - name: Lint
        run: npx eslint src/ --max-warnings 0
      - name: セキュリティスキャン
        uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/typescript
            .semgrep/custom-rules.yml

  llm-specific-checks:
    needs: detect-llm-pr
    if: needs.detect-llm-pr.outputs.is_llm_generated == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: TODO/FIXMEチェック
        run: |
          count=$(grep -rn "TODO\|FIXME\|HACK" --include="*.ts" src/ | wc -l)
          if [ "$count" -gt 0 ]; then
            echo "::error::LLM生成コードに未解決のTODO/FIXMEが $count 件あります"
            exit 1
          fi
      - name: 空のcatchブロック検出
        run: npx semgrep --config .semgrep/llm-patterns.yml src/

llm-specific-checks ジョブをLLM生成と判定されたPRにのみ適用している点がポイントです。ブランチ命名規則(llm/feature-name)やPRラベル(llm-generated)を運用ルールとして決めておくことで、通常のPRに余計なチェックをかけずに済みます。

このパイプラインは最小構成です。まず静的解析のゲートを固め、コントラクトテストやゴールデンファイルテストのジョブを段階的に追加していくのが、現実的な進め方です。