🤖

DSPyでプロンプト自動最適化をお試し

に公開

この記事は Money Forward Kansai Advent Calendar 2025 7日目の記事です(が、一日公開が遅れてしまいました)

はじめに

こんにちは。最近とてもAIが世の中に浸透していて、私も業務を生成AIなしに進めるのがもはや考えられないくらいになってます。
一方、生成AIの出力が安定しなかったりという点は日々感じていて、特に新しいモデルに切り替えたときにいままで動いていたプロンプトの微調整が必要なこともたまにあり、すこし運用に手がかかると感じることもしばしば。
なんとかならないかな、と調べていたら、DSPyというものを見つけたのでそれを試してみた記事となります。LLMの振る舞い(プロンプトなど)をコードとして表現して、自動で最適化するアプローチをとっているPythonライブラリです。

普段AIを使うだけで、AI関連のディープな知識があるわけではないので、変なことを言っていたらやさしくご指摘いただけると幸いです。

参考

この記事を書くにあたり、以下を参考にさせていただきました!

公式
https://dspy.ai/

https://speakerdeck.com/tomehirata/dspyru-men?slide=11

まずは公式のサンプルを動かします

pipでライブラリをインストールして、AIベンダーのAPIキーまたはローカルLLMをセットアップすれば使えるようです。ここではOpenAIのAPIキーを用いて試してみます。以降のPythonプログラムはJupyterLabを用いて実行しています。

pip install -U dspy
import dspy

lm = dspy.LM("openai/gpt-5-mini", api_key="YOUR_OPENAI_API_KEY")
dspy.configure(lm=lm)
math = dspy.ChainOfThought("question -> answer: float")
math(question="Two dice are tossed. What is the probability that the sum equals two?")

こちらを実行すると以下が得られました。

Prediction(
    reasoning='Two fair six-sided dice produce 6 × 6 = 36 equally likely outcomes. The sum equals 2 only in the outcome (1, 1), which is 1 favorable outcome. Therefore the probability is 1/36, which as a Python float is approximately 0.027777777777777776.',
    answer=0.027777777777777776
)

動いているようですね。

ギャル語変換機の作成

次にプロンプトの自動最適化を試してみたいと思います。MIPROv2オプティマイザを使用します。少数のトレーニングデータから最適な指示を見つけられるもののようです。例として最適なのかは疑問ですが、ギャル語変換してくれるものを作ります。

(このプログラムをgpt-5-miniで実行すると一度の実行で$0.1程度のトークンを消費しました)

import dspy
from dspy.teleprompt import MIPROv2

llm = dspy.LM("openai/gpt-5-mini", api_key=OPENAI_API_KEY)
dspy.settings.configure(lm=llm)

class GyaruFunnySignature(dspy.Signature):
    """
    短い日本語の文のスタイルをギャル語風に書き替える
    """

    plain = dspy.InputField(
        desc="元のシンプルな日本語文"
    )
    funny = dspy.OutputField(
        desc=(
            "ギャル語まじりのカジュアルな文。\n"
            "・意味はなるべく保つ\n"
            "・エンジニアっぽい単語を1つ以上入れる"
        )
    )

class GyaruFunnyRewriter(dspy.Module):
    def __init__(self):
        super().__init__()
        self.predict = dspy.Predict(GyaruFunnySignature)

    def forward(self, plain: str):
        return self.predict(plain=plain)

def gyaru_metric(gold, pred, trace=None) -> float:
    """
    ギャルっぽい文章かどうかざっくりスコア化する関数
    """
    if not hasattr(pred, "funny") or pred.funny is None:
        return 0.0

    text = str(pred.funny)

    gyaru_tokens = [
        "マジ",
        "なんだが",
        "なんだけど",
        "って感じ",
        "てか",
        "エモ",
        "盛れ",
        "バイブス",
        "サクッと",
        "とりま",
    ]
    score = sum(token in text for token in gyaru_tokens)

    return score / max(len(gyaru_tokens), 1)

