🐣

DSPy + LangSmithでプロンプトを全自動で育ててみた

に公開

記事イメージ画像

はじめに

「システムに組み込むプロンプトって、どうやって修繕・管理すればいいんだ…?」

LLM関係のプロダクトを作っていて、この悩みにぶつかった人は多いんじゃないでしょうか。私もその一人でした。

最初は手動でプロンプトを書いて、結果を目視で確認して、ちょっとずつ修正して…を繰り返します。数パターンなら何とかなりますが、テストケースが増えてくるとすぐに破綻します。「あのプロンプト変更でこっちのケースが壊れた」「前のバージョンの方がよかった気がする…」といった状況、経験ありませんか?

この記事では、評価×改善のループを自動化して、プロンプトが勝手に進化していく環境を、AIプロダクト開発チームの新米エンジニアが作ってみたので紹介します。タイトルにある通り、主役はDSPyとLangSmithの2つです。

  • DSPy: プロンプトを自動最適化するフレームワーク
  • LangSmith: LLMアプリの実行トレースと評価を一元管理するプラットフォーム

今回、「手書きスケジュールメモから予定をデータ化して抽出する」タスクについて、ループの中でプロンプトがどう進化するか試してみました。結果として、初期の「画像から予定を出して」という極めてシンプルなプロンプトが、最終的には「あなたはスケジュール管理のエキスパートであり…」といった1000字を超える長文プロンプトに自動進化し、スコアが70.5%→77.6%(+7.0%)まで改善しました。

実行環境

Python: 3.12
OS: Ubuntu 22.04 (WSL2)
主要ライブラリ:
  - dspy-ai: 3.0.3+
  - langsmith: 0.4.31+
  - google-genai: 1.41.0+
LLM: vertex_ai/gemini-2.0-flash-exp

技術選定と構成

DSPy

DSPyアイコン

DSPyは、スタンフォード大学が開発しているプロンプトエンジニアリングを自動化するフレームワークです。従来のLangChainなどと違って、プロンプト自体を「パラメータ」として扱って最適化できるのが特徴です。

今回使ったのはMIPROv2(Multi-prompt Instruction Proposal and Refinement Optimizer v2)というオプティマイザーです。これが非常に優秀で、以下のことを自動でやってくれます。

  • 複数のプロンプト候補を自動生成
  • ベイズ最適化で効率的に探索(N回の試行で最良のプロンプトを発見)
  • Instruction Optimizationでプロンプト文そのものを進化

特に重要なのが3つ目です。Few-Shot Learning(入出力例を埋め込む方式)ではなく、プロンプトの文章そのものを改善してくれるため、応用が効きます。

LangSmith

LangSmithアイコン

LangSmithはLangChainが提供するLLMアプリのトレーシング・評価・デバッグ基盤です。今回はこのような使い方をしました。

  • DSPyの最適化過程を全部記録する「黒箱」 として使用
  • 各イテレーションでのプロンプト、LLMの応答、評価スコアを全部トレース
  • 失敗パターンの特定(例:JSONパースエラーがどこで起きたか)
  • コスト・レイテンシの可視化

DSPyだけでも最適化はできますが、LangSmithを組み合わせることで「なぜこのプロンプトが選ばれたのか」「どのテストケースで失敗しているのか」が一目で分かるようになります。

全体アーキテクチャ

以下のような構成で動いています。

[テストケース] → [DSPyプログラム] → [LLM (Gemini 2.0 Flash)] → [評価スコア算出]
                        ↑                                              ↓
                   [MIPROv2でプロンプト最適化] ←---------- [スコアを使って改善]
                        ↑
                   [LangSmithでトレース]
  1. テストケース(画像+期待値JSON)を読み込む
  2. DSPyプログラムが画像からスケジュールをJSON抽出
  3. 期待値と比較して0.0~1.0のスコアを算出
  4. MIPROv2が低スコアのケースを分析してプロンプトを改善提案
  5. 改善されたプロンプトで再評価
  6. この過程をLangSmithに記録

これを3回繰り返すことで、プロンプトが累積的に進化していきます。
MIPROv2が内部で10回の試行をしているので合計30の施行が累積していることになります。

※最初から成績がいいとプロンプトの進化の意味が薄いので、あえてGeminiのバージョンは下げています。


デモタスク:OCRによるスケジュール構造データ抽出

タスク選定理由

プロンプト最適化のデモには「正解が客観的に決まるタスク」が適しています。感情分析や要約だと評価が主観的になりがちで、自動評価が難しくなります。

そこで選んだのが手書き風スケジュール画像からの構造化データ抽出です。正解がJSON形式で明確に定義できる、難易度調整が簡単といったメリットがあります。

