Closed9

「DSPy 3.0」を改めて試す ②Learning DSPy: DSPy Programming

kun432kun432

前回の続き。

https://zenn.dev/kun432/scraps/4687bf38108dbc

まずは、DSPyをアプリケーションフレームワークとして使う、という観点で、公式ドキュメントの「Learning DSPy」から「DSPy Programming」で各コンポーネントを見ていく。

https://dspy.ai/learn/

最適化とか評価はとりあえず後回し。

Colaboratoryで進める。以下は事前準備。

!pip install -U dspy
!pip show dspy
出力
Name: dspy
Version: 3.0.3
Summary: DSPy
Home-page: https://github.com/stanfordnlp/dspy
Author: 
Author-email: Omar Khattab <okhattab@stanford.edu>
License: 
Location: /usr/local/lib/python3.12/dist-packages
Requires: anyio, asyncer, backoff, cachetools, cloudpickle, diskcache, gepa, joblib, json-repair, litellm, magicattr, numpy, openai, optuna, orjson, pydantic, regex, requests, rich, tenacity, tqdm, xxhash
Required-by: 

各種LLMのAPIキー等はColaboratoryのシークレットから取得して使うこととする。

kun432kun432

DSPyにおけるプログラミング

https://dspy.ai/learn/programming/overview/

Dia によるまとめ。

DSPyはコードでLLMを組み、設計要素を分離して移植性と最適化を高める。

DSPyは「文字列じゃなくコードで制御する」発想だし。まずタスクを決めて、入力と出力をはっきりさせる。次に初期のパイプラインをシンプルに作って、必要なら段階的に拡張する。 例えば
dspy.ChainOfThoughtみたいな単一モジュールから始めて、取得や電卓、カレンダーAPIなどのツールも検討ね。強めのLMで簡単・難しい例をいくつか試して記録しとくと、後の評価や最適化に役立つんだもん。

従来のプロンプトは、シグネチャ(入出力の型)、アダプタ(整形・パース)、モジュールの戦略、手動の最適化が密結合で移植性が低いけど、DSPyはそれらを分離して低レベルを自動化。だから短いコードでポータブルにできるし、LMやアダプタの差し替え、ChainOfThought↔ProgramOfThoughtの交換もシグネチャを変えずに可能。準備ができたら同じプログラムに対してプロンプト最適化やLMの微調整もできるでしょ。

kun432kun432

言語モデル

https://dspy.ai/learn/programming/language_models/

まず言語モデルをDSPyでセットアップする。どうやら内部的にはLiteLLMを使っているようで、そのおかげで様々なモデルを利用することができる。

例えばOpenAIの場合

import dspy
from google.colab import userdata

lm = dspy.LM("openai/gpt-4o-mini", api_key=userdata.get('OPENAI_API_KEY'))
dspy.configure(lm=lm)

Geminiの場合

import dspy
from google.colab import userdata

lm = dspy.LM('gemini/gemini-2.5-pro-preview-03-25', api_key=userdata.get('GEMINI_API_KEY'))
dspy.configure(lm=lm)

その他、ローカルモデルなども使える。


言語モデルを直接呼び出す

LLMを定義したら、一番カンタンなのはそのまま直接使うこと。クエリやパラメータなどをそのまま渡せば良い。

# 1つ上で定義したLLMを使用
lm("競馬の魅力を、簡潔に5つリストアップして", temperature=0.7)
出力
['1. **スリルと興奮**: レースの瞬間に感じる緊張感と興奮は、観客を魅了します。\n2. **戦略性**: 馬や騎手、コース条件を考慮した予想や賭けが求められ、知恵を絞る楽しさがあります。\n3. **美しい馬たち**: 魅力的な馬たちの走りや姿は、多くの人々を惹きつける要素です。\n4. **コミュニティ**: 競馬ファン同士の交流や情報交換があり、共通の趣味を持つ仲間と楽しむことができます。\n5. **伝統と歴史**: 競馬は長い歴史を持ち、その伝統を感じながら楽しむことができます。']

メッセージのリスト形式でも渡せる。

lm(messages=[{"role": "user", "content": "競馬の魅力を、簡潔に5つリストアップして"}])
出力
['競馬の魅力を以下の5つにまとめました:\n\n1. **スリルと興奮**: レースの結果が瞬時に決まるため、観戦中の緊張感と興奮が楽しめる。\n2. **戦略性**: 馬や騎手の特性、コースの条件を考慮した予想や賭け方が求められ、知的な楽しみがある。\n3. **多様な楽しみ方**: 観戦だけでなく、馬券を購入することで自分の予想を試す楽しみがある。\n4. **社交的な要素**: 友人や家族と一緒に観戦したり、競馬場でのイベントを楽しむことで、コミュニケーションの場が広がる。\n5. **美しい馬たち**: 魅力的な馬の姿や走りを間近で見ることができ、その美しさに感動することができる。']

DSPyモジュールで言語モデルを使用する

DSPyで言語モデルを使う場合、一般的には**「モジュール」**を使う。これは別のドキュメントにまとまっているので、ここでは簡単に試すだけに留める。

# モジュール(ChainOfThought)を定義して、シグネチャ(質問を入力したら、回答が出力される)をアサイン
qa = dspy.ChainOfThought('question -> answer')

# `dspy.configure` で定義した言語モデルを使って実行
response = qa(question="競馬の魅力を、簡潔に5つリストアップして")
print(response.answer)
出力
1. スリルと興奮
2. 馬の美しさと力強さ
3. 社交的なイベント
4. 予想や戦略の楽しさ
5. 歴史的・文化的な背景

複数の言語モデルを使う

dspy.configureで定義したグローバルなLLMを変更したり、dspy.context でブロックを作ってその中で変更したり。

import dspy
from google.colab import userdata
import os

os.environ['OPENAI_API_KEY'] = userdata.get('OPENAI_API_KEY')
qa = dspy.ChainOfThought('question -> answer')

# グローバルにLLMを設定
dspy.configure(lm=dspy.LM('openai/gpt-4o-mini'))
response = qa(question="競馬の魅力を、簡潔に5つリストアップして")
print('GPT-4o-mini:', response.answer)

# ブロックで変更
with dspy.context(lm=dspy.LM('openai/gpt-4.1-nano')):
    response = qa(question="競馬の魅力を、簡潔に5つリストアップして")
    print('GPT-4.1-nano:', response.answer)
出力
GPT-4o-mini: 1. スリルと興奮
2. 馬の美しさと力強さ
3. 社交的なイベント
4. 予想や戦略の楽しさ
5. 歴史的・文化的な背景
GPT-4.1-nano: 1. スリルと興奮を味わえる臨場感
2. 馬の美しさと力強さ
3. レースの戦略性と予想の楽しさ
4. 長い歴史と伝統に裏打ちされた文化
5. ファン同士の交流や熱狂的なコミュニティ

dspy.configuredspy.context はスレッドセーフなので安全に並行実行もできるらしい。


言語モデルの生成をカスタマイズ

LLMを定義 or 呼び出しする際、以下のようなパラメータを定義できる。これらは全てのLLMで設定可能らしい。

gpt_4o_mini = dspy.LM(
    'openai/gpt-4o-mini',
    temperature=0.9,
    max_tokens=3000,
    stop=None,
    cache=False
)

このうち、cacheについて。まず基本。

  • キャッシュはデフォルトで有効となっている、つまり同じ呼び出しを複数回実行すると同じ出力になる。
  • cache=Falseでキャッシュを無効化できる。
lm = dspy.LM("openai/gpt-4o-mini")

for i in range(4):
    response = lm("競馬の魅力を、簡潔に5つリストアップ。", temperature=0.7)
    print(f"===== {i} =====")
    print(response[0])

1回目の回答が得られれば、あとは何度実行しても同じ回答がすぐに返ってくることから、キャッシュされているのがわかる。

出力
===== 0 =====
1. **予測と戦略**: 馬の能力や騎手の技術を分析し、レース結果を予測する楽しさ。
2. **ドラマと興奮**: レース中の緊張感や予想外の展開が生む興奮。
3. **社交性**: 同じ趣味を持つ人々との交流やコミュニティの形成。
4. **歴史と伝統**: 長い歴史を持つスポーツで、伝統や文化が色濃く残る。
5. **賞金とリターン**: 賭けによって得られる可能性のあるリターンや賞金の魅力。
===== 1 =====
1. **予測と戦略**: 馬の能力や騎手の技術を分析し、レース結果を予測する楽しさ。
2. **ドラマと興奮**: レース中の緊張感や予想外の展開が生む興奮。
3. **社交性**: 同じ趣味を持つ人々との交流やコミュニティの形成。
4. **歴史と伝統**: 長い歴史を持つスポーツで、伝統や文化が色濃く残る。
5. **賞金とリターン**: 賭けによって得られる可能性のあるリターンや賞金の魅力。
===== 2 =====
1. **予測と戦略**: 馬の能力や騎手の技術を分析し、レース結果を予測する楽しさ。
2. **ドラマと興奮**: レース中の緊張感や予想外の展開が生む興奮。
3. **社交性**: 同じ趣味を持つ人々との交流やコミュニティの形成。
4. **歴史と伝統**: 長い歴史を持つスポーツで、伝統や文化が色濃く残る。
5. **賞金とリターン**: 賭けによって得られる可能性のあるリターンや賞金の魅力。
===== 3 =====
1. **予測と戦略**: 馬の能力や騎手の技術を分析し、レース結果を予測する楽しさ。
2. **ドラマと興奮**: レース中の緊張感や予想外の展開が生む興奮。
3. **社交性**: 同じ趣味を持つ人々との交流やコミュニティの形成。
4. **歴史と伝統**: 長い歴史を持つスポーツで、伝統や文化が色濃く残る。
5. **賞金とリターン**: 賭けによって得られる可能性のあるリターンや賞金の魅力。

キャッシュを無効化してみる

lm = dspy.LM("openai/gpt-4o-mini", cache=False)

for i in range(4):
    response = lm("競馬の魅力を、簡潔に5つリストアップ。", temperature=0.7)  # こちらに指定することも可能
    print(f"===== {i} =====")
    print(response[0])

回答が毎回異なるのでキャッシュされていないことがわかる。