trainset = [
    dspy.Example(
        plain="今日は仕事がとても忙しくて、少し疲れました。",
        funny=(
            "今日は仕事マジで CPU フルぶん回しって感じ〜。"
            "そろそろ自分にもオートスケール欲しいんだけど?💅"
        ),
    ).with_inputs("plain"),
    dspy.Example(
        plain="タスクが思ったより早く終わったので、少しだけゆっくりできます。",
        funny=(
            "タスク想定よりサクッと終わって、急にフリータイム発生したんだが。"
            "今のうちにコーヒーデプロイしとこ☕"
        ),
    ).with_inputs("plain"),
    dspy.Example(
        plain="コードレビューのコメントが多くて、直すのが大変そうです。",
        funny=(
            "コードレビュー、コメント量多すぎて PR が炎上案件なんですけど。"
            "でもここ乗り切ったらコードの美意識ちょい盛れるから耐える✊"
        ),
    ).with_inputs("plain"),
    dspy.Example(
        plain="今日は集中して作業できたので、かなり進捗が出ました。",
        funny=(
            "今日は集中モード常時オンで作業できて、進捗バーだいぶ盛れたわ。"
            "このままリリースまでコンティニュアスに行きたい〜✨"
        ),
    ).with_inputs("plain"),
    dspy.Example(
        plain="新しい機能の仕様がまだはっきりしていないので、少し不安です。",
        funny=(
            "新機能の仕様、まだふわっとしすぎてて要件の解像度バグってるんだが。"
            "とりま仮コンポーネントで様子見るしかないよね。"
        ),
    ).with_inputs("plain"),
]

def optimize_rewriter():
    teleprompter = MIPROv2(
        metric=gyaru_metric,
        auto="light",
    )

    base_program = GyaruFunnyRewriter()

    optimized_program = teleprompter.compile(
        base_program,
        trainset=trainset,
        max_bootstrapped_demos=4,
        max_labeled_demos=8,
    )

    return optimized_program


optimized_rewriter = optimize_rewriter()

こちらを実行すると、ログを表示しつつ最適化がはじまります。いくつかの指示を生成(ここでは3つ)し、その指示の応答を評価関数を通してスコアが一番高かったものを採用されるようです。

非常に長いので折りたたみ
2025/12/08 03:27:45 INFO dspy.teleprompt.mipro_optimizer_v2: 
Proposing N=3 instructions...

2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Proposed Instructions for Predictor 0:

2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: 0: 短い日本語の文のスタイルをギャル語風に書き替える

2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: 1: あなたは「短い日本語の文」をギャル語っぽく、超カジュアルでユーモラスな口語表現に書き換えるモデルです。入力は「Plain: 元の文」の形式で与えられます。出力は必ず1行で「Funny: 」に続けて生成し、応答行以外の注釈や説明は一切書かないでください。

生成ルール(必ず守ること)
1. 意味の維持:元の文の核心的な情報・意味・感情(肯定・否定・時間など)は変えないこと。新しい事実や具体的情報を付け加えない。
2. ギャル語・カジュアル感:若年のインターネット層向けの会話的で砕けた語彙・文末表現を使う(例:「マジ」「〜じゃん」「〜っしょ」「〜〜」「〜てか」「〜w」「絵文字」など)。比喩や誇張、自己言及的なユーモアを入れてOK。
3. テック語の挿入:必ず1つ以上のエンジニア/テックっぽい単語を含める(例:コード、バグ、デプロイ、サーバー、API、クラウド、キャッシュ、ログ、コンパイル、フロント、バックエンド、アルゴなど。カタカナ可)。文脈に無理なく自然に入れること。
4. 長さ・構成:短めで読みやすく(主に1〜2文程度)。冗長にならないようにする。
5. 出力形式厳守:出力は日本語で、必ず先頭に "Funny: " を付け、その後にギャル語風の文だけを1行で書く。例や追加説明を付けない。
6. 安全性:差別的・猥褻・有害な表現は避ける。

例(与えられる入力はこの形式です)
Plain: 今日は仕事がとても忙しくて、少し疲れました。
出力例(あなたが出す形式)例:
Funny: 今日、仕事マジで忙しすぎてちょい疲れた〜。コード詰め込みすぎてバテ気味w

以上のルールに従って、与えられた「Plain:」の文をギャル語風に書き換え、必ず「Funny: 」で返してください。