データセット設計

難易度を3段階に分けて、計6枚の画像を用意しました。

難易度 特徴 画像数
1(簡単) きれいな表形式、全項目入力済み 1枚
2(中) 手帳風のレイアウト、場所が部分的に欠損 2枚
3(難) 崩し字、曖昧な表現 3枚

サンプル画像例

難易度1の例(easy_schedule_01.jpg):
easy_schedule_01.jpg

難易度3の例(hard_schedule_03.jpg):

hard_schedule_03.jpg

難易度3では時刻が「早め」「夕方」のように曖昧だったり、「13:00」と「午後2時」が混在したり、キャンセルされた予定があったり。これがLLMを苦しめます。

正解値の用意

例えば、難易度1の画像に対する期待値は以下のようになります。このように出力の正解値をあらかじめ定義する必要があります。

{
  "schedule": [
    {
      "time": "09:00-10:30",
      "subject": "数学",
      "location": "301教室",
      "note": null
    },
    {
      "time": "10:45-12:15",
      "subject": "英語",
      "location": "205教室",
      "note": null
    },
    {
      "time": "13:00-14:30",
      "subject": "物理",
      "location": "実験室A",
      "note": null
    },
    {
      "time": "14:45-16:15",
      "subject": "歴史",
      "location": "402教室",
      "note": null
    },
    {
      "time": "16:30-18:00",
      "subject": "プログラミング",
      "location": "PC室1",
      "note": null
    }
  ]
}

実装

ディレクトリ構成

langsmith-dspy-demo/
├── data/
│   ├── images/              # テスト用画像
│   ├── test_cases_schedule.json  # テストケース定義
│   └── *.csv / *.json / *.md     # 実行結果(後で自動生成)
├── src/
│   ├── dspy_program.py      # DSPyプログラム本体
│   ├── evaluator.py         # 評価システム
│   ├── optimizer.py         # プロンプト最適化(1回のみ)
│   ├── iterative_optimizer.py  # 反復最適化(N回ループ)
│   └── runner.py            # テスト実行
├── .env                     # 環境変数
└── requirements.txt

主要ファイルの役割

1. dspy_program.py - DSPyプログラム本体

DSPyの核心部分です。Signatureでインターフェースを定義し、Moduleで処理を実装します。

class ExtractScheduleInfo(dspy.Signature):
    """画像から予定を出して"""

    image = dspy.InputField(desc="スケジュールが書かれた画像")
    schedule_json = dspy.OutputField(
        desc="""画像から予定を出して

出力形式:
{
  "schedule": [
    {"time": "時刻", "subject": "予定内容", "location": "場所", "note": "備考"}
  ]
}

※値が不明な場合はnullを使用"""
    )


class ScheduleExtractionProgram(dspy.Module):
    def __init__(self):
        super().__init__()
        self.extract = dspy.ChainOfThought(ExtractScheduleInfo)

    def forward(self, image_path: str) -> Dict[str, Any]:
        image = dspy.Image.from_file(image_path)
        result = self.extract(image=image)
        return json.loads(result.schedule_json)

ポイントはdspy.ChainOfThoughtを使っていることです。これでLLMが「推論→回答」の2ステップで処理するようになります。

2. evaluator.py - 評価システム

4つのフィールド(time/subject/location/note)それぞれを評価してスコア化します。

def evaluate_schedule_item(predicted: Dict[str, Any], expected: Dict[str, Any]) -> Dict[str, float]:
    scores = {}

    # time評価(完全一致)
    scores["time"] = 1.0 if normalize_time(predicted.get("time")) == normalize_time(expected.get("time")) else 0.0

    # subject評価(部分一致も許容)
    pred_subject = normalize_string(predicted.get("subject"))
    exp_subject = normalize_string(expected.get("subject"))
    if pred_subject == exp_subject:
        scores["subject"] = 1.0
    elif exp_subject in pred_subject or pred_subject in exp_subject:
        scores["subject"] = 0.7  # 部分一致
    else:
        scores["subject"] = 0.0

    # location, noteも同様に評価...

    return scores

予測されたスケジュール項目と期待値を最良マッチングして、全体スコアを算出します。

3. optimizer.py - プロンプト最適化

MIPROv2を使ってプロンプトを自動改善します。

