プロンプトをもう一度書き直そうとしてエディタの空白を眺めていた金曜の夜、私は「これ、いつまで続ければいいんだろう」と本気で考えました。Few-shot の例を一行いじるたびに、別のテストケースが壊れます。仕様が変わるたびに、3 つの prompt template が連鎖的に古くなっていきます。チームが大きくなるほど「最終版」は更新されないまま属人化していきます。プロンプトエンジニアリングは、コードレビューで合意形成しようとしても判断基準が掴みにくく、ベテランの直感に依存しがちな領域です。
DSPy は、この「プロンプト職人芸」をコンパイル可能なプログラムに置き換えるためのフレームワークです。スタンフォード NLP から生まれ、現在は Databricks のチームが中心となって発展している成熟したプロジェクトで、2026 年に入ってからは LangGraph や CrewAI と組み合わせた事例も急速に増えてきました。発想の転換には、少し時間がかかるかもしれません。「モデルに何と言うか」を考えるのをやめて、「どんな形の入力を渡し、どんな形の出力を受け取りたいか」だけを宣言するのです。言い回しは Optimizer が埋めてくれます。
以下は、最初の Signature を切り出すところから、評価をゲートにした本番デプロイまで、Antigravity 上で私が実際に回している手順です。請求書抽出と為替レート照会、それに社内向けの小さなエージェントが題材で、固有名詞と数値は差し替えていますが、コードはそのまま動きます。
「プロンプト職人芸」が抱える3つの構造的な弱点
最初にお断りしておくと、私は手書きのプロンプトを否定したいわけではありません。プロトタイプ段階や、評価データを集めにくい初期フェーズでは、人が書いた方が立ち上がりは速いです。問題は、プロダクトが育って評価が定量化できる段階に入ったあと、なぜか手書きの prompt template だけが旧来のままチーム内に残り続ける現象にあります。
私が現場で繰り返し見てきた弱点は3つです。
第一に、変更の影響範囲が見えないこと。prompts/extract_invoice.txt を一行いじったとき、本番の精度が上がったのか下がったのか、評価データに対して回さない限り判断できません。それでも「動いているっぽいから出す」運用が定着すると、リグレッションがしばらく経ってから本番ログで発覚します。
第二に、最適化のループが手作業であること。「Few-shot を増やしてみる」「Chain-of-Thought を足してみる」「JSON 形式を強制してみる」といった改善は、ほぼすべて人間がアイデアを出して試行する流れになります。これは複数の改善を組み合わせると組合せ爆発を起こし、再現も難しくなります。
第三に、モデルが変わると全部やり直しになること。Gemini Flash 用に磨いたプロンプトが、コスト最適化のために Gemma 4 へ移したいときにそのまま流用できる保証はありません。プロンプト資産がモデル依存になっている時点で、移行コストが高止まりします。
DSPy は、プロンプトを「文字列」ではなく「型付きの関数(Signature)」として定義し、その関数を満たす最適なプロンプトをデータから自動的に学習させるアプローチで、これら3つの問題に正面から向き合っています。
DSPy の世界観 — Signatures・Modules・Optimizers
DSPy のコア要素は3つです。
Signatures は LLM 呼び出しの入出力の「型」を表します。たとえば「請求書テキストを受け取り、金額・日付・取引先名を返す」という関数の宣言です。実装はオプティマイザに任せ、開発者は意図だけを記述します。
Modules はその Signature を実行する戦略の単位です。dspy.Predict(単純な一発予測)、dspy.ChainOfThought(思考連鎖を内部で展開する)、dspy.ReAct(ツール呼び出しを伴う)といった戦略がビルトインで提供されており、開発者はビジネスロジックの一部として組み合わせます。
Optimizers は、評価メトリクスを最大化する形でプロンプトや Few-shot 例を自動的に探索するコンポーネントです。BootstrapFewShot は教師データから良質な例を自動抽出して埋め込み、MIPROv2 は指示文と Few-shot を協調的に最適化します。ここに GEPA が加わったことで、メトリクスが「点数」だけでなく「なぜ落ちたか」を言葉で返せる場合に、その言葉を次の指示文の提案へ使えるようになりました——後の章で触れます。
この3要素を Antigravity の中で扱うとき、エディタが提供する補完・差分プレビュー・チェックポイント機能との相性がとても良く、私自身は LangGraph と組み合わせる前段の「素材作り」フェーズで多用しています。
Antigravity プロジェクトに DSPy を入れる初期セットアップ
具体的なセットアップから始めましょう。Python 3.11 以上の仮想環境を Antigravity のターミナルで作り、依存を入れます。
# Antigravity のターミナルから実行
python -m venv .venv
source .venv/bin/activate
# DSPy 本体と評価用ユーティリティ(パッケージ名は dspy。古い記事にある dspy-ai は旧名です)
pip install "dspy>=3.0" "litellm>=1.50.0" python-dotenv
# 構造化出力の検証用
pip install pydantic
.env に LLM プロバイダーの API キーを入れておき、Antigravity の Custom Rules で「本ファイル内の os.getenv は必ず .env 経由を前提」と明文化しておくと、AI が誤って実キーを書き込むリスクを減らせます。こうした規約を Rules に書くか Skills に書くかで迷ったときの整理は、Rules と Skills のどちらに書くか迷ったとき — 指示の置き場所を決め直しました に書き残しています。
最初に、最小構成で疎通を確認します。
# scripts/dspy_smoke.py
import os
from dotenv import load_dotenv
import dspy
load_dotenv()
# Gemini 3.8 Flash(GA)。2.5 Flash は既存利用者向けに提供が絞られているため、
# 新規プロジェクトでは 3.8 Flash か 3.5 Flash-Lite を選びます
lm = dspy.LM(
"gemini/gemini-3.8-flash",
api_key=os.environ["GEMINI_API_KEY"],
temperature=0.0,
max_tokens=1024,
)
dspy.configure(lm=lm)
# 最小の Signature: 質問 → 回答
class SimpleQA(dspy.Signature):
"""質問に1〜2文で簡潔に回答する。"""
question: str = dspy.InputField()
answer: str = dspy.OutputField(desc="1〜2文の日本語回答")
predict = dspy.Predict(SimpleQA)
result = predict(question="DSPy は何を解決するために作られた?")
print(result.answer)
期待出力(モデル次第で文面は変わりますが、構造は安定します):
DSPy はプロンプトの手書き運用が抱える再現性・最適化・モデル移行の難しさを、型付きの宣言と自動最適化で解決するために作られました。
ここまで動けば、後の章で扱うすべての応用が同じ枠組みで書けます。Antigravity の Manager Surface でこの scripts/dspy_smoke.py をエージェントに渡し、「同じ Signature を ChainOfThought で書き換えて」と指示すると、dspy.ChainOfThought(SimpleQA) への置換差分を一発で出してくれます。
実用ケース1: 構造化抽出パイプラインを Module 化する
抽象論ばかりでは退屈なので、私が実案件で何度も書いた「請求書 PDF からの構造化抽出」を題材にします。読者が同じ枠組みを応用しやすいよう、外部 OCR 結果を文字列入力として受け取る前提にします。
# pipelines/invoice_extraction.py
from typing import List
from pydantic import BaseModel, Field
import dspy
class LineItem(BaseModel):
description: str = Field(description="商品名・サービス名")
quantity: float = Field(description="数量。不明なら 1.0")
unit_price: float = Field(description="単価(税抜・JPY)")
class Invoice(BaseModel):
vendor: str = Field(description="取引先名")
invoice_date: str = Field(description="発行日 YYYY-MM-DD")
total_amount: float = Field(description="合計金額(税込・JPY)")
line_items: List[LineItem] = Field(default_factory=list)
class ExtractInvoice(dspy.Signature):
"""OCR テキストから請求書のフィールドを抽出する。
数値は半角・整数または小数で返し、日付は YYYY-MM-DD に正規化する。
自信がない場合でも null は返さず、最も確からしい値を埋める。"""
ocr_text: str = dspy.InputField(desc="請求書 PDF を OCR したプレーンテキスト")
invoice: Invoice = dspy.OutputField(desc="抽出済みの構造化データ")
class InvoiceExtractor(dspy.Module):
def __init__(self):
super().__init__()
# ChainOfThought で内部的に推論を展開させ、抽出精度を底上げする
self.extract = dspy.ChainOfThought(ExtractInvoice)
def forward(self, ocr_text: str) -> Invoice:
return self.extract(ocr_text=ocr_text).invoice
この InvoiceExtractor を呼び出すコードは、プロンプト文字列を一行も含みません。Signature の docstring と Pydantic の Field description が、そのまま LLM への指示として組み立てられ、後段の Optimizer がそれをデータから磨き上げてくれます。
なぜ ChainOfThought をかぶせたかと言うと、構造化抽出は「金額の合計が line_items の合計と合わないと困る」という整合性要件があり、暗黙的に内部推論を経由させた方が安定するためです。dspy.Predict で十分な軽量タスクと、dspy.ChainOfThought を選ぶべき重いタスクの線引きについては、評価データを回しながら判断するのが原則です。
実用ケース2: ReAct でツール呼び出しを伴うエージェントを書く
抽出だけでなく「外部 API を叩いてから答える」エージェントを書きたい場面もあります。DSPy は dspy.ReAct を使えば、ツール呼び出しと LLM 推論をひとつの Module として扱えます。Antigravity の Multi-Agent 機能と相性が良く、私はオーケストレーターから DSPy 製のサブエージェントを呼び出すパターンをよく使います。
# agents/exchange_rate_agent.py
import requests
import dspy
def get_exchange_rate(from_ccy: str, to_ccy: str) -> str:
"""為替レートを取得する。失敗時は理由を文字列で返す。"""
try:
r = requests.get(
"https://api.exchangerate.host/convert",
params={"from": from_ccy, "to": to_ccy},
timeout=5.0,
)
r.raise_for_status()
rate = r.json().get("result")
return f"1 {from_ccy} = {rate} {to_ccy}" if rate else "rate not available"
except requests.RequestException as e:
return f"error: {e}"
class FxAnswer(dspy.Signature):
"""ユーザーの為替に関する質問に、必要なら為替レートを取得してから答える。"""
question: str = dspy.InputField()
answer: str = dspy.OutputField(desc="日本語の自然な文章で答える")
agent = dspy.ReAct(FxAnswer, tools=[get_exchange_rate], max_iters=4)
if __name__ == "__main__":
print(agent(question="100 ドルって今日いくら?").answer)
このコードで重要なのは、max_iters=4 のように上限を必ず設定することです。設定しないとツール呼び出しと推論が暴走してコストを焼き続けるリスクがあります。コスト面の制御は Antigravity でAIエージェントのトークンコストを半減させる戦略 で深掘りしているので、本番運用前に必ず読んでおきたいトピックです。
評価メトリクスの設計が、最適化の質をすべて決める
ここからが DSPy の本領で、同時に最も詰まりやすい工程です。Optimizer は与えられたメトリクスを最大化するように動くので、メトリクスがズレていると「数字は良いけど現場では使えない」モデルに収束します。
# eval/invoice_metric.py
from typing import Any
from pipelines.invoice_extraction import Invoice
def invoice_match(example, pred, trace=None) -> float:
"""ground-truth と pred を突き合わせ、0.0〜1.0 のスコアを返す。
重要なフィールドを重みづけして評価する。"""
gt: Invoice = example.invoice
pr: Invoice = getattr(pred, "invoice", None)
if pr is None:
return 0.0
score = 0.0
if gt.vendor.strip() == pr.vendor.strip():
score += 0.3
if gt.invoice_date == pr.invoice_date:
score += 0.2
# 金額は ±0.5% 以内を正解扱い(OCR の桁ズレと数学的丸め誤差を吸収)
if pr.total_amount and abs(gt.total_amount - pr.total_amount) / max(gt.total_amount, 1) < 0.005:
score += 0.3
# ライン明細は件数一致+金額合計が一致すれば加点
if len(gt.line_items) == len(pr.line_items):
score += 0.1
gt_sum = sum(li.quantity * li.unit_price for li in gt.line_items)
pr_sum = sum(li.quantity * li.unit_price for li in pr.line_items)
if abs(gt_sum - pr_sum) / max(gt_sum, 1) < 0.01:
score += 0.1
return score
私の経験則ですが、メトリクス設計で詰まる原因の大半は「重みづけが現場の優先度と一致していない」ことにあります。たとえば請求書抽出なら、取引先の正解率より金額の正解率の方がビジネス的にずっと重要です。にもかかわらず、メトリクスを 1/N の単純平均で書いてしまうと、Optimizer は「全部同じ重要度」と勘違いして最適化します。
評価ハーネスの組み立て方は題材が違っても骨格は同じで、Gemma 4 × Antigravity で組む検索品質の評価ハーネス で書いた分割と採点の設計が、そのまま抽出タスクにも使えます。
Optimizer を動かす — BootstrapFewShot から MIPROv2 へ
メトリクスと教師データが揃えば、いよいよ最適化です。最初は軽量な BootstrapFewShot で挙動を確認し、その後 MIPROv2 に切り替えるのが定番ルートです。
# train/optimize_invoice.py
import json
import dspy
from dspy.teleprompt import BootstrapFewShotWithRandomSearch, MIPROv2
from pipelines.invoice_extraction import InvoiceExtractor, Invoice
from eval.invoice_metric import invoice_match
# 教師データの読み込み(最低 30 件、できれば 100 件以上を推奨)
def load_examples(path: str):
out = []
with open(path) as f:
for line in f:
row = json.loads(line)
out.append(
dspy.Example(
ocr_text=row["ocr_text"],
invoice=Invoice(**row["invoice"]),
).with_inputs("ocr_text")
)
return out
trainset = load_examples("data/invoice_train.jsonl")
valset = load_examples("data/invoice_val.jsonl")
# Step 1: BootstrapFewShotWithRandomSearch で初期最適化
bs_optimizer = BootstrapFewShotWithRandomSearch(
metric=invoice_match,
max_bootstrapped_demos=4,
max_labeled_demos=2,
num_candidate_programs=8,
num_threads=4,
)
bs_program = bs_optimizer.compile(InvoiceExtractor(), trainset=trainset, valset=valset)
# Step 2: MIPROv2 で指示文と例を協調最適化
mipro = MIPROv2(
metric=invoice_match,
auto="medium", # light / medium / heavy
num_threads=4,
)
optimized = mipro.compile(
bs_program,
trainset=trainset,
valset=valset,
)
# 以前ここに書いていた requires_permission_to_run 引数は非推奨になり、
# True を渡すと例外になります。付けないのが現行の正解です
# compile() の戻り値に score 属性は付かないので、Evaluate で改めて測る
evaluate = dspy.Evaluate(devset=valset, metric=invoice_match, num_threads=4, display_progress=True)
print("val score:", evaluate(optimized).score)
# 成果物(指示文・Few-shot・LM 設定)を JSON で保存し、本番ではこれをロードする
optimized.save("artifacts/invoice_v3.json")
実務上は、auto="light" から始めて改善幅が頭打ちになったら medium → heavy と上げるのが安全です。heavy はトークンコストが跳ね上がるので、私はメトリクスがプラトーに達したか CI のサンプル評価で確認してから回しています。
なお compile() は渡した student を書き換えず、新しいコピーを返します。つまり同じ InvoiceExtractor() に対して別の Optimizer や別の予算で何度でも compile() を回し、結果を並べて比べられるのです。私はこの性質に気づくまで、比較のたびにモジュールを作り直しておりました。
GEPA を選ぶ日 — メトリクスが「理由」を語れるとき
MIPROv2 はメトリクスをスカラーとしてしか見ません。候補 A が候補 B より高いか低いか、それだけです。一方 dspy.GEPA は、メトリクスが dspy.Prediction(score=..., feedback=...) の形で「なぜその点数なのか」を文章で返すと、その文章を反省用のモデルに読ませて次の指示文を提案します。請求書抽出のように「合計金額が 12% ずれている」「明細が 1 行欠けている」と言葉で説明できる失敗が大半を占める処理では、この差が指示文の質にそのまま出ます。
逆に、メトリクスが 0/1 や単純な一致率しか返せないなら、GEPA に払う反省モデルの費用は回収できないかもしれません。その場合は MIPROv2 のままで十分です。点数しか出せないなら MIPROv2、理由まで書けるなら GEPA。 私はいまこの一行で使い分けています。
前述の invoice_match は捨てずに、feedback を添える薄いラッパーを重ねるだけで済みます。GEPA のメトリクスは (gold, pred, trace, pred_name, pred_trace) の 5 引数を受け取る必要があるので、シグネチャだけ合わせます。
# eval/invoice_metric_gepa.py
import dspy
from eval.invoice_metric import invoice_match
def invoice_match_with_feedback(gold, pred, trace=None, pred_name=None, pred_trace=None):
"""GEPA 用: 点数に加えて、どのフィールドが外れたかを文章で返す。"""
score = invoice_match(gold, pred, trace)
gt = gold.invoice
pr = getattr(pred, "invoice", None)
if pr is None:
return dspy.Prediction(score=0.0, feedback="invoice が返らなかった。出力の型を守ること。")
notes = []
if gt.vendor.strip() != pr.vendor.strip():
notes.append(f"vendor が不一致(正: {gt.vendor} / 予測: {pr.vendor})")
if gt.invoice_date != pr.invoice_date:
notes.append(f"invoice_date が不一致(正: {gt.invoice_date} / 予測: {pr.invoice_date})")
if gt.total_amount:
drift = abs(gt.total_amount - (pr.total_amount or 0.0)) / gt.total_amount
if drift >= 0.005:
notes.append(f"total_amount が {drift:.1%} ずれている")
if len(gt.line_items) != len(pr.line_items):
notes.append(f"line_items が {len(gt.line_items)} 件のところ {len(pr.line_items)} 件")
feedback = ";".join(notes) if notes else "全フィールド一致"
return dspy.Prediction(score=score, feedback=feedback)
# train/optimize_invoice_gepa.py(抜粋・trainset / valset は前掲と同じ)
import dspy
from pipelines.invoice_extraction import InvoiceExtractor
from eval.invoice_metric_gepa import invoice_match_with_feedback
# 反省用モデルは温度を上げ、長い出力を許す
reflection_lm = dspy.LM("gemini/gemini-3.8-flash", temperature=1.0, max_tokens=8000)
gepa = dspy.GEPA(
metric=invoice_match_with_feedback,
auto="light", # auto / max_full_evals / max_metric_calls のうち 1 つだけを指定
reflection_lm=reflection_lm,
num_threads=4,
)
optimized = gepa.compile(InvoiceExtractor(), trainset=trainset, valset=valset)
optimized.save("artifacts/invoice_gepa_v1.json")
予算の指定は auto・max_full_evals・max_metric_calls のうちちょうど 1 つです。2 つ書くと起動時に止まります。私は最初、MIPROv2 の癖で auto と num_threads 以外にも上限を足してしまい、そこで一度つまずきました。
Antigravity からデバッグする — トレーシングと差分
最適化前後の挙動差を可視化したい場面では、DSPy の inspect_history() と Antigravity のチェックポイント機能の組み合わせが効きます。最適化後のプログラムを保存する直前に、評価セットの一部に対して inspect を回し、結果を Markdown としてリポジトリにコミットしておくと、後から見返したときの判断材料になります。
# scripts/diff_optimized.py
import dspy
from pipelines.invoice_extraction import InvoiceExtractor
baseline = InvoiceExtractor()
# 状態だけを保存した JSON は、同じクラスを組み立ててから .load() で読み込む
# (dspy.load() は save_program=True で丸ごと保存したディレクトリ用)
optimized = InvoiceExtractor()
optimized.load("artifacts/invoice_v3.json")
samples = [...] # 代表的な OCR テキストを 5〜10 件
for s in samples:
print("==== sample ====")
print("baseline:", baseline(ocr_text=s).model_dump())
print("optimized:", optimized(ocr_text=s).model_dump())
dspy.inspect_history(n=5)
私は Antigravity の Custom Command でこのスクリプトを「Compare DSPy programs」として登録し、評価結果を CHANGELOG にコピペできるようにしています。プロンプトのバージョン管理という観点では、Antigravity でプロンプトのバージョニング & A/B テストを本番運用する方法 と組み合わせると、コンパイル成果物の管理フローが綺麗に揃います。
本番デプロイのパターン — コンパイル成果物の扱い
DSPy は最適化後に得られた指示文・Few-shot 例・モデル設定を JSON として保存できます。本番環境ではこの JSON だけをロードして使えるので、Web サービス側に巨大な学習依存を持ち込まずに済むのが利点です。
私が採用している構成はこうです。
- 学習用ジョブは、GitHub Actions の self-hosted runner による深夜バッチ
- 成果物(
artifacts/*.json)は S3 互換ストレージ(私は Cloudflare R2)にバージョン付きで置く
- 本番 API は起動時に最新のバージョンタグをロードし、
/healthz でロード済みバージョンを返す構成
- 新バージョンへの切り替えは、評価サンプルでの平均スコアが旧バージョンを N% 上回ったときのみ自動承認とし、超えなかったときは Slack 通知のうえで人間が判断
この自動承認ゲートは、評価ドリブン開発の最重要ピースです。Optimizer はメトリクスを上げる方向にしか動きませんが、本番にはメトリクスに含まれない「現場の感覚」が必ず残ります。コスト面では、Antigravity で複数 LLM プロバイダーを切り替えるフェイルオーバー戦略 と組み合わせて、コンパイル済み成果物をプロバイダーごとに保持する設計にすると、価格改定や障害時にもサービス継続できます。
DSPy を選ぶべきでない場面
ここまで DSPy を勧める話ばかりしてきましたので、私が「これは手書きのままでいい」と判断している場面も書いておきます。
出力スキーマがまだ毎週動いている段階では、導入を急がない方がよいです。フィールドが増えたり消えたり、同じフィールド名の意味が変わったりしている間は、Signature が定まりません。コンパイル成果物は作り直しの連続になり、最適化の恩恵よりも管理コストが上回ります。私の目安は「出力の形が2週間変わらなかったら移行のサイン」です。
一度きりの処理も向きません。単発のメール要約や、その場限りの翻訳のように評価データが存在しないタスクでは、Optimizer に与える信号がありません。こうした用途で人がプロンプトを書くことは、妥協ではなく適材適所です。DSPy が効くのは、繰り返し呼ばれて、かつ良し悪しを測れる処理に限られます。
レイテンシ要件が厳しい場合も注意が必要です。200ms を切る応答が求められる軽量なタスクでは、推論ステップや Few-shot ブロックの追加分が精度の伸びを食い潰すことがあります。dspy.Predict と手書きプロンプトの両方を計測してから決めてください。
最後に、評価基盤がまったくないチームは、DSPy より先に評価から着手した方が確実です。フレームワークは規律の下流にあります。評価を作る手間を回避する目的で導入すると、壊れたループが YAML の分だけ複雑になって戻ってくるだけでした。
コスト実測 — 私のワークロードで起きた変化
期待値を合わせていただくために、実際に動かしているパイプラインの数字を出しておきます。前述の請求書抽出を、当時の既定モデルだった Gemini 2.5 Flash で回し、本番で1日およそ 4,000 件を処理している構成です。いまは 3.8 Flash に載せ替えていますので、絶対額はそのときどきの単価で変わります。読んでいただきたいのは比率の方です。手書きの ChainOfThought プロンプトで運用していた時期と、200 件の学習セットで MIPROv2 によるコンパイルを済ませたあとの比較になります。
コストが半分になったことを、私は最初うまく説明できませんでした。あとから腑に落ちたのは、人間の書くプロンプトは不安の分だけ長いという単純な事実です。念のための補足、重ねた言い換え、想定外への保険。Optimizer にはその不安がないので、スコアが落ちる直前まで指示文を削ります。結果として、私が手で書くより短い指示文に収束していきます。
数字の読み方として一点だけ添えます。F1 が 0.81 から 0.89 に上がったこと自体より、調整に費やす時間の中身が「文章を練る」から「データを見る」へ移ったことの方が、運用上は大きな変化でした。前者は増やしても再現しませんが、後者は資産として積み上がります。
マルチエージェント構成の中での DSPy の置き場所
実際の本番システムは、単一の DSPy モジュールで完結することはほとんどありません。多くはグラフです。オーケストレーションは LangGraph や Antigravity のマルチエージェント実行系に任せ、DSPy モジュールはその中の専門ワーカーとして配置する形になります。
私が繰り返し採用している分担はこうです。
- オーケストレータ(LangGraph のステートマシン、あるいは Antigravity の Manager Surface)が、会話状態を見て次に呼ぶワーカーを決める役
- 各ワーカーは JSON にコンパイル済みの DSPy
Module
- ワーカーはステートレス——型付き入力を受け、型付き出力を返し、記憶はオーケストレータ側に置きます
- オーケストレータのテストは結合テスト、ワーカーのテストはホールドアウトに対する評価実行
この線引きの利点は、片方だけを差し替えられることです。オーケストレーションのロジックを変えたいときにワーカーを再コンパイルする必要はなく、逆も同じです。私はリポジトリ上で agents/orchestrator/ と agents/workers/<name>/ を分け、ワーカーごとに manifest.json を置いてコンパイル成果物のパスを指しています。昇格時に書き換えるのはそのパスだけで、オーケストレータのコードには触れません。
呼び出しをまたいで状態を持つワーカー(長時間の調査エージェント等)でも、状態を DSPy の内側に入れないでください。状態はオーケストレータが保持し、ワーカーはそれを入力として受け取り、出力を返し、次の状態はオーケストレータが書きます。この形にしておくと、任意の1回の呼び出しを評価セットに対して単体で再生でき、グラフ全体を引きずり回さずに済みます。
CI に組み込む — 回帰を止めるワークフロー
設計の話だけでは動きませんので、請求書抽出で実際に回している GitHub Actions の骨格を置いておきます。候補プログラムを評価し、差分をプルリクエストに書き戻し、許容値を超えて指標が落ちたときだけマージを止める構成です。
# .github/workflows/dspy-eval.yml
name: dspy-eval
on:
pull_request:
paths:
- "pipelines/**"
- "agents/**"
- "eval/**"
- "data/eval/**"
jobs:
evaluate:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- name: Run evaluation
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
python eval/run_holdout.py --module pipelines.invoice_extraction:InvoiceExtractor \
--artifact artifacts/invoice_latest.json \
--data data/eval/invoice_holdout.jsonl \
--report eval-report.md
- name: Comment evaluation report on PR
uses: marocchino/sticky-pull-request-comment@v2
with:
path: eval-report.md
- name: Enforce non-regression
run: python eval/check_regression.py eval-report.md --tolerance 0.01
check_regression.py は、リポジトリ内の小さな JSON(eval/baseline.json)から前回の本番スコアを読み、最新レポートと比較して、許容値を引いた値を下回っていれば非ゼロで終了するだけのスクリプトです。短いので全文を置いておきます。
# eval/check_regression.py
import argparse
import json
import re
import sys
from pathlib import Path
BASELINE = Path("eval/baseline.json") # {"score": 0.89, "artifact": "invoice_v3.json"}
SCORE_RE = re.compile(r"^\|\s*mean_score\s*\|\s*([0-9.]+)\s*\|", re.M)
def read_report_score(report: Path) -> float:
m = SCORE_RE.search(report.read_text(encoding="utf-8"))
if not m:
sys.exit("report に mean_score の行が見つかりません(run_holdout.py の出力形式を確認)")
return float(m.group(1))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("report", type=Path)
ap.add_argument("--tolerance", type=float, default=0.01)
ap.add_argument("--promote", action="store_true",
help="本番デプロイ成功後に手で叩く。baseline を今回の値で上書きする")
args = ap.parse_args()
current = read_report_score(args.report)
baseline = json.loads(BASELINE.read_text())["score"] if BASELINE.exists() else 0.0
floor = baseline - args.tolerance
print(f"baseline={baseline:.4f} current={current:.4f} floor={floor:.4f}")
if args.promote:
BASELINE.write_text(json.dumps({"score": current}, indent=2) + "\n")
print("baseline を更新しました")
return
if current < floor:
sys.exit(f"REGRESSION: {current:.4f} < {floor:.4f}")
print("OK: 回帰なし")
if __name__ == "__main__":
main()
ベースラインの更新は自動では行いません。本番デプロイが成功したあと、メンテナが --promote を手で叩いたときだけ書き換わるようにしています。自動で上書きする版も一度試しましたが、たまたま良かった一回の数字が新しい床になってしまい、次の PR が理不尽に止まりました。床は人が決める、と線を引き直しています。
ここまで組むと、コードレビューで差分が見える・回帰が自動で止まる・どの成果物がどの PR に対応するかが残る、の3つが同時に手に入ります。前節のデプロイ構成と合わせると、プロンプトの変更がデータベースのマイグレーションと同じ扱いになります。小さく、レビュー可能で、戻せる——その三つが揃うのです。この状態にしておくと、変更に対する心理的なハードルが目に見えて下がります。
よくある間違いと落とし穴
ここまで来ると、自分でも DSPy を運用できる気がしてくると思いますが、私が実際にハマった落とし穴を共有します。
1. 評価データの汚染(leakage): trainset と valset を同一ソースからランダムに切ると、似た事例が両方に混ざります。Optimizer は valset に過剰適合した指示文を吐き出し、本番では精度が大きく落ちます。私は時系列で分割するか、取引先などの軸で分割しています。
2. メトリクスが微分不可能で粗すぎる: 「完全一致したら 1.0、それ以外は 0.0」というメトリクスは、Optimizer に勾配を与えません。前述のように、フィールドごとに部分点を与える形に書き換えると、最適化が安定して進むようになります。
3. max_iters を設定し忘れた dspy.ReAct: ツールが失敗を返し続けると、ReAct はエージェントとして粘り強く再試行します。これがコストを焼き尽くす元凶です。私は本番では max_iters=4 以下、開発時でも max_iters=8 までに必ず制限をかけます。
4. モデルの非決定性を無視した評価: temperature=0.0 でも完全に同じ出力が返るわけではありません(特に長文出力)。1 サンプルで判定せず、3〜5 回平均を取るか、dspy.LM のキャッシュ(既定で有効・cache=False で無効化)を活かして CI の再実行を同じ結果に固定します。キャッシュの置き場や上限を変えたいときは dspy.configure_cache() です。以前この記事に書いていた dspy.settings.configure(rm=...) は検索モデル(retriever)用の設定であり、キャッシュとは無関係でした——お詫びして訂正いたします。
5. コンパイル済み成果物のスキーマを変更してそのままロードする: Signature にフィールドを追加した状態で旧バージョンの JSON をロードすると、初期化時にではなく実行時にエラーが出ます。スキーマ変更時は必ず再コンパイルし、バージョンタグを上げる運用にしておきましょう。
6. ログを取らずに本番に出す: DSPy のトレース(inspect_history)は本番では巨大になるので絞る必要がありますが、完全に切るとデバッグができません。私はサンプリング率を 1% 程度にして OpenTelemetry に流しています。可観測性については Antigravity で AIエージェントの分散トレーシングを実装する設計図 を参考にしてみてください。
個人開発者の視点から(実体験メモ)
チーム開発の文脈で書いてきましたが、私自身は個人開発の側からこの仕組みに辿り着きました。レビュアーが自分しかいない環境では、プロンプトの良し悪しを判断する相手もまた自分です。昨日の自分が何を根拠にその一行を書いたのか、一週間後には思い出せません。評価セットは、私にとって「もう一人のレビュアー」を雇う手段でした。
最初に作った評価データは、たった 30 件でした。少なすぎると分かっていましたが、200 件揃うまで待っていたら着手しないことも分かっていました。30 件で回してみると、Optimizer が選んだ Few-shot の中に、自分では絶対に選ばなかった「地味な例」が入っていて、そこで初めて自分の選好が偏っていたことに気づきました。この気づきの方が、その回のスコア上昇より価値がありました。
もう一つ、個人開発ならではの制約としてコストがあります。MIPROv2 は探索の過程でモデルを何度も叩きますので、深夜に軽い気持ちで走らせて翌朝に請求額を見て青くなる、という失敗を私はしています。今は学習用の実行に必ず上限を設け、試行回数と対象件数を先に決めてから走らせるようにしました。max_iters を切る話を落とし穴の項に入れたのは、私がそこで一度痛い目に遭ったからです。
そして、これは効率とは別の話なのですが、プロンプトを書き直す夜が減ったことで、手を動かす対象が変わりました。文章の言い回しを悩む時間が、どのデータを集めるべきかを考える時間に置き換わりました。個人で開発を続けていると、この「悩む対象の質」が続けられるかどうかを左右します。私にとって DSPy の最大の効能は、スコアの数字ではなく、そちらでした。
次のアクション — まずは1つの Signature を切り出す
ここまで読んでくださった方への提案は1つだけです。明日、自分のプロダクトの中で「同じプロンプトを繰り返し書き直しているコード」を1か所だけ探し、その入出力を dspy.Signature として書き出してみてください。それだけで、これまで暗黙だった意図が型として可視化され、評価データを集める前段として大きな前進になります。
DSPy は銀の弾丸ではなく、評価データを集めて回すループそのものを支える道具です。私自身は、Antigravity を「DSPy プログラムを育てる温室」として使うことで、プロンプトに振り回される夜が確実に減ったと感じています。同じ感覚を、皆さんも明日からのコミットで掴んでいただけたら嬉しいです。