😸

DSPyのLMのスコープ管理:マルチエージェントに向けて

に公開

はじめに

多くのDSPyチュートリアルでは、下記のようにモデルをインスタンス化しているのを見かける。

lm = dspy.LM(f"{provider}/{model}")
dspy.configure(lm=lm)

ここで providergeminimodelgemini-2.5-flash であれば無料で使える(google ai studioが太っ腹で、APIを無料で利用できる。もちろんリミットはあるが、ossを使うより遥かに手元のリソースが少なくて済むのでお勧めである。)

基本的には上記のような設定を一度実施したら、そのコード内では PredictChainOfThought を呼び出すと、暗黙的に dspy.condigure で設定されたモデルが利用される。要するにグローバルスコープでモデルが配置されるということである。

この記事を読むと、上記の使い方からステップアップして下記のことができるようになる。

  • ある Module を利用するときに、そのインスタンスに特定のモデルを割り当てられる
  • あるスコープ内の処理に対してまとめて特定のモデルを割り当てられる
  • マルチエージェントシステムでタスクに応じてコストの高い/安いモデルを割り当てられる

ということで早速基本的な構文と仕組みを見ていく。

複数のモデルのインスタンス化

複数のモデルをインスタンス化することが可能なので、ここでは例えば温度パラメータが異なる二つのモデルをインスタンス化してみる。

lm_precise = dspy.LM(
    'openai/gpt-4o-mini',
    temperature=0.0,      # 決定的な出力
    max_tokens=500,       # 最大500トークン
    top_p=1.0
)

lm_creative = dspy.LM(
    'openai/gpt-4o-mini',
    temperature=0.9,      # より創造的
    max_tokens=500,
    top_p=0.95
)

温度パラメータは高ければ高いほどランダムネスが高まるので、運が良ければ創造的な、運が悪ければ突拍子もないことを出力させられる。このように2つのインスタンスを準備しておくことで、これらを使い分けましょうということが今回の題目だ。

プロンプトを投げて確認

下記は個別のインスタンスにプロンプトを投げかける例であるため、当然のごとく異なる出力が得られるのを確認できる。

# temperatureの違いを比較
prompt = "創造的な物語の冒頭を1文で書いてください。"

print("Precise (temperature=0.0):")
print(lm_precise(prompt))

print("Creative (temperature=0.9):")
print(lm_creative(prompt))

Precise (temperature=0.0):
['月明かりに照らされた古びた図書館の扉が、静寂の中でひとりの少女を呼び寄せるように、微かにきしんだ。']
Creative (temperature=0.9):
['星々が瞬く夜空の下、古びた図書館の扉が静かに開かれ、一冊の禁じられた本が、自らの運命を求めて静かに呼びかけてきた。']

ただし、DSPyでは上記のようなプログラムの中にプロンプトを直接書きこむような方針を推奨していない。

グローバルスコープでの文章生成

DSPyでは入出力のシグネチャだけを与えて、プロンプトないしコンテキストは最適化で求めようというスタンスである。例えば下記のように書くのである。

poem_maker = dspy.Predict("thema->short_poem")
poem_maker(thema="独創的な物語").first_sentence

'ある日、誰も知らない小さな村に、空から降ってきた不思議な種が芽を出し、村の運命を変える物語が始まった。'

Predict モジュールのインスタンス化時に与えた引数がシグネチャを示す。そこに書かれている文字列によって、テーマを与えたらショートポエムを書くように内部でプロンプトが準備される。ちなみに、文章を生成させる際のキーワード引数に流用されるため、シグネチャの文字列に日本語は使えない。残念。

今回の本題は、この時に使われる言語モデルは何なのかである?正解は冒頭で設定した dspy.configureによって指定されたモデルである。これをここに使い分ける場合はどうするかを次に見る。

複数モデルの使い分け set_lm メソッド

Predict モジュールを利用すると暗黙的にグローバルスコープのモデルが使われたのを見た。

set_lm メソッドの確認

まずは、Predict モジュールを複数インスタンス化して、その後、それぞれに個別のモデルを割り当てる処理を書いてみる。一旦シグネチャは固定の物を使う。(実応用だと、タスクの難易度で使い分けたいので、もしかしたら個別のシグネチャの方が現実的かもしれないが、まあやり方は変わらない)