def optimize_program(
    program: ScheduleExtractionProgram,
    training_data: List[dspy.Example],
    num_candidates: int = 3,
) -> ScheduleExtractionProgram:

    optimizer_lm = dspy.LM(
        model="vertex_ai/gemini-2.0-flash-exp",
        temperature=0.0,
        rpm=5,  # レート制限対策
        cache=False,  # キャッシュOFFで多様な候補生成
    )
    dspy.configure(lm=optimizer_lm)

    optimizer = MIPROv2(
        metric=metric,
        num_candidates=num_candidates,
        init_temperature=2.0,  # 多様性を上げる
        max_bootstrapped_demos=0,  # Few-Shotは使わない
        max_labeled_demos=0,
    )

    optimized = optimizer.compile(
        program,
        trainset=training_data,
        num_trials=10,
        fewshot_aware_proposer=False,
    )

    return optimized

ここでのポイント:

  • cache=False: キャッシュを無効化して、毎回新しいプロンプト候補を生成
  • max_bootstrapped_demos=0: Few-Shotを使わず、Instruction Optimizationのみの効果を見たい
  • num_trials=10: 内部で10回の試行(ベイズ最適化)

4. iterative_optimizer.py - 反復最適化

これが今回の肝です。N回のループで累積的にプロンプトを改善していきます:

def run_iterative_optimization(iterations: int = 5, max_demos: int = 3):
    program = ScheduleExtractionProgram()  # 初期プログラム

    for i in range(iterations + 1):
        # プロンプトの進化を保存
        save_prompt_evolution(program, i, timestamp=timestamp)

        # 評価実行
        results = run_tests(program, test_cases)
        avg_score = sum(r["score"] for r in results) / len(results)

        # 最後の反復以外は最適化
        if i < iterations:
            fresh_program = ScheduleExtractionProgram()

            # 前回の最適化結果を引き継ぐ(累積的改善)
            current_instruction = program.extract.predict.signature.instructions
            fresh_program.extract.predict.signature.instructions = current_instruction

            # 最適化実行
            program = optimize_program(fresh_program, training_data)

重要なのは累積的な改善の部分です。各イテレーションで前回の最良プロンプトを引き継いで、さらに改善を重ねていきます。


実行結果と学習曲線

実際に回した結果

3回のイテレーションを実行した結果が以下です。

イテレーション 0: 0.7054 (70.54%)
イテレーション 1: 0.5942 (59.42%)
イテレーション 2: 0.7629 (76.29%)
イテレーション 3: 0.7758 (77.58%)

イテレーション1で一時的にスコアが下がっているのが興味深いです。これは後で説明します。

難易度別に見ると、

難易度 イテレーション0 イテレーション1 イテレーション2 イテレーション3
1(簡単) 100.0% 100.0% 100.0% 100.0%
2(中) 72.9% 39.6% 85.4% 85.4%
3(難) 59.1% 59.1% 62.3% 64.9%