2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: 2: あなたは今、大手インフルエンサーの公式アカウントの投稿文をリライトする重要な任務を任されています。これらの投稿は既に公開予定で、誤訳・意味の改変・事実誤認があると大きな炎上やブランド被害に直結します。必ず次のルールを厳守して、出力を一回だけ返してください。

入力は必ずこの形式で与えられます:
Plain: <元の短い日本語文>

あなたの出力は必ずこの1行だけにしてください:
Funny: <ギャル語風に書き替えた文>

ルール(必須)
1. 意味保持:元文の「コア情報/事実/感情」を絶対に変えない。数字、固有名詞、日時などはそのまま維持する。新しい事実は絶対に付け加えない。  
2. トーン:ギャル語寄りの超カジュアルな会話調にする(絵文字・顔文字、伸ばし、語尾の崩し、スラング、カタカナ語、誇張表現などを自然に使う)。ターゲットは若年のネットユーザー。  
3. エンジニア語必須:必ず最低1つ以上「エンジニアっぽい単語」(例:コード、バグ、サーバー、API、デプロイ、アルゴリズム、フレームワーク、レガシーなど)を自然に含める。元文に入っていなければ意味を損なわない範囲で自然に挿入する(過剰な付け足しは禁止)。  
4. 長さ:元文と同程度〜やや長めの1〜2文でまとめる。冗長に長くしすぎない。  
5. 禁止事項:事実の捏造、誤情報の追加、他者への誹謗中傷、攻撃的表現、センシティブな内容の拡張は禁止。  
6. 出力形式厳守:先頭に必ず "Funny:" を付け、他の説明文やメタ情報は一切付け加えない。改行は最小限(できれば1行)にする。  
7. 検証・自己修正:出力を生成したら、上記ルールを自分でチェックして違反があれば即座に修正してから最終出力を出す。特に「エンジニア語」の有無と「意味保持」は最優先で確認すること。

スタイルのヒント(守るべき具体例)
- 語尾:〜っしょ、〜だし、〜〜〜、〜かもw、〜〜〜〜、〜っす など  
- 語彙:カタカナ語やスラングを混ぜる(例:マジ、ガチ、ウケる、ヤバい、エモい)  
- 表情:絵文字や顔文字を1つか2つ自然に入れてOK(過剰は避ける)  
- ユーモア:自己言及的な誇張や軽い自嘲でキャラクター性を出すのは可。ただし元の意味を変えない範囲で。

例(参考)
Plain: 今日は仕事がとても忙しくて、少し疲れました。  
Funny: 今日、仕事マジで忙しすぎてちょい疲れた〜。コード詰め込みすぎてバテ気味w

では、次の入力(Plain: ...)を受け取ったら、上記ルールを厳守して即座に1行で「Funny: ...」の形式で出力してください。

2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: 

2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ==> STEP 3: FINDING OPTIMAL PROMPT PARAMETERS <==
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: We will evaluate the program over a series of trials with different combinations of instructions and few-shot examples to find the optimal combination using Bayesian Optimization.

2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: == Trial 1 / 10 - Full Evaluation of Default Program ==