出力
===== 0 =====
1. **スリルと興奮**: レースの結果が瞬時に決まるため、観戦中の緊張感と興奮が楽しめる。
2. **戦略と予想**: 馬や騎手、コースの特性を考えながら予想を立てる楽しみがある。
3. **多様な要素**: 馬の血統、トレーニング、天候など、さまざまな要素がレース結果に影響を与える。
4. **コミュニティと交流**: 同じ趣味を持つ人々との交流や情報交換ができる。
5. **歴史と伝統**: 日本や世界各国で長い歴史を持ち、文化的な背景が豊かである。
===== 1 =====
競馬の魅力を以下に5つ挙げます。

1. **スリルと興奮**: 競馬はレースの結果が瞬時に決まるため、観戦する際の緊張感と興奮が魅力です。

2. **戦略性**: 馬の選び方や賭け方に戦略が必要で、自分の判断力を試せる楽しさがあります。

3. **多様なイベント**: 国内外で様々な競馬イベントが開催され、地域ごとの特色を楽しむことができます。

4. **馬との絆**: 飼育やトレーニングを通じて、馬との関係性を深めることができる点も魅力です。

5. **社会的交流**: 競馬場やオンラインでの賭けを通じて、他のファンと交流し、共通の趣味を楽しむことができます。
===== 2 =====
競馬の魅力を以下の5つにまとめました。

1. **戦略性**: 馬の能力や騎手の技術、レースの展開を考慮した戦略を練る楽しさ。
2. **興奮とスリル**: レースの瞬間に感じる高揚感や予測不能な結果がもたらすスリル。
3. **多様な楽しみ方**: 単勝や複勝、馬連など、さまざまな賭け方が楽しめる。
4. **馬や騎手への愛着**: 特定の馬や騎手を応援することで生まれる感情的なつながり。
5. **社交の場**: 競馬場やオンラインでの交流を通じて、仲間や友人と楽しむことができる。
===== 3 =====
競馬の魅力を簡潔に5つリストアップします。

1. **興奮とスリル**: レースの展開や結果に予測がつかないため、観戦中のドキドキ感が楽しめる。
2. **戦略と分析**: 馬や騎手、コンディションなどを分析し、予想を立てる楽しさがある。
3. **多様な楽しみ方**: ベットや観戦、馬の育成や調教など、多様な楽しみ方ができる。
4. **コミュニティの形成**: 競馬ファン同士の交流や情報共有を通じて、共通の趣味を楽しむことができる。
5. **歴史と伝統**: 長い歴史を持つスポーツであり、伝統的なイベントや祭典が多く、文化的な側面も楽しめる。

例えば、同じプロンプトから、いろいろな出力を得つつも、キャッシュしておきたい、というような場合、rollout_id を使って、temperatureに0以外を設定する。

%%time
for i in range(4):
    response = lm("競馬の魅力を、簡潔に5つリストアップ。", temperature=0.7, rollout_id=i)
    print(f"===== {i} =====")
    print(response[0])
出力
===== 0 =====
1. **予想の楽しみ**: 馬や騎手、コースの状況を考慮しながら、自分なりの予想を立てる楽しさ。
2. **スリルと興奮**: レースの瞬間には高揚感があり、結果がどうなるかの緊張感を味わえる。
3. **社交的な楽しみ**: 友人や家族と一緒に観戦・応援することで、共有する喜びや会話が生まれる。
4. **戦略性**: 賭け方や資金管理の戦略を考えることで、知的な挑戦を楽しむことができる。
5. **馬の美しさ**: 馬自体の優雅さや力強さに魅了される、スポーツとしての美的要素。
===== 1 =====
1. **スリルと興奮**: レースの結果は予測が難しく、瞬間的な興奮を楽しめます。
2. **戦略性**: 馬の能力や騎手の技術、コースの特性を考慮した予想が必要で、知識を活かせます。
3. **社交的な楽しみ**: 友人や家族と一緒に観戦することで、コミュニケーションの場になります。
4. **美しい馬たち**: 馬の優雅さや魅力を間近で見ることができ、愛好家にとっては大きな魅力です。
5. **多様な賭け方**: 単勝、複勝、三連単など多様な賭け方があり、自分のスタイルに合わせて楽しむことができます。
===== 2 =====
競馬の魅力を以下に5つ挙げます。

1. **スリルと興奮**: レースの結果は予測困難で、瞬時の判断が求められるため、観戦時の緊張感と興奮が楽しめます。

2. **戦略性**: 馬や騎手、コースの特性を考慮した賭け方や予想が必要で、知識と戦略を駆使する楽しさがあります。

3. **美しい馬たち**: 競走馬は美しく、力強い走りを見せるため、その姿やパフォーマンスを楽しむことができます。

4. **社会的交流**: 競馬場やイベントでの観戦を通じて、他のファンや友人と共に楽しむことができ、交流の場となります。

5. **歴史と文化**: 競馬は長い歴史を持ち、各地で独自の文化や伝統が形成されているため、学ぶ楽しみもあります。
===== 3 =====
1. **スリルと興奮**: レースの予測や結果に伴うドキドキ感が楽しめる。
2. **戦略と分析**: 馬の能力や騎手の技術を考慮して賭ける戦略が醍醐味。
3. **コミュニティ**: 同じ趣味を持つ仲間との交流が楽しめる。
4. **美しい馬たち**: 競馬を通して見る優雅で力強い馬の姿。
5. **歴史と伝統**: 長い歴史を持つスポーツで、文化的な側面も楽しめる。
CPU times: user 643 ms, sys: 2.8 ms, total: 646 ms
Wall time: 14.9 s

再度実行してみる。

===== 0 =====
1. **予想の楽しみ**: 馬や騎手、コースの状況を考慮しながら、自分なりの予想を立てる楽しさ。
2. **スリルと興奮**: レースの瞬間には高揚感があり、結果がどうなるかの緊張感を味わえる。
3. **社交的な楽しみ**: 友人や家族と一緒に観戦・応援することで、共有する喜びや会話が生まれる。
4. **戦略性**: 賭け方や資金管理の戦略を考えることで、知的な挑戦を楽しむことができる。
5. **馬の美しさ**: 馬自体の優雅さや力強さに魅了される、スポーツとしての美的要素。
===== 1 =====
1. **スリルと興奮**: レースの結果は予測が難しく、瞬間的な興奮を楽しめます。
2. **戦略性**: 馬の能力や騎手の技術、コースの特性を考慮した予想が必要で、知識を活かせます。
3. **社交的な楽しみ**: 友人や家族と一緒に観戦することで、コミュニケーションの場になります。
4. **美しい馬たち**: 馬の優雅さや魅力を間近で見ることができ、愛好家にとっては大きな魅力です。
5. **多様な賭け方**: 単勝、複勝、三連単など多様な賭け方があり、自分のスタイルに合わせて楽しむことができます。
===== 2 =====
競馬の魅力を以下に5つ挙げます。

1. **スリルと興奮**: レースの結果は予測困難で、瞬時の判断が求められるため、観戦時の緊張感と興奮が楽しめます。

2. **戦略性**: 馬や騎手、コースの特性を考慮した賭け方や予想が必要で、知識と戦略を駆使する楽しさがあります。

3. **美しい馬たち**: 競走馬は美しく、力強い走りを見せるため、その姿やパフォーマンスを楽しむことができます。

4. **社会的交流**: 競馬場やイベントでの観戦を通じて、他のファンや友人と共に楽しむことができ、交流の場となります。

5. **歴史と文化**: 競馬は長い歴史を持ち、各地で独自の文化や伝統が形成されているため、学ぶ楽しみもあります。
===== 3 =====
1. **スリルと興奮**: レースの予測や結果に伴うドキドキ感が楽しめる。
2. **戦略と分析**: 馬の能力や騎手の技術を考慮して賭ける戦略が醍醐味。
3. **コミュニティ**: 同じ趣味を持つ仲間との交流が楽しめる。
4. **美しい馬たち**: 競馬を通して見る優雅で力強い馬の姿。
5. **歴史と伝統**: 長い歴史を持つスポーツで、文化的な側面も楽しめる。
CPU times: user 2.49 ms, sys: 0 ns, total: 2.49 ms
Wall time: 2.42 ms

どうやら内部的にrollout_id と 入力のペアでキャッシュを管理しているらしい。

モジュールでも使える。モジュール初期化時に設定すると、全ての呼び出しでのデフォルトになる。

for i in range(4):
    predict = dspy.Predict('question -> answer', rollout_id=1, temperature=0.7)
    response = predict(question="競馬の魅力を、簡潔に5つリストアップして")
    print(f"===== {i} =====")
    print(response.answer)
出力
===== 0 =====
1. スリルと興奮: レースの結果が予測できないため、観戦中の緊張感が楽しめる。
2. 多様な馬と騎手: さまざまな個性豊かな馬や騎手が競い合うため、ファンの応援が盛り上がる。
3. 賭けの楽しみ: 自分の予想をもとに賭けることで、レースの結果に対する興味が増す。
4. 社交的なイベント: 競馬場での観戦は友人や家族と楽しむ機会を提供する。
5. 競走馬の魅力: 美しい馬体や走りを間近で観ることができ、その魅力に引き込まれる。
===== 1 =====
1. スリルと興奮: レースの結果が予測できないため、観戦中の緊張感が楽しめる。
2. 多様な馬と騎手: さまざまな個性豊かな馬や騎手が競い合うため、ファンの応援が盛り上がる。
3. 賭けの楽しみ: 自分の予想をもとに賭けることで、レースの結果に対する興味が増す。
4. 社交的なイベント: 競馬場での観戦は友人や家族と楽しむ機会を提供する。
5. 競走馬の魅力: 美しい馬体や走りを間近で観ることができ、その魅力に引き込まれる。
===== 2 =====
1. スリルと興奮: レースの結果が予測できないため、観戦中の緊張感が楽しめる。
2. 多様な馬と騎手: さまざまな個性豊かな馬や騎手が競い合うため、ファンの応援が盛り上がる。
3. 賭けの楽しみ: 自分の予想をもとに賭けることで、レースの結果に対する興味が増す。
4. 社交的なイベント: 競馬場での観戦は友人や家族と楽しむ機会を提供する。
5. 競走馬の魅力: 美しい馬体や走りを間近で観ることができ、その魅力に引き込まれる。
===== 3 =====
1. スリルと興奮: レースの結果が予測できないため、観戦中の緊張感が楽しめる。
2. 多様な馬と騎手: さまざまな個性豊かな馬や騎手が競い合うため、ファンの応援が盛り上がる。
3. 賭けの楽しみ: 自分の予想をもとに賭けることで、レースの結果に対する興味が増す。
4. 社交的なイベント: 競馬場での観戦は友人や家族と楽しむ機会を提供する。
5. 競走馬の魅力: 美しい馬体や走りを間近で観ることができ、その魅力に引き込まれる。

