LESSON 40分

ストーリー

佐藤CTO
プロンプトを少し変更したら、今まで正しく答えていた質問に間違った回答を返すようになった
佐藤CTO
プロンプトはコードと同じだ。バージョン管理、テスト、レビュー、ロールバック。全てが必要だ。“適当にプロンプトを書き換えてデプロイ”は本番では許されない
あなた
プロンプトのCI/CDですか。確かにコード変更と同じ扱いが必要ですね

プロンプトのバージョン管理

プロンプトをコードとして管理

// プロンプトの定義
interface PromptTemplate {
  id: string;
  version: string;
  name: string;
  description: string;
  systemPrompt: string;
  userPromptTemplate: string;
  variables: string[];        // テンプレート変数
  model: string;              // 対象モデル
  temperature: number;
  maxTokens: number;
  createdAt: string;
  tags: string[];
}

// ファイルベースのプロンプト管理
// prompts/
// ├── rag-answer/
// │   ├── v1.0.0.yaml
// │   ├── v1.1.0.yaml
// │   └── v2.0.0.yaml
// ├── query-classifier/
// │   ├── v1.0.0.yaml
// │   └── v1.1.0.yaml
// └── metadata-extractor/
//     └── v1.0.0.yaml

YAMLベースのプロンプト定義

# prompts/rag-answer/v2.0.0.yaml
id: rag-answer
version: "2.0.0"
name: "RAG回答生成"
description: "検索コンテキストに基づいて質問に回答する"
model: gpt-4o-mini
temperature: 0
maxTokens: 1000

systemPrompt: |
  あなたは社内技術ナレッジベースのアシスタントです。

  ## ルール
  1. <context>タグ内の情報のみを根拠にして回答する
  2. 情報が不足している場合は「該当する情報が見つかりませんでした」と回答する
  3. 回答には必ず [出典N] を付与する
  4. 推測や一般知識での補完は行わない

  ## 出力形式
  - Markdown形式
  - 簡潔で構造化された回答

userPromptTemplate: |
  <context>
  {{contexts}}
  </context>

  質問: {{query}}

variables:
  - contexts
  - query

tags:
  - rag
  - production

プロンプトレジストリ

import * as fs from 'fs';
import * as yaml from 'yaml';
import * as path from 'path';

class PromptRegistry {
  private prompts: Map<string, Map<string, PromptTemplate>> = new Map();

  constructor(private readonly promptsDir: string) {}

  async loadAll(): Promise<void> {
    const promptDirs = fs.readdirSync(this.promptsDir);

    for (const dir of promptDirs) {
      const dirPath = path.join(this.promptsDir, dir);
      if (!fs.statSync(dirPath).isDirectory()) continue;

      const versions = new Map<string, PromptTemplate>();
      const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.yaml'));

      for (const file of files) {
        const content = fs.readFileSync(path.join(dirPath, file), 'utf-8');
        const template = yaml.parse(content) as PromptTemplate;
        versions.set(template.version, template);
      }

      this.prompts.set(dir, versions);
    }
  }

  get(id: string, version?: string): PromptTemplate {
    const versions = this.prompts.get(id);
    if (!versions) throw new Error(`Prompt not found: ${id}`);

    if (version) {
      const template = versions.get(version);
      if (!template) throw new Error(`Version not found: ${id}@${version}`);
      return template;
    }

    // 最新バージョンを返す
    const latest = [...versions.entries()]
      .sort(([a], [b]) => b.localeCompare(a, undefined, { numeric: true }))
      [0];
    return latest[1];
  }

  render(id: string, variables: Record<string, string>, version?: string): {
    systemPrompt: string;
    userPrompt: string;
  } {
    const template = this.get(id, version);

    let userPrompt = template.userPromptTemplate;
    for (const [key, value] of Object.entries(variables)) {
      userPrompt = userPrompt.replace(new RegExp(`{{${key}}}`, 'g'), value);
    }

    return {
      systemPrompt: template.systemPrompt,
      userPrompt,
    };
  }
}

A/Bテスト

プロンプトのA/Bテスト設計

interface PromptExperiment {
  id: string;
  name: string;
  description: string;
  startDate: Date;
  endDate: Date;
  variants: {
    name: string;
    promptId: string;
    promptVersion: string;
    weight: number;  // トラフィックの割合 (0-1)
  }[];
  metrics: string[];  // 計測するメトリクス
}

class PromptExperimentManager {
  constructor(
    private readonly registry: PromptRegistry,
    private readonly metricsStore: MetricsStore,
  ) {}

  async getVariant(
    experimentId: string,
    userId: string,
  ): Promise<{ promptId: string; promptVersion: string; variantName: string }> {
    const experiment = await this.loadExperiment(experimentId);

    // ユーザーIDベースの決定論的な振り分け
    const hash = this.hashString(`${experimentId}:${userId}`);
    const bucket = (hash % 100) / 100;

    let cumulative = 0;
    for (const variant of experiment.variants) {
      cumulative += variant.weight;
      if (bucket < cumulative) {
        return {
          promptId: variant.promptId,
          promptVersion: variant.promptVersion,
          variantName: variant.name,
        };
      }
    }

    // フォールバック
    const last = experiment.variants[experiment.variants.length - 1];
    return {
      promptId: last.promptId,
      promptVersion: last.promptVersion,
      variantName: last.name,
    };
  }

  async recordResult(
    experimentId: string,
    variantName: string,
    metrics: Record<string, number>,
  ): Promise<void> {
    await this.metricsStore.record({
      experimentId,
      variantName,
      metrics,
      timestamp: new Date(),
    });
  }

