コンテンツにスキップ

STUDY NOTES

第25回: DSPy + MIPROv2 で「枝豆の妖精」チャットボットの自動プロンプト最適化

ここから連載は LLM アプリケーションの最適化フェーズに入る。第25回は DSPy という「プロンプトを手で書かず、プログラムとして書いて自動最適化させる」フレームワークの最小入門

比喩: 普通のプロンプト開発は「文章を試行錯誤で書き直す → 評価」を手でやる。DSPy は 「Signature(入出力の型定義)」+「評価関数 metric」を書くと、optimizer がプロンプトと few-shot demo を勝手にチューニングしてくれるPyTorch における loss.backward() + optimizer.step() を、自然言語プロンプトに対してやるフレームワーク。

サンプルは 「枝豆の妖精」スタイル(語尾「のだ」、一人称「ボク」)を守るチャットボットを MIPROv2 で最適化する話。最適化前は GPT-4.1-nano に「枝豆の妖精として答えて」と言うだけだったプロンプトを、MIPROv2 がより良い指示文 + 適切な few-shot 例に自動で書き換えて、評価スコアを上げる。

概念 DSPy での名前 比喩
入出力の型定義 dspy.Signature TypeScript の interface
1 つの LLM 呼び出しユニット dspy.Predict(Signature) PyTorch の nn.Linear
複数 Predict を束ねたモジュール dspy.Module PyTorch の nn.Module
評価関数 metric(example, prediction) -> float PyTorch の criterion(pred, target)
最適化器 dspy.MIPROv2 PyTorch の Adam
最適化対象 プロンプト文 + few-shot demo PyTorch の weights

DSPy とは何か — 思想とアーキテクチャ

連載のここから第28回まで DSPy が主役になるので、最小サンプルを動かす前に「そもそも DSPy が何を解決しようとしているフレームワークか」を押さえる。

一言定義

DSPy = 「プロンプトを手書きするのをやめて、LLM 呼び出しを コードのように書いて、データで自動最適化 するためのフレームワーク」

スタンフォード大学 Omar Khattab 氏ら (Stanford NLP) が開発。元の名前は Demonstrate-Search-Predict、後に Declarative Self-improving Python の意味に進化。Apache-2.0 ライセンスの Python ライブラリ。GitHub stanfordnlp/dspy

なぜ DSPy が生まれたか — LangChain との対比

LangChain 系の世界観 (2022〜2024 年の主流)

prompt = PromptTemplate("""
あなたは枝豆の妖精です。一人称は「ボク」、語尾は「のだ」「なのだ」を使ってください。
以下の例を参考にしてください:
質問: こんにちは
応答: こんにちはなのだ!ボクは枝豆の妖精さんなのだ!
...
質問: {query}
応答:
""")
chain = prompt | llm

問題点:

  • プロンプトは 人間が書く文字列。良いプロンプトを書くのは職人芸
  • モデルが変わるたび (gpt-4 → gpt-4.1-nano など) に書き直しが必要
  • 「もっと良いプロンプトないかな?」を試すには 手作業のトライアンドエラー
  • 評価関数とプロンプト改善が 疎結合(LangSmith で別途見る)

DSPy 系の世界観 (2024 年〜)

class EdamameFairy(dspy.Signature):
    """枝豆の妖精として応答する"""  # ← これだけ。具体的な指示は書かない
    query: str = dspy.InputField()
    history: list[str] = dspy.InputField()
    response: str = dspy.OutputField()

bot = dspy.Predict(EdamameFairy)

ポイント:

  • プロンプトは書かない。「入力と出力の型」だけ宣言する
  • 具体的な指示文と Few-Shot 例は オプティマイザが自動生成
  • データセット + 評価関数を渡すと、機械学習のハイパーパラメータチューニングと同じノリでプロンプトが最適化される
DSPy の 3 層構造
flowchart TD
    L1["1. Signature 層<br>やりたいことを型で宣言<br>dspy.Signature クラス"] --> L2
    L2["2. Module 層<br>どう推論するかを選ぶ<br>Predict / ChainOfThought / ReAct"] --> L3
    L3["3. Optimizer 層<br>データで最適化する<br>BootstrapFewShot / MIPROv2 / GEPA"]
    D[("トレーニングデータセット")] --> L3
    E["評価関数 metric"] --> L3
    L3 -.->|生成と選択| P["最適化されたプロンプト<br>と Few-Shot 例"]

① Signature 層: 「やりたいこと」の宣言

PyTorch でいう nn.Module の定義に近い。

