AIが書いたコードがPRに溢れるようになってから、私のレビュー時間は逆に増えました。生成されたコードは一見きれいに見えるのですが、セキュリティの考慮が抜けていたり、テストが薄かったり、微妙な設計上の問題を含んでいたりします。人間の目だけで追うには限界があります。
そこで試したのが、AIエージェントにレビューをさせる逆転の発想です。AIが書いたコードをAIがチェックします。聞こえはシンプルですが、単一のLLMに「このコードをレビューして」と投げるだけでは不十分でした。コードレビュー、テスト確認、セキュリティスキャンはそれぞれ専門性が異なり、一つのエージェントに全部やらせると品質が落ちます。
AgentKit 2.0とGitHub Actionsを使って、3つの専門エージェントが並列に動くCI/CD品質ゲートを構築する方法を順番にご紹介します。実際に本番で動かして学んだ設計上の判断や落とし穴も含めて、できる限り具体的に書きます。
品質ゲートの全体アーキテクチャ
マルチエージェント品質ゲートは、PRが作成・更新されるたびに自動実行されます。全体の流れはシンプルです。
GitHub ActionsがPRイベントをトリガーし、Antigravity上で動くAgentKit 2.0オーケストレーターが起動します。オーケストレーターは3つのサブエージェントを並列に起動します。
- コードレビューエージェント: diffを読み、設計上の問題・可読性・ベストプラクティス逸脱を指摘する
- テスト生成エージェント: 変更されたコードに対応するテストケースが不足していれば自動生成する
- セキュリティスキャンエージェント: SQLインジェクション・XSS・秘密情報のハードコードなどを検出する
3つのエージェントが完了したら、オーケストレーターが結果を統合してGitHub PRにコメントとして投稿します。問題が重大であればPRをブロックします。
この設計の核心は「エージェントの専門化」です。コードレビューと脆弱性検出では必要なコンテキストもプロンプトの最適化方向も異なります。一つのエージェントに全部やらせると、どれも中途半端になります。私が最初に試した単一エージェント方式では、セキュリティの指摘が薄くてSQLインジェクションを見逃したことがありました。
AgentKit 2.0 オーケストレーターの実装
オーケストレーターはAgentKit 2.0のcreateOrchestratorを使って実装します。以下は実際に使っているコードをベースにした実装です。
// scripts/quality-gate/orchestrator.ts
import { createOrchestrator, createAgent } from "@google/agentkit";
import { GoogleGenAI } from "@google/genai";
import { Octokit } from "@octokit/rest";
const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
interface PRContext {
owner: string;
repo: string;
prNumber: number;
diff: string;
changedFiles: string[];
headSha: string;
}
interface AgentResult {
agentName: string;
findings: Finding[];
severity: "critical" | "warning" | "info" | "pass";
tokensUsed: number;
}
interface Finding {
file: string;
line?: number;
message: string;
severity: "critical" | "warning" | "info";
suggestion?: string;
}
export async function runQualityGate(ctx: PRContext): Promise<void> {
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
// 3エージェントを並列実行(Promiseの並列化)
const [reviewResult, testResult, securityResult] = await Promise.allSettled([
runCodeReviewAgent(ctx),
runTestGenerationAgent(ctx),
runSecurityScanAgent(ctx),
]);
// 結果の集約
const results: AgentResult[] = [];
for (const result of [reviewResult, testResult, securityResult]) {
if (result.status === "fulfilled") {
results.push(result.value);
} else {
// エージェント失敗時は警告として記録(全体を止めない)
console.error("Agent failed:", result.reason);
results.push({
agentName: "unknown",
findings: [{
file: "N/A",
message: `エージェント実行エラー: ${result.reason?.message ?? "unknown"}`,
severity: "warning",
}],
severity: "warning",
tokensUsed: 0,
});
}
}
// PRへのコメント投稿
const comment = formatComment(results);
await octokit.issues.createComment({
owner: ctx.owner,
repo: ctx.repo,
issue_number: ctx.prNumber,
body: comment,
});
// クリティカルな問題があればPRチェックを失敗させる
const hasCritical = results.some(r =>
r.findings.some(f => f.severity === "critical")
);
if (hasCritical) {
await octokit.repos.createCommitStatus({
owner: ctx.owner,
repo: ctx.repo,
sha: ctx.headSha,
state: "failure",
description: "Critical issues found by AI quality gate",
context: "ai-quality-gate",
});
process.exit(1);
}
await octokit.repos.createCommitStatus({
owner: ctx.owner,
repo: ctx.repo,
sha: ctx.headSha,
state: "success",
description: "AI quality gate passed",
context: "ai-quality-gate",
});
}Promise.allSettledを使っている点が重要です。Promise.allを使うとどれか一つが失敗したときに全体が失敗しますが、allSettledなら失敗したエージェントだけスキップして残りの結果を使えます。エージェント実行は外部APIへの依存が多く、一時的なエラーが頻繁に起きます。本番ではallSettledが安全です。
コードレビューエージェントの実装
コードレビューエージェントは、Gemini 2.5 Proを使います。コードの意味理解が深く、設計上の問題を捉えるのに向いています。Flashより高価ですが、このエージェントだけはProを使う価値があると判断しています。
// scripts/quality-gate/agents/code-review-agent.ts
import { GoogleGenAI } from "@google/genai";
const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
export async function runCodeReviewAgent(ctx: PRContext): Promise<AgentResult> {
const systemPrompt = `あなたはシニアエンジニアとしてPRコードレビューを行います。
以下の観点でdiffを分析し、JSON形式で結果を返してください。
観点:
- コードの可読性と保守性
- DRY原則・単一責任原則の逸脱
- エラーハンドリングの漏れ
- パフォーマンス上の問題(N+1クエリ、不要なループ等)
- 型安全性の問題(TypeScriptの場合)
出力形式:
{
"findings": [
{
"file": "ファイルパス",
"line": 行番号,
"message": "問題の説明",
"severity": "critical|warning|info",
"suggestion": "改善案"
}
]
}
severity基準:
- critical: バグ・データ損失リスク・重大なパフォーマンス問題
- warning: ベストプラクティス逸脱・可読性の大きな問題
- info: スタイル・軽微な改善提案`;
const userPrompt = `以下のdiffをレビューしてください。
変更ファイル: ${ctx.changedFiles.join(", ")}
\`\`\`diff
${ctx.diff.slice(0, 50000)}
\`\`\``;
// diffが長すぎる場合は先頭50,000文字に切り詰める
const response = await client.models.generateContent({
model: "gemini-2.5-pro",
contents: [{ role: "user", parts: [{ text: userPrompt }] }],
config: {
systemInstruction: systemPrompt,
responseMimeType: "application/json",
temperature: 0.1, // 低温でより一貫した判断を促す
maxOutputTokens: 4096,
},
});
const raw = response.text ?? "{}";
let parsed: { findings: Finding[] };
try {
parsed = JSON.parse(raw);
} catch {
// JSONパース失敗時はフォールバック
console.error("Code review agent: JSON parse failed", raw.slice(0, 200));
parsed = { findings: [] };
}
const severity = parsed.findings.some(f => f.severity === "critical")
? "critical"
: parsed.findings.some(f => f.severity === "warning")
? "warning"
: "pass";
return {
agentName: "コードレビュー",
findings: parsed.findings,
severity,
tokensUsed: response.usageMetadata?.totalTokenCount ?? 0,
};
}responseMimeType: "application/json" を指定することで、Geminiが確実にJSON形式で返します。これを指定しないとマークダウンのコードブロックで囲まれた文字列が返ってきて、パースが複雑になります。最初にこれを知らなくて、正規表現でJSONを抽出するコードを書いていました。
テスト自動生成エージェントの実装
テストエージェントはFlashを使います。テスト生成はPro相当の深い思考より、変更コードに対応したテンプレートを高速に生成する方が重要だからです。
// scripts/quality-gate/agents/test-generation-agent.ts
export async function runTestGenerationAgent(ctx: PRContext): Promise<AgentResult> {
// テスト対象ファイルのみ抽出(テストファイル自体は除外)
const sourceFiles = ctx.changedFiles.filter(f =>
!f.includes(".test.") &&
!f.includes(".spec.") &&
!f.includes("__tests__") &&
(f.endsWith(".ts") || f.endsWith(".tsx") || f.endsWith(".js"))
);
if (sourceFiles.length === 0) {
return {
agentName: "テスト生成",
findings: [{ file: "N/A", message: "テスト対象のソースファイルなし", severity: "info" }],
severity: "pass",
tokensUsed: 0,
};
}
const systemPrompt = `あなたはテストエンジニアです。
変更されたコードに対してVitest/Jestでのテストケースが充足しているか確認し、
不足しているテストがあれば具体的なテストコードを提案してください。
出力形式:
{
"findings": [
{
"file": "変更されたファイル",
"message": "テスト状況の説明",
"severity": "warning|info|pass",
"suggestion": "追加すべきテストコードの例"
}
]
}`;
const userPrompt = `以下の変更を確認し、テストカバレッジを評価してください。
変更ソースファイル: ${sourceFiles.join(", ")}
\`\`\`diff
${ctx.diff.slice(0, 40000)}
\`\`\`
特に以下を確認してください:
1. 追加された関数・メソッドへのユニットテスト
2. エラーケース・境界値のテスト
3. 非同期処理のテスト`;
const response = await client.models.generateContent({
model: "gemini-2.5-flash",
contents: [{ role: "user", parts: [{ text: userPrompt }] }],
config: {
systemInstruction: systemPrompt,
responseMimeType: "application/json",
temperature: 0.2,
maxOutputTokens: 8192,
},
});
const raw = response.text ?? "{}";
let parsed: { findings: Finding[] };
try {
parsed = JSON.parse(raw);
} catch {
parsed = { findings: [{ file: "N/A", message: "テスト評価の解析に失敗しました", severity: "info" }] };
}
const severity = parsed.findings.some(f => f.severity === "warning") ? "warning" : "pass";
return {
agentName: "テスト生成",
findings: parsed.findings,
severity,
tokensUsed: response.usageMetadata?.totalTokenCount ?? 0,
};
}テスト対象ファイルの抽出フィルターは重要です。テストファイル自体が変更されているときにそれをさらにテストしろと指摘するのはノイズになります。また、.ts・.tsx・.jsに限定することで、マークダウンやYAMLなど非コードファイルへのテスト提案を防ぎます。
セキュリティスキャンエージェントの実装
セキュリティエージェントはFalse Positiveとの戦いです。過検知が多いとエンジニアが警告を無視するようになります。私の運用では、Criticalを「即修正必須」、Warningを「マージ前に確認推奨」として、infoは基本的にノイズとして扱っています。
// scripts/quality-gate/agents/security-scan-agent.ts
export async function runSecurityScanAgent(ctx: PRContext): Promise<AgentResult> {
const systemPrompt = `あなたはセキュリティエンジニアです。
コードの変更を以下の脆弱性パターンで分析してください。
チェック項目:
- SQLインジェクション(動的クエリ構築)
- XSS(未サニタイズのユーザー入力)
- 秘密情報のハードコード(APIキー・パスワード・トークン)
- 安全でない乱数生成
- 認証・認可の迂回
- オープンリダイレクト
- 依存パッケージの直接インストール指示(requirements.txtなしのpip install等)
重要: 確実に脆弱性と判断できるものだけをcritical/warningにしてください。
推測や可能性レベルのものはinfoにとどめてください。
出力形式:
{
"findings": [
{
"file": "ファイルパス",
"line": 行番号,
"message": "脆弱性の説明(具体的に)",
"severity": "critical|warning|info",
"suggestion": "修正方法"
}
]
}`;
const userPrompt = `以下のdiffをセキュリティ観点でスキャンしてください。
\`\`\`diff
${ctx.diff.slice(0, 45000)}
\`\`\``;
const maxRetries = 3;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await client.models.generateContent({
model: "gemini-2.5-flash",
contents: [{ role: "user", parts: [{ text: userPrompt }] }],
config: {
systemInstruction: systemPrompt,
responseMimeType: "application/json",
temperature: 0.1,
maxOutputTokens: 4096,
},
});
const raw = response.text ?? "{}";
let parsed: { findings: Finding[] };
try {
parsed = JSON.parse(raw);
} catch {
parsed = { findings: [] };
}
const severity = parsed.findings.some(f => f.severity === "critical")
? "critical"
: parsed.findings.some(f => f.severity === "warning")
? "warning"
: "pass";
return {
agentName: "セキュリティスキャン",
findings: parsed.findings,
severity,
tokensUsed: response.usageMetadata?.totalTokenCount ?? 0,
};
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < maxRetries) {
// 指数バックオフ: 2秒、4秒、8秒
await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
}
}
}
// 3回リトライしても失敗した場合はwarningとして記録してスキップ
return {
agentName: "セキュリティスキャン",
findings: [{
file: "N/A",
message: `スキャンを完了できませんでした: ${lastError?.message ?? "unknown"}`,
severity: "warning",
}],
severity: "warning",
tokensUsed: 0,
};
}セキュリティエージェントだけ指数バックオフのリトライを実装しています。セキュリティスキャンの漏れは致命的なので、エラー時にもなるべく実行しきるためです。一方、3回試みて失敗した場合はcriticalではなくwarningとして記録しています。スキャン自体が失敗したことで次のデプロイを完全にブロックするより、「確認できなかった」という警告を残してエンジニアに判断を委ねる設計です。
GitHub Actions ワークフローへの統合
GitHub Actionsのワークフロー設定です。このYAMLをリポジトリの .github/workflows/ai-quality-gate.yml に配置します。
# .github/workflows/ai-quality-gate.yml
name: AI Quality Gate
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main, develop]
jobs:
quality-gate:
name: AI Code Review & Security Scan
runs-on: ubuntu-latest
timeout-minutes: 10 # エージェントが無限ループしないよう上限設定
permissions:
contents: read
pull-requests: write
statuses: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # diff取得のため全履歴が必要
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci --workspace=scripts/quality-gate
- name: Get PR diff
id: diff
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr diff ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} > /tmp/pr.diff
echo "diff_size=$(wc -c < /tmp/pr.diff)" >> $GITHUB_OUTPUT
- name: Run AI Quality Gate
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
npx tsx scripts/quality-gate/index.ts \
--diff /tmp/pr.diff \
--pr $PR_NUMBER \
--owner $REPO_OWNER \
--repo $REPO_NAME \
--sha $HEAD_SHA
- name: Upload agent logs
if: always()
uses: actions/upload-artifact@v4
with:
name: quality-gate-logs
path: /tmp/quality-gate-*.json
retention-days: 7timeout-minutes: 10 は必ず設定してください。私が最初に運用したとき、Geminiのレート制限に引っかかったエージェントがリトライループで30分以上走り続けたことがありました。タイムアウトなしのアクションはGitHub Actionsの課金を予想外に増やします。
また fetch-depth: 0 も重要です。デフォルトでは最新コミットだけがチェックアウトされるため、ベースブランチとの差分が取れません。
オーケストレーターの結果統合とコメント整形
エージェントの結果を読みやすいGitHubコメントに変換する部分です。
// scripts/quality-gate/format-comment.ts
export function formatComment(results: AgentResult[]): string {
const totalFindings = results.flatMap(r => r.findings);
const criticals = totalFindings.filter(f => f.severity === "critical");
const warnings = totalFindings.filter(f => f.severity === "warning");
const totalTokens = results.reduce((sum, r) => sum + r.tokensUsed, 0);
const statusEmoji = criticals.length > 0 ? "🔴" : warnings.length > 0 ? "🟡" : "✅";
const statusText = criticals.length > 0
? `${criticals.length}件のCritical問題が見つかりました。マージ前に修正が必要です。`
: warnings.length > 0
? `${warnings.length}件の警告があります。確認を推奨します。`
: "問題は検出されませんでした。";
let comment = `## ${statusEmoji} AI品質ゲート結果\n\n${statusText}\n\n`;
for (const result of results) {
if (result.findings.length === 0) continue;
comment += `### ${result.agentName}\n\n`;
const agentCriticals = result.findings.filter(f => f.severity === "critical");
const agentWarnings = result.findings.filter(f => f.severity === "warning");
const agentInfos = result.findings.filter(f => f.severity === "info");
for (const finding of [...agentCriticals, ...agentWarnings, ...agentInfos]) {
const icon = finding.severity === "critical" ? "🔴" : finding.severity === "warning" ? "🟡" : "ℹ️";
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
comment += `${icon} **${location}**\n`;
comment += `${finding.message}\n`;
if (finding.suggestion) {
comment += `> 💡 ${finding.suggestion}\n`;
}
comment += "\n";
}
}
comment += `---\n`;
comment += `*消費トークン合計: ${totalTokens.toLocaleString()} tokens*\n`;
comment += `*実行エージェント: ${results.map(r => r.agentName).join(", ")}*\n`;
return comment;
}コメントの末尾にトークン消費量を表示しています。これはコスト把握に役立ちます。1PR あたり何トークン使っているかを可視化することで、「大きなPRは分割してほしい」という文化的なインセンティブにもなります。
よくある落とし穴と対処法
実際に運用してつまずいたポイントを正直に書きます。
1. 巨大なdiffでコンテキストウィンドウをオーバーフローする
1000行を超えるdiffを投げると、Geminiがdiffの末尾を無視することがあります。私の対策は、diffを50,000文字で切り詰めることと、変更ファイルが20件を超えるPRはエージェントをスキップしてコメントで「PRを分割してください」と通知することです。大きすぎるPRは人間のレビューも難しいので、エージェントではなくPRサイズ自体を問題として扱う設計にしました。
2. レート制限エラーが頻発する
3エージェントを並列実行するとGemini APIのRPM(リクエスト/分)制限に当たりやすくなります。対策は2つあります。まず、Flash系モデルとPro系モデルを混在させてクォータを分散させることです。次に、GitHub Actionsのconcurrency設定を使って同一ブランチのワークフローが同時に複数走らないようにすることです。
concurrency:
group: quality-gate-${{ github.head_ref }}
cancel-in-progress: true3. セキュリティエージェントが暗号化されたenv変数をハードコードとして誤検知する
.env.example に含まれる YOUR_API_KEY_HERE のようなプレースホルダーを「APIキーのハードコード」と判断することがあります。システムプロンプトに「.env.exampleと.env.sampleは除外してください」と明示することで大幅に改善しました。
4. コードレビューエージェントがコメントを毎回再生成する
synchronizeイベント(commitのpush)ごとにコメントが追記されて、PRが長大なコメントで埋まります。対策は、直前のAI品質ゲートコメントを検索して更新(edit)するか、1PRにつき1コメントだけ残す処理を追加することです。
// 既存のコメントを更新する
const existingComments = await octokit.issues.listComments({
owner: ctx.owner, repo: ctx.repo, issue_number: ctx.prNumber,
});
const existingComment = existingComments.data.find(c =>
c.body?.startsWith("## ") && c.body.includes("AI品質ゲート結果")
);
if (existingComment) {
await octokit.issues.updateComment({
owner: ctx.owner, repo: ctx.repo,
comment_id: existingComment.id, body: comment,
});
} else {
await octokit.issues.createComment({
owner: ctx.owner, repo: ctx.repo,
issue_number: ctx.prNumber, body: comment,
});
}5. エージェントが「問題なし」を返してくれない
プロンプトを工夫しないと、エージェントが必ず何かを指摘しようとします。「何も問題がなければ空の配列を返してよい」と明示的に伝えることが大切です。そうしないと、些細なコーディングスタイルを全てwarningとして上げてくるノイジーなエージェントができあがります。
コスト管理と本番モニタリング
運用コストの試算と、LangFuseを使った可視化について紹介します。
1PRあたりの典型的なトークン消費量は、私の場合以下の通りです。
- コードレビューエージェント(Pro): 約8,000〜25,000 tokens
- テスト生成エージェント(Flash): 約5,000〜15,000 tokens
- セキュリティスキャンエージェント(Flash): 約4,000〜12,000 tokens
中規模のPRで合計2万トークン前後、大きなPRで5万トークン程度です。Gemini 2.5 Proの価格(2026年5月時点)で試算すると、1PR当たり$0.05〜$0.15程度が目安です。1日に10本のPRがマージされるチームでも月$30〜$50程度で収まります。
LangFuseとの連携はAntigravity × LangFuse エージェント可観測性ガイドで詳しく解説していますが、品質ゲートのコンテキストでは以下の4つのメトリクスをトレースすることをおすすめします。
- 各エージェントのlatency(何秒かかったか)
- トークン消費量(モデルごと)
- Finding件数と severity 分布
- False Positive率(エンジニアが「この指摘は不要」とコメントした割合)
False Positive率は手動追跡が必要ですが、月次でPRコメントをサンプリングして計算することで、プロンプトの改善サイクルを回せます。私の経験では、最初の1ヶ月で3回プロンプトを修正して、False Positive率を40%から15%まで下げることができました。
AgentKitの実装詳細はAgentKit 2.0 完全ガイドを、マルチエージェントの並列設計パターンは上級マルチエージェントオーケストレーションも参考にしてください。
今日から始める一歩
まずはセキュリティスキャンエージェントだけをGitHub Actionsに組み込んでみてください。コードレビューやテスト生成より実装が単純で、効果がわかりやすいからです。
.github/workflows/ai-security-scan.yml として単一エージェントのワークフローを作り、1週間動かしてみてください。どんな指摘が来るか、どれが本物の問題でどれがノイズかを把握してから、3エージェント構成に拡張するのが、スムーズに本番導入できる順序だと思います。
AIが書いたコードをAIがレビューします。少し奇妙に聞こえますが、人間のレビューと組み合わせることで、明らかに品質が上がりました。レビューのバックログが積み上がることもなくなりました。参考になれば幸いです。