class QA(dspy.Signature):
    """質問応答"""
    question = dspy.InputField()
    answer = dspy.OutputField()

# デフォルトLMを使用
qa_default = dspy.Predict(QA)

# 特定のLMを指定(ローカル設定)
qa_precise = dspy.Predict(QA)
qa_precise.set_lm(lm_precise)
qa_creative = dspy.Predict(QA)
qa_creative.set_lm(lm_creative)

set_lm メソッドが重要な点だ。これでとあるシグネチャを割り当てたPredictモジュールのインスタンスに、個別のLLMを割り当てられるようになった。こうしてそれぞれのインスタンスに質問を投げかけてみる。

# 同じ質問で異なるLMの応答を比較
question = "DSPyの発展について一行で"

print("質問:", question)

result_precise = qa_precise(question=question)
print("Precise LM:")
print(result_precise.answer)

result_creative = qa_creative(question=question)
print("Creative LM:")
print(result_creative.answer)

質問: DSPyの発展について一行で
Precise LM:
DSPyは、データサイエンスのプロセスを簡素化し、効率的にするためのツールとして進化してきました。
Creative LM:
DSPyは、データ駆動型の意思決定を支援するためのプラットフォームとして進化し、機械学習モデルの開発を簡素化しています。

内容は圧倒的に間違っているが、一応、異なるモデルからの出力が得られているのが確認できる。

カスタムモジュールでの適応的なモデル利用

上記でPredictモジュールに対して個別のモデルを設定できることを見た。ChainOfThoughtなども同様で dspy.Module を継承しているものは全て同様にセットできる。これを利用して、更に1つのカスタムモジュール内に PredictChainOfThought を押し込んで使ってみる。

# 複雑度に応じてLMを切り替えるモジュール
class AdaptiveQA(dspy.Module):
    def __init__(self, lm_precise, lm_creative):
        super().__init__()
        self.qa_simple = dspy.Predict(QA)
        self.qa_simple.set_lm(lm_precise)
        self.qa_complex = dspy.ChainOfThought(QA)
        self.qa_complex.set_lm(lm_creative)
    
    def forward(self, question):
        # 質問の長さで複雑度を判定(簡易版)
        if len(question) < 50:
            print("→ シンプルなLMを使用")
            return self.qa_simple(question=question)
        else:
            print("→ 複雑なLM(CoT付き)を使用")
            return self.qa_complex(question=question)

adaptive_qa = AdaptiveQA(lm_precise, lm_creative)

見ての通り、コンストラクタで設定された2つのモジュールがある。それぞれに個別のモデルを設定しており、forwardメソッドでは質問文の長さに応じて利用するモジュールが分岐するというシンプルなものだ。もちろんもっともっと複雑にしても良いのかもしれない。コンストラクタで専門分野ごとのモデルが構えられていて、質問文からどのモデル/モジュールにタスクを割り当てるのかを判定するモジュールがいても良いだろう。

兎にも角にも、上記のように複数のモデルを押し込んでforwardの内部でPythonで書ける普通の制御構文によりモデルを使い分けることができるのである。

# 短い質問
short_q = "DSPyとは?"
result = adaptive_qa(question=short_q)
print(f"質問: {short_q}")
print(f"回答: {result.answer}\n")

# 長い質問
long_q = "DSPyと従来のプロンプトエンジニアリング手法を比較して、それぞれの利点と欠点を詳しく説明してください。"
result = adaptive_qa(question=long_q)
print(f"質問: {long_q}")
print(f"回答: {result.answer}")

→ シンプルなLMを使用
質問: DSPyとは?
回答: DSPyとは、データサイエンスのプロセスを簡素化し、効率的に行うためのフレームワークやツールのことを指します。特に、データの前処理、モデルの構築、評価、デプロイメントなどのステップを支援するために設計されています。DSPyは、データサイエンティストがより迅速に洞察を得ることを可能にし、データ分析のプロセスを自動化することを目指しています。
→ 複雑なLM(CoT付き)を使用
質問: DSPyと従来のプロンプトエンジニアリング手法を比較して、それぞれの利点と欠点を詳しく説明してください。
回答: DSPyはデータ指向アプローチを用い、柔軟性と自動化が特徴で、エラーを減らすための機能もあるが、学習曲線が存在し、データ形式に制限がある。一方、従来のプロンプトエンジニアリングは短期間での結果が得やすいが、エラーに対する脆弱性が高い。

