DSPyによるプロンプト最適化の自動化:手動プロンプトエンジニアリングを置き換える宣言的LLMプログラミング

AI tutorial - IT technology blog
AI tutorial - IT technology blog

本番LLMパイプラインのプロンプト調整に取り組んだことがあれば、あのサイクルはよく分かるはずだ。一文修正して、テストして、また修正して、モデルが意図通りに解釈してくれることを願う。私もその経験をした——検索拡張型QAシステムの最適化プロジェクトで3週間費やし、その半分はプロンプトの調査だった。そんなときDSPyに出会い、ワークフローが一変した。

以下では実践的な観点から解説する。DSPyが本当に強い場面、そうでない場面、そして動くパイプラインを立ち上げる方法だ。

手動プロンプトエンジニアリング vs DSPyの宣言的アプローチ

従来のプロンプトエンジニアリングはこうだ。文字列を書き、タスクの指示を埋め込み、いくつかの例を追加して、モデルが意図を正しく解釈してくれることを願う。精度が落ちたとき——モデルを変更したり、ドメインが変わったり、データの分布が変化したりすると——手でプロンプトを書き直しに戻る。

DSPy(Declarative Self-improving Python)は異なるアプローチを取る。プロンプトを書く代わりに、何が欲しいか——入力フィールド、出力フィールド、制約条件——を定義し、指示の表現方法はDSPyに任せる。プログラムはTelepromptersと呼ばれる一連のオプティマイザによって最適化されたプロンプトにコンパイルされる。

実践における主な違いはこうだ:

  • 手動アプローチ:プロンプト文字列を自分で管理する。モデルを変えると壊れる。最適化は人間が行うため遅い。
  • DSPyアプローチ:シグネチャ(入出力)を定義する。DSPyはラベル付きサンプルとメトリクス関数に基づいてプロンプトを自動生成・最適化する。

私にとって理解の助けになったアナロジー:DSPyのプロンプトに対する関係は、ORMのSQLに対する関係と同じだ。同じ基盤のシステムを使いながらも、抽象化レイヤーが面倒な部分を引き受けてくれるので、コードが実際に何をしているかに集中できる。

メリットとデメリット:本番運用後の率直な評価

DSPyが優れている点

  • モデル非依存の最適化:プロンプトを書き直すことなく、GPT-4をClaudeやLlamaに切り替えられる。シグネチャはそのまま維持され、DSPyが新しいモデル向けに再最適化する。
  • 再現性の高いパイプライン:Markdownのプロンプトファイルが入ったフォルダの代わりに、明示的なロジックを持つバージョン管理されたPythonコードが手に入る。
  • コンポーザブルなモジュールdspy.ChainOfThoughtdspy.ReAct、カスタムモジュールをレゴブロックのように連結できる。各モジュールが自分のプロンプト生成を担当する。
  • メトリクス駆動の改善成功指標(完全一致、F1、カスタムスコアラー)を定義し、少量のラベル付きデータセットを提供するだけで、BootstrapFewShotMIPROなどのDSPyオプティマイザが自動的により良いプロンプトを探してくれる。

本番に投入したドキュメント分類パイプラインでは、GPT-4のバージョンアップが2回あっても精度はF1スコア約89%で安定していた。DSPy導入前は、モデルの更新があるたびに少なくとも1日の手動プロンプト調整が必要だった。

DSPyが苦手な点

  • 学習曲線:DSPy独自の抽象概念——シグネチャ、モジュール、Teleprompter——を習得する必要がある。概念が腑に落ちるまでに1〜2日の摩擦を見込んでおこう。
  • 最適化コストBootstrapFewShotWithRandomSearchを実行すると複数のLLM呼び出しが発生する。GPT-4で30件のトレーニングサンプルを1回実行するだけで、データセットのサイズによっては5〜20ドルかかる場合がある。積極的にキャッシュを活用しよう。
  • デバッグが難しい:パイプラインの動作がおかしいとき、生成されたプロンプトは何層か下に埋もれている。どこを見ればいいかを知っておく必要がある。
  • 小規模データは使えるが、極小規模データは難しい:オプティマイザがシグナルを見つけるには十分なラベル付きサンプルが必要だ。20件未満だと結果にノイズが増える。

推奨セットアップ

オプティマイザに触れる前に、まず環境を整えよう。DSPyはPython 3.9以上に対応し、OpenAI、Anthropic、Google、ローカルのOllamaなどをすぐに使える。

# 仮想環境を作成する
python -m venv venv
source venv/bin/activate

# DSPyをインストールする
pip install dspy-ai

# OpenAIバックエンドの場合
pip install openai

# Anthropicバックエンドの場合
pip install anthropic

次に言語モデルを設定する。DSPyはグローバルなLMオブジェクトを使用する——スクリプトの冒頭で一度設定すれば、すべてのモジュールが自動的にそれを使う:

import dspy

# OpenAI
lm = dspy.LM('openai/gpt-4o-mini', api_key='sk-...')

# またはAnthropic
# lm = dspy.LM('anthropic/claude-3-haiku-20240307', api_key='sk-ant-...')

# またはローカルのOllama
# lm = dspy.LM('ollama_chat/llama3', api_base='http://localhost:11434')

dspy.configure(lm=lm)

本番環境では、APIキーを環境変数から読み込む。ソースファイルに認証情報を直接書き込まないこと。

import os
lm = dspy.LM('openai/gpt-4o-mini', api_key=os.environ['OPENAI_API_KEY'])
dspy.configure(lm=lm)