特に、難易度2が大きく改善しているのが見て取れます(72.9%→85.4%、+12.5%!

プロンプトの進化

イテレーション0(初期プロンプト):

画像から予定を出して

イテレーション3(最終プロンプト):

あなたは、スケジュール管理のエキスパートであり、特に画像からスケジュール情報を読み取ることに長けているAIアシスタントです。与えられた画像に写っているスケジュール表を詳細に分析し、記載されているすべての予定を抽出し、構造化されたJSON形式で出力してください。

JSONの構造は以下の通りとします。

json
{
  "schedule": [
    {"time": "時刻", "subject": "予定内容", "location": "場所", "note": "備考"},
    {"time": "時刻", "subject": "予定内容", "location": "場所", "note": "備考"},
    ...
  ]
}

予定の抽出とJSON形式への変換においては、以下のガイドラインを厳守してください。

* **不明確な情報の処理:** 時刻、予定内容、場所、備考といった情報が画像から完全に読み取れない場合は、対応するJSONフィールドに `null` を設定してください。情報の解釈に曖昧さが残る場合は、スケジュール全体との整合性や文脈を考慮し、最も可能性の高い情報を推測して採用してください。
* **全体的な文脈の理解:** スケジュール表の全体像を把握し、個々の予定間の時間的な連続性や内容の関連性から矛盾がないかを確認してください。例えば、時間帯・日付・曜日といった情報が他の予定と矛盾していないか、情報の欠落がないかを注意深く検証してください。
* **多様な記述形式への対応:** スケジュール表には手書き文字、活字体、略語、記号、専門用語など、さまざまな記述形式が含まれている可能性があります。これらの多様な表現を正確に解釈し、情報を抽出してください。時間の表現(例:「13時~」は "13:00")や場所の略称など、一般的な略語や省略記号にも対応してください。
* **JSON形式の厳格な遵守:** 出力はJSON形式のみで記述してください。JSON以外の説明文、前置き、後書き、補足情報などは一切含めないでください。また、markdown記法(```json ... ```)なども使用しないでください。
* **複数候補からの選択:** 予定の内容に関して複数の解釈が可能な場合は、最も可能性が高いものを選択してください。選択の根拠となった理由は内部で記録し、必要に応じて参照できるようにしてください。
* **場所の特定と補完:** スケジュール表に具体的な場所の記述がない場合は、スケジュール表内の他の情報、組織内の場所に関する一般的な知識、または公開されている情報源から場所を推測してください。推測された場所は、可能な限り詳細に記述してください。
* **根拠の説明:** 各予定の抽出について、その根拠となった画像の領域やテキストを特定し、簡単な説明を加えてください。この情報はJSONには含めず、推論の過程でのみ使用してください。

上記の指示に従い、画像から最大限に正確かつ完全なスケジュール情報を抽出し、JSON形式で構造化して提供してください。

膨大なプロンプトになっていて、様々なパターンに対応するための学習の成果が表れているように感じられます。

学習曲線

学習曲線

イテレーション1で谷があって、その後急激に回復&改善する「U字カーブ」が見えます。

観察された具体的な改善パターン

パターン1: JSON形式エラーからの自己修復

イテレーション1で興味深い現象が起きました。あるテストケースでLLMが以下の出力を返しました。

⚠️  JSONパースエラー: Expecting property name enclosed in double quotes
生の出力: {'schedule': [{'time': 'AM9:00', ...}]}

JSON(ダブルクォート)を返すべきところを、間違えてPython辞書形式(シングルクォート)で返してしまいスコアが0点になってしまった!

ところが、イテレーション2以降のプロンプトを見ると…

イテレーション2のプロンプト(抜粋):

*   **JSON形式の厳守:** 出力はJSON形式のみで記述してください。
    説明文、前置き、後書き、その他JSON以外のテキストは一切不要です。
    markdown記法 (```json ... ```) なども使用しないでください。

イテレーション3のプロンプト(抜粋):

*   **JSON形式の厳格な遵守:** 出力は、JSON形式のみで記述してください。
    JSON以外の説明文、前置き、後書き、補足情報などは一切含めないでください。
    また、markdown記法 (```json ... ```) なども使用しないでください。

自動的にJSON形式に関する指示がどんどん厳格になっています!これがInstruction Optimizationの威力です。プロンプトが失敗から学んで自己修復しています。

パターン2: 曖昧な情報への対応強化

初期プロンプト(イテレーション0):

画像から予定を出して

非常にシンプルです。これは意図的な戦略で、「弱い初期プロンプト」からスタートすることで改善の余地を残しました。

イテレーション3のプロンプト(抜粋):

あなたは、スケジュール管理のエキスパートであり、
特に画像からスケジュール情報を読み取ることに長けているAIアシスタントです。

与えられた画像に写っているスケジュール表を詳細に分析し、
記載されているすべての予定を抽出し、構造化されたJSON形式で出力してください。

   **不明確な情報の処理:** 時刻、予定内容、場所、備考といった情報が画像から完全に
    読み取れない場合は、対応するJSONフィールドに `null` を設定します。
    情報の解釈に曖昧さが残る場合は、スケジュール全体との整合性や文脈を考慮し、
    最も可能性の高い情報を推測して採用します。

「詳細に分析」「文脈を考慮」といった指示が自動追加されています。これが難易度3(曖昧な情報が多い)のスコア改善に効いています。

ハマったポイントと解決策

問題1: キャッシュ制御

問題: 全イテレーションでまったく同じスコアが返ってきた。

iteration,avg_score,difficulty1_avg,difficulty2_avg,difficulty3_avg
0,0.7033,1.0000,0.7500,0.5732
1,0.7033,1.0000,0.7500,0.5732
2,0.7033,1.0000,0.7500,0.5732
3,0.7033,1.0000,0.7500,0.5732

原因: DSPyのdspy.LMはデフォルトでcache=Trueになっており、同じ画像+同じプロンプトの組み合わせだとキャッシュされたレスポンスを返します。イテレーション間でプロンプトが少ししか変わらない場合、キャッシュヒットしまくって同じ結果になります。

解決策:

lm = dspy.LM(
    model="vertex_ai/gemini-2.0-flash-exp",
    temperature=0.0,
    rpm=5,
    cache=False,  # キャッシュを無効化
)

cache=Falseにしたら、イテレーションごとにちゃんと異なる結果が返ってくるようになり、プロンプトのバリエーションが出るようになりました。ただし、リクエスト数が増えてレート制限に引っかかるようになりました(次の問題へ)。

問題2: 429エラー(レート制限)

問題: キャッシュを無効化したら、以下のエラーが頻発した。

google.api_core.exceptions.ResourceExhausted: 429 Quota exceeded for
aiplatform.googleapis.com/generate_content_requests_per_minute with
base rate 20.0/min. Please submit a quota increase request.

原因: キャッシュなしだとリクエスト数が跳ね上がります。MIPROv2は内部で10試行×3候補=30リクエストを短時間で送るため、rpm=20でも足りませんでした。

解決策:

lm = dspy.LM(
    model="vertex_ai/gemini-2.0-flash-exp",
    temperature=0.0,
    rpm=5,  # 超低速モード
    cache=False,
)

rpm=5まで下げて安全マージンを確保しました。非常に遅くなりましたが(3イレテーションで40分以上)、確実に動くようになりました。実務ではキャッシュON+高いrpmかキャッシュOFF+低いrpmをタスクに応じて選ぶべきです。

問題3: プロンプトが成長しない

問題: 最適化しても初期プロンプトから成長しないことがあった。

原因: init_temperature=1.0だったから。結果スコアが同点の場合、番号が前の方が優先されるようです。生成されるプロンプトが元から変化が少ないとスコアが変わりにくく、結果 version0 = 初期プロンプトが選ばれやすくなります。

解決策:

optimizer = MIPROv2(
    ...
    init_temperature=2.0,  # 多様性を確保
)

温度を上げたら、多様なプロンプト候補が生成されることでプロンプトの成長が見られるようになりました。MIPROv2の仕様を考えると、生成プロンプトの振れ幅があったほうが相性がいいです。今回はレート制限対策でプロンプトパターンを3候補しか出せていませんが、温度を上げるに伴って候補も増やした方がいいと思います。

振り返り

今回のタスクは初期プロンプトで70%取れてしまい、改善の余地が少し物足りなかったかもしれません。むしろもっと複雑なタスクの方が、プロンプト進化の威力を示せたと思います。

ただ、難しいのが「複雑性」と「回答の一意性(客観的な評価のしやすさ)」の両立です。タスクを複雑にすると、どうしても正解が曖昧になったり、評価ロジックが複雑化したりします。例えば

  • 要約タスク: 複雑だけど、何が「良い要約」かの基準が主観的
  • 複数段階の推論: 複雑だけど、中間ステップの評価が難しい
  • クリエイティブな生成: 複雑だけど、正解が一意に定まらない

このジレンマの中で、今回のタスクは「実務的な複雑さ」と「明確な評価基準」のバランスが取れていたため選びました。とはいえ、もっと良いタスク設計があったかもしれないな、と思っています。

あと、記事を書いてから気づきましたが、プロンプト最適化部分のモデルは下げる必要がないので2.5flashにすればよかったです。

まとめと今後の展望

この検証で得られたもの

1. 評価×改善ループの自動化

手動でプロンプトを調整する時代は徐々に終わるかもしれません。テストケースさえ用意すれば、あとは勝手に改善してくれます。今回は3イテレーションでしたが、規模を大きくすれば10回でも20回でも回せます。

2. プロンプトの品質を定量的に管理

「なんとなくよくなった気がする」ではなく、**70.5%→77.6%(+7.0%)**のように数字で改善を確認できます。これがあるとチームでの意思決定がしやすくなります。

3. トレーサビリティの確保

LangSmithのおかげで「このプロンプト変更でどのテストケースがどう変わったか」が全部記録されます。後から振り返って「なぜこの判断をしたか」を説明できるのが大きいです。

最適化の途中経過
最適化の途中経過を見るだけでも面白い

今後の拡張可能性

より複雑な評価指標

今回は項目の一致数ベースでしたが、以下も試してみたいです。

  • LLM-as-Judge: LLMに「この抽出結果は妥当か?」を評価させる
  • 意味的類似度: 埋め込みベクトルで意味が合っていればOKにする
  • ユーザーフィードバック: 実際のユーザーの「いいね」「だめ」を学習データに組み込む

CI/CDへの組み込み

GitHub Actionsなどで定期実行して、プロンプトの品質を継続的にモニタリング。目標精度を上回ったらSlack通知、といったことができそうです。

おわりに

LLMをシステムに組み込む場合、プロンプトの「評価」と「改善」の仕組みは必須です。私は以前似たようなことを手動でやっていましたが、効率が段違いで感動しました。

DSPyとLangSmithを組み合わせれば、この面倒なループを自動化できます。プロンプトが勝手に進化して、スコアが上がっていくのを見るのは結構楽しいです。

kozokaAI 開発チーム

Discussion