プロンプト、手直す時代を、ぶっ壊す👍——DSPyで始める自動チューニング
はじめに
こんにちは!
株式会社エクスプラザのhyodoです!
LLMを使ったアプリケーションを開発していて、こんな経験はありませんか?
「プロンプトちょっと変えたら精度が上がった!……と思ったら別のケースで壊れた」
「GPT-4oで動いてたプロンプト、Claude に変えたら全然ダメだった」
「few-shot例を何パターンも手で試してるけど、どれがベストか分からない」
プロンプトチューニングは、やればやるほど「これ人間がやる仕事なのか?」という気持ちになります。評価基準もあいまいで、改善したつもりが別の場所で劣化してることに後から気づく。モデルをアップデートしたら最初からやり直し。
この「プロンプトの手作業」を、データに基づいて自動で最適化できるフレームワークがDSPyです。
今回は、DSPyを使って商品レビューの分析ツールを作りながら、プロンプトの自動チューニングがどう動くのかを紹介します。
動作環境
この記事のコードは以下の環境で動作確認しています。
- Python 3.12
- dspy 3.1.3
- openai 2.28.0
$ pip install dspy openai
プロンプトチューニングの何がつらいのか
LLMアプリケーションの開発で、プロンプトを調整する作業は避けて通れません。ただ、この作業にはいくつか構造的な問題があります。
試行錯誤が属人的で再現できない。 「こう書いたら良くなった」というノウハウが個人の頭の中にしかない。チームメンバーが別のプロンプトを試しても、なぜそれが良いのか悪いのかを定量的に説明できない。
モデルを変えるとプロンプトも作り直しになる。 GPT-4oで調整したプロンプトがClaude Sonnet 4でも同じように動く保証はありません。モデルのアップデートや乗り換えのたびに、チューニング作業が発生します。
「良いプロンプト」の基準があいまい。 5件のテストケースで良い結果が出ても、50件に増やすとボロボロだったりする。かといって網羅的にテストするのは時間がかかる。
要するに、プロンプトチューニングは「職人芸」になりやすいです。DSPyは、この職人芸をプログラムとデータで置き換えようとするフレームワークです。
DSPyの考え方
DSPyのアプローチを一言で言うと、**「プロンプトを人間が書くのではなく、プログラムとして定義してデータで最適化する」**です。
機械学習を知っている人なら、こう考えると分かりやすいです。
従来のプロンプトチューニング:
プロンプトを手で書く → 試す → 結果を見る → 手で直す → 試す → ...
DSPy:
入出力を定義する → 処理フローを組む → 評価関数を作る → オプティマイザに渡す → 最適化されたプロンプトが出てくる
機械学習では「モデルの構造を定義して、損失関数を決めて、データで学習する」という流れがあります。DSPyはこの考え方をプロンプトチューニングに持ち込んでいて、プロンプトを機械学習における「重み」のように扱います。人間がプロンプトの文面を直接いじるのではなく、入出力の型と処理の流れを定義して、あとはデータと評価関数を頼りにフレームワークが最適なプロンプトを探してくれます。
実際に作って最適化してみる
ここからは、DSPyを使って商品レビューの分析ツールを作ってみます。レビュー文を入力すると、感情(ポジティブ/ネガティブ)、評価ポイント、改善提案を出力するツールです。
入出力を定義する(Signature)
DSPyでは、LLMに「何を入力して何を出力するか」をSignatureというクラスで定義します。
import dspy
class ReviewAnalysis(dspy.Signature):
"""商品レビューを分析して、感情・評価ポイント・改善提案を出力する"""
review_text = dspy.InputField(desc="商品レビューの本文")
sentiment = dspy.OutputField(desc="positive または negative")
key_points = dspy.OutputField(desc="レビューの主な評価ポイント(箇条書き)")
suggestion = dspy.OutputField(desc="商品やサービスの改善提案")
ここで書いているのは「入力はレビュー本文、出力は感情・評価ポイント・改善提案」という型の定義だけです。プロンプトの文面は一切書いていません。DSPyが実行時にSignatureの情報からプロンプトを組み立ててくれます。
docstringとフィールドのdescが、DSPyがプロンプトを生成するときのヒントになります。ここを丁寧に書くと、最適化前でもそれなりの精度が出ます。
処理フローを組む(Module)
Signatureは「何を入出力するか」の定義で、「どう処理するか」はModuleで組みます。
シンプルにSignatureを1つだけ使う場合はこうなります。
class ReviewAnalyzer(dspy.Module):
def __init__(self):
super().__init__()
self.analyze = dspy.ChainOfThought(ReviewAnalysis)
def forward(self, review_text: str) -> dspy.Prediction:
return self.analyze(review_text=review_text)
dspy.ChainOfThoughtは、LLMに段階的に考えさせてから回答を出させるモジュールです。DSPyにはほかにもdspy.Predict(直接回答)やdspy.ReAct(ツール呼び出しを交えた推論)などが用意されていて、タスクに合ったものを選べます。
もっと複雑な処理をしたい場合は、複数のSignatureをModuleの中で繋げます。たとえば「まず感情を判定して、その結果を踏まえて改善提案を出す」という2段構成にしたければ、こう書きます。
class SentimentJudge(dspy.Signature):
"""レビューの感情を判定する"""
review_text = dspy.InputField(desc="商品レビューの本文")
sentiment = dspy.OutputField(desc="positive または negative")
key_points = dspy.OutputField(desc="レビューの主な評価ポイント(箇条書き)")
class ImprovementAdvisor(dspy.Signature):
"""感情判定の結果をもとに改善提案を行う"""
review_text = dspy.InputField(desc="商品レビューの本文")
sentiment = dspy.InputField(desc="判定された感情")
key_points = dspy.InputField(desc="評価ポイント")
suggestion = dspy.OutputField(desc="商品やサービスの改善提案")
class TwoStepAnalyzer(dspy.Module):
def __init__(self):
super().__init__()
self.judge = dspy.ChainOfThought(SentimentJudge)
self.advisor = dspy.Predict(ImprovementAdvisor)
def forward(self, review_text: str) -> dspy.Prediction:
judgment = self.judge(review_text=review_text)
advice = self.advisor(
review_text=review_text,
sentiment=judgment.sentiment,
key_points=judgment.key_points,
)
return dspy.Prediction(
sentiment=judgment.sentiment,
key_points=judgment.key_points,
suggestion=advice.suggestion,
)
Signatureで「何を入出力するか」を定義し、Moduleで「どの順序でLLMを呼ぶか」を組む。この2つの概念がDSPyの基本です。
学習データを準備する
最適化にはデータが必要です。入力(レビュー本文)と期待する出力(感情、評価ポイント)のペアを用意します。
import dspy
trainset = [
dspy.Example(
review_text="画面がきれいで動作もサクサク。バッテリーも1日持つので満足です。",
sentiment="positive",
key_points="画面品質が高い、動作が快適、バッテリー持ちが良い",
).with_inputs("review_text"),
dspy.Example(
review_text="買って1週間で充電できなくなった。サポートに連絡しても返答が遅い。",
sentiment="negative",
key_points="充電不良、サポート対応が遅い",
).with_inputs("review_text"),
dspy.Example(
review_text="デザインは良いけど重すぎる。長時間持っていると手が疲れる。",
sentiment="negative",
key_points="デザインは良い、重量が重い、長時間使用に不向き",
).with_inputs("review_text"),
# ... 実際にはもっと多くのデータを用意する
]
.with_inputs("review_text")は、review_textが入力フィールドであることをDSPyに教えています。それ以外のフィールド(sentiment、key_points)は正解ラベルとして扱われます。
データ量の目安ですが、最適化手法にもよるものの、20〜30件程度の学習データがあればそれなりに効果が出ます。件数だけでなく、データの多様性も大事です。ポジティブなレビューばかり集めると、ネガティブなケースや判断が微妙なケースで精度が出ません。エッジケース(短文、皮肉、ポジネガが混在するレビューなど)を意識的に含めておくと、最適化後の汎化性能が変わってきます。
評価関数を作る
最適化で「良いプロンプトかどうか」を判断するために、評価関数を定義します。ここが一番頭を使うところです。
今回は感情判定の正解率と、評価ポイントの妥当性をLLMで評価する2段構えにします。
import dspy
class PointsQuality(dspy.Signature):
"""評価ポイントの品質を判定する"""
review_text = dspy.InputField(desc="元のレビュー本文")
predicted_points = dspy.InputField(desc="モデルが出力した評価ポイント")
reference_points = dspy.InputField(desc="正解の評価ポイント")
score = dspy.OutputField(desc="0から10のスコア", format=int)
points_evaluator = dspy.ChainOfThought(PointsQuality)
def review_metric(example, prediction, trace=None):
# 感情判定の正誤(完全一致)
sentiment_correct = (
example.sentiment.strip().lower() == prediction.sentiment.strip().lower()
)
# 評価ポイントの品質をLLMで採点
with dspy.context(lm=eval_lm):
quality = points_evaluator(
review_text=example.review_text,
predicted_points=prediction.key_points,
reference_points=example.key_points,
)
points_score = min(10, max(0, int(quality.score))) / 10.0
return (1.0 if sentiment_correct else 0.0) * 0.5 + points_score * 0.5
感情判定は文字列の完全一致で判定し、評価ポイントの品質はLLMに採点させています。最適化に使うモデルと評価に使うモデルを分けることで、評価の客観性を保てます。
MIPROv2で最適化を実行する
学習データ、Module、評価関数が揃ったら、オプティマイザに渡して最適化を実行します。DSPyにはいくつかのオプティマイザがありますが、プロンプト最適化で実績があるMIPROv2を使います。
import dspy
# 推論用のモデル
lm = dspy.LM(model="openai/gpt-4o-mini")
dspy.configure(lm=lm)
# 評価用のモデル(推論用と分ける)
eval_lm = dspy.LM(model="openai/gpt-4o")
# Moduleのインスタンス化
analyzer = TwoStepAnalyzer()
# オプティマイザの設定
optimizer = dspy.MIPROv2(
metric=review_metric,
prompt_model=eval_lm,
auto="light",
max_bootstrapped_demos=2,
max_labeled_demos=2,
)
# 最適化の実行
optimized_analyzer = optimizer.compile(
analyzer,
trainset=trainset,
)
auto="light"は最適化の探索範囲を控えめにする設定です。"medium"や"heavy"にすると探索範囲が広がりますが、その分API呼び出しの回数とコストが増えます。初回は"light"で試して、効果が見えたら範囲を広げるのがおすすめです。
MIPROv2の最適化は内部で3つのステップを踏みます。まず現在のModuleで学習データに対して推論を実行し、その結果を評価関数でスコアリングします(トレース収集)。次に、スコアの高かった応答から特徴を抽出して、プロンプトの候補を複数生成します(候補生成)。最後に、生成した候補をベイズ最適化で評価し、最もスコアの高い組み合わせを選びます。
最適化前後の出力を比較する
最適化の効果を確認します。
test_review = "音質は良いけど、ノイズキャンセリングが弱い。通勤電車だと外の音がかなり入ってくる。"
# 最適化前
before = analyzer(review_text=test_review)
print("【最適化前】")
print(f"感情: {before.sentiment}")
print(f"評価ポイント: {before.key_points}")
print(f"改善提案: {before.suggestion}")
print("---")
# 最適化後
after = optimized_analyzer(review_text=test_review)
print("【最適化後】")
print(f"感情: {after.sentiment}")
print(f"評価ポイント: {after.key_points}")
print(f"改善提案: {after.suggestion}")
最適化後のModuleには、MIPROv2が見つけた最適なシステムプロンプトとfew-shot例が組み込まれています。手で書いたプロンプトではなく、データと評価関数から自動で導き出されたものです。
最適化されたModuleは保存して再利用できます。
# 保存
optimized_analyzer.save("artifact/review_analyzer")
# 読み込み
loaded_analyzer = TwoStepAnalyzer()
loaded_analyzer.load("artifact/review_analyzer")
DSPyを使ってみて感じたこと
プロンプトの「管理」が楽になる
DSPyを使うと、プロンプトの改善が「テキストの書き直し」ではなく「データの追加と評価関数の改善」になります。Gitで差分管理しやすいですし、「なぜこのプロンプトが良いのか」を評価スコアで説明できます。
チームで開発する場合、「プロンプトを変えたらスコアが下がった」「学習データを増やしたらスコアが上がった」という議論ができるので、属人的な職人芸から抜け出せます。
評価関数の設計が肝
一方で、評価関数の設計がいい加減だと最適化の意味がなくなります。今回のように「LLMにスコアをつけさせる」アプローチは手軽ですが、評価用LLM自体の判断がブレることもあります。
タスクによっては、文字列の完全一致や正規表現によるパターンマッチなど、LLMに頼らない評価基準を組み合わせたほうが安定します。
向いているプロジェクト、向いていないプロジェクト
DSPyがハマるのは、「入力と期待する出力のペアをある程度の量で用意できる」タスクです。分類、要約、情報抽出、定型的な文章生成などが向いています。
逆に、正解が1つに定まらない創造的なタスク(小説の執筆、ブレストのアイデア出しなど)は、評価関数が作りにくいのでDSPyのメリットが出にくいです。
また、学習データの準備コストも無視できません。20〜30件程度のデータ準備は必要で、そのデータの品質が最適化結果に直結します。「プロンプトを手で直す時間」と「学習データを準備する時間」のどちらが投資対効果が高いかは、プロジェクトの規模やタスクの反復頻度によって変わります。
まとめ
DSPyは、プロンプトチューニングを「手作業での書き直し」から「データと評価関数による自動最適化」に変えるフレームワークです。
記事のポイントを整理します。
- Signatureで入出力の型を定義し、Moduleで処理フローを組むのがDSPyの基本。プロンプトの文面は自分で書かない。
- MIPROv2などのオプティマイザに学習データと評価関数を渡すと、最適なシステムプロンプトとfew-shot例を自動で探してくれる。
- 評価関数の設計が一番大事。 ここが雑だと最適化しても意味がない。
- 分類・要約・情報抽出のように「正解を定義しやすいタスク」で使うと効果が大きい。
プロンプトを手で直し続ける作業に疲れたら、DSPyを試してみてください。
最後までお読みいただきありがとうございました!
参考リンク
「プロダクトの力で、豊かな暮らしをつくる」をミッションに、法人向けに生成AIのPoC、コンサルティング〜開発を支援する事業を展開しております。 エンジニア募集しています。カジュアル面談応募はこちらから: herp.careers/careers/companies/explaza
Discussion