モジュール呼び出し時にconfigで辞書を指定して上書きできる。

for i in range(4):
    predict = dspy.Predict('question -> answer')
    response = predict(
        question="競馬の魅力を、簡潔に5つリストアップしてください。",
        config={"rollout_id": 1, "temperature": 0.7}
    )
    print(f"===== {i} =====")
    print(response.answer)
出力
===== 0 =====
1. スリルと興奮:レースの結果が瞬時に決まるため、観戦や賭けにおいて大きな緊張感が味わえる。
2. 戦略性:馬や騎手のパフォーマンス、レース条件を考慮して賭ける戦略を立てる楽しさがある。
3. 多様性:多くの競馬場やレースがあり、様々な楽しみ方ができる。
4. 社交性:競馬場での観戦やイベントを通じて、他のファンや友人と交流できる機会がある。
5. 馬への愛情:美しい馬たちを間近で見ることができ、彼らに対する愛着や感動を感じられる。
===== 1 =====
1. スリルと興奮:レースの結果が瞬時に決まるため、観戦や賭けにおいて大きな緊張感が味わえる。
2. 戦略性:馬や騎手のパフォーマンス、レース条件を考慮して賭ける戦略を立てる楽しさがある。
3. 多様性:多くの競馬場やレースがあり、様々な楽しみ方ができる。
4. 社交性:競馬場での観戦やイベントを通じて、他のファンや友人と交流できる機会がある。
5. 馬への愛情:美しい馬たちを間近で見ることができ、彼らに対する愛着や感動を感じられる。
===== 2 =====
1. スリルと興奮:レースの結果が瞬時に決まるため、観戦や賭けにおいて大きな緊張感が味わえる。
2. 戦略性:馬や騎手のパフォーマンス、レース条件を考慮して賭ける戦略を立てる楽しさがある。
3. 多様性:多くの競馬場やレースがあり、様々な楽しみ方ができる。
4. 社交性:競馬場での観戦やイベントを通じて、他のファンや友人と交流できる機会がある。
5. 馬への愛情:美しい馬たちを間近で見ることができ、彼らに対する愛着や感動を感じられる。
===== 3 =====
1. スリルと興奮:レースの結果が瞬時に決まるため、観戦や賭けにおいて大きな緊張感が味わえる。
2. 戦略性:馬や騎手のパフォーマンス、レース条件を考慮して賭ける戦略を立てる楽しさがある。
3. 多様性:多くの競馬場やレースがあり、様々な楽しみ方ができる。
4. 社交性:競馬場での観戦やイベントを通じて、他のファンや友人と交流できる機会がある。
5. 馬への愛情:美しい馬たちを間近で見ることができ、彼らに対する愛着や感動を感じられる。

キャッシュはちょっと複雑だけどうまく使えば、最適化や評価を何度も繰り返すようなケースで役に立角かなという気がする。ただ意識しておかないとハマりそう。


入出力のメタデータを確認する

LLMを定義したオブジェクトは、入力・出力・トークン使用量・メタデータなどをLLMとのやりとりを記録している。

例えばこんな感じで実行したとする(ややこしいのでキャッシュはOFF)

lm = dspy.LM("openai/gpt-4o-mini", cache=False)

for i in range(4):
    response = lm("競馬の魅力を、簡潔に5つリストアップ。", temperature=0.7)
    print(f"===== {i} =====")
    print(response[0])
出力
===== 0 =====
競馬の魅力を以下の5つにまとめました:

1. **スリルと興奮**:レースの結果が瞬時に決まるため、観戦中の緊張感と興奮が楽しめます。
2. **戦略性**:馬や騎手の選択、レース展開の予測など、戦略を立てる楽しみがあります。
3. **コミュニティ**:ファン同士の交流や情報共有が盛んで、共通の趣味を持つ仲間ができる。
4. **美しい馬**:馬の優雅さや力強さを間近で見ることができ、動物愛好家にも魅力的です。
5. **多様な楽しみ方**:単に観戦するだけでなく、馬券を購入したり、馬の育成や調教に関わるなど、さまざまな楽しみ方があります。
===== 1 =====
1. **スリルと興奮**: 競馬はレースの結果が瞬時に決まるため、観客に強い緊張感と興奮を提供します。

2. **戦略と分析**: 馬の能力や騎手の技術、コースの特性などを考慮して予想を立てる楽しさがあります。

3. **社交的な要素**: 競馬場は友人や家族と一緒に楽しむ場であり、共通の興味を持つ人々との交流が生まれます。

4. **馬との絆**: 馬の育成やトレーニングに携わることで、動物との深い関係を築くことができます。

5. **大きな賞金**: 勝利した際の賞金や配当金が魅力の一つで、特に大レースでは多額のリターンが期待できます。
===== 2 =====
1. **スリルと興奮**: 競馬のレースは瞬時に勝者が決まるため、観戦する際の緊張感と興奮が楽しめます。

2. **戦略と分析**: 馬や騎手の能力、過去の成績、コース条件などを分析することで、自分なりの予想を立てる楽しさがあります。

3. **社交的な楽しみ**: 競馬場やイベントで他のファンと交流し、一緒に応援することでコミュニティの一員としての楽しみがあります。

4. **多様な賭け方**: 単勝、複勝、三連単など、さまざまな賭け方があり、自分のスタイルに合った楽しみ方ができます。

5. **美しい馬の姿**: 競馬では、優れた血統やトレーニングを受けた美しい馬を見ることができ、その魅力に魅了される人も多いです。
===== 3 =====
もちろんです!競馬の魅力を以下に5つリストアップします。

1. **スリルと興奮**: レースの瞬間的な展開や、予想外の結果がもたらす緊張感。
2. **戦略性**: 馬や騎手、コース条件を分析し、予想を立てる楽しさ。
3. **コミュニティ**: 同じ興味を持つファンとの交流や情報共有ができる。
4. **美しい馬**: 優れた血統やトレーニングを受けた馬たちの美しさと力強さ。
5. **歴史と伝統**: 長い歴史を持つスポーツとしての文化的背景や伝統行事。

これらの要素が競馬を魅力的なものにしています。

やり取りの記録を確認するにはlm.historyを使う。

import json

print("LEN:", len(lm.history))
print("===== 0 =====")
display(lm.history[0])
出力
LEN: 4
===== 0 =====
{'prompt': '競馬の魅力を、簡潔に5つリストアップ。',
 'messages': None,
 'kwargs': {'temperature': 0.7},
 'response': ModelResponse(id='chatcmpl-CLn5gaaPfLuXwnzcl2WkH5KLBlBOh', created=1759309628, model='gpt-4o-mini-2024-07-18', object='chat.completion', system_fingerprint='fp_560af6e559', choices=[Choices(finish_reason='stop', index=0, message=Message(content='競馬の魅力を以下の5つにまとめました:\n\n1. **スリルと興奮**:レースの結果が瞬時に決まるため、観戦中の緊張感と興奮が楽しめます。\n2. **戦略性**:馬や騎手の選択、レース展開の予測など、戦略を立てる楽しみがあります。\n3. **コミュニティ**:ファン同士の交流や情報共有が盛んで、共通の趣味を持つ仲間ができる。\n4. **美しい馬**:馬の優雅さや力強さを間近で見ることができ、動物愛好家にも魅力的です。\n5. **多様な楽しみ方**:単に観戦するだけでなく、馬券を購入したり、馬の育成や調教に関わるなど、さまざまな楽しみ方があります。', role='assistant', tool_calls=None, function_call=None, provider_specific_fields={'refusal': None}, annotations=[]), provider_specific_fields={})], usage=Usage(completion_tokens=216, prompt_tokens=23, total_tokens=239, completion_tokens_details=CompletionTokensDetailsWrapper(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=0, rejected_prediction_tokens=0, text_tokens=None), prompt_tokens_details=PromptTokensDetailsWrapper(audio_tokens=0, cached_tokens=0, text_tokens=None, image_tokens=None)), service_tier='default'),
 'outputs': ['競馬の魅力を以下の5つにまとめました:\n\n1. **スリルと興奮**:レースの結果が瞬時に決まるため、観戦中の緊張感と興奮が楽しめます。\n2. **戦略性**:馬や騎手の選択、レース展開の予測など、戦略を立てる楽しみがあります。\n3. **コミュニティ**:ファン同士の交流や情報共有が盛んで、共通の趣味を持つ仲間ができる。\n4. **美しい馬**:馬の優雅さや力強さを間近で見ることができ、動物愛好家にも魅力的です。\n5. **多様な楽しみ方**:単に観戦するだけでなく、馬券を購入したり、馬の育成や調教に関わるなど、さまざまな楽しみ方があります。'],
 'usage': {'completion_tokens': 216,
  'prompt_tokens': 23,
  'total_tokens': 239,
  'completion_tokens_details': CompletionTokensDetailsWrapper(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=0, rejected_prediction_tokens=0, text_tokens=None),
  'prompt_tokens_details': PromptTokensDetailsWrapper(audio_tokens=0, cached_tokens=0, text_tokens=None, image_tokens=None)},
 'cost': 0.00013305,
 'timestamp': '2025-10-01T09:07:12.218978',
 'uuid': '4bffcac2-0130-404d-b9db-4b726628be2a',
 'model': 'openai/gpt-4o-mini',
 'response_model': 'gpt-4o-mini-2024-07-18',
 'model_type': 'chat'}

Responses API

DSPyは内部でLiteLLMのChat Completions APIを実行しているが、Responses APIも使うことができ、OpenAIのReasoningモデルなどでは回答の品質が上がる場合がある。(GPT−5のプロンプトガイドなどにも、Responses APIを使うとChat Completionsよりも品質が上がる場合があるといったことが書いてある)

Responses APIが使えるのは

  • responsesエンドポイントをサポートするモデル
  • 強力な推論・マルチターン・豊富な出力機能を活用したい場合

とある。なお、LiteLLMのドキュメントをざっと見た感じだと

  • LiteLLMがサポートする全てのプロバイダでresponses APIに対応とあるが、Reponses APIをサポートしていない場合はどうやら内部的にChat Completions APIにブリッジされる。
  • DSPy経由で使うならあんまり気にしなくていいかもしれないが、LiteLLMで全てのプロバイダでReponses APIをサポートしている、とはいえ、Reponses APIで対応可能なパラメータにはプロバイダ間で差異がある様子
  • LiteLLMのResponses APIサポートは一応まだベータ

