STUDY NOTES
第28回: DSPy + GEPA で ReAct エージェントを最適化 — ファイル探索タスクで LLM-as-Judge を実装¶
第26-27回(RAG パイプライン最適化)の延長線上にある、DSPy 最適化シリーズの最終形。今度は最適化対象が「単純な Predict」ではなく「ReAct エージェント(ツール呼び出しの while ループ)」になる。
新規性のポイント: -
dspy.ReAct(signature, tools)で組んだエージェントを丸ごと GEPA で最適化できる - 「ツール仕様の保持」が肝。Signature の docstring だけを最適化すると、ツール定義が失われる事故が起きる →tool_specをInputFieldとして明示することで保護 - 評価は完全に LLM-as-Judge。RAG 回(EM 評価)と違い、ファイル探索レポートの質は数値化困難なので、評価用 LLM (gpt-4.1-mini) に「criteria に基づいて 0-10 点で採点」させる - criteria を Example ごとに JSON-Schema 級に厳密化することで、LLM 評価のブレを抑える
サンプルアプリは「ファイル探索エージェント」: task と working_directory を受けて、ls_directory / read_file / write_file の 3 ツールで探索し、report という Markdown 形式のレポートを生成する。他のサンプル回フォルダ(../12, ../17, ../20 等)を実際のテスト題材として使う**メタな構成。
| 概念 | 第26-27回(RAG) | 第28回(ReAct Agent) |
|---|---|---|
| 最適化対象 | 2 Predict (rewrite + generate) | dspy.ReAct 1 個(内部に思考 + tool call の while ループ) |
| 評価 | EM (Exact Match) | LLM-as-Judge + criteria + trajectory 検証 |
| ツール定義 | Signature だけ最適化で OK | tool_spec を InputField で外出しして保護 |
| trainset | 50 質問 (JQaRA) | 10 タスク(直交する 7 ディレクトリ) |
| 学習信号 | score + 部分一致 feedback | score + explanation + improvement_suggestions の 3 軸 |
全体像¶
28/
├── config.py ← SMART/FAST/EVAL の 3 モデルを使い分け
├── agent_module.py ← ★ FileExplorationAgent (dspy.ReAct 包む)
├── agent_tool_specs.py ← ツール関数から仕様文字列を生成(ツール仕様保護)
├── dataset_loader.py ← 厳密な criteria 付きの 10 train + 5 test
├── agent_optimization_gepa.py ← LLM-as-Judge metric + GEPA compile
├── agent_evaluation.py ← baseline vs optimized を test 5問で比較
├── main.py ← 任意タスク実行 CLI
└── artifact/
└── agent_gepa_optimized_latest.json → agent_gepa_optimized_<ts>_score<NNN>.json
GEPA 最適化フロー(27回との差分を強調):
flowchart TD
DS[load_file_exploration_dataset<br/>train: 10タスク、test: 5タスク<br/>各タスクに厳密な criteria] --> Trainset
Trainset[trainset 10タスク] --> GEPA
subgraph Agent ["FileExplorationAgent (dspy.ReAct)"]
Sig[FileExplorationSignature<br/>task, working_directory, tool_spec → report]
Tools[ls_directory / read_file / write_file]
ReAct[dspy.ReAct max_iters=10]
Sig --> ReAct
Tools --> ReAct
end
Agent --> GEPA
EvalLM[eval_lm: gpt-4.1-mini<br/>LLM-as-Judge] --> Metric
Metric[gepa_llm_judge_metric<br/>ReportEvaluation Signature<br/>= score + explanation<br/>+ improvement_suggestions] --> GEPA
GEPA[dspy.GEPA<br/>auto=light, reflection_lm=smart] --> Refl
Refl[reflection_lm が improvement_suggestions を読んで<br/>FileExplorationSignature の指示文を書き換え]
Refl --> Best[最良候補]
Best --> Save[artifact/agent_gepa_optimized_<ts>_score<NN>.json]
style Sig fill:#FFF2E8,stroke:#ED7100
style Metric fill:#E1F5FE,stroke:#0288D1
style Refl fill:#E8F5E9,stroke:#3F8624
ReAct エージェントの推論時の内部フロー:
sequenceDiagram
participant User
participant Agent as FileExplorationAgent
participant ReAct as dspy.ReAct
participant LM as fast_lm (gpt-4.1-nano)
participant Tool as ls_directory / read_file / write_file
participant FS as ファイルシステム
User->>Agent: agent(task="...", working_directory="../12")
Agent->>Agent: generate_tool_specifications で tool_spec 生成
Agent->>ReAct: agent(task, working_directory, tool_spec)
loop max_iters=10 まで
ReAct->>LM: 「次に何をすべきか」を思考
LM-->>ReAct: thought + tool_name + tool_args (JSON)
ReAct->>Tool: ls_directory("..", recursive=True, pattern="*.py")
Tool->>FS: glob
FS-->>Tool: ファイル一覧
Tool-->>ReAct: observation (テキスト)
ReAct->>ReAct: trajectory に thought/tool/args/obs を蓄積
end
ReAct->>LM: 「もう十分。最終 report を書け」
LM-->>ReAct: 最終 report
ReAct-->>Agent: Prediction(report=..., trajectory={...})
Agent-->>User: report + trajectory
tool_specを InputField で渡すのがこの回の最大の発明。GEPA は通常 Signature のinstructions(docstring)を書き換えるが、InputFieldは書き換え対象にならないので、ツール仕様が消えるリスクがゼロになる。
使用ライブラリ・原理¶
dspy.ReAct(signature, tools, max_iters) — DSPy 版 ReAct エージェント¶
self.agent = dspy.ReAct(
signature=FileExplorationSignature,
tools=[ls_directory, read_file, write_file],
max_iters=10,
)
内部でやっていること:
toolsをdspy.Toolでラップ(docstring → description、型ヒント → JSON Schema)- Signature の InputField を context として、「thought → tool_name → tool_args」を LLM に生成させる
- ツール実行 → observation を Signature に追加 → 次の thought 生成
max_iters回ループ or LLM が「終了」を宣言したら、最終OutputField(ここではreport)を生成Prediction(report=..., trajectory={thought_0, tool_name_0, ...})を返す
dspy.Tool(func) — Python 関数を ReAct 用ツールに変換¶
from agent_tool_specs import generate_tool_specifications
def generate_tool_specifications(tools: list) -> str:
tool_objects = [dspy.Tool(func) for func in tools]
specs = []
for i, tool in enumerate(tool_objects, 1):
spec = f"({i}) {tool.name}, whose description is <desc>{tool.desc}</desc>. "
spec += f"It takes arguments {tool.args}."
specs.append(spec)
return "\n".join(specs)
dspy.Tool(func)で docstring →.desc, 型ヒント →.args(JSON Schema dict) に変換.nameは関数名がそのまま- これを文字列化して
tool_specInputField に渡すことで、ReAct が「使えるツールの正確な仕様」を毎回プロンプトで認識できる
LLM-as-Judge による評価 (ReportEvaluation Signature)¶
class ReportEvaluation(dspy.Signature):
task: str = dspy.InputField(desc="ファイル探索タスクの説明")
report: str = dspy.InputField(desc="エージェントが生成したレポート")
criteria: str = dspy.InputField(desc="評価基準の完全な記述(点数配分まで明示)")
trajectory: str = dspy.InputField(desc="エージェントのツール呼び出し履歴")
score: int = dspy.OutputField(desc="0-10 の整数")
explanation: str = dspy.OutputField(desc="評価理由 200-400 文字")
improvement_suggestions: str = dspy.OutputField(desc="GEPA リフレクション用の具体的改善提案")
evaluator = dspy.ChainOfThought(ReportEvaluation)
ポイント:
criteriaには点数配分まで明示された厳密な評価基準を渡す(曖昧語 NG)trajectoryでツール呼び出し履歴も評価 LLM に見せる → 「実は read_file してた」を拾えるimprovement_suggestionsを出力させることで、GEPA の reflection LM に直接フィードバックできる
criteria を Example ごとに厳密に書く工夫¶
dspy.Example(
task="rag_module.pyの3つのクラスを特定し...",
working_directory="../26",
criteria="""以下の基準で0-10点で評価してください:
1. 必須ファイルの読み取り(1点)
- rag_module.pyを読んだか: 1点
2. 必須要素の言及(7点)
- RewriteQueryクラスの特定(dspy.Signature継承): 1.5点
- GenerateAnswerクラスの特定(dspy.Signature継承): 1.5点
- RAGQAクラスの特定(dspy.Module継承): 1点
- forwardメソッドでのdspy.settings.rm(rewritten)呼び出し: 2点
- result.passagesでのパッセージ取得: 1点
3. 情報統合(2点)
- RAGQAのforwardメソッドの処理フロー(rewrite → retrieve → generate)を説明: 1.5点
- dspy.Retrieveを使わずに直接rmを呼び出す理由に言及できれば: 0.5点
オプション(加点):
- CLAUDE.mdまたはREADME.mdでdspy.Retrieve非使用の理由を確認: +0.5点""",
difficulty="easy"
).with_inputs("task", "working_directory")
- 「曖昧な表現禁止」を criteria 内に明記して、LLM-as-Judge のブレを抑える
- 「ハルシネーション判定ルール」も明文化: 「ファイルを読まずに推測で説明した場合は全て 0 点」
- 各項目の点数配分を細かく明示することで、reflection LM が「どこで失点したか」を読み取れる
cache=False で評価精度を確保¶
return dspy.LM(
model=f"openai/{model_name}",
...,
cache=False # Disable prompt caching for accurate evaluation
)
DSPy LM はデフォルトで prompt+response をキャッシュする。最適化中は同じプロンプトを何度も評価したいので、キャッシュ HIT で「前回の運の良い結果」を引っ張ってきてしまうと探索が偏る。cache=False で disable。
ファイル別の役割¶
| ファイル | 役割 |
|---|---|
config.py |
OpenAI/Azure 切替 + SMART/FAST/EVAL の 3 LM。cache=False がポイント |
agent_module.py |
中核。FileExplorationSignature と FileExplorationAgent(dspy.Module) |
agent_tool_specs.py |
generate_tool_specifications(tools) -> str。dspy.Tool を経由して tool 仕様を文字列化 |
dataset_loader.py |
厳密 criteria 付きの 10 train / 5 test / 3 mini_test。他のサンプル回フォルダを題材にする |
agent_optimization_gepa.py |
ReportEvaluation Signature + create_gepa_llm_judge_metric + dspy.GEPA(auto="light") |
agent_evaluation.py |
baseline vs optimized を test 5問で評価 + Markdown レポート出力 |
main.py |
任意タスクで agent を実行する CLI |
行レベルの工夫(中核ロジックの抜粋)¶
① tool_spec を InputField で渡す設計 (agent_module.py:222-276)¶
class FileExplorationSignature(dspy.Signature):
"""ファイル探索タスクのシグネチャ..."""
task: str = dspy.InputField(desc="タスクの説明...")
working_directory: str = dspy.InputField(desc="探索する作業ディレクトリのパス")
tool_spec: str = dspy.InputField( # ①
desc="利用可能なツールの仕様(引数の名前、型、デフォルト値を含む詳細な説明)"
)
report: str = dspy.OutputField(desc="...")
class FileExplorationAgent(dspy.Module):
def __init__(self, max_iters: int = 10, verbose: bool = True):
super().__init__()
self.max_iters = max_iters
tools = [ls_directory, read_file, write_file]
self.tool_spec = generate_tool_specifications(tools) # ②
self.agent = dspy.ReAct( # ③
signature=FileExplorationSignature,
tools=tools,
max_iters=max_iters,
)
def forward(self, task, working_directory="."):
working_directory_abs = str(Path(working_directory).resolve())
result = self.agent(
task=task,
working_directory=working_directory_abs,
tool_spec=self.tool_spec # ④
)
return result
| 行 | やってること | なぜそうする |
|---|---|---|
| ① | tool_spec を InputField として宣言 |
GEPA は instructions(docstring)を書き換えるが、InputField は書き換えない。これでツール仕様が永久に保護される |
| ② | generate_tool_specifications でツール仕様を文字列化 |
docstring + 型ヒントから JSON Schema を抽出して整形 |
| ③ | dspy.ReAct に同じ tools を渡す |
ReAct がツールを実際に呼び出せるよう、関数 reference も別途渡す |
| ④ | forward で tool_spec を明示的に注入 |
これによりプロンプトに毎回ツール仕様が含まれる → GEPA で指示文が変わってもツール仕様は永遠に保持 |
ハマりどころ: もし tool_spec を InputField にせず docstring の中に書いたら、GEPA の reflection LM が「指示文を簡潔にしよう」とツール仕様を削除してしまい、エージェントが壊れる事故が起きる。第28回の「失敗の教訓」がこの設計らしい(criteria のオプション項目に「第1回失敗の教訓」と書かれている)。
② LLM-as-Judge metric の構築 (agent_optimization_gepa.py:198-296)¶
def create_gepa_llm_judge_metric(eval_lm):
evaluator = dspy.ChainOfThought(ReportEvaluation) # ①
def gepa_llm_judge_metric(gold, pred, trace=None, pred_name=None, pred_trace=None):
if not hasattr(pred, 'report') or not pred.report:
return dspy.Prediction(
score=0.0,
feedback="[ERROR] No report generated",
improvement_suggestions="レポートを生成するために..."
)
# trajectory をフォーマット
trajectory_str = ""
if hasattr(pred, 'trajectory') and pred.trajectory:
trajectory_items = []
for k, v in pred.trajectory.items():
v_str = str(v)
if len(v_str) > 500:
v_str = v_str[:500] + "... (truncated)" # ②
trajectory_items.append(f"{k}: {v_str}")
trajectory_str = "\n".join(trajectory_items)
with dspy.context(lm=eval_lm): # ③
eval_result = evaluator(
task=gold.task,
report=pred.report,
criteria=gold.criteria,
trajectory=trajectory_str
)
raw_score = eval_result.score
try:
score = float(raw_score)
score = min(10.0, max(0.0, score)) / 10.0
except (ValueError, TypeError):
score = 0.0
feedback = f"Score: {raw_score}/10"
if hasattr(eval_result, 'explanation') and eval_result.explanation:
feedback += f" | {eval_result.explanation[:100]}..."
return dspy.Prediction( # ④
score=score,
feedback=feedback,
improvement_suggestions=eval_result.improvement_suggestions,
explanation=eval_result.explanation,
raw_score=raw_score
)
return gepa_llm_judge_metric
| 行 | やってること | なぜそうする |
|---|---|---|
| ① | dspy.ChainOfThought(ReportEvaluation) で CoT 付き評価器 |
単発 Predict より評価精度が上がる。reasoning フィールドが自動付与され、評価 LLM が「考えてから採点」する |
| ② | trajectory の各観測を 500 文字で truncate | 全部詰めると context overflow。長いファイル内容は要約 |
| ③ | with dspy.context(lm=eval_lm) で評価用 LM を一時切替 |
global LM (fast_lm) を汚さず、評価だけ gpt-4.1-mini を使う |
| ④ | dspy.Prediction(score, feedback, improvement_suggestions, ...) を返す |
GEPA は score + feedback 必須、それ以外は任意。improvement_suggestions を渡すと reflection LM の入力が豊富になる |
③ GEPA optimizer の組み立て (agent_optimization_gepa.py:436-446)¶
optimizer = dspy.GEPA(
metric=gepa_llm_metric, # ①
auto="light", # ②
reflection_lm=reflection_lm,
)
optimized_agent = optimizer.compile(
agent,
trainset=train_examples, # ③
)
| 行 | やってること | なぜそうする |
|---|---|---|
| ① | LLM-as-Judge metric を渡す | スコア + 自然言語 explanation + improvement_suggestions を含むので reflection LM が学習信号として濃い |
| ② | auto="light" でも 3 時間かかる |
ReAct 1 タスク = 10 ツール呼び出し × 10 タスク × 6 候補 ≈ 600 LLM 呼び出し |
| ③ | valset を渡さない(27 回と違う) |
trainset 10 タスクで最適化、test 5 タスクは別途 evaluation スクリプトで評価。サンプル数が極小なので valset を切ると trainset が枯れる判断 |
学んだこと(要点)¶
- ReAct エージェントの最適化は「Signature の docstring 最適化」+「ツール仕様の保護」の二段構え。
tool_specを InputField にする発明が肝 - LLM-as-Judge の質は criteria の厳密さで決まる。「具体的に何点配分か」「何が NG か」を明記しないと評価 LLM がブレる
trajectoryを評価入力に含めることで、レポート本文の冗長性に騙されず「実際に何をしたか」で採点できる → ハルシネーション検出が可能cache=Falseは最適化中必須。これがないと「同じプロンプトの過去成功例を引きずる」現象が起きるimprovement_suggestionsを GEPA reflection LM に流すことで、抽象的でない具体的な指示文改良が走る- テスト題材を他の連載回フォルダにするメタ構成は実用例として面白い。「自分の過去コードを別 LLM に説明させる」セルフリフレクション
- trainset を 10 タスクまで絞れるのが GEPA の強み。MIPROv2 だとここまで少ないと収束が不安定
- ReAct の
max_iters=10は妥当。長くしすぎると context overflow、短くしすぎると複雑タスクで未完了で終わる - agent + tools + LLM-as-Judge の三位一体で 1 つの最適化系を成す。どれか 1 つが弱いと全体が動かない
拡張アイデア¶
- trainset の自動拡張 — 既存 10 タスクを LLM に渡して「似たような別タスクを 20 個生成」させ、半教師あり学習風に増やす
auto="medium"/"heavy"への引き上げ — README に「3 時間で auto=light」と書いてあるので、mediumなら 6-9 時間、heavyなら 1 日級。コスト見積もり + 効果測定- エージェントを Claude Opus に差し替え —
fast_lmをclaude-opus-4-7にして、ベースライン精度を比較。「smart model 単体」vs 「nano + GEPA 最適化」のコスト効率比較 - ツールを増やす —
grep_in_files,count_lines,python_run等を追加し、より複雑なタスク(テスト実行、依存関係抽出)が解けるか実験 - MIPROv2 との比較 — 同じデータ・同じエージェントで MIPROv2 にも掛けて、どちらが ReAct エージェントに向いているか定量比較
- trajectory パターンの統計分析 —
pred.trajectoryを pandas にロードし、「成功 vs 失敗での思考ステップ数の分布」「失敗時に多いツールエラー」等を可視化 - Code-Aware Embedding を導入 — read_file の前に「ファイル名のリストを embed して関連度上位 N 件だけ open する」段階を追加。10 ファイル全部読まずに済む
現代版に移植するなら¶
1. API キー設定は .env.op + op run に切り替える(CLAUDE.md ルール 8)¶
# 28/.env.op
PROVIDER_NAME=openai
OPENAI_API_KEY=op://Personal/openai-api-key/credential
SMART_MODEL=gpt-4.1
FAST_MODEL=gpt-4.1-nano
EVAL_MODEL=gpt-4.1-mini
起動:
op run --env-file=.env.op -- uv run python agent_evaluation.py
op run --env-file=.env.op -- uv run python agent_optimization_gepa.py
2. SMART/FAST/EVAL を最新モデルへ¶
SMART_MODEL(gpt-4.1) →gpt-5/claude-opus-4-7で reflection 品質向上FAST_MODEL(gpt-4.1-nano) →gpt-5-nano/claude-haiku-4-5で実行コスト削減EVAL_MODEL(gpt-4.1-mini) →gpt-5-miniで評価精度向上
特に EVAL_MODEL の質が直接スコアの信頼性に直結するので妥協しないこと。
3. tool_spec を JSON 形式にする¶
現状は (1) tool_name, whose description is <desc>...</desc>. It takes arguments {...}. という人読み形式だが、JSON Schema 形式のほうが LLM の解釈精度が高いことが多い。json.dumps([tool.args_schema for tool in tools], indent=2) のほうがロバスト。
4. agent_evaluation.py の文字化け絵文字¶
print("=📚 Loading test dataset...") のような書き方は stdout エンコーディング次第で壊れる。logging に切り替えてフォーマットを統一すべき。
5. test set への valset 分離検討¶
現状 trainset 10 + test 5 で、最適化中の valset が無い。「trainset 7 + valset 3 + test 5」のほうが過剰適合検知に有利。サンプル数を増やすほうが先決だが、最適化中の中間チェックは欲しい。
6. dspy.GEPA の track_stats=True を追加¶
27回でも書いたが、track_stats=True を入れると候補プールの推移が記録される。デバッグ・分析に必須。
既知の不具合・注意点¶
agent_evaluation.py:167, 174, 181, 184, 196, 200, 207の=接頭辞:print("=📚 Loading...")は意図不明な余分な=。意図的なら絵文字との視覚的セパレータかもしれないが、誤植にしか見えない- trainset = 10 タスク、test = 5 タスクは少なすぎる: LLM-as-Judge のスコアバラツキを考えると、test 30+ 欲しい。連載サンプルなのでこの規模だが、production 評価には不足
recursive=Trueの glob が非効率:ls_directory(".", recursive=True, pattern="*.py")はpath_obj.glob("**/*.py")で全部展開してから返す。大規模ディレクトリで OOM の余地read_fileのmax_chars=10000制限: 30k トークンの長ファイルだと最初の 10k だけ。情報損失を agent 側に伝えてないので、agent が「ファイル全部読んだ」と勘違いする可能性write_fileが_INITIAL_CWD基準:--directory ../12で起動しても、書き込みはスクリプト起動 cwd。ユーザに分かりにくい。working_directoryベースのほうが直感的Teeクラスが context manager でない: 27 回と同じ問題os.symlinkの Windows 問題: 27 回と同じ問題improvement_suggestionsが GEPA に正しく渡るか確認なし:dspy.Prediction(improvement_suggestions=...)を返しているが、GEPA がこの追加フィールドを reflection LM に渡すかは DSPy version 依存。LangSmith trace で確認推奨- criteria の長さ: 一部 example の criteria は 500 文字超。
gpt-4.1-miniでも context は十分だが、gpt-4.1-nanoで評価しようとすると不十分になる可能性
記事参照¶
- Software Design 2026 年 1 月号(推定)連載第28回「DSPy + GEPA で ReAct エージェント最適化」
- 関連: 第27回 STUDY_NOTES — DSPy + GEPA で RAG 最適化。同じ optimizer の RAG 版
- 関連: 第26回 STUDY_NOTES — DSPy + MIPROv2 で RAG 最適化
- 関連: 第11回 STUDY_NOTES — ReAct Agent の基本
- 公式
dspy.ReActドキュメント: https://dspy.ai/api/modules/ReAct/ - 公式
dspy.GEPAドキュメント: https://dspy.ai/api/optimizers/GEPA/ - LLM-as-Judge ベストプラクティス: https://www.anthropic.com/research/evaluating-feature-steering
作成: 2026-05-25 / 最終更新: 2026-06-10