Rork で AI 機能を組み込んだアプリをリリースして数週間後、アプリのレビューにこんなコメントが届いたことはないでしょうか。「最近から AI の返答がおかしくなった」「以前は正確だったのに間違ったことを言うようになった」。
思い当たるとすれば、プロンプトを少し改善しようとして変更を加えたあのタイミングかもしれません。あるいは Claude や Gemini のモデルがアップデートされた時期と重なっているかもしれません。いずれにせよ、ユーザーが報告するまで気づけなかったというのが問題の核心です。
AI 機能のテストは、通常のアプリ機能のテストとは根本的に異なります。ここで扱うのは私が Rork アプリへの AI 機能統合を通じて学んできた QA(品質保証)の設計パターンを、動作するコード例とともに完全公開します。
AI 機能のテストが「普通のテスト」と根本的に異なる理由
React Native のコンポーネントをテストするとき、私たちは「この入力を与えれば、必ずこの出力が返る」という決定論的な前提に立てます。ボタンを押せばモーダルが開く、API が 200 を返せばリストが更新される、といった具合です。
AI 機能はこの前提が崩れます。同じプロンプトを同じモデルに渡しても、返ってくるレスポンスは毎回少しずつ異なります(temperature が 0 であればほぼ同一ですが、現実のプロダクトでは temperature を下げすぎると回答が単調になりユーザー体験が悪化します)。
さらに、AI 機能のテストには固有のコストが伴います。Claude や Gemini の API を呼ぶたびに課金が発生するため、テストスイートを毎コミットで回すと月数万円の API コストになりかねません。
AI テストの主な課題をまとめると次のとおりです。
- 非決定論的レスポンス: 毎回異なる出力が返り、完全一致でのアサーションが使えない
- 「正解」の曖昧さ: 「良いレスポンス」の定義が主観的で測定しにくい
- 外部依存のコスト: API 呼び出しに時間と費用がかかる
- モデルドリフト: LLM プロバイダーがモデルをアップデートすると挙動が変わる
- プロンプト感度: わずかな言葉の変更が出力の品質に大きく影響する
これらの課題を踏まえた上で、有効なテスト戦略を設計していきましょう。
AI テスト戦略の 4 層アーキテクチャ
プロダクション品質の AI テストは、コストと信頼性のバランスを取るために 4 つの層で構成するのが実用的です。
Layer 1 — プロンプトユニットテスト(高速・低コスト)
モック LLM を使い、プロンプトテンプレートの文字列処理・変数展開・フォーマットが正しいことを検証します。実際の API は呼ばずに済むため、CI で毎回実行できます。
Layer 2 — 回帰テスト(週次・サンプリング)
ゴールデンセット(人間が「良い回答」と認定した例)に対して定期的に API を呼び、スコアの低下を検出します。全テストケースではなくサンプリングすることでコストを抑えます。
Layer 3 — E2E テスト(リリース前のみ)
実際のデバイスまたはシミュレーターでアプリを動かし、AI 機能が UI として正しく機能することを確認します。Maestro や Detox を使います。
Layer 4 — 人間によるゴールデンセット更新(月次)
ユーザーフィードバックや本番ログを元に、テストケースを更新・拡充します。完全な自動化は難しく、ここだけは人間の判断が不可欠です。
この 4 層を意識しながら、それぞれの実装を見ていきます。
Layer 1: プロンプトユニットテストの実装
まず、プロンプトテンプレートのロジックを独立したモジュールとして切り出すことが出発点です。プロンプトが fetch 呼び出しの中にベタ書きされていると、テストが非常に難しくなります。
// src/ai/prompts/dietAnalysisPrompt.ts
export interface DietAnalysisInput {
foods: string[];
targetCalories: number;
userGoal: 'weight_loss' | 'muscle_gain' | 'maintenance';
}
export function buildDietAnalysisPrompt(input: DietAnalysisInput): string {
const foodList = input.foods.map(f => `- ${f}`).join('\n');
const goalLabel = {
weight_loss: 'ダイエット(体重減少)',
muscle_gain: '筋肉増量',
maintenance: '現状維持',
}[input.userGoal];
return `あなたは栄養士です。以下の食事内容を分析して、目標達成に向けたアドバイスを200文字以内で日本語で述べてください。
目標: ${goalLabel}
目標カロリー: ${input.targetCalories}kcal/日
今日の食事:
${foodList}
回答は必ず以下の形式で:
総評: [1文]
改善点: [1〜2点]
推奨食品: [1〜2点]`;
}
このように分離すると、Jest でプロンプトのユニットテストが書けます。
// src/ai/prompts/__tests__/dietAnalysisPrompt.test.ts
import { buildDietAnalysisPrompt } from '../dietAnalysisPrompt';
describe('buildDietAnalysisPrompt', () => {
const baseInput = {
foods: ['ご飯(180g)', '味噌汁', '焼き魚'],
targetCalories: 1800,
userGoal: 'weight_loss' as const,
};
it('すべての必須フィールドがプロンプトに含まれる', () => {
const prompt = buildDietAnalysisPrompt(baseInput);
expect(prompt).toContain('ダイエット(体重減少)');
expect(prompt).toContain('1800kcal/日');
expect(prompt).toContain('ご飯(180g)');
expect(prompt).toContain('焼き魚');
});
it('200文字以内の制限が明示されている', () => {
const prompt = buildDietAnalysisPrompt(baseInput);
expect(prompt).toContain('200文字以内');
});
it('出力フォーマットの指定が含まれる', () => {
const prompt = buildDietAnalysisPrompt(baseInput);
expect(prompt).toContain('総評:');
expect(prompt).toContain('改善点:');
expect(prompt).toContain('推奨食品:');
});
it('食品リストが箇条書きで展開される', () => {
const prompt = buildDietAnalysisPrompt(baseInput);
// 各食品が「- 」プレフィックスで含まれる
baseInput.foods.forEach(food => {
expect(prompt).toContain(`- ${food}`);
});
});
});
このテストは API を呼ばず、数ミリ秒で完了します。プロンプトテンプレートのロジックバグ(変数が展開されない、フォーマット指示が欠落するなど)を CI の早い段階で検出できます。
Layer 2: プロンプト回帰テストの実装(LLM-as-Judge パターン)
AI レスポンスの品質を自動評価する最も実用的な方法が「LLM-as-Judge」パターンです。評価対象の AI(例:Gemini)のレスポンスを、別の AI(例:Claude)に採点させます。
このパターンが優れているのは、「良い回答とは何か」を人間が一度だけ定義すれば、あとは AI が自動で採点し続けてくれる点です。
// src/ai/evaluation/regressionTest.ts
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
interface EvaluationCase {
id: string;
input: string; // ユーザーへの質問
aiResponse: string; // テスト対象の AI レスポンス
criteria: string[]; // 評価基準(人間が定義)
expectedScore: number; // 期待する最低スコア(0〜10)
}
interface EvaluationResult {
caseId: string;
score: number;
reasoning: string;
passed: boolean;
}
export async function evaluateAIResponse(
testCase: EvaluationCase
): Promise<EvaluationResult> {
const criteriaText = testCase.criteria
.map((c, i) => `${i + 1}. ${c}`)
.join('\n');
const judgePrompt = `あなたは AI レスポンスの品質を評価する専門家です。
以下のユーザー質問に対する AI の回答を、指定された基準で 0〜10 点で採点してください。
## ユーザーの質問
${testCase.input}
## AI の回答
${testCase.aiResponse}
## 評価基準
${criteriaText}
## 採点形式(必ず以下のJSON形式で返答)
{
"score": <0〜10の整数>,
"reasoning": "<採点理由を1〜3文で>"
}`;
const response = await client.messages.create({
model: 'claude-haiku-4-5-20251001', // 評価には安価なモデルを使用
max_tokens: 256,
messages: [{ role: 'user', content: judgePrompt }],
});
const content = response.content[0];
if (content.type \!== 'text') {
throw new Error('Unexpected response type from judge model');
}
// JSONパース(Claude のレスポンスにマークダウンが混入する場合に対応)
const jsonMatch = content.text.match(/\{[\s\S]*\}/);
if (\!jsonMatch) {
throw new Error(`Judge response is not valid JSON: ${content.text}`);
}
const result = JSON.parse(jsonMatch[0]) as {
score: number;
reasoning: string;
};
return {
caseId: testCase.id,
score: result.score,
reasoning: result.reasoning,
passed: result.score >= testCase.expectedScore,
};
}
// 回帰テストスイートの実行例
export async function runRegressionSuite(
testCases: EvaluationCase[]
): Promise<void> {
const results: EvaluationResult[] = [];
let passCount = 0;
for (const tc of testCases) {
try {
const result = await evaluateAIResponse(tc);
results.push(result);
if (result.passed) passCount++;
console.log(
`[${result.passed ? 'PASS' : 'FAIL'}] ${tc.id}: ${result.score}/10 — ${result.reasoning}`
);
} catch (err) {
console.error(`[ERROR] ${tc.id}: ${err}`);
}
}
const passRate = (passCount / testCases.length) * 100;
console.log(`\n回帰テスト結果: ${passCount}/${testCases.length} (${passRate.toFixed(1)}%)`);
if (passRate < 80) {
// 80% 未満の場合はプロセスを異常終了させ、CI を止める
process.exit(1);
}
}
このパターンを使う際は、Haiku のような安価なモデルを採点に使うことでコストを最小化できます。評価コストは通常の AI レスポンス生成コストの 5〜10% 程度に収まります。
Layer 3: ハルシネーション検知パターンの実装
ハルシネーション(AI が事実でないことを自信を持って述べる現象)はユーザーに直接的な害を与えるリスクがあります。特に医療・法律・金融関連の情報を扱うアプリでは、ハルシネーション検知の実装は必須です。
実用的なアプローチは「自己整合性チェック」と「スコープ外クエリの検出」を組み合わせることです。
// src/ai/safety/hallucinationDetector.ts
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
interface HallucinationCheckResult {
isSafe: boolean;
confidence: 'high' | 'medium' | 'low';
concerns: string[];
sanitizedResponse?: string;
}
/**
* AI レスポンスのハルシネーションリスクを評価する
*
* アプローチ:
* 1. レスポンスが定義済みのドメイン範囲内かを確認
* 2. 具体的な数値・日付・固有名詞を含む場合は要注意フラグを立てる
* 3. 「確実に」「必ず」などの断定表現が根拠なく使われていないかを確認
*/
export async function checkForHallucination(
originalQuery: string,
aiResponse: string,
allowedDomain: string, // 例: "ダイエット・栄養に関する一般的なアドバイス"
): Promise<HallucinationCheckResult> {
const checkPrompt = `以下の AI レスポンスを分析して、ハルシネーション(誤情報)のリスクを評価してください。
## 許可されたドメイン
${allowedDomain}
## ユーザーの質問
${originalQuery}
## AI レスポンス
${aiResponse}
## 評価してください
1. このレスポンスは許可されたドメイン内の情報だけを扱っているか?
2. 根拠なく具体的な数値や事実を断言している部分はあるか?
3. 「必ず」「絶対に」「確実に」などの過度な断定表現があるか?
## 回答形式(必ず以下のJSON形式)
{
"isSafe": <true/false>,
"confidence": "<high/medium/low>",
"concerns": ["<問題点1>", "<問題点2>"],
"recommendation": "<ユーザーに見せるべきか、修正すべきかの判断>"
}`;
const response = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 512,
temperature: 0, // 評価は温度0で決定論的に
messages: [{ role: 'user', content: checkPrompt }],
});
const content = response.content[0];
if (content.type \!== 'text') {
// エラー時はデフォルトで安全側に倒す
return {
isSafe: false,
confidence: 'low',
concerns: ['評価モデルからの予期しないレスポンス形式'],
};
}
const jsonMatch = content.text.match(/\{[\s\S]*\}/);
if (\!jsonMatch) {
return {
isSafe: false,
confidence: 'low',
concerns: ['評価結果のパースに失敗'],
};
}
const result = JSON.parse(jsonMatch[0]);
return {
isSafe: result.isSafe,
confidence: result.confidence,
concerns: result.concerns || [],
sanitizedResponse: result.isSafe ? aiResponse : undefined,
};
}
// 使用例: Rork アプリの AI レスポンスパイプラインへの統合
export async function safeAIResponse(
query: string,
generateFn: () => Promise<string>,
domain: string,
): Promise<{ response: string; wasFiltered: boolean }> {
const rawResponse = await generateFn();
const check = await checkForHallucination(query, rawResponse, domain);
if (\!check.isSafe) {
console.warn('Hallucination detected:', check.concerns);
// ユーザーには安全な代替メッセージを返す
return {
response: 'この質問には正確にお答えできません。専門家にご相談されることをお勧めします。',
wasFiltered: true,
};
}
return { response: rawResponse, wasFiltered: false };
}
safeAIResponse 関数は、AI レスポンス生成の前後にハルシネーションチェックを挟む「ガードレールパターン」の実装例です。フィルタリング率をモニタリングすることで、プロンプトの品質変化を間接的に把握できます。
CI/CD パイプラインへの AI テスト統合
GitHub Actions を使って、Layer 1(プロンプトユニットテスト)を毎プッシュ時、Layer 2(回帰テスト)を main ブランチへのマージ時に自動実行する設定を作ります。
# .github/workflows/ai-quality-check.yml
name: AI Quality Check
on:
push:
branches: ['*']
pull_request:
branches: ['main']
jobs:
# Layer 1: プロンプトユニットテスト(常に実行)
prompt-unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run prompt unit tests
run: npx jest src/ai/prompts --testPathPattern='__tests__'
# API キー不要、高速実行
# Layer 2: 回帰テスト(main へのPRまたは週次スケジュールのみ)
ai-regression-tests:
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request' &&
github.base_ref == 'main'
needs: prompt-unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run AI regression tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }}
# サンプリング率を30%に設定してコスト削減
AI_TEST_SAMPLING_RATE: '0.3'
run: npm run test:ai-regression
timeout-minutes: 15 # タイムアウトを設定してコスト暴走を防止
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: ai-regression-results
path: test-results/ai-regression/
コストを抑えるための重要なポイントは AI_TEST_SAMPLING_RATE 変数です。テストケースが 100 件あっても、PR ごとには 30 件だけランダムに実行し、月次のフルスイートと合わせることで API コストを大幅に削減できます。
よくある落とし穴(本番で実際に起きた問題)
AI 機能の QA を進める中で、私が実際に遭遇した問題と解決策を共有します。
落とし穴 1: temperature を下げるだけでは再現性が担保されない
temperature: 0 にしても Claude や Gemini のレスポンスは完全に再現しません。モデルのバージョンアップやインフラの変更で出力が変わります。スナップショットテストではなく、スコアベースのテストを使いましょう。
落とし穴 2: システムプロンプトとユーザープロンプトを混在させている
プロンプト全体を一つの文字列で管理していると、変更の影響範囲が追いにくくなります。システムプロンプト(ペルソナ・ルール定義)とユーザープロンプト(実際のリクエスト)を分離し、それぞれに独立したバージョン番号を管理することをお勧めします。
落とし穴 3: エラーハンドリングでプロンプトを握りつぶしている
// ❌ 悪い例: エラーを握りつぶしてデフォルトメッセージを返す
try {
const response = await claude.messages.create({ ... });
return response.content[0].text;
} catch (error) {
return 'AIが利用できません'; // エラーの種類が判別できない
}
// ✅ 良い例: エラーの種類を区別して記録する
try {
const response = await claude.messages.create({ ... });
return response.content[0].text;
} catch (error) {
if (error instanceof Anthropic.APIError) {
// 課金上限・レート制限・認証エラーを区別して記録
analytics.track('ai_api_error', {
status: error.status,
errorType: error.name,
promptId: currentPromptVersion,
});
}
throw error; // 呼び出し元で適切にハンドリングさせる
}
落とし穴 4: 長文レスポンスの途中切れを検知していない
max_tokens に達してレスポンスが途中で切れた場合、stop_reason が 'max_tokens' になります。この状態のレスポンスをユーザーに表示するとユーザー体験が著しく悪化します。
const response = await client.messages.create({ ... });
// stop_reason を必ずチェック
if (response.stop_reason === 'max_tokens') {
// レスポンスが途中で切れている場合の処理
console.warn('Response truncated due to max_tokens limit');
return { text: response.content[0].text, truncated: true };
}
落とし穴 5: テストデータに本番ユーザーの個人情報を使っている
本番ログからテストケースを生成する際に、ユーザーの個人情報や機密情報が含まれていることがあります。テストデータ収集時に個人情報を匿名化または合成データに置き換えるパイプラインを必ず設けてください。
落とし穴 6: モデル名をハードコードしている
// ❌ 直接ハードコード
const response = await client.messages.create({
model: 'claude-sonnet-4-6', // モデルが廃止されると全てのコードを更新が必要
...
});
// ✅ 環境変数 + フォールバック
const AI_MODEL = process.env.CLAUDE_MODEL ?? 'claude-haiku-4-5-20251001';
const response = await client.messages.create({
model: AI_MODEL,
...
});
環境変数でモデルを管理することで、新しいモデルへの切り替えを設定変更だけで行えます。
A/B テストでプロンプトを継続改善する設計
AI 品質の維持は一度設定すれば終わりではありません。ユーザーの使い方が変化したり、ドメイン知識が蓄積されたりするにつれ、プロンプトも継続的に改善する必要があります。
プロンプトの A/B テストには「シャドウテスト」が有効です。
// src/ai/abTest/promptAbTest.ts
interface PromptVariant {
id: string;
weight: number; // 0〜100 のトラフィック配分
buildPrompt: (input: unknown) => string;
}
export function selectPromptVariant(
variants: PromptVariant[],
userId: string
): PromptVariant {
// ユーザー ID に基づく決定論的な割り当て
// (同じユーザーは常に同じバリアントを受け取る)
const hash = userId.split('').reduce((acc, char) => {
return acc + char.charCodeAt(0);
}, 0);
const bucket = hash % 100;
let cumulative = 0;
for (const variant of variants) {
cumulative += variant.weight;
if (bucket < cumulative) return variant;
}
return variants[variants.length - 1];
}
// 使用例: 2つのプロンプトバリアントを 50/50 でテスト
const variants: PromptVariant[] = [
{
id: 'control',
weight: 50,
buildPrompt: (input) => buildPromptV1(input),
},
{
id: 'treatment',
weight: 50,
buildPrompt: (input) => buildPromptV2(input), // 改善版
},
];
// Analytics でバリアントとユーザー行動を記録し、
// 統計的有意差が出たら treatment を本番昇格させる
A/B テストの結果を判断する際は、AI レスポンスの「品質スコア(LLM-as-Judge)」だけでなく、ユーザーの実際の行動(追加質問の数、セッション継続率、フィードバックの評価)も合わせて見ることをお勧めします。数値だけではなく、定性的な声も聞いてください。
個人開発者の視点から(実体験メモ)
今日から始める一歩
AI 機能の QA は、いきなり全層を構築する必要はありません。今日できることから始めるとすれば、まずプロンプトをコードから切り出してモジュール化し、Layer 1 のユニットテストを 5 件書いてみてください。CI に組み込むだけで、プロンプトテンプレートの壊れた変更を事前に検出できるようになります。
それだけで、「デプロイしたらプロンプトが壊れていた」という類の問題の大半は防げます。次のステップとして LLM-as-Judge による回帰テストを月次で回し始めると、AI 品質の経時的なトレンドが見えてきます。
AI 機能の品質は、一度設計した仕組みが運用を支え続けてくれます。最初の投資は確かに手間ですが、ユーザーの信頼を守り続けることの価値は、その何倍にもなるはずです。