コンテンツにスキップ

STUDY NOTES

第28回: DSPy + GEPA で ReAct エージェントを最適化 — ファイル探索タスクで LLM-as-Judge を実装

第26-27回(RAG パイプライン最適化)の延長線上にある、DSPy 最適化シリーズの最終形。今度は最適化対象が「単純な Predict」ではなく「ReAct エージェント(ツール呼び出しの while ループ)」になる。

新規性のポイント: - dspy.ReAct(signature, tools) で組んだエージェントを丸ごと GEPA で最適化できる - 「ツール仕様の保持」が肝。Signature の docstring だけを最適化すると、ツール定義が失われる事故が起きる → tool_specInputField として明示することで保護 - 評価は完全に LLM-as-Judge。RAG 回(EM 評価)と違い、ファイル探索レポートの質は数値化困難なので、評価用 LLM (gpt-4.1-mini) に「criteria に基づいて 0-10 点で採点」させる - criteria を Example ごとに JSON-Schema 級に厳密化することで、LLM 評価のブレを抑える

サンプルアプリは「ファイル探索エージェント」: taskworking_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,
)

内部でやっていること:

  1. toolsdspy.Tool でラップ(docstring → description、型ヒント → JSON Schema)
  2. Signature の InputField を context として、「thought → tool_name → tool_args」を LLM に生成させる
  3. ツール実行 → observation を Signature に追加 → 次の thought 生成
  4. max_iters 回ループ or LLM が「終了」を宣言したら、最終 OutputField(ここでは report)を生成
  5. 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_spec InputField に渡すことで、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) -> strdspy.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_specInputField として宣言 GEPA は instructions(docstring)を書き換えるが、InputField は書き換えない。これでツール仕様が永久に保護される
generate_tool_specifications でツール仕様を文字列化 docstring + 型ヒントから JSON Schema を抽出して整形
dspy.ReAct に同じ tools を渡す ReAct がツールを実際に呼び出せるよう、関数 reference も別途渡す
forwardtool_spec を明示的に注入 これによりプロンプトに毎回ツール仕様が含まれる → GEPA で指示文が変わってもツール仕様は永遠に保持

ハマりどころ: もし tool_specInputField にせず 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 つが弱いと全体が動かない

拡張アイデア

  1. trainset の自動拡張 — 既存 10 タスクを LLM に渡して「似たような別タスクを 20 個生成」させ、半教師あり学習風に増やす
  2. auto="medium" / "heavy" への引き上げ — README に「3 時間で auto=light」と書いてあるので、medium なら 6-9 時間、heavy なら 1 日級。コスト見積もり + 効果測定
  3. エージェントを Claude Opus に差し替えfast_lmclaude-opus-4-7 にして、ベースライン精度を比較。「smart model 単体」vs 「nano + GEPA 最適化」のコスト効率比較
  4. ツールを増やすgrep_in_files, count_lines, python_run 等を追加し、より複雑なタスク(テスト実行、依存関係抽出)が解けるか実験
  5. MIPROv2 との比較 — 同じデータ・同じエージェントで MIPROv2 にも掛けて、どちらが ReAct エージェントに向いているか定量比較
  6. trajectory パターンの統計分析pred.trajectory を pandas にロードし、「成功 vs 失敗での思考ステップ数の分布」「失敗時に多いツールエラー」等を可視化
  7. 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.GEPAtrack_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_filemax_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