class Translate(dspy.Signature):
    """英語を日本語に翻訳する"""
    english: str = dspy.InputField()
    japanese: str = dspy.OutputField()

これだけで、フレームワークが「こういう入出力の関数だな」と理解する。docstring + InputField/OutputField.desc最適化前の初期プロンプト になる。

② Module 層: 「推論パターン」の選択

同じ Signature でも、実行戦略を選べる。

naive    = dspy.Predict(Translate)         # 一発回答
cot      = dspy.ChainOfThought(Translate)  # 推論ステップを挟む
react    = dspy.ReAct(Translate, tools=[]) # ツール使用するエージェント

ここが革新的: 「Chain-of-Thought にしたい?じゃあ Module 差し替えるだけ」で済む。プロンプトを書き換える必要がない。25回は dspy.Predict、28回で dspy.ReAct を扱う。

③ Optimizer 層: データで最適化

DSPy で最も重要な層。同じ Module を、データを使って性能改善する。

Optimizer やること 連載登場回
BootstrapFewShot トレーニングデータから Few-Shot 例を自動選択 DSPy の基本
MIPROv2 プロンプト指示文 + Few-Shot を 両方とも生成・最適化 25, 26回
GEPA 進化的アルゴリズムで世代を重ねて改善(Reflective Prompt Evolution) 27, 28回
BootstrapFinetune 結果を使ってモデル自体をファインチューニング

MIPROv2 の中身は (1) 指示文プロポーザル(prompt_model が候補を何十通りも生成) → (2) Few-Shot 例ブートストラップ(trainset から有望な入出力を抽出) → (3) ベイズ最適化で組み合わせを探索、の 3 段階。25回では prompt_model=gpt-4.1-mini + task_model=gpt-4.1-nano の 2 モデル構成で走らせる。