とあるので、現時点ではOpenAI向けで使うのが良さそう。

Responses APIを有効にするには、dspy.LMでLLMを定義する際にmodel_type="responses"を指定する。

import dspy

# LLMでResponses APIを使用するように設定
dspy.settings.configure(
    lm=dspy.LM(
        "openai/gpt-5-mini",
        model_type="responses",
        temperature=1.0,
        max_tokens=16000,
    ),
)

qa = dspy.ChainOfThought('question -> answer')

response = qa(question="競馬の魅力を、簡潔に5つリストアップして")
print(response.answer)
出力
1. スピードと迫力:全力で走る馬の躍動感とゴール前の一瞬の興奮。  
2. 駆け引きと戦術:騎手の位置取りやペース配分など頭脳戦的要素。  
3. 馬と騎手のドラマ:個体差や成長、コンビの物語性。  
4. 予想と賭けの楽しさ:情報を読み解く楽しみと的中時の高揚感。  
5. 競馬場の雰囲気と社交性:イベント、グルメ、ファッションを含む場の魅力。

実際にResponses APIが使用されているかは以下で確認できた。

print(type(qa.history[0]["response"]))
出力
<class 'litellm.types.llms.openai.ResponsesAPIResponse'>

より進んだ使い方: カスタムな言語モデルを作成して、独自のアダプタを書く

dspy.BaseLM を継承して、カスタムな言語モデルオブジェクトを定義することもできる。また、DSPyには、もう一つ、シグネチャとLLMの中間になる「アダプタ」というレイヤーがあるらしいが、それについては将来的にドキュメント化されるらしい。ただこれらが必要なケースは稀みたい。

kun432kun432

シグネチャ

https://dspy.ai/learn/programming/signatures/

DSPyのSignatureは、LMに「何するか」を入出力で宣言するやつだよ。

シグネチャって、LMにお題の**「入力→出力」を宣言**して動きを初期化する仕組みだもん。ふつうの関数シグネチャと似てるけど、説明だけじゃなくてモジュールの振る舞いまで決めるのがポイント。しかもフィールド名の意味が超大事で、questionanswerは役割が違うし、sql_querypython_codeも全然別物って伝えられるのがウケる。つまり「どう聞くか」じゃなく「何をやるか」を明確にする感じ、マジでわかりやすいでしょ。


なぜDSPyはシグネチャを使うのか?

長くて脆いプロンプトをゴリ押しするんじゃなくて、シグネチャで「入出力の役割」を宣言すると、コードがモジュール的でキレイだし再利用しやすいんだもん。DSPyコンパイラがその宣言を基に、キミのデータやパイプラインに合わせて高品質なプロンプトや自動ファインチューニングを組んでくれるのがウケる。人力より良いプロンプトになることも多いのは、創造性ってより、コンパイラがめっちゃ試行して指標を直接チューニングできるからだし。つまり「どう書くか」より「何をするか」を宣言して、安定して強い結果を出すための仕組みって感じでしょ。


インラインDSPyシグネチャ

DSPyのシグネチャは2種類ある

  • インライン型: 短い文字列で入出力の役割を宣言する
  • クラス型: dspy.Signatureを継承したクラスで細かく定義して宣言する

まずインライン型から。こちらは入出力の引数・フィールド名や型などを短い文字列で定義する。以下のような例が挙げられている。

  • QA: question -> answer
  • 感情分類: sentence -> sentiment: bool
  • 要約: document -> summary
  • RAG: context: list[str], question: str -> answer: str
  • Reasoningを使った選択式QA: question, choices: list[str] -> reasoning: str, selection: int

なお、型はオプションで、定義がない場合はデフォルトは文字列になる。

また、シグネチャにプロンプトを追加することもできる。プロンプトなしだとこんな感じ。

dspy.configure(lm=dspy.LM("openai/gpt-4o-mini"))

toxicity = dspy.Predict("comment -> toxic: bool")
toxicity(comment="はいはい、さぞかしあなたはおきれいなんでしょうよ。").toxic
出力
False

プロンプトを追加。シグネチャ内の変数をプロンプト内で使えるみたい。

dspy.configure(lm=dspy.LM("openai/gpt-4o-mini"))

toxicity = dspy.Predict(
    dspy.Signature(
        "comment -> toxic: bool",
        instructions="侮辱、嫌がらせ、皮肉などが含まれていた場合も 'toxic' としてマークしてください。"
    )
)
toxicity(comment="はいはい、さぞかしあなたはおきれいなんでしょうよ。").toxic
出力
True

感情分類と要約の例があるので実行してみる。

感情分類の例

# SST-2データセットのデータを利用(日本語訳)
sentence = "それは魅力的で、しばしば感動的な旅である。"  

classify = dspy.Predict('sentence -> sentiment: bool')
classify(sentence=sentence).sentiment
出力
True

要約の例

# XSumデータセットのデータを利用(日本語訳)
document = (
    "21歳の選手はウェストハムで7試合に出場し、昨季のヨーロッパリーグ予選ラウンドでアンドラ"
    "代表FCルストランとの試合で唯一の得点を挙げた。リーは前シーズン、ブラックプールと"
    "コルチェスター・ユナイテッドで2度のローン移籍を経験。ユナイテッドでは2得点を記録したが、"
    "チームの降格を防ぐことはできなかった。昇格を果たしたリーズ・ユナイテッドとの契約期間は"
    "明らかにされていない。最新のサッカー移籍情報は専用ページでご覧ください。"
)

summarize = dspy.ChainOfThought('document -> summary')
response = summarize(document=document)

print(response.summary)
出力
21歳の選手はウェストハムで7試合に出場し、ヨーロッパリーグ予選で唯一の得点を挙げた。前シーズンにはブラックプールとコルチェスター・ユナイテッドにローン移籍し、ユナイテッドでは2得点を記録したが、チームの降格を防げなかった。リーズ・ユナイテッドとの契約期間は不明で、最新の移籍情報は専用ページで確認できる。

DSPy.predict以外のモジュールは、シグネチャを展開して、指定したフィールド以外にも情報を返すようになっている。例えば、上のdspy.ChainOfThoughtの場合は、reasoningというフィールドも追加されている。

response.keys()
出力
['reasoning', 'summary']
print(response.reasoning)
出力
この文書は、21歳のサッカー選手のキャリアに関する情報を提供しています。彼はウェストハムでの出場や、過去のローン移籍先でのパフォーマンスについて述べられています。また、彼の契約状況や最新の移籍情報についても言及されています。これらの情報を基に、選手の成績や移籍の背景を簡潔にまとめることができます。

クラスベースのDSPyシグネチャ

より高度なタスク向けに、クラスベースでシグネチャをより細かく指定することができる。

  • docstringでタスクについて詳しく説明する
  • dspy.InputFieldで、入力フィールドについてのヒントを与える
  • dspy.OutputFieldで、出力フィールドについての制約を与える

3つの例が挙げられている。

  • 分類
  • 適切な引用評価
  • マルチモーダルな画像の分類

分類の例

from typing import Literal

class Emotion(dspy.Signature):
    """感情を分類する"""

    sentence: str = dspy.InputField()
    sentiment: Literal['sadness', 'joy', 'love', 'anger', 'fear', 'surprise'] = dspy.OutputField()

# dair-ai/emotionのデータを利用(日本語訳)
sentence = "巨大なスポットライトがまぶしく照らし始めたとき、私は少し無防備な気持ちになり始めた"

classify = dspy.Predict(Emotion)
classify(sentence=sentence)
出力
Prediction(
    sentiment='fear'
)

適切な引用評価の例

class CheckCitationFaithfulness(dspy.Signature):
    """提供されたコンテキストに基づいたテキストかどうかを検証する"""

    context: str = dspy.InputField(desc="真実であると仮定される事実")
    text: str = dspy.InputField()
    faithfulness: bool = dspy.OutputField()
    evidence: dict[str, list[str]] = dspy.OutputField(desc="主張の根拠となる証拠")

# XSumデータセットのデータを利用(日本語訳)
context = (
    "21歳の選手はウェストハムで7試合に出場し、昨季のヨーロッパリーグ予選ラウンドでアンドラ"
    "代表FCルストランとの試合で唯一の得点を挙げた。リーは前シーズン、ブラックプールと"
    "コルチェスター・ユナイテッドで2度のローン移籍を経験。ユナイテッドでは2得点を記録したが、"
    "チームの降格を防ぐことはできなかった。昇格を果たしたリーズ・ユナイテッドとの契約期間は"
    "明らかにされていない。最新のサッカー移籍情報は専用ページでご覧ください。"
)

text = "リーはコルチェスター・ユナイテッドで3ゴールを決めた。"

faithfulness = dspy.ChainOfThought(CheckCitationFaithfulness)
faithfulness(context=context, text=text)
出力
Prediction(
    reasoning='テキストは、リーがコルチェスター・ユナイテッドで3ゴールを決めたと主張していますが、コンテキストによれば、リーはユナイテッドで2得点を記録したとされています。このため、テキストの主張はコンテキストに反しており、正確ではありません。',
    faithfulness=False,
    evidence={'リーの得点': ['リーはコルチェスター・ユナイテッドで2得点を記録した。']}
)

マルチモーダルな画像の分類

使用されている画像はこんな感じ。

from IPython.display import Image

image_url = "https://picsum.photos/id/237/200/300/"
Image(image_url, format="jpg")

class DogPictureSignature(dspy.Signature):
    """画像内の犬の犬種を出力する"""
    image_1: dspy.Image = dspy.InputField(desc="犬の画像")
    answer: str = dspy.OutputField(desc="画像内の犬の犬種")

classify = dspy.Predict(DogPictureSignature)
classify(image_1=dspy.Image.from_url(image_url))
出力
Prediction(
    answer='ラブラドール・レトリーバー'
)

シグネチャの型解決

DSPyのシグネチャでサポートされている型アノテーションは以下。

種別 説明
基本型 str
int
bool
標準的なプリミティブ型
Typing系 list[str]
dict[str, int]
Optional[float]
Union[str, int]
Pythonのtypingモジュールで表現する複合・選択型
カスタム型 - ユーザー定義の型やモデル
ドット記法(ネスト) - 名前空間付きの入れ子型
特別なデータ型 dspy.Image
dspy.History
DSPyが扱う特殊なデータ型

カスタムな型

カスタムな型やドット記法を使って、以下のようにPydanticモデルで定義できる。