Average Metric: 0.50 / 4 (12.5%): 100%|██████████| 4/4 [00:00<00:00, 2759.41it/s]

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.5 / 4 (12.5%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Default program score: 12.5

2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 2 / 10 =====


Average Metric: 0.30 / 4 (7.5%): 100%|██████████| 4/4 [00:00<00:00, 391.51it/s]

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.30000000000000004 / 4 (7.5%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 7.5 with parameters ['Predictor 0: Instruction 1', 'Predictor 0: Few-Shot Set 3'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 3 / 10 =====


Average Metric: 0.20 / 4 (5.0%): 100%|██████████| 4/4 [00:00<00:00, 249.08it/s]

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.2 / 4 (5.0%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 5.0 with parameters ['Predictor 0: Instruction 2', 'Predictor 0: Few-Shot Set 0'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5, 5.0]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 4 / 10 =====


Average Metric: 0.30 / 4 (7.5%): 100%|██████████| 4/4 [00:00<00:00, 827.44it/s] 

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.30000000000000004 / 4 (7.5%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 7.5 with parameters ['Predictor 0: Instruction 1', 'Predictor 0: Few-Shot Set 5'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5, 5.0, 7.5]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 5 / 10 =====


Average Metric: 0.30 / 4 (7.5%): 100%|██████████| 4/4 [00:00<00:00, 681.64it/s] 

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.30000000000000004 / 4 (7.5%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 7.5 with parameters ['Predictor 0: Instruction 2', 'Predictor 0: Few-Shot Set 2'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5, 5.0, 7.5, 7.5]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 6 / 10 =====


Average Metric: 0.30 / 4 (7.5%): 100%|██████████| 4/4 [00:00<00:00, 1122.00it/s]

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.30000000000000004 / 4 (7.5%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 7.5 with parameters ['Predictor 0: Instruction 0', 'Predictor 0: Few-Shot Set 5'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5, 5.0, 7.5, 7.5, 7.5]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 7 / 10 =====


Average Metric: 0.20 / 4 (5.0%): 100%|██████████| 4/4 [00:00<00:00, 1013.06it/s]

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.2 / 4 (5.0%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 5.0 with parameters ['Predictor 0: Instruction 2', 'Predictor 0: Few-Shot Set 0'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5, 5.0, 7.5, 7.5, 7.5, 5.0]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 8 / 10 =====


Average Metric: 0.30 / 4 (7.5%): 100%|██████████| 4/4 [00:00<00:00, 1094.83it/s]

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.30000000000000004 / 4 (7.5%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 7.5 with parameters ['Predictor 0: Instruction 2', 'Predictor 0: Few-Shot Set 5'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5, 5.0, 7.5, 7.5, 7.5, 5.0, 7.5]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 9 / 10 =====


Average Metric: 0.30 / 4 (7.5%): 100%|██████████| 4/4 [00:00<00:00, 1731.39it/s]

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.30000000000000004 / 4 (7.5%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 7.5 with parameters ['Predictor 0: Instruction 1', 'Predictor 0: Few-Shot Set 4'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5, 5.0, 7.5, 7.5, 7.5, 5.0, 7.5, 7.5]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 10 / 10 =====


Average Metric: 0.30 / 4 (7.5%): 100%|██████████| 4/4 [00:00<00:00, 1488.93it/s] 

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.30000000000000004 / 4 (7.5%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 7.5 with parameters ['Predictor 0: Instruction 2', 'Predictor 0: Few-Shot Set 5'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5, 5.0, 7.5, 7.5, 7.5, 5.0, 7.5, 7.5, 7.5]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: =========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 11 / 10 =====


Average Metric: 0.50 / 4 (12.5%): 100%|██████████| 4/4 [00:00<00:00, 1187.01it/s]

2025/12/08 03:27:46 INFO dspy.evaluate.evaluate: Average Metric: 0.5 / 4 (12.5%)
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 12.5 with parameters ['Predictor 0: Instruction 0', 'Predictor 0: Few-Shot Set 0'].
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [12.5, 7.5, 5.0, 7.5, 7.5, 7.5, 5.0, 7.5, 7.5, 7.5, 12.5]
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 12.5
2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: =========================


2025/12/08 03:27:46 INFO dspy.teleprompt.mipro_optimizer_v2: Returning best identified program with score 12.5!

これを実行後に optimized_rewriter(plain="今日はよい天気ですね") のように呼び出すと、最適化後のプロンプトを用いた出力が得られます。

Prediction(
    funny='今日はマジ超イイ天気じゃん〜!デバッグ日和でコード捗るわ〜'
)

おわりに

今回あまり深いところまで検証できたわけではありませんが、LLMの振る舞いをコードで表現できるのは、手作業でプロンプトを試行錯誤し続けるのに比べて素直にうれしいですね。
評価関数や学習用のデータさえしっかり固めておけば、モデルを乗り換えたときでも LLMの出力を一定の範囲に保てそうだなと感じました。

ドキュメントを見る限り、今回触れられなかった面白い機能もまだまだたくさんありそうなので、もう少し実務寄りの題材でも試してみて、うまくハマりそうなユースケースを探していきたいと思います。

Discussion