「PyTorch とのアナロジー」が一番わかりやすい
ディープラーニング DSPy
nn.Module でネットワーク定義 dspy.Signature で関数定義
forward() で順伝播 Predict / ChainOfThought / ReAct で推論
損失関数(CrossEntropy) 評価関数(metric
勾配降下(Adam プロンプト最適化(MIPROv2
トレーニングデータ トレーニングデータ(同じ)
重み(.pt 最適化済みプロンプト(.json
model.load_state_dict() chatbot.load("...json")

LLM 時代の PyTorch」と呼ばれる所以がこれ。プロンプトを書くのではなく、LLM の振る舞いを学習させる

25回における具体化
DSPy の概念 25回での実体
Signature EdamameFairyBot (chatbot_module.py)、query + historyresponse
Module dspy.Predict (一発回答、ChainOfThought 不使用)
Optimizer MIPROv2(auto="light") (chatbot_tuning.py)
データセット databricks-dolly-15k-ja-zundamon 30件、train 24 / eval 6
評価関数 gpt-4.1-mini を LLM-as-Judge にして 0〜10 で採点
prompt_model gpt-4.1-mini(プロンプト候補生成役)
task_model gpt-4.1-nano(実際に推論する本番モデル)
最適化済み artifact artifact/edamame_fairy_model.json(自動生成プロンプト + 自動選択 Few-Shot 例)
人間が書いたもの Signature と評価関数 + データだけ
DSPy が作ったもの 良いプロンプト本文 と 良い Few-Shot 例

artifact/edamame_fairy_model.json を覗くと、signature.instructions には人間が書いていない「ユーザーからの質問や会話に対して、枝豆の妖精として親しみやすく可愛らしい口調で日本語で返答してください。必ず一人称は「ボク」を使い、語尾には自然に『のだ』または『なのだ』を用いてください…」が自動生成され、demos には「魚の種類はどっち?イコクエイラクブカとロープ」のような 引っ掛け質問への対処例が自動選択されている。これが MIPROv2 の出力。

ハマる場面 / ハマらない場面

DSPy が圧倒的に効く場面

  • 構造化出力(JSON / Pydantic への抽出)— Signature がそのまま型になる
  • 評価可能なタスク(RAG の正答率、分類、抽出)— 数値で測れるとオプティマイザが効く
  • 小さい LLM で大きい LLM 並みの性能を出したい(蒸留に近い使い方、25回もこの構造)
  • A/B テストでプロンプトを改善し続けたい— 同じデータで再学習できる
  • 複数モデル間でプロンプトを移植したい— Signature を変えずに dspy.configure(lm=...) を差し替えるだけ

DSPy が向かない場面

  • オープンエンドな生成(詩、小説、自由作文)— 評価関数が作れない
  • 対話の流れが複雑なエージェント— LangGraph のほうが向く(24回のような Supervisor + 共有メモリ)
  • 1 回だけ動かせばいい使い捨てプロンプト— オーバーキル
  • プロトタイプ段階で評価データがまだない— 最適化のしようがない
業界での位置付け

LLM 開発の課題が「性能を上げる」から「運用しながら改善し続ける」にシフトしている流れに乗っている。

2022年: 「とにかく動かす」     → ChatGPT, LangChain
2024年: 「複雑なエージェント」 → LangGraph, AutoGen, CrewAI
2025年: 「最適化と再現性」     → DSPy, GEPA, BAML

連載が 25 回から DSPy 一色になるのもこの流れに合わせて。26 回で 日本語 RAG が EM 6.7% → 83.3% という劇的改善を達成して、DSPy の威力を実例で示す。

一行まとめ

DSPy = 「プロンプトを書くな、評価関数とデータを書け。あとは自動最適化に任せろ」というパラダイムシフト。 機械学習の「PyTorch でモデル定義 → データで学習」を、そのまま LLM プロンプトに持ち込んだフレームワーク。


全体像

25/
├── chatbot_module.py            ← Signature と Module 定義(最適化対象)
├── chatbot_tuning.py            ← MIPROv2 で最適化 + MLflow で実験管理
├── main.py                      ← 最適化済みモデルを load して対話 REPL
└── artifact/
    └── edamame_fairy_model.json ← 最適化済み「プロンプト + few-shot demo」が保存

実行フロー:

flowchart TD
    subgraph Train ["チューニングフェーズ chatbot_tuning.py"]
        DS[databricks-dolly-15k-ja-zundamon<br/>HuggingFace データセット 30件] --> Split[train:eval = 8:2 分割]
        Split --> Train1[train 24件]
        Split --> Eval1[eval 6件]
        Init[初期 EdamameFairyBot<br/>Signature の docstring がプロンプト] --> MIP
        Train1 --> MIP[MIPROv2.compile<br/>auto=light]
        Metric[LLM-as-Judge metric<br/>gpt-4.1-mini が 0-10 で採点] --> MIP
        MIP --> Opt[最適化済み Bot<br/>プロンプト文 + few-shot demo が更新]
        Opt --> Eval2[Eval 24件で平均スコア計算]
        Eval2 --> MLflow[(MLflow に記録)]
        Opt --> Save[artifact/edamame_fairy_model.json に保存]
    end

    subgraph Inference ["推論フェーズ main.py"]
        Save --> Load[chatbot.load JSON]
        User[ユーザ入力] --> Chat[gpt-4.1-nano で推論<br/>最適化済みプロンプト適用]
        Load --> Chat
        Chat --> Reply[妖精スタイル応答]
    end

「最適化済みモデル」の正体: PyTorch のように重みが学習されるわけではない。artifact/edamame_fairy_model.json を見ると分かるが、保存されているのは「最適化された Signature 指示文 + few-shot demo 配列」だけ。LLM 自体は同じ GPT-4.1-nano。重みではなく "プロンプト" を学習しているのが DSPy の核。


使用ライブラリ・原理

dspy.Signature — 入出力の型 + 振る舞いの宣言
class ConversationSignature(dspy.Signature):
    """枝豆の妖精として対話する"""                    # ← この docstring が初期プロンプトの基礎になる
    query = dspy.InputField(desc="ユーザーからの質問や発言")
    history = dspy.InputField(desc="過去の対話履歴", format=list, default=[])
    response = dspy.OutputField(desc="枝豆の妖精としての応答。語尾に「のだ」「なのだ」...")

ポイント:

  • docstring が「タスクの指示文」になる。MIPROv2 はここを書き換える
  • InputField.desc / OutputField.desc も最適化対象になりうる
  • LangChain の ChatPromptTemplate のような文字列テンプレートを書かないのが革新的。プロンプトは「Signature + DSPy が自動生成する Prompt Adapter」が組み立てる
dspy.Predict(Signature) — Signature 1 つにつき LLM 呼び出し 1 回
self.respond = dspy.Predict(ConversationSignature)

呼ぶと内部で:

  1. Signature の docstring + field descriptions から プロンプトを自動構築
  2. LLM に投げて応答を取得
  3. OutputField を Pydantic 的にバリデートして Prediction オブジェクトに詰める
dspy.Module — Predict を組み合わせた最適化単位
class EdamameFairyBot(dspy.Module):
    def __init__(self):
        super().__init__()
        self.respond = dspy.Predict(ConversationSignature)

    def forward(self, query, history=None):
        return self.respond(query=query, history=history or [])

PyTorch の nn.Module.forward() と完全同型__init__ で sub-Predict を attribute として宣言し、forward で呼び出す。bot(query=..., history=...)forward が呼ばれる。

dspy.MIPROv2 — Multi-prompt Instruction Proposal Optimizer v2
optimizer = dspy.MIPROv2(
    metric=llm_style_metric,
    prompt_model=eval_lm,
    auto="light",
    max_bootstrapped_demos=2,
    max_labeled_demos=1,
)
optimized_chatbot = optimizer.compile(chatbot, trainset=train_data, minibatch_size=20)

MIPROv2 が裏でやっていること(論文ベース):

  1. Bootstrap: 初期チャットボットを train データで走らせて、metric が高得点だった応答を「良い demo 候補」として収集
  2. Instruction proposal: prompt_model(評価用 LM = GPT-4.1-mini)に「もっと良いタスク指示文を提案して」と頼んで、複数の指示文候補を生成
  3. Bayesian optimization: 「指示文候補 × few-shot demo 候補」の組合せで mini-batch 評価し、metric を最大化する組合せを探索
  4. 最後に勝った組合せで Signature を上書き

主要パラメータ:

パラメータ 役割
metric スコア関数。(example, prediction) -> float(0-1 推奨)
prompt_model 指示文生成・改良に使う LM。本当に賢いやつにすると効く
auto "light" / "medium" / "heavy" で予算プリセット
max_bootstrapped_demos 学習済みモデルが自動で集める demo 数の上限
max_labeled_demos train データから直接拾う demo 数の上限
評価関数 (metric) の設計 — 4 パターン + 直感的なコツ

DSPy オプティマイザは「metric の戻り値を最大化するように」プロンプトと Few-Shot を試行錯誤するので、metric の設計が最適化の質を直接決める。PyTorch でいう loss 関数の設計と同じくらい重要。

最小契約

DSPy オプティマイザが期待するシグネチャはこれだけ:

def metric(example, prediction, trace=None) -> float:
    """
    example     : 教師データ (.query, .answer など、dspy.Example のインスタンス)
    prediction  : Module の出力 (.response など、dspy.Prediction のインスタンス)
    trace       : 中間推論ステップ (省略可、ReAct 系で使う)
    戻り値       : 0.0〜1.0 の float か bool
    """

予測 vs 正解(or 期待スタイル)を見て、何点か返す関数」。それだけ。

パターン①: ルールベース(最速・最安)

正解と完全一致するか、だけを見る。RAG や QA タスクの定番。

def exact_match(example, prediction, trace=None) -> float:
    return float(example.answer.strip() == prediction.answer.strip())
  • 直感: 答えが「東京」なら「東京」とだけ書け、それ以外は 0 点
  • 得意: ファクト系 QA(26回の JQaRA RAG はこれ
  • 苦手: スタイル、ニュアンス、創造性
  • コスト: ほぼゼロ(LLM コールなし)
パターン②: 部分一致 / F1(中庸)

正解に含まれるキーワードの過不足で点数を出す。

def f1_score(example, prediction, trace=None) -> float:
    pred_tokens = set(prediction.answer.split())
    gold_tokens = set(example.answer.split())
    if not pred_tokens:
        return 0.0
    p = len(pred_tokens & gold_tokens) / len(pred_tokens)
    r = len(pred_tokens & gold_tokens) / len(gold_tokens)
    return 2 * p * r / (p + r) if (p + r) else 0.0
  • 直感: 「東京都新宿区」と答えるべきところを「新宿区」と答えたら 50 点くらい
  • 得意: 長文要約、固有名詞抽出
  • 苦手: 順序、論理構造
パターン③: LLM-as-Judge(25回が採用、詳細は次セクション)

別の LLM に「これって何点?」と聞く。主観的品質を測れる唯一の方法。本番 LLM (nano) は安く、採点 LLM (mini) は賢くの分業がポイント。25回の実コードは次のセクション「LLM-as-Judge metric の書き方」で詳説。

  • 直感: 「枝豆の妖精っぽさ」みたいなものは正解データを書けない。なら「もう一人の AI 採点者」を雇って、ルーブリック(点数配分)を渡して採点させればいい
  • 得意: スタイル、トーン、可読性、論理性
  • 苦手: 安定性(採点者の気分でブレる)、コスト(毎回 LLM コール)
パターン④: 複合スコア(実務でよく使う)

ルールベース + LLM-as-Judge の合わせ技。ハードルを段階的に上げてコストを節約

def composite_metric(example, prediction, trace=None) -> float:
    # ① 形式チェック(必須項目が入ってるか)
    if "なのだ" not in prediction.response:
        return 0.0  # 形式違反は即ゼロ、LLM コールに進まない

    # ② JSON 構造のバリデーション
    try:
        parsed = json.loads(prediction.json_output)
    except json.JSONDecodeError:
        return 0.0

    # ③ ファクト一致(70% の重み)
    fact_score = 1.0 if parsed["answer"] == example.answer else 0.0

    # ④ スタイル評価(30% の重み、LLM-as-Judge)
    style_score = llm_style_metric(example, prediction)

    return 0.7 * fact_score + 0.3 * style_score
  • 直感: 「不合格条件を先に切る → 数値で測れる部分は数値で → 主観部分だけ LLM に頼む」
  • 早期 return で高い LLM コール費用を回避
評価関数を書くときの直感的なコツ
  1. 「採点者が見るルーブリック」を先に紙に書く 評価関数を書く前に「人間が 1000 件採点するなら、何の基準で何点配分?」を日本語で書き出す。25回のコメント内ルーブリック (3+2+3+2=10) がそのまま LLM への指示になっているのはこの順序だから。

  2. 「0 点と 10 点の例」を 3 つずつ手で書ける? 書けないなら、その評価関数はオプティマイザに渡しても無意味。何が良くて何が悪いかを言語化できないなら、機械にもできない

  3. 最初は粗くて OK、改善はあとから

    def metric_v1(example, prediction, trace=None) -> float:
        return float("のだ" in prediction.response)  # ← まずこれで動かす
    
    このレベルで MIPROv2 を走らせて「とりあえず動く」を確認してから、徐々に複雑化する。最初から完璧な metric を書こうとすると 1 行も書けない。

  4. 「ちょうど中間の答え」が境界点として返るか確認

    assert metric(ex_good, pred_good)     > 0.9   # 完璧な応答
    assert metric(ex_good, pred_bad)      < 0.1   # 完全にダメ
    assert 0.3 < metric(ex_good, pred_mediocre) < 0.7  # 微妙(重要)
    
    中間が 0 か 1 しか返さない飽和した metric だと、MIPROv2 は勾配を見失う。連続値で滑らかに動くことが大事。

  5. LLM-as-Judge は「採点プロンプトのチューニング」も必要 採点 LLM 自体がブレると、最適化の土台が崩れる。temperature=0 必須、ChainOfThought で理由を言わせると安定、同じ応答を 3 回採点して標準偏差を見るのがデバッグの基本。

連載の評価関数パターン早見表
タスク metric の種類 採点者
25 スタイル統一チャットボット LLM-as-Judge (10点満点ルーブリック) gpt-4.1-mini
26 日本語 RAG (JQaRA) Exact Match なし (文字列比較)
27 日本語 RAG + GEPA Exact Match + Recall@k なし
28 ReAct エージェント LLM-as-Judge (タスク完遂度) gpt-4o

ひとことまとめ: 評価関数 = 「LLM の通知表をどう付けるか」を Python 関数で定義したもの。 書くべきは「完璧な metric」ではなく「完璧じゃなくても、良い方向に勾配が出る metric」。 これは PyTorch の loss 関数設計と全く同じ発想で、ここで「DSPy は LLM 時代の PyTorch」と呼ばれる理由に繋がる。

LLM-as-Judge metric の書き方
def create_style_metric(eval_lm):
    class StyleEvaluation(dspy.Signature):
        response = dspy.InputField(...)
        criteria = dspy.InputField(...)
        score = dspy.OutputField(desc="スコア(0-10)", format=int)
        explanation = dspy.OutputField(desc="評価理由")

    evaluator = dspy.ChainOfThought(StyleEvaluation)

    def llm_style_metric(_, prediction, __=None):
        criteria = """以下の基準で0-10点で評価してください: ..."""
        with dspy.context(lm=eval_lm):                                        # ← 評価時だけ別 LM に切替
            eval_result = evaluator(response=prediction.response, criteria=criteria)
        score = min(10, max(0, float(eval_result.score))) / 10.0
        return score
    return llm_style_metric

要点:

  • metric の中で別の DSPy モジュールを使えるdspy.ChainOfThought は CoT 付きの Predict)
  • with dspy.context(lm=...) で評価用 LM だけ切り替えできる。global 設定の dspy.configure(lm=...) を汚さない
  • 戻り値は [0, 1] レンジに正規化推奨。MIPROv2 は単純に高ければ良いと解釈する
MLflow autolog の DSPy 統合
mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)
mlflow.set_experiment(MLFLOW_EXPERIMENT_NAME)
mlflow_dspy.autolog(log_compiles=True, log_evals=True, log_traces_from_compile=True)
with mlflow.start_run(run_name=MLFLOW_RUN_NAME):
    optimized_chatbot = optimizer.compile(...)

mlflow.dspy.autolog() を有効化すると、optimizer.compile() の中で起こる全 LLM 呼び出し・全 metric 評価が MLflow に記録される。MIPROv2 は数百回 LLM を叩くので、後で「どの指示文候補がどんなスコアだったか」を MLflow UI で振り返れる。


ファイル別の役割

ファイル 役割
chatbot_module.py ConversationSignature(docstring がプロンプト) + EdamameFairyBot(dspy.Module)forwardPredict を呼ぶだけ)
chatbot_tuning.py データ読込 + train/eval 分割 + LLM-as-Judge metric 構築 + MIPROv2.compile + MLflow autolog + 評価 + 保存
main.py 最適化済み JSON を load → gpt-4.1-nano で対話 REPL(過去 5 回分を deque で履歴管理)
artifact/edamame_fairy_model.json DSPy が save した最適化済みプロンプト + few-shot demo

行レベルの工夫(中核ロジックの抜粋)

① Signature の docstring と OutputField.desc がスタイル定義の全て (chatbot_module.py:7-11)
class ConversationSignature(dspy.Signature):
    """枝豆の妖精として対話する"""                                              # ①
    query = dspy.InputField(desc="ユーザーからの質問や発言")
    history = dspy.InputField(desc="過去の対話履歴", format=list, default=[])
    response = dspy.OutputField(desc="枝豆の妖精としての応答。語尾に「のだ」「なのだ」を自然に使い、一人称は「ボク」。親しみやすく可愛らしい口調で、日本語として自然な文章")  # ②
やってること なぜそうする
docstring に「タスクの目的」を 1 行で書く これが MIPROv2 への初期プロンプトになる。MIPROv2 はここを「より高スコアな指示文」に書き換えることが多い
OutputField.desc にスタイル要件を明示的に箇条書き 「のだ」「ボク」「親しみやすい」など、metric の評価軸と同じ軸で書く。LLM が応答時に守るべき制約

比較: 普通の LangChain 流なら system_prompt = "あなたは枝豆の妖精で..." と長文を書く。DSPy 流は 「型と説明だけ書く」「プロンプトは生成させる」

② MIPROv2 への compile 呼び出し (chatbot_tuning.py:91-118)
optimizer = dspy.MIPROv2(
    metric=llm_style_metric,                                                  # ①
    prompt_model=eval_lm,                                                     # ②
    auto="light",                                                             # ③
    max_bootstrapped_demos=2,
    max_labeled_demos=1,
)

mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)
mlflow.set_experiment(MLFLOW_EXPERIMENT_NAME)
mlflow_dspy.autolog(log_compiles=True, log_evals=True, log_traces_from_compile=True)  # ④

with mlflow.start_run(run_name=MLFLOW_RUN_NAME):
    optimized_chatbot = optimizer.compile(                                    # ⑤
        chatbot,
        trainset=train_data,
        minibatch_size=20
    )
やってること なぜそうする
metric は (example, prediction) -> float の関数 MIPROv2 はこれを最大化する。今回は LLM-as-Judge 方式で「スタイル準拠スコア」を返す
prompt_model=eval_lm指示文提案に使う LM を分離 賢い LM(GPT-4.1-mini)に「もっと良いプロンプト書いて」と頼む。実行時の chat_lm(GPT-4.1-nano = 安く速い)と役割を分ける
auto="light" で総 LLM 呼び出し回数を抑制 light = 最大 1-2 イテレーション、medium = 6 程度、heavy = 18 程度。学習費用と効果のトレードオフ
mlflow_dspy.autolog(...) で compile 中の全イベントを MLflow に流す 後で「どの指示文候補がスコア何点だったか」を追跡できる
optimizer.compile(chatbot, trainset=...) で実際の最適化実行 minibatch_size=20 で 20 サンプルずつ評価 → ベイズ最適化で次の候補を選ぶ
③ 学習結果の保存と読み込み (chatbot_tuning.py:178-184 / main.py:24-28)
# tuning 側
optimized_bot.save(OPTIMIAZED_MODEL_PATH)  # ← JSON 1 ファイルにシリアライズ

# main 側
chatbot = EdamameFairyBot()
chatbot.load(OPTIMIAZED_MODEL_PATH)        # ← 同じ Signature 構造の Module に load

JSON の中身(artifact/edamame_fairy_model.json):

{
  "respond": {
    "demos": [
      {"query": "魚の種類は...", "history": [], "response": "ごめんねなのだ、ボクは枝豆の妖精..."},
      {"query": "アリスの両親には...", "history": [], "response": "ごめんなのだ、ボクには..."}
    ],
    "signature": {
      "instructions": "ユーザーからの質問や会話に対して、枝豆の妖精として親しみやすく可愛らしい口調で...",
      "fields": [...]
    }
  }
}

保存されるのは「指示文 + few-shot demo 配列」だけ。LLM の重みは触らない(プロンプトの最適化なので当然)。

④ 対話 REPL での履歴管理 (main.py:31-52)
history = deque(maxlen=5)                                                     # ①

while True:
    user_input = input("\nあなた: ")
    if user_input.lower() in ['quit', 'exit', '終了']:
        break

    history_list = [f"User: {h[0]}\nBot: {h[1]}" for h in history]            # ②

    result = chatbot(query=user_input, history=history_list)                  # ③
    print(f"🌱妖精: {result.response}")

    history.append((user_input, result.response))
やってること なぜそうする
deque(maxlen=5) で履歴を直近 5 ターンに制限 古い履歴を context に詰めすぎないため。古いものは自動的に左端から落ちる
(user, bot) タプルを "User: ...\nBot: ..." に整形 Signature の historyformat=list 指定なので、リスト要素は文字列
chatbot(query=..., history=...)forward 経由 Predict 実行 内部で最適化された指示文 + few-shot demo + 履歴 + クエリが gpt-4.1-nano に送られる

学んだこと(要点)

  • DSPy はプロンプト最適化のための PyTorch。「Signature = nn.Module の interface」「Predict = nn.Linear」「Optimizer = Adam」「metric = loss」と置き換えれば理解しやすい
  • 「重み」ではなく「プロンプト + few-shot demo」を学習する。最適化後の artifact は JSON 1 ファイル
  • LLM-as-Judge metric は便利だが評価 LM のバイアスを学んでしまうリスクあり。本番では人間ラベル + LLM-as-Judge のハイブリッド推奨
  • prompt_modelchat_lm を分離するパターンが効果的。「賢い & 高い LM」で最適化 → 「速い & 安い LM」で推論
  • MIPROv2 の auto="light" でも数百回の LLM 呼び出しになる。最適化 1 回 = $1-5 程度は覚悟(GPT-4.1-mini 想定)
  • MLflow autolog の DSPy 統合は学習過程の振り返りに必須。MIPROv2 がどの指示文をどう改良したかを後追いできる
  • Signature の docstring の質が初期スコアに直結する。「枝豆の妖精として対話する」程度でも MIPROv2 が肉付けしてくれる
  • DSPy は dspy.ChainOfThought / dspy.ReAct / dspy.ProgramOfThought など複数 module を提供。Predict は最小単位
  • DSPy 3.0 系(本サンプル)と 2.x の API は微妙に違うoptimizer.compile() の戻り値が Module であること、autolog の名前空間が mlflow_dspy であることなどに注意

拡張アイデア

  1. auto="medium" / "heavy" で比較 — 同じ train データで light vs medium vs heavy を MLflow に並べて、どこから収穫逓減になるかを観察。コスト効率の良いポイントを見つける
  2. より良いデータセットで実験 — 30 件は明らかに少ない。takaaki-inada/databricks-dolly-15k-ja-zundamon の全 15k から数百件サンプルして比較
  3. metric を 2 段にする — 「スタイル準拠スコア」+「内容の正確性スコア」を組み合わせて weighted sum。MIPROv2 の metric は 1 つの float なので、内部で重み付けして合算
  4. dspy.ChainOfThought(ConversationSignature) に差し替え — Predict を CoT 版に置き換えて、推論を「思考過程 → 最終応答」の 2 段に。スタイル精度が上がるかを実験
  5. 他の optimizer と比較BootstrapFewShot / BootstrapFinetune / KNNFewShot 等。MIPROv2 が常に best とは限らない
  6. 永続化と CI 統合artifact/*.json の hash を pin して、CI で「同じ train データから再最適化して decent なスコアが出るか」を回帰テスト
  7. DSPy Visualizationdspy.inspect_history(n=3) で過去 LLM 呼び出しを覗ける機能を使い、最適化前後の prompt diff を出す

現代版に移植するなら

1. API キー設定は .env.op + op run に切り替える(CLAUDE.md ルール 8)
# 25/.env.op
OPENAI_API_KEY=op://Personal/openai-api-key/credential
MLFLOW_PORT=5001

起動:

# MLflow サーバ
op run --env-file=.env.op -- uv run mlflow server --backend-store-uri sqlite:///mlflow.db --host 0.0.0.0 --port 5001

# 最適化
op run --env-file=.env.op -- uv run python chatbot_tuning.py

# 推論
op run --env-file=.env.op -- uv run python main.py
2. gpt-4.1-mini / gpt-4.1-nano の見直し

本サンプルは 2025 年中頃の gpt-4.1 系を使っているが、2026 年現在は gpt-5-mini / gpt-5-nano 等が利用可能。第27回でも同じ問題に触れているが、DSPy は dspy.LM(model="openai/<id>")<id> を差し替えるだけ。ただし MIPROv2 の prompt_model は smart 寄りを推奨(最適化品質が露骨に変わる)。

3. openai==1.99.5 の version pin を緩める

pyproject.tomlopenai==1.99.5 と完全 pin している。DSPy 3.x が依存する範囲なら openai>=1.50.0,<2 程度の幅にする方が uv sync の競合解決が安定する。

4. evaluation_data の評価が単純すぎる

chatbot_tuning.py:121-129for example in evaluation_data: と直列に評価しているが、dspy.Evaluate クラスを使えば num_threads=8 で並列化できる:

from dspy import Evaluate
evaluator = Evaluate(devset=evaluation_data, metric=llm_style_metric, num_threads=8)
avg_eval_score = evaluator(optimized_chatbot)

評価時間が体感 5-8 倍速くなる。

5. chatbot.load(...) で thread safety を考える

DSPy module は基本 stateless(demo は読み取り専用)だが、dspy.configure(lm=...) がプロセスグローバルなので、FastAPI 等でマルチワーカー化する場合は with dspy.context(lm=...) を必ず使うこと。

6. printlogging

特に chatbot_tuning.py の進捗 print は logging.info に。CI で長時間実行する時に log level で抑制可能になる。


既知の不具合・注意点

  • OPTIMIAZED_MODEL_PATH の typo: OPTIMIZED ではなく OPTIMIAZEDchatbot_module.py 側で import しているので動くが見るたびに気になる
  • llm_style_metric の引数 _ __: (example, prediction, trace=None) -> float の signature を _ / __ で受けている。DSPy 3.x の metric signature が version によって違うため、Linter で警告が出ることも
  • データセット 30 件は本当に少ない: MIPROv2 が demo を選ぶ余地が狭く、ほぼ trivial な最適化に終わる。学習用としては最小に振っているが、production では 200-1000 件は欲しい
  • MLFLOW_PORT=5000 のデフォルトと README の 5001 が食い違う: chatbot_tuning.py:14os.getenv("MLFLOW_PORT", "5000") と README の MLFLOW_PORT=5001 が違うので、.env を書かないと port mismatch
  • load_dataset の HF キャッシュ: 初回実行で ~/.cache/huggingface/datasets/ に数 MB の dataset が落ちる。HF_DATASETS_OFFLINE=1 に注意
  • history 形式が学習と推論で違う: 学習側 (chatbot_tuning.py:170) では history=[] の空リスト、推論側 (main.py:45) では ["User: ...\nBot: ..."] の文字列リスト。最適化時に履歴を考慮していないので、推論で履歴を渡しても効果が薄い。本来は history を含む train データで最適化すべき
  • max_bootstrapped_demos=2, max_labeled_demos=1 の値設定が暗黙: 30件のデータで 2+1=3 demo だと割合的に少ない。dataset サイズに応じて見直すべき
  • MLflow autolog の log_traces_from_compile=True は重い: 数百回の trace が全部 MLflow に流れるので、mlflow.db のサイズが急速に膨張する

記事参照

  • Software Design 2025 年 10 月号(推定)連載第25回「DSPy + MIPROv2 入門」
  • 関連: 第27回 STUDY_NOTES — 同じ DSPy 系で GEPA を使った RAG 最適化
  • 公式 DSPy ドキュメント: https://dspy.ai/
  • MIPROv2 論文: https://arxiv.org/abs/2406.11695
  • HuggingFace dataset: https://huggingface.co/datasets/takaaki-inada/databricks-dolly-15k-ja-zundamon
  • MLflow DSPy integration: https://mlflow.org/docs/latest/llms/dspy/index.html

作成: 2026-05-25 / 最終更新: 2026-06-10