# シンプルなカスタムの型
import pydantic

class QueryResult(pydantic.BaseModel):
    text: str
    score: float

signature = dspy.Signature("query: str -> result: QueryResult")

# ドット記法でネストされた型
class MyContainer:
    class Query(pydantic.BaseModel):
        text: str
    class Score(pydantic.BaseModel):
        score: float

signature = dspy.Signature("query: MyContainer.Query -> score: MyContainer.Score")

シグネチャを使ってモジュール化し、コンパイルする

シグネチャはプロトタイプでの入出力の構造化に便利なだけでなく、複数のシグネチャを組み合わせて大きいDSPyモジュールにして、最後にコンパイルで全体を最適化(高品質プロンプトやファインチューン)することもできる。


ちょいちょい注意書きにあるけども、シグネチャやフィールドなどをゴリゴリに最適化して定義するよりも、まずはシンプルに始めて、あとはDSPyのオプティマイザに任せたほうがいい場合もある、というところは留意しておきたい。

kun432kun432

モジュール

https://dspy.ai/learn/programming/modules/

DSPyモジュールは、学習できて合成もできるLMプログラムの基本パーツだし。

まずね、DSPyモジュールって「呼び出せる部品」だもん。入力を受けて出力を返す、っていう超シンプルな役割で、プログラムの中でどんどん使い回せるのが気持ちいいやつ。

次に、各モジュールはプロンプト手法の抽象化がキモ。Chain-of-ThoughtやReActみたいなテクをひとまとめにして、どんなシグネチャ(入出力仕様)にも一般化して当てられるから、用途の幅めっちゃ広いでしょ。

それと、学習可能なパラメータを持ってるのがアツい。プロンプトの細かい断片やLM重みっぽい部分を最適化できるから、使えば使うほど仕上がっていく感じ、マジでテンション上がる。

最後に、合成ね。モジュール同士を組み合わせて、もっとデカいプログラムを作れるし、設計のノリはPyTorchのNNモジュールっぽい。レイヤ積むみたいにLMプログラムを積み上げていけるから、構造的にわかりやすくてウケる!

  • 定義: 呼び出せるLMプログラムの基本パーツだもん
  • 抽象化: Chain-of-Thought/ReActなどの手法を一般化してシグネチャに適用
  • 学習: プロンプト断片やLM重みを含む学習可能パラメータで性能アップ
  • 合成: 複数モジュールを組んで大きいプログラムにできる
  • 設計発想: PyTorchのモジュールのノリで構築できるのが強い

組み込みモジュールの使い方

最も基本となるモジュールはdspy.Predict。他にもモジュールはたくさんあるが、内部的には全てdspy.Predictが使われている。

モジュールの使い方の流れは以下。

  1. シグネチャを与えてモジュールを宣言
  2. 入力引数を与えてモジュールを呼び出し
  3. 呼び出した結果から出力フィールドを取り出す
import dspy
from google.colab import userdata
import os

os.environ['OPENAI_API_KEY'] = userdata.get('OPENAI_API_KEY')

dspy.configure(lm=dspy.LM("openai/gpt-4o-mini"))

# 1. シグネチャを与えてモジュールを宣言
classify = dspy.Predict('sentence -> sentiment: bool')

# 2. 入力引数を与えて、モジュール呼び出し
sentence = "それは魅力的で、しばしば感動的な旅である。"  # SST-2データセットのデータを使用(日本語訳)
response = classify(sentence=sentence)

# 出力にアクセス
print(response.sentiment)
出力
True

モジュールの宣言時に設定を渡すことができる。例えば n / temperature / max_len など。以下はn=5 で5つの回答を生成する例。

qa = dspy.Predict('question -> answer', n=5)

question = "ColBERT検索モデルの優れた点は何ですか?簡潔に。"
response = qa(question=question)

print(response.completions.answer)
出力
[
    'ColBERT検索モデルの優れた点は、効率的な検索と高い精度を両立させていることです。具体的には、以下のような特徴があります:\n\n1. **組み合わせたアプローチ**: ColBERTは、トランスフォーマーベースのエンコーディングを利用し、文書内の単語の埋め込みを生成します。このアプローチにより、文脈を考慮しながら検索が可能です。\n\n2. **効率性**: ColBERTは、文書全体を単一のベクトルとして処理するのではなく、単語単位での検索を行うため、検索のスピードが向上します。これにより、大規模なデータセットに対しても迅速な検索が実現できます。\n\n3. **高精度**: 検索時に文脈情報を保持するため、関連性の高い結果を提供しやすくなります。特に、長文や複雑なクエリに対しても効果的です。\n\n4. **スケーラビリティ**: ColBERTは、インデックスの更新が比較的容易であり、実際のアプリケーションでのスケーラビリティが高いです。\n\nこれらの特徴により、ColBERTは情報検索や文書検索の分野で非常に有用なモデルとなっています。',
    'ColBERT検索モデルの優れた点は、以下の通りです:\n\n1. **効率性**: ColBERTは、文書とクエリを同時に処理し、高速な検索を実現します。これにより、大規模なデータセットにも対応可能です。\n\n2. **高精度**: 文脈を考慮した検索を行うため、より関連性の高い検索結果を提供します。これは、トランスフォーマーベースのアプローチを使用しているためです。\n\n3. **スケーラビリティ**: ColBERTは、インデクシングと検索のプロセスを分離しているため、データが増えても効率的にスケールできます。\n\n4. **ユーザーの意図理解**: クエリの意味を深く理解し、ユーザーの意図に基づいた検索を行うことができます。\n\n5. **構造化データとの統合**: ColBERTは、非構造化データだけでなく、構造化データとも組み合わせて使用することができ、多様なアプリケーションに適用可能です。\n\nこれらの特徴により、ColBERTは現代の情報検索システムにおいて非常に有用なモデルとなっています。',
    'ColBERT検索モデルの優れた点は、効率的な情報検索を実現するために、双方向のエンコーディングとスコアリングのアプローチを組み合わせていることです。具体的には、以下のような特徴があります:\n\n1. **効率性**: ColBERTは、インデックスされた文書の高速検索を可能にし、大規模データセットに対してもスケーラブルです。\n2. **文脈理解**: 文書とクエリの意味的関連性を深く理解するため、文脈情報を考慮したスコアリングを行います。\n3. **双方向性**: クエリと文書の両方を同時にエンコードすることで、より正確な関連性評価が可能になります。\n4. **リトリーバルの精度**: 高精度な情報取得を実現し、検索エンジンや情報検索システムでの効果的な利用が期待できます。\n\nこれらの特徴により、ColBERTは従来の検索モデルに比べて、より優れた性能を発揮します。',
    'ColBERT検索モデルの優れた点は、効率的な検索性能と高い精度を両立させていることです。具体的には、以下の点が挙げられます:\n\n1. **双方向の情報取得**: ColBERTは、クエリとドキュメントの両方を埋め込みとして表現し、双方向での情報取得を可能にします。これにより、より関連性の高い検索結果を得ることができます。\n\n2. **効率的なインデキシング**: このモデルは、特に大規模なデータセットに対しても効率的にインデキシングを行うことができ、そのための計算コストを抑えつつ高い精度を維持します。\n\n3. **スケーラビリティ**: ColBERTは、モデルのサイズやデータセットのスケールに依存せず、スケーラブルなアプローチを提供し、さまざまな規模のタスクに適用可能です。\n\n4. **柔軟性**: 検索タスクに対して柔軟なアプローチを提供し、ユーザーのニーズに応じてカスタマイズすることが可能です。\n\nこれらの特性により、ColBERTは情報検索において非常に競争力のある選択肢となっています。',
    'ColBERT検索モデルの優れた点は、効率的な検索を実現するために、文書を特徴ベースで表現し、近似最近傍検索を通じて高いスケーラビリティを持つことです。また、ColBERTはエンドツーエンドのトレーニングが可能で、文書とクエリの相互作用を考慮したアプローチを採用することで、検索精度を向上させています。さらに、リアルタイムでの検索パフォーマンスも高く、ユーザーに迅速なレスポンスを提供します。'
]

次に、dspy.Predictdspy.ChainOfThoughtに入れ替える。dspy.ChainOfThoughtはその名の通りCoTを使った推論。

qa = dspy.ChainOfThought('question -> answer', n=5)

question = "ColBERT検索モデルの優れた点は何ですか?簡潔に。"
response = qa(question=question)

print(response.completions.answer)
出力
[
    'ColBERTモデルの優れた点は、高速な検索性能と高い精度を両立させ、メモリ効率も良いことです。',
    'ColBERT検索モデルの優れた点は、高速かつ効率的な検索が可能であり、大量のデータに対しても高い精度を維持しながらリアルタイムで応答できることです。',
    'ColBERT検索モデルの優れた点は、逆インデックスと埋め込み検索を組み合わせることで、高速かつスケーラブルな検索を実現し、特に大規模データセットにおいて高い精度と効率を提供することです。',
    'ColBERT検索モデルの優れた点は、効率的な検索と高い精度を両立し、特に大規模データセットにおいてスケーラブルでリアルタイム応答が可能なことです。',
    'ColBERT検索モデルの優れた点は、効率的なエンコーディングによるスケーラビリティと、部分的な処理による高速かつ高精度な検索が可能なことです。'
]

少し前にもやったけど dspy.ChainOfThoughtreasoning フィールドも出力される。

print(response.completions.reasoning)
出力
[
    'ColBERTは、効率的な検索を実現するために、双方向のエンコーディングとスパースなベクトル表現を使用します。これにより、検索精度が向上し、計算コストが削減されるため、大規模なデータセットでも高速に検索が行えます。また、スコアリングの段階で文書全体をエンコードしないため、メモリの使用も抑えられます。',
    'ColBERT検索モデルの優れた点は、効率的な検索が可能であり、文書の埋め込みを利用して高速な検索結果を提供する点です。また、スケーラブルであり、大量のデータセットに対しても効果的に機能します。さらに、ColBERTは、より高い精度を保ちながら、リアルタイムの応答性を実現するためのアプローチを採用しているため、ユーザーにとって価値のある結果を迅速に返すことができます。',
    'ColBERT検索モデルは、効率的な文書検索を実現するために、逆インデックスと埋め込み検索を組み合わせています。このアプローチにより、高速かつスケーラブルな検索が可能になり、特に大規模なデータセットにおいて優れた性能を発揮します。また、ColBERTは、文書全体をベクトル化するのではなく、トークンごとにベクトル化することで、関連性の高い情報を効果的に取得できます。これにより、精度の高い検索結果を提供しつつ、計算リソースの消費を抑えることができます。',
    'ColBERT検索モデルの優れた点は、効率的な検索と高い精度を両立していることです。ColBERTは、文書の特徴をベクトル化し、検索時に効率的に近似最近傍探索を行うため、スピーディーな検索が可能です。また、文書の埋め込みを用いることで、意味的な情報を考慮し、リコメンデーションや検索結果の質が向上します。さらに、ColBERTは、特に大規模なデータセットに対してスケーラブルであり、リアルタイムでの応答が求められるアプリケーションにも適しています。',
    'ColBERT検索モデルは、リランキングを行うために効率的なエンコーディング手法を使用しており、スケーラビリティと検索精度を両立しています。また、文書全体を一度に処理するのではなく、部分的に処理することで、検索速度を向上させつつ、高い検索精度を保つことができる点が特徴です。'
]