実装ガイド:最適化されたパイプラインの構築

ステップ1 — シグネチャを定義する

シグネチャはDSPyに入力と出力を伝えるものだ。プロンプトテキストは不要——フィールド名とオプションの説明だけでいい:

import dspy

class ClassifySupport(dspy.Signature):
    """カスタマーサポートチケットをカテゴリに分類する。"""
    ticket_text: str = dspy.InputField(desc="カスタマーサポートチケットの生テキスト")
    category: str = dspy.OutputField(desc="billing、technical、account、other のいずれか")
    confidence: str = dspy.OutputField(desc="high、medium、low のいずれか")

そのdocstringは生成されたプロンプトの一部になる。正確に書くこと——曖昧な指示は曖昧な出力を生む。

ステップ2 — モジュールを構築する

シグネチャをモジュールでラップする。中間の推論ステップが精度向上に役立つ場合はdspy.ChainOfThoughtを使う:

class SupportClassifier(dspy.Module):
    def __init__(self):
        self.classify = dspy.ChainOfThought(ClassifySupport)

    def forward(self, ticket_text):
        return self.classify(ticket_text=ticket_text)

# まず最適化なしでテストする
classifier = SupportClassifier()
result = classifier(ticket_text="今月2回請求されました。返金してください。")
print(result.category)    # billing
print(result.confidence)  # high

ステップ3 — Teleprompterで最適化する

ここがDSPyの真骨頂だ。ラベル付きデータセットとメトリクス関数を準備して、オプティマイザを実行しよう:

from dspy.evaluate import Evaluate
from dspy.teleprompt import BootstrapFewShot

# ラベル付きサンプル
trainset = [
    dspy.Example(ticket_text="二重請求の返金リクエスト", category="billing").with_inputs("ticket_text"),
    dspy.Example(ticket_text="iOS 17でアプリがクラッシュする", category="technical").with_inputs("ticket_text"),
    dspy.Example(ticket_text="パスワードのリセット方法を教えてください", category="account").with_inputs("ticket_text"),
    dspy.Example(ticket_text="サブスクリプションはいつ更新されますか?", category="billing").with_inputs("ticket_text"),
    dspy.Example(ticket_text="APIに接続できません", category="technical").with_inputs("ticket_text"),
    # 信頼性の高い結果を得るには15〜30のサンプルを追加する
]

# メトリクス:カテゴリの完全一致
def accuracy_metric(example, prediction, trace=None):
    return example.category.lower() == prediction.category.lower()

# オプティマイザを実行する
teleprompter = BootstrapFewShot(metric=accuracy_metric, max_bootstrapped_demos=4)
optimized_classifier = teleprompter.compile(SupportClassifier(), trainset=trainset)

# 最適化されたプログラムを保存する
optimized_classifier.save("support_classifier_optimized.json")

最適化後、DSPyは最適なfew-shotサンプルを自動的に選択し、指示の構造も調整している可能性がある。オプティマイザを再実行しなくても後から読み込める:

classifier = SupportClassifier()
classifier.load("support_classifier_optimized.json")

ステップ4 — 複数のモジュールを連結する

実際のパイプラインは通常複数のステップがある。DSPyはうまく組み合わせられる:

class SummarizeTicket(dspy.Signature):
    """サポートチケットを1文で要約する。"""
    ticket_text: str = dspy.InputField()
    summary: str = dspy.OutputField()

class FullSupportPipeline(dspy.Module):
    def __init__(self):
        self.summarize = dspy.Predict(SummarizeTicket)
        self.classify = dspy.ChainOfThought(ClassifySupport)

    def forward(self, ticket_text):
        summary = self.summarize(ticket_text=ticket_text).summary
        classification = self.classify(ticket_text=summary)
        return classification

デバッグのヒント

予期しない出力が出た場合は、ロジックが間違っていると決めつける前に、DSPyが生成した実際のプロンプトを確認しよう:

# LLMに送信される内容を確認するためにverboseモードを有効にする
with dspy.context(lm=lm):
    result = classifier(ticket_text="カードが拒否されました")
    print(dspy.inspect_history(n=1))  # 最後のLLM呼び出しを表示する

知っておくべきことがある。DSPyはデフォルトでLLM呼び出しをキャッシュする。開発中はコスト節約になる。本番環境では、基盤となるデータが変更された場合にキャッシュがいつ無効化されるかを把握しておくこと。

DSPyを選ぶべき場面

DSPyが最も適しているのは以下の場合だ:

  • パイプラインに個別にチューニングしづらい複数のLLM呼び出しが連なっている場合
  • リグレッションなしに基盤モデルを切り替える必要がある場合
  • 最適化に使えるラベル付きデータがある(または生成できる)場合
  • リリース間でのプロンプトの安定性が生のスループットよりも重要な場合

一回限りのスクリプトや単純な1回呼び出しのタスクでは、オーバーヘッドに見合わない。丁寧に作られた手動プロンプトのほうが早く動く。しかし、数ヶ月にわたってメンテナンス・進化させるものであれば、宣言的アプローチはコストを回収できる——メンテナンス負担の軽減、深夜のデバッグセッションの減少、そしてパイプラインを壊さないモデル更新だ。

オプティマイザは一度だけ実行する。それ以降、パイプラインは他のサービスと同様にテスト、バージョン管理、デプロイできる普通のPythonコードだ。

Share: