はじめに
こんにちは!
先日、AIを活用したアプリケーションの機能拡張を行う中で、ある重要な課題に気づきました。
それは、増え続けるプロンプトをどう体系的に管理するか、という問題です。単純な文字列連結で対応していた初期の実装は、機能の複雑化とともに限界を迎えていました。コードの保守性が低下し、プロンプトの変更による影響範囲が把握しづらくなり、結果として出力の安定性も損なわれる事態に陥っていたのです。
今回は、この問題に対する私なりの解決策として、試行錯誤の末にたどり着いた「クラスベースの設計パターン」を紹介したいと思います。
前知識:システムプロンプトとは
システムプロンプトはAIの動作を指定する重要な要素です。AIに対して「どのように振る舞うべきか」を定義し、AIアシスタントの性格やルール、制約などを指示します。
具体例:
翻訳AIの場合:
あなたは翻訳者です。日本語をテキストを英語に翻訳してください。
専門用語は適切に訳し、自然な英語を心がけてください。
カスタマーサポートAIの場合:
あなたはカスタマーサポート担当者です。
ユーザーの質問に親切かつ正確に答えてください。
わからないことは推測せず、わかりませんと答えてください。
このように、AIの役割を決めるのがシステムプロンプトです。
システムプロンプトの肥大化と管理の問題
私が開発を進めていく中で機能が増えるに従い、以下のような問題が発生しました:
- 複雑性の増加: 機能が増えるに従い、プロンプトも肥大化
- 再利用性の低さ: 異なるシナリオで同じルールを重複記述
- メンテナンス性の低下: 修正時に複数箇所を変更する必要がある
- 可視性の低さ: どのルールがどこで定義されているか不明確
簡単に言うと「プロンプトのスパゲッティコード化」です。
試行錯誤の過程
🔴 第1段階:1つにまとめられた文字列
最初のコードは、ルールを「【基本ルール】」「【禁止事項】」などのセクションでまとめられていたものの、全て1つの文字列として書いていました。
const systemPrompt = `あなたはAIアシスタントです。
【基本ルール】
- ユーザーのリクエストに対して親切に対応する
- 日本語で回答する
- 5000文字以内に収める
- テキストと画像の両方に対応する
【禁止事項】
- 違法な行為についての指示を与えない
- 個人情報の漏洩につながる回答をしない
- ハラスメントや差別的な内容を含まない
- 医学的・法的な判断を直接するな
【詳細ルール】
- 会話の文脈を理解して回答する
- わからないことは無理に答えない
- 定期的に確認を取る
【出力形式】
JSON形式で以下のように返す:
{
"type": "message",
"content": "回答内容"
}
【安全性ガイドライン】
- 回答前に倫理的に問題ないか確認する
- ユーザーの意図を汲み取る
- 懸念事項があれば提示する
`
確かに動きます。でも、修正するたびにこの長い文字列全体を編集する必要がありました。
修正の過程で他の部分に予期しない影響を与える可能性がある状況です。
🟡 第2段階:変数に分けてみた
次に、ルールごとに変数に分けることを思いつきました。
const baseRules = `- ユーザーのリクエストに対して親切に対応する
- 日本語で回答する
- 5000文字以内に収める`
const prohibitions = `- 違法な行為についての指示を与えない
- 個人情報の漏洩につながる回答をしない
- ハラスメントや差別的な内容を含まない`
const detailedRules = `- 会話の文脈を理解して回答する
- エラーが発生した場合は詳しく説明する
- わからないことは無理に答えない`
const systemPrompt = `あなたはAIアシスタントです。
【基本ルール】
${baseRules}
【禁止事項】
${prohibitions}
【詳細ルール】
${detailedRules}
`
これで管理が改善されました。
しかし、シナリオに応じて出し分ける必要が出てくると...
let systemPrompt = basePrompt
if (isDeveloperMode) {
systemPrompt += developerRules
if (isDebugEnabled) {
systemPrompt += debugRules
}
}
if (isDetailedMode) {
systemPrompt += strictProhibitions
}
// ... さらにif文が続く
確かに条件分岐で対応できます。でも、シナリオが増えるたびに新しいif文を追加する必要がありました。
条件の増加に伴い、複数の条件分岐を組み合わせる必要が生じ、コード全体の複雑性が急速に増加してしまいました。
🟠 第3段階:if文地獄に陥った
この段階では、機能追加に伴い複数のシナリオに対応するため、条件分岐が増殖してしまいました。
let systemPrompt = basePrompt
if (isDeveloperMode) {
systemPrompt += developerRules
if (isDebugEnabled) {
systemPrompt += debugRules
}
}
if (isDetailedMode) {
systemPrompt += strictProhibitions
}
// ... さらにif文が続く
if文を追加することで対応できます。しかし、新しい条件を追加するたびに既存のロジックを修正する必要がありました。
新しい条件の追加や修正が既存のif文に影響を与える可能性があり、予期しないバグが発生するリスクが高まる一方です。
対応策の検討
if文地獄の問題に直面した時、可読性の向上とメンテナンスのしやすさを考えると、別のアプローチが必要であると感じました。
そこで、コンポーネントベースのアプローチであれば、各ルールを独立させながら、柔軟に組み合わせることができると気づきました。
実装と結果
実際にクラスベースのコンポーネント設計を実装してみました。
基本的な考え方
| 要素 | 役割 | 効果 |
|---|---|---|
| 基底コンポーネント | 基底クラス | 全コンポーネント共通のインターフェース |
| 各種ルールコンポーネント | 各ルール実装 | 単一責任でルールを定義 |
| ビルダークラス | 組み立て | コンポーネントを組み合わせる |
実装構造
// 基底クラス
abstract class PromptComponent {
abstract build(options: BuildOptions): string
protected shouldInclude(options: BuildOptions): boolean {
return true
}
}
// 具体的なコンポーネント例
class BaseRulesComponent extends PromptComponent {
build(_options: BuildOptions): string {
return 'あなたの基本的な役割と振る舞いについて'
}
}
class ProhibitionsComponent extends PromptComponent {
protected shouldInclude(options: BuildOptions): boolean {
// 詳細モードの時のみ含める
return options.isDetailedMode
}
build(_options: BuildOptions): string {
return '避けるべき行動と禁止事項について'
}
}
class DetailedRulesComponent extends PromptComponent {
build(_options: BuildOptions): string {
return '具体的な実行ルールと指示'
}
}
class OutputFormatComponent extends PromptComponent {
build(_options: BuildOptions): string {
return 'レスポンスの形式とフォーマット'
}
}
class SafetyGuidelinesComponent extends PromptComponent {
build(_options: BuildOptions): string {
return '安全性と倫理的な制約'
}
}
// ビルダークラス
export class PromptBuilder {
private components: PromptComponent[] = []
constructor() {
this.components = [
new BaseRulesComponent(),
new ProhibitionsComponent(),
new DetailedRulesComponent(),
new OutputFormatComponent(),
new SafetyGuidelinesComponent(),
// ... 他のコンポーネント
]
}
build(options: BuildOptions): string {
return this.components
.filter((component) => component.shouldInclude(options))
.map((component) => component.build(options))
.filter((part) => part.trim() !== '')
.join('\n\n')
}
}
補足:2つのフィルタの役割
1. build呼び出し前のフィルタ
.filter((component) => component.shouldInclude(options)): コンポーネント自体を含めるかどうかを判定(build呼び出し前のフィルタ)
-
shouldIncludeがfalseなら、そのコンポーネントはbuild呼び出しが行われない - 例:
ProhibitionsComponentはisDetailedModeがtrueの時のみ含まれる
2. build呼び出し後のフィルタ
.filter((part) => part.trim() !== ''): build実行結果が空文字列でないかをチェック(build呼び出し後のフィルタ)
-
shouldIncludeがtrueでも、buildが空文字列を返す可能性がある - 例:条件によっては空文字列を返す設計の場合や、バグで空文字列が返された場合の安全策
実装前後の比較
| 項目 | 改善前 | 改善後 |
|---|---|---|
| 条件分岐 | if文が増殖 | ビルダーで集約 |
| 修正の影響範囲 | 不明確 | 明確 |
| 新機能追加 | 複雑 | 新クラスを追加するだけ |
使用例
// 詳細モードで実行する場合
const options: BuildOptions = {
isDetailedMode: true,
isDeveloperMode: false,
}
const prompt = builder.build(options)
// → 自動的に禁止事項が含まれる
主な学び
コンポーネント化により以下が実現できました:
- 変更箇所の限定 - 特定ルールの修正は該当コンポーネントのみ
- テスト容易性 - 各コンポーネントを独立してテスト可能
- 再利用性向上 - 同じコンポーネントを複数シナリオで活用
- 可視性向上 - どのシナリオでどのコンポーネントが使われるか明確
- 複雑性削減 - 各コンポーネントは単純なロジックのみ
- 拡張性向上 - 新シナリオでも既存コンポーネント流用可能
- 保守性向上 - ルール変更時はコンポーネント内のみ修正
この設計パターンの注意点
この設計パターンは多くのメリットをもたらしますが、注意すべき点もあります。
1. 小さなプロジェクトでは構造が過剰になりやすい
プロンプトがシンプルで、ルールが少ない場合には、この設計パターンは過剰な構造化になる可能性があります。
- 数個のルールしかない場合は、単純な文字列連結や配列結合で十分なこともある
- クラス設計などが必要になる一方で、プロンプトがシンプルな場合は得られるメリットが限定的
2. 構造の理解が必要
新規メンバーが参加する際や、追加機能を開発する際に、この設計パターンの理解が必要になります。
- 基底クラス、コンポーネント、ビルダーの関係を把握する必要がある
- 各コンポーネントの役割と、どのように組み合わせられるかを理解する必要がある
- 設計思想を理解しないと、誤った使い方をしてしまう可能性がある
これらの点を踏まえ、プロジェクトの規模やチーム構成、将来的な拡張性を考慮して、この設計パターンの採用を判断することが重要です。
応用パターン
このパターンは、以下のシナリオで活躍します。
1. モードによる出し分け
// 開発者モードで追加のデバッグ情報を含める
class DebugInfoComponent extends PromptComponent {
protected shouldInclude(options: BuildOptions): boolean {
return options.isDeveloperMode
}
}
2. ユーザー設定による出し分け
// ユーザー設定に応じたプロンプト調整
class VerbosityComponent extends PromptComponent {
constructor(private userSettings: UserSettings) {}
protected shouldInclude(): boolean {
return this.userSettings.enableDetailedExplanations
}
}
3. 使用言語による出し分け
// 言語設定ファイルからプロパティの値を取得する形式
const messages = {
ja: {
prohibitions: '日本語での禁止事項',
baseRules: '基本的なルール',
},
en: {
prohibitions: 'English prohibitions',
baseRules: 'Basic rules',
},
}
class LocalizedProhibitionsComponent extends PromptComponent {
constructor(private language: string) {}
build(): string {
return messages[this.language]?.prohibitions ?? 'Default prohibitions'
}
}
まとめ
最初は「プロンプトはAIにしてもらいたいことを書けばいいだけ」と思っていましたが、機能が増えるにつれてその考えが変わりました。
システムプロンプト設計も、アプリケーション設計と同じくらい重要であることに気付きました。
特に学びになったのは、以下の点です:
- 複雑な問題には、デザインパターンが有効
- 「後で修正しやすい」設計の重要性
- 保守性と拡張性は、最初から組み込むべき
開発を通して、想定外のことが度々おきますが、その時に「どう設計するか」が非常に重要です。
今後AIアプリを作る際に、複雑なシステムプロンプトの管理で迷った時の参考になればと思います。