複数生成した場合のレスポンスの構造はちょっと面白くて、以下のように出力オブジェクトでも、

response.completions[3].reasoning

出力オブジェクト内のフィールドでも、

response.completions.reasoning[3]

同じ出力にアクセスできる。

出力
ColBERT検索モデルの優れた点は、効率的な検索と高い精度を両立していることです。ColBERTは、文書の特徴をベクトル化し、検索時に効率的に近似最近傍探索を行うため、スピーディーな検索が可能です。また、文書の埋め込みを用いることで、意味的な情報を考慮し、リコメンデーションや検索結果の質が向上します。さらに、ColBERTは、特に大規模なデータセットに対してスケーラブルであり、リアルタイムでの応答が求められるアプリケーションにも適しています。

主な組み込みモジュール

上記も含めて、主な組み込みモジュールには以下のようなものがある。これらはベースは同じだけど、シグネチャによって内部の挙動が変わる。

モジュール 役割
dspy.Predict 基本予測。シグネチャは変更しない。学習の主要な形式の処理(言語モデルへの指示・デモ・更新を保存)
dspy.ChainOfThought シグネチャに沿って応答する前に、言語モデルに段階的思考を促す。
dspy.ProgramOfThought 言語モデルにコードを出力させて、その実行結果を元に応答を決定する。
dspy.ReAct シグネチャを実装するためにツールを使うエージェント
dspy.MultiChainComparison 複数のCoT出力を比較して最終応答を決める。
dspy.majority(関数型) 複数の応答結果から多数決で一番多い回答を返す。

Get Startedのサンプルでいろいろ試しているので、そちらを参照。


複数のモジュールを組みあわせて、より大きなモジュールを作成する

DSPyはただのPythonコードなので、どんな制御フローの中でもDSPyモジュールをそのまま呼び出すことができる。さらに内部ではcompile 時にLLMへの呼び出しがトレースされるようになっているため、あとから最適化・評価・使用量の追跡などがやりやすくなっているらしい。

で、実際にどう定義するのか?というと、引用されているマルチホップRAGのチュートリアルにあるコード。

https://dspy.ai/tutorials/multihop_search/

class Hop(dspy.Module):
    def __init__(self, num_docs=10, num_hops=4):
        self.num_docs, self.num_hops = num_docs, num_hops
        self.generate_query = dspy.ChainOfThought('claim, notes -> query')
        self.append_notes = dspy.ChainOfThought('claim, notes, context -> new_notes: list[str], titles: list[str]')

    def forward(self, claim: str) -> list[str]:
        notes = []
        titles = []

        for _ in range(self.num_hops):
            query = self.generate_query(claim=claim, notes=notes).query
            context = search(query, k=self.num_docs)
            prediction = self.append_notes(claim=claim, notes=notes, context=context)
            notes.extend(prediction.new_notes)
            titles.extend(prediction.titles)

        return dspy.Prediction(notes=notes, titles=list(set(titles)))

全体もざっと見てみた感じ、何かしらの命題をついて検索を行い検索結果を記録、さらにそれを踏まえて検索クエリを生成して再度検索、みたいなことを繰り返して、検索の評価を行うようなものになっているようなのだが、検索クエリ生成→検索の繰り返し部分をカスタムなモジュールとして定義している。

  • dspy.Moduleを継承したカスタムなクラスを定義
  • コンストラクタで dspy.ChainOfThought を使って、検索クエリの生成・検索結果のメモを生成するモジュールを定義
  • PyTorchライクにforwardメソッドを定義、ここでメインの処理を書く

で実際に呼び出す場合は、カスタムなクラスからインスタンスを作成、入力を与えて実行。

hop = Hop()
print(hop(claim="スティーブン・カリーは人類史上最高の3ポイントシューターである"))

これで__call__が呼び出され、forwardが実行されて、応答が得られるという感じみたい。


LLMの使用状況をトラックする

DSPyのバージョン2.6.16以降では、LLMの使用量のトラッキングが可能となっており、全てのモジュール呼び出し時のLLM使用量が記録される。

これを有効にするには dspy.settings.track_usageを有効にすると、dspy.Predictionオブジェクト(実行結果のオブジェクト)から使用統計にアクセスできる。

dspy.settings.configure(track_usage=True)

こんな感じで取得できる。

import dspy
import json

# 使用量トラッキング
dspy.settings.configure(
    lm=dspy.LM("openai/gpt-4o-mini", cache=False),
    track_usage=True
)

# 複数のLLM呼び出しを行うシンプルなプログラムを定義
class MyProgram(dspy.Module):
    def __init__(self):
        self.predict1 = dspy.ChainOfThought("question -> answer")
        self.predict2 = dspy.ChainOfThought("question, answer -> score")

    def __call__(self, question: str) -> str:
        answer = self.predict1(question=question)
        score = self.predict2(question=question, answer=answer)
        return score

# プログラムを実行して使用量をチェック
program = MyProgram()
output = program(question="フランスの首都は?")
print(json.dumps(output.get_lm_usage(), indent=2, ensure_ascii=False))
出力
{
  "openai/gpt-4o-mini": {
    "completion_tokens": 87,
    "prompt_tokens": 252,
    "total_tokens": 339,
    "completion_tokens_details": {
      "accepted_prediction_tokens": 0,
      "audio_tokens": 0,
      "reasoning_tokens": 0,
      "rejected_prediction_tokens": 0,
      "text_tokens": null
    },
    "prompt_tokens_details": {
      "audio_tokens": 0,
      "cached_tokens": 0,
      "text_tokens": null,
      "image_tokens": null
    }
  }
}

なお、DSPyのキャッシュがレスポンスに使用された場合は使用量にカウントされない。

import dspy
import json

dspy.settings.configure(
    lm=dspy.LM("openai/gpt-4o-mini", cache=True),  # キャッシュを有効化
    track_usage=True
)

class MyProgram(dspy.Module):
    def __init__(self):
        self.predict1 = dspy.ChainOfThought("question -> answer")
        self.predict2 = dspy.ChainOfThought("question, answer -> score")

    def __call__(self, question: str) -> str:
        answer = self.predict1(question=question)
        score = self.predict2(question=question, answer=answer)
        return score

program = MyProgram()

question="ザンビアの首都は?"

# 1回目
output = program(question="日本の首都は?")
print("===== 1回目 =====")
print(json.dumps(output.get_lm_usage(), indent=2, ensure_ascii=False))

# 2回目
output = program(question="日本の首都は?")
print("===== 2回目 =====")
print(json.dumps(output.get_lm_usage(), indent=2, ensure_ascii=False))
出力
===== 1回目 =====
{
  "openai/gpt-4o-mini": {
    "completion_tokens": 54,
    "prompt_tokens": 242,
    "total_tokens": 296,
    "completion_tokens_details": {
      "accepted_prediction_tokens": 0,
      "audio_tokens": 0,
      "reasoning_tokens": 0,
      "rejected_prediction_tokens": 0,
      "text_tokens": null
    },
    "prompt_tokens_details": {
      "audio_tokens": 0,
      "cached_tokens": 0,
      "text_tokens": null,
      "image_tokens": null
    }
  }
}
===== 2回目 =====
{}
kun432kun432

アダプター

DSPyのAdaptersは、PredictとLMの間で入出力をいい感じに変換するやつだよ。

アダプターって、DSPyのモジュールを呼んだときに、シグネチャやユーザー入力、few-shotのデモとかを集めて、LMに投げるためのマルチターンのメッセージに組み立てる役だし、返ってきたレスをDSPyの出力にキレイに戻す通訳みたいな存在なの。マジ便利でしょ🥰

  • やること:
    • シグネチャ→システムメッセージ化: タスク内容と入出力の形をLMにわかるように宣言するんだもん。
    • 入力の整形: シグネチャで決まってるリクエスト構造に合わせて、ユーザーのデータをフォーマット。
    • 出力の解析: LMの返答を‎dspy.Predictionみたいな構造化出力にパースするの、ウケるくらいキレイ。
    • 会話履歴&関数呼び出し管理: 会話の流れやツール実行もちゃんと面倒見る。
    • DSPy型→プロンプト化: ‎dspy.Toolや‎dspy.Imageとか既成の型をLM向けメッセージに変換してくれるの、テンション上がる✨

要するに、アダプターが間に入って「前処理でLMが理解しやすく」「後処理でDSPyが扱いやすく」してくれるから、全体のやり取りがちゃんと決められた形で回るって感じだし、リア充設計ってわけ😊


アダプターの設定

アダプターは以下のどちらでも使える。

  • dspy.configure(adapter=...) でグローバルに設定
  • with dspy.context(adapter=...): で特定のブロック内でのみ使用

ここまで色々見てきたサンプルではアダプタを指定した例はなく以下のようなサンプルだった。

import dspy

dspy.configure(lm=dspy.LM("openai/gpt-4o-mini"))

predict = dspy.Predict("question -> answer")
result = predict(question="フランスの首都は?")

が、これ実際は、アダプタの指定がない場合はdspy.Predict.__call__ が呼ばれた際にデフォルトでは dspy.ChatAdapter が呼ばれていたみたい。

なので以下と同じことになる。

import dspy

dspy.configure(
    lm=dspy.LM("openai/gpt-4o-mini"),
    adapter=dspy.ChatAdapter()  # これがデフォルト
)

predict = dspy.Predict("question -> answer")
result = predict(question="フランスの首都は?")

システムにおけるアダプターの役割