まあ、依然として利用しているモデル(gpt-4o-mini)の学習の打ち切り時にはDSPyの情報はさほどなかったのか、よくわからん回答をしているが、分岐していることは分かる。まずここまでで、使い分けをするようなモジュールを自分でも作れるようになった。

実は次に示す方法が、モデルをグローバルに展開しているDSPyとして取り回しやすい方法になる。

with 構文によるスコープ内モデル切り替え

普通にPredict モジュールを作って呼び出すとグローバルに展開(dspy.configure)されたモデルが自動的に利用される。それをモジュールごとにハードに設定していたのが上記までのset_lmだ。下記はモジュールに直接モデルを割り当てるのではなく、特定のスコープ内でのみ指定したモデルを使うという書き方だ。

with dspy.context によるスコープ表現

これはPythonの with 構文によって表現できる。

with dspy.context(lm=lm_precise):
    fact_check = dspy.Predict("question -> answer")
    result = fact_check(question="Pythonが最初にリリースされた年は?")
    print(f"質問: Pythonが最初にリリースされた年は?")
    print(f"回答: {result.answer}")

上記ではwith構文内では lm_precise が利用される。もちろん CoT でも良いし Predict が複数あっても良い。構文の外ではグローバルに展開されたモデルが利用される。
これを用いることで処理に応じて動的にモジュールに割り当てるモデルを切り替えることができる。モジュール内にはhistoryと呼ばれるこれまでの応答の履歴が入っており、ここにはプロンプトやメッセージ、利用されたモデルの記録もある。

with 構文で1つの Predict モジュールに対して利用するモデルを動的に変更していった場合に、履歴のモデルを確認するコードが下記だ。

qa = dspy.Predict("question -> answer")

lm1 = dspy.LM("gemini/gemini-2.5-pro")
lm2 = dspy.LM("openai/gpt-4o-mini")

print("レベル0: デフォルトLM")
result = qa(question="テスト1")
latest = qa.history[-1]
print(f"使用LM: {latest["model"]}\n")

with dspy.context(lm=lm1):
    print("レベル1: Precise LM")
    result = qa(question="テスト2")
    latest = qa.history[-1]
    print(f"使用LM: {latest["model"]}\n")
    
    with dspy.context(lm=lm2):
        print("レベル2: Creative LM(ネストされた)")
        result = qa(question="テスト3")
        latest = qa.history[-1]
        print(f"使用LM: {latest["model"]}\n")
    
    print("レベル1に戻る: Precise LM")
    result = qa(question="テスト4")
    latest = qa.history[-1]
    print(f"使用LM: {latest["model"]}\n")

print("レベル0に戻る: デフォルトLM")
result = qa(question="テスト5")
latest = qa.history[-1]
print(f"使用LM: {latest["model"]}\n")

レベル0: デフォルトLM
使用LM: gemini/gemini-2.5-flash
レベル1: lm1
使用LM: gemini/gemini-2.5-pro
レベル2: lm2
使用LM: openai/gpt-4o-mini
レベル1に戻る: lm1
使用LM: gemini/gemini-2.5-pro
レベル0に戻る: デフォルトLM
使用LM: gemini/gemini-2.5-flash

ちゃんとネストされたレベルに応じて、そのスコープで設定されたモデルが利用されているのが分かる。with を利用することで、モデルの割り当てを変えるためだけにモジュールを乱立させる必要はなくなった。

まとめ

LM切り替えの3つの方法まとめ

それぞれの切り替えかたのまとめを表にする。

方法 用途 スコープ
dspy.configure(lm=...) グローバル設定 全体 基本設定
module.set_lm(...) 特定モジュール モジュール 永続的な変更
with dspy.context(lm=...) 一時的切り替え コードブロック タスク別LM

あとは個々のモジュールを上手に組み合わせてエージェントシステムを構築し、作成したモジュール全体のプロンプト最適化を行うのがDSPyの大枠である。例えば GEPA を用いたプロンプト最適化の例は下記の記事で書いた。

https://zenn.dev/cybernetics/articles/39fb763aca746c

Discussion