  private hashString(str: string): number {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const char = str.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash;
    }
    return Math.abs(hash);
  }

  private async loadExperiment(id: string): Promise<PromptExperiment> {
    // データストアから実験設定を読み込む
    throw new Error('Not implemented');
  }
}

プロンプトの評価とCI/CD

自動評価パイプライン

interface PromptEvalCase {
  input: Record<string, string>;  // テンプレート変数
  expectedOutput?: string;         // 期待される出力(完全一致ではなく意味的な比較)
  criteria: {
    containsKeywords?: string[];   // 含むべきキーワード
    excludesKeywords?: string[];   // 含むべきでないキーワード
    maxLength?: number;
    minLength?: number;
    formatCheck?: 'json' | 'markdown';
    customCheck?: (output: string) => boolean;
  };
}

class PromptEvaluator {
  constructor(
    private readonly registry: PromptRegistry,
    private readonly llm: LLMService,
  ) {}

  async evaluate(
    promptId: string,
    version: string,
    testCases: PromptEvalCase[],
  ): Promise<{
    passRate: number;
    results: { case: PromptEvalCase; output: string; passed: boolean; reason?: string }[];
  }> {
    const results: { case: PromptEvalCase; output: string; passed: boolean; reason?: string }[] = [];

    for (const testCase of testCases) {
      const { systemPrompt, userPrompt } = this.registry.render(
        promptId,
        testCase.input,
        version,
      );

      const output = await this.llm.complete(systemPrompt + '\n\n' + userPrompt);
      const { passed, reason } = this.checkCriteria(output, testCase.criteria);

      results.push({ case: testCase, output, passed, reason });
    }

    const passRate = results.filter(r => r.passed).length / results.length;
    return { passRate, results };
  }

  private checkCriteria(
    output: string,
    criteria: PromptEvalCase['criteria'],
  ): { passed: boolean; reason?: string } {
    if (criteria.containsKeywords) {
      const missing = criteria.containsKeywords.filter(kw => !output.includes(kw));
      if (missing.length > 0) {
        return { passed: false, reason: `Missing keywords: ${missing.join(', ')}` };
      }
    }

    if (criteria.excludesKeywords) {
      const found = criteria.excludesKeywords.filter(kw => output.includes(kw));
      if (found.length > 0) {
        return { passed: false, reason: `Found excluded keywords: ${found.join(', ')}` };
      }
    }

    if (criteria.maxLength && output.length > criteria.maxLength) {
      return { passed: false, reason: `Output too long: ${output.length} > ${criteria.maxLength}` };
    }

    if (criteria.formatCheck === 'json') {
      try { JSON.parse(output); } catch {
        return { passed: false, reason: 'Invalid JSON output' };
      }
    }

    if (criteria.customCheck && !criteria.customCheck(output)) {
      return { passed: false, reason: 'Custom check failed' };
    }

    return { passed: true };
  }
}

CI/CDパイプライン

# .github/workflows/prompt-ci.yml
name: Prompt Quality Gate
on:
  pull_request:
    paths:
      - 'prompts/**'

jobs:
  prompt-eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Detect changed prompts
        id: changes
        run: |
          CHANGED=$(git diff --name-only ${{ github.event.pull_request.base.sha }} | grep "^prompts/" | cut -d'/' -f2 | sort -u)
          echo "prompts=$CHANGED" >> $GITHUB_OUTPUT

      - name: Run prompt evaluation
        run: |
          for prompt in ${{ steps.changes.outputs.prompts }}; do
            npm run prompt:eval -- --prompt "$prompt" --threshold 0.90
          done

      - name: Compare with baseline
        run: npm run prompt:compare -- --baseline main

      - name: Post results to PR
        if: always()
        run: npm run prompt:report >> $GITHUB_STEP_SUMMARY
プロンプトの回帰テストセット例
// tests/prompts/rag-answer.test.ts
const ragAnswerTestCases: PromptEvalCase[] = [
  {
    input: {
      contexts: '[出典1] PostgreSQLのデフォルトポートは5432です。',
      query: 'PostgreSQLのデフォルトポートは?',
    },
    criteria: {
      containsKeywords: ['5432', '出典1'],
      maxLength: 500,
    },
  },
  {
    input: {
      contexts: '[出典1] Kubernetesのデプロイ手順については記載なし',
      query: 'Terraformの使い方を教えて',
    },
    criteria: {
      containsKeywords: ['見つかりませんでした'],
      excludesKeywords: ['Terraform'],  // コンテキストにない情報で回答してはいけない
    },
  },
  {
    input: {
      contexts: '[出典1] JWTの有効期限は通常15分-1時間。\n[出典2] リフレッシュトークンは7-30日。',
      query: 'トークンの有効期限の設計方針は?',
    },
    criteria: {
      containsKeywords: ['出典'],
      minLength: 50,
    },
  },
];

まとめ

ポイント内容
バージョン管理YAMLファイルでプロンプトを定義し、Gitで管理
レジストリプロンプトの読み込み、バージョン解決、テンプレートレンダリング
A/BテストユーザーIDベースの振り分けでプロンプト変更の効果を測定
CI/CDPRでプロンプト変更時に自動評価を実行し、品質ゲートで保護

チェックリスト

  • プロンプトのバージョン管理パターンを理解した
  • プロンプトレジストリの実装を理解した
  • A/Bテストの設計パターンを理解した
  • CI/CDへの組み込み方を理解した

次のステップへ

プロンプト管理のベストプラクティスを学びました。次のセクションでは、AIシステムの安全性を確保するガードレールとセーフティについて学びます。

プロンプトはコードと同じ。バージョン管理、テスト、レビュー、CI/CDの全てが必要。


推定読了時間: 40分