処理フローは以下のような流れになる。

  1. モジュール呼び出し: dspy.Module に入力を渡して呼び出し
  2. Predict実行: LLMからの応答を取得するために、内部で dspy.Predict が呼び出される
  3. Adapter.format() による整形: シグネチャ・入力・few-shotsをマルチターンメッセージに変換
  4. LLM応答生成: 送信されたメッセージに対する応答をLLMが生成
    • LLM APIとのやり取りを行うのは軽量LLMラッパーであるdspy.LM
  5. Adapter.parse() による解析: LLMからの応答をシグネチャにあわせた構造化DSPy出力に変換
  6. 出力: dspy.Predict に出力結果が返る

Adapter.format()は明示的に呼び出すこともできる。

import json

signature = dspy.Signature("question -> answer")
inputs = {"question": "2 + 2は?"}
demos = [{"question": "1 + 1は?", "answer": "2"}]

adapter = dspy.ChatAdapter()
print(json.dumps(adapter.format(signature, demos, inputs), indent=2, ensure_ascii=False))
出力
[
  {
    "role": "system",
    "content": "Your input fields are:\n1. `question` (str):\nYour output fields are:\n1. `answer` (str):\nAll interactions will be structured in the following way, with the appropriate values filled in.\n\n[[ ## question ## ]]\n{question}\n\n[[ ## answer ## ]]\n{answer}\n\n[[ ## completed ## ]]\nIn adhering to this structure, your objective is: \n        Given the fields `question`, produce the fields `answer`."
  },
  {
    "role": "user",
    "content": "[[ ## question ## ]]\n1 + 1は?"
  },
  {
    "role": "assistant",
    "content": "[[ ## answer ## ]]\n2\n\n[[ ## completed ## ]]\n"
  },
  {
    "role": "user",
    "content": "[[ ## question ## ]]\n2 + 2は?\n\nRespond with the corresponding output fields, starting with the field `[[ ## answer ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`."
  }
]

なるほど、LLMに実際に送信されているのはこういう形になるのね。


アダプターの種類

上記にでてきたChatAdapterを含めて、複数のアダプターが用意されている。まずざっくり表にしてみた。

項目 ChatAdapter JSONAdapter
目的・特徴 すべてのLMで使える汎用フォーマット。[[ ## field_name ## ]]マーカーで入出力を区切り、非プリミティブ型にはJSONスキーマを含める。 署名どおりのJSONオブジェクトで返すよう促す。response_format対応モデルで構造化出力が安定。
出力形式 マーカー付きフィールド構造(テキスト) JSON(指定フィールドのみ)
対応モデルの目安 ほぼ全モデルで可 response_formatをネイティブ対応するモデルが望ましい
メリット 互換性が広い/失敗時は自動でJSONAdapterにフォールバック パースが堅牢/余計なトークンが少なく低レイテンシ
デメリット/注意点 出力トークンが多くなりがちで遅延が気になる用途には不向き 構造化出力に非対応の小型モデル(例:Ollamaの一部)では崩れやすい
ユースケース例 モデル互換性重視/形式要件が緩め 厳密な構造化出力が必要/低レイテンシ重視
設定(SDK引数 / APIパラメータ) adapter=dspy.ChatAdapter() /
(特になし)
adapter=dspy.JSONAdapter() /
response_format

あとここには書いてないけど、TwoStepAdapterっていうのもAPIリファレンスにはある。現状はその3つなのかな。

ChatAdapter

デフォルトのアダプター。フィールドごとの区切りを[[ ## field_name ## ]]というマーカーで指定していて、フィールドがPythonのプリミティブ型以外の場合はJSONスキーマで表現されるらしい。

ここは実際に見たほうが速い。dspy.inspect_history()を使えば、ChatAdapterが整形したメッセージをわかりやすく確認できる。

import dspy
import pydantic

dspy.configure(
    lm=dspy.LM("openai/gpt-4o-mini"),
    adapter=dspy.ChatAdapter()
)


class ScienceNews(pydantic.BaseModel):
    text: str
    scientists_involved: list[str]


class NewsQA(dspy.Signature):
    """指定された科学分野に関するニュースを取得する"""

    science_field: str = dspy.InputField()
    year: int = dspy.InputField()
    num_of_outputs: int = dspy.InputField()
    news: list[ScienceNews] = dspy.OutputField(desc="科学ニュース")


predict = dspy.Predict(NewsQA)
predict(science_field="コンピュータ理論", year=2022, num_of_outputs=1)
dspy.inspect_history()
出力
[2025-10-02T04:04:56.696680]

System message:

Your input fields are:
1. `science_field` (str): 
2. `year` (int): 
3. `num_of_outputs` (int):
Your output fields are:
1. `news` (list[ScienceNews]): 科学ニュース
All interactions will be structured in the following way, with the appropriate values filled in.

[[ ## science_field ## ]]
{science_field}

[[ ## year ## ]]
{year}

[[ ## num_of_outputs ## ]]
{num_of_outputs}

[[ ## news ## ]]
{news}        # note: the value you produce must adhere to the JSON schema: {"type": "array", "$defs": {"ScienceNews": {"type": "object", "properties": {"scientists_involved": {"type": "array", "items": {"type": "string"}, "title": "Scientists Involved"}, "text": {"type": "string", "title": "Text"}}, "required": ["text", "scientists_involved"], "title": "ScienceNews"}}, "items": {"$ref": "#/$defs/ScienceNews"}}

[[ ## completed ## ]]
In adhering to this structure, your objective is: 
        指定された科学分野に関するニュースを取得する


User message:

[[ ## science_field ## ]]
コンピュータ理論

[[ ## year ## ]]
2022

[[ ## num_of_outputs ## ]]
1

Respond with the corresponding output fields, starting with the field `[[ ## news ## ]]` (must be formatted as a valid Python list[ScienceNews]), and then ending with the marker for `[[ ## completed ## ]]`.


Response:

[[ ## news ## ]]
[
    {
        "text": "2022年、コンピュータ理論の分野で新たなアルゴリズムが発表され、計算の効率性が大幅に向上しました。このアルゴリズムは、特に大規模データセットの処理において、従来の手法と比較して数倍の速度を実現しています。",
        "scientists_involved": [
            "田中太郎",
            "佐藤花子"
        ]
    }
]

[[ ## completed ## ]]

実際にはカラーリングされて表示される。Colaboratoryだとこんな感じ。

dspy.ChainOfThoughtでも試してみた。

dspy.configure(
    lm=dspy.LM("openai/gpt-4o-mini"),
    adapter=dspy.ChatAdapter()
)

math = dspy.ChainOfThought("question -> answer: float")
math(question="二つのサイコロを振って、その合計が2になる確率は?")
dspy.inspect_history()
出力
[2025-10-02T04:13:46.194228]

System message:

Your input fields are:
1. `question` (str):
Your output fields are:
1. `reasoning` (str): 
2. `answer` (float):
All interactions will be structured in the following way, with the appropriate values filled in.

[[ ## question ## ]]
{question}

[[ ## reasoning ## ]]
{reasoning}

[[ ## answer ## ]]
{answer}        # note: the value you produce must be a single float value

[[ ## completed ## ]]
In adhering to this structure, your objective is: 
        Given the fields `question`, produce the fields `answer`.


User message:

[[ ## question ## ]]
二つのサイコロを振って、その合計が2になる確率は?

Respond with the corresponding output fields, starting with the field `[[ ## reasoning ## ]]`, then `[[ ## answer ## ]]` (must be formatted as a valid Python float), and then ending with the marker for `[[ ## completed ## ]]`.


Response:

[[ ## reasoning ## ]]
二つのサイコロを振ったとき、合計が2になるのは、サイコロの両方が1のときだけです。サイコロはそれぞれ6面を持っているため、全ての組み合わせは6 × 6 = 36通りです。その中で合計が2になる組み合わせは1通り((1, 1))です。したがって、合計が2になる確率は1/36です。

[[ ## answer ## ]]
0.027777777777777776

[[ ## completed ## ]]

JSON Adapter

こちらは出力をJSONで行うもの。(LiteLLMの)response_formatパラメータを使ったStructured Outputに対応しているモデルで使える。

ChatAdapterと同じサンプルコードで、アダプタだけ書き換える。

import dspy
import pydantic

dspy.configure(
    lm=dspy.LM("openai/gpt-4o-mini"),
    adapter=dspy.JSONAdapter()
)


class ScienceNews(pydantic.BaseModel):
    text: str
    scientists_involved: list[str]


class NewsQA(dspy.Signature):
    """指定された科学分野に関するニュースを取得する"""

    science_field: str = dspy.InputField()
    year: int = dspy.InputField()
    num_of_outputs: int = dspy.InputField()
    news: list[ScienceNews] = dspy.OutputField(desc="科学ニュース")


predict = dspy.Predict(NewsQA)
predict(science_field="コンピュータ理論", year=2022, num_of_outputs=1)
dspy.inspect_history()
出力
[2025-10-02T04:18:37.849114]

System message:

Your input fields are:
1. `science_field` (str): 
2. `year` (int): 
3. `num_of_outputs` (int):
Your output fields are:
1. `news` (list[ScienceNews]): 科学ニュース
All interactions will be structured in the following way, with the appropriate values filled in.

Inputs will have the following structure:

[[ ## science_field ## ]]
{science_field}

[[ ## year ## ]]
{year}

[[ ## num_of_outputs ## ]]
{num_of_outputs}

Outputs will be a JSON object with the following fields.

{
  "news": "{news}        # note: the value you produce must adhere to the JSON schema: {\"type\": \"array\", \"$defs\": {\"ScienceNews\": {\"type\": \"object\", \"properties\": {\"scientists_involved\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"title\": \"Scientists Involved\"}, \"text\": {\"type\": \"string\", \"title\": \"Text\"}}, \"required\": [\"text\", \"scientists_involved\"], \"title\": \"ScienceNews\"}}, \"items\": {\"$ref\": \"#/$defs/ScienceNews\"}}"
}
In adhering to this structure, your objective is: 
        指定された科学分野に関するニュースを取得する


User message:

[[ ## science_field ## ]]
コンピュータ理論

[[ ## year ## ]]
2022

[[ ## num_of_outputs ## ]]
1

Respond with a JSON object in the following order of fields: `news` (must be formatted as a valid Python list[ScienceNews]).


Response:

{"news":[{"text":"2022年、コンピュータ理論の分野で新たなアルゴリズムが発表され、計算の効率性が大幅に向上しました。このアルゴリズムは、特に大規模データセットの処理において、従来の手法と比較して数倍の速度を実現しています。","scientists_involved":["山田太郎","佐藤花子","鈴木一郎"]}]}

入力に関する定義はChatAdapterと似たような感じだけど、出力に関する定義と実際の出力がJSONになっているのがわかる。

kun432kun432

ツール

https://dspy.ai/learn/programming/tools/

DSPyのツール機能は、LLMに外部関数やAPIを使わせて実際に行動・取得・処理させる仕組みだよ。

DSPyは、言語モデル(LLM)を「ただ文章を出すだけの子」から「実際に手を動かせる子」に進化させるためのツール利用エージェントを用意してるんだよね。ここで言うツールって、外部の関数・API・サービスのこと。例えば「天気APIを叩く」「Web検索する」「計算する」みたいな実行を、LLMが状況を考えながら呼び分けできるようにする感じ。ウケるけど、これでLLMの行動範囲めっちゃ広がるのだし。

その使い方は大きく2つのアプローチに分かれるよ。

  1. dspy.ReAct
    完全自動型。モデルが「今の状況」を一歩ずつ推論して、どのツールをいつ使うかまで面倒見てくれる。推論とツール呼び出しの流れを一括管理するスタイルで、質問→答えの形を決めておけば、内部で必要なツールを勝手に選んで実行してくれる感じ。テンション上がる自動運転モードってイメージ。
  2. 手動ツール操作
    dspy.Tool と ‎dspy.ToolCalls それにカスタム署名を使って、どのツールをどう呼ぶかを自分でコントロールするやつ。実行の順番や引数、実際の呼び出しを細かく管理したいときに便利。いわばマニュアル車で攻める感じ、細部まで自分で握るから調整自由度が高いでしょ。

たとえで言うと、ReActは「ナビ付き自動運転で目的地まで行ってくれる」モード、手動操作は「自分でルート組んで運転して、寄り道や裏道も自由に選べる」モード。場面に合わせて使い分ければ、LLMに「情報を取りに行かせる」「データを加工させる」「段取りを踏ませる」みたいな現実的なアクションを任せやすくなるんだ。

まとめとしては、「LLMを拡張するにはツールが超大事」「やり方は自動(ReAct)と手動の二本柱」ってこと。まずはReActで全体の流れを掴んで、必要になったら手動で細部を詰める、って順番がわかりやすいと思う、マジで。

アプローチ1: dspy.ReAct を使用したフルマネージド

dspy.ReActモジュールは、いわゆるReActパターン。LLMが今の状況を反復的に推論し、ツールを呼び出しを判断する。

import dspy

# ツールを関数として定義
def get_weather(city: str) -> str:
    """任意の都市の現在の天気を取得する"""
    # 実際には天気APIを呼び出す
    return f"{city} の現在の天気は晴れ、気温は24℃です。"

def search_web(query: str) -> str:
    """Web検索を行い、情報を取得する"""
    # 実際には検索APIを呼び出す
    return f"'{query}' に関する検索結果: [関連情報, ...]"

# ReActエージェントを作成
react_agent = dspy.ReAct(
    signature="question -> answer",   # 入出力のシグネチャ
    tools=[get_weather, search_web],  # 利用可能なツールのリスト
    max_iters=5                       # ツール呼び出しの最大反復数
)

# エージェントを実行
result = react_agent(question="東京の今の天気は?")
print(result.answer)
print("実行されたツール呼び出し:", result.trajectory)
出力
東京の現在の天気は晴れ、気温は24℃です。
実行されたツール呼び出し: {'thought_0': '東京の現在の天気を知るために、天気情報を取得する必要があります。', 'tool_name_0': 'get_weather', 'tool_args_0': {'city': '東京'}, 'observation_0': '東京 の現在の天気は晴れ、気温は24℃です。', 'thought_1': '東京の現在の天気は晴れで、気温は24℃です。この情報をもとに、質問に対する答えをまとめることができます。', 'tool_name_1': 'finish', 'tool_args_1': {}, 'observation_1': 'Completed.'}

アプローチ2: 手動のツール処理

より細かくツール呼び出しを制御したい場合は dspy.Tool を使って手動で処理できる。

import dspy

class ToolSignature(dspy.Signature):
    """手動でツール制御するためのシグネチャ"""
    question: str = dspy.InputField()
    tools: list[dspy.Tool] = dspy.InputField()
    outputs: dspy.ToolCalls = dspy.OutputField()

def weather(city: str) -> str:
    """任意の都市の現在の天気を取得する"""
    return f"{city} の現在の天気は晴れです。"

def calculator(expression: str) -> str:
    """数式を評価する"""
    try:
        result = eval(expression)  # 注意: 本番環境ではより安全に使えるようにすること
        return f"結果は {result} です。"
    except:
        return "不正な数式"

# ツールインスタンスを作成
tools = {
    "weather": dspy.Tool(weather),
    "calculator": dspy.Tool(calculator)
}

# 推論器を作成
predictor = dspy.Predict(ToolSignature)

# 推論を実行
response = predictor(
    question="ニューヨークの天気は?",
    tools=list(tools.values())
)

# ツール呼び出しを実行
for call in response.outputs.tool_calls:
    # 個々のツール呼び出しを処理
    # 訳注: call.execute() は 本記事執筆2日前に追加されていて、まだパッケージ(v3.0.3)では使えない。
    # result = call.execute()
    # その場合は以下のように実行すればよい。次のパッケージバージョンではおそらく上のように実行できる。
    result = tools[call.name](**call.args)
    print(f"ツール: {call.name}")
    print(f"引数: {call.args}")
    print(f"結果: {result}")

print(response)
出力
ツール: weather
引数: {'city': 'ニューヨーク'}
結果: ニューヨーク の現在の天気は晴れです。
Prediction(
    outputs=ToolCalls(tool_calls=[ToolCall(name='weather', args={'city': 'ニューヨーク'})])
)

ツールに関する2つのクラスがある

  • dspy.Tool: Python関数をDSPyのツールとして使えるようにするためのクラス
  • dspy.Tool: LLMからのツール呼び出し出力を表すクラス。それを元にツールを実行するexecuteメソッドなどがある
    • が、上のコード内に書いてある通り、execute() は新しく追加されたメソッドで執筆時点ではつかなかった。

dspy.Toolのサンプルコード

def my_function(param1: str, param2: int = 5) -> str:
    """引数を受けるサンプル関数"""
    return f"引数 {param1}, 値{param2} で処理しました。"

# ツールを作成
tool = dspy.Tool(my_function)

# ツールのプロパティ
print(tool.name)        # ツール名
print(tool.desc)        # ツールの説明(docstring)
print(tool.args)        # 引数のスキーマ
print(str(tool))        # 完全なツールの説明
出力
my_function
引数を受けるサンプル関数
{'param1': {'type': 'string'}, 'param2': {'type': 'integer', 'default': 5}}
my_function, whose description is <desc>引数を受けるサンプル関数</desc>. It takes arguments {'param1': {'type': 'string'}, 'param2': {'type': 'integer', 'default': 5}}.

dspy.ToolCallsのサンプルコード。ここはサンプルだけ。

# ツール呼び出しの応答を取得したあとで実行
for call in response.outputs.tool_calls:
    print(f"ツール名: {call.name}")
    print(f"引数: {call.args}")

    # 個々のツール呼び出しを複数のやり方で実行
    
    # オプション1: 自動検出 (locals/globals から自動で関数を検出)
    result = call.execute()  # 名前で関数を探す

    # オプション2: ツールを辞書で渡す(最も明示的)
    result = call.execute(functions={"weather": weather, "calculator": calculator})

    # オプション3: ツールオブジェクトをリストで渡す
    result = call.execute(functions=[dspy.Tool(weather), dspy.Tool(calculator)])

    print(f"結果: {result}")

モデルのネイティブFunction Callingの使用

DSPyアダプターはモデルのネイティブFunction Callingに対応している・・・ってのが何を言っているのか、ってところなんだけど、今どきのモデルではほぼほぼFunction Callingに対応していて、DSPyでも基本的にはそれを使えるようになっているということだと思う。

逆に、Function Callingが使えない場合は、テキストベースで解析することになる(多分ツールのスキーマや出力の定義なども全てプロンプトで処理する様になるのだと思う)が、おそらく小型のローカルモデルなんかがそれにあたるのではないかと思う。

このネイティブFunction Callingについてのデフォルト設定がアダプタごとに異なる

  • ChatAdapter: ネイティブFunction Callingは無効。テキスト解析を使う(use_native_function_calling=False)
  • JSONAdapter: ネイティブFunction Callingが有効。(use_native_function_calling=True)

デフォルト値を変更する場合は、アダプタ作成時に変更すれば良い。

import dspy

# ChatAdapter でネイティブFunction Callingを有効化
chat_adapter_native = dspy.ChatAdapter(use_native_function_calling=True)

# ChatAdapter でネイティブFunction Callingを無効化
json_adapter_manual = dspy.JSONAdapter(use_native_function_calling=False)

# `dspy.configure`でアダプタを指定
dspy.configure(lm=dspy.LM(model="openai/gpt-4o"), adapter=chat_adapter_native)

また、モデルでネイティブFunction Callingが利用可能かどうかは litellm.supports_function_calling() でチェックされている。仮にuse_native_function_calling=True が指定されている場合でも、モデルが非対応であればテキストベースの解析にフォールバックするらしい。

このあたりの挙動はMLflowでトレーシングすると確認できるみたい。MLflowはTTSの学習で少し使ったことがあるけど、ちゃんと触ったことがない・・・

https://dspy.ai/tutorials/observability/#tracing


ベストプラクティス

まあ一般的なFunction Callingと同じで、

  • ツールの説明は重要(docstringできちんと定義)
  • 型ヒントも定義
  • パラメータはシンプルな型 or Pydanticモデルで

あたり。あとReActを使うべきか、手動でやるべきか、というところにも参考に。

kun432kun432

まとめ

DSPy、以前からよくLangChainあたりのフレームワークとも比較されていることが多くて、個人的にはプロンプトの最適化ツールという印象を持っていたので、いまいちピンときていなかった(深く触ってもなかったので、自分の勝手な思い込みだったんだけど。)今回、アプリケーションフレームワークとしての機能を一通り触ってみて、ある程度使える印象を持ったので、認識を改めれたのは良かったと思う。

とりあえず次はチュートリアルにある「Build AI Programs with DSPy」をいろいろ試してみるつもり。

https://dspy.ai/tutorials/

そこが終われば、評価・最適化に進もうかと思う。

このスクラップは2日前にクローズされました