プロンプトをもう一度書き直そうとしてエディタの空白を眺めていた金曜の夜、私は「これ、いつまで続ければいいんだろう」と本気で考えました。Few-shot の例を一行いじるたびに、別のテストケースが壊れます。仕様が変わるたびに、3 つの prompt template が連鎖的に古くなっていきます。チームが大きくなるほど「最終版」は更新されないまま属人化していきます。プロンプトエンジニアリングは、コードレビューで合意形成しようとしても判断基準が掴みにくく、ベテランの直感に依存しがちな領域です。
DSPy は、この「プロンプト職人芸」をコンパイル可能なプログラムに置き換えるためのフレームワークです。スタンフォード NLP から生まれ、現在は Databricks のチームが中心となって発展している成熟したプロジェクトで、2026 年に入ってからは LangGraph や CrewAI と組み合わせた事例も急速に増えてきました。ここで扱うのはAntigravity を開発環境として使いながら、DSPy で自己最適化する LLM プログラムを本番運用するまでの工程を一気通貫で解説します。
「プロンプト職人芸」が抱える3つの構造的な弱点
最初にお断りしておくと、私は手書きのプロンプトを否定したいわけではありません。プロトタイプ段階や、評価データを集めにくい初期フェーズでは、人が書いた方が立ち上がりは速いです。問題は、プロダクトが育って評価が定量化できる段階に入ったあと、なぜか手書きの prompt template だけが旧来のままチーム内に残り続ける現象にあります。
私が現場で繰り返し見てきた弱点は3つです。
第一に、変更の影響範囲が見えないこと。prompts/extract_invoice.txt を一行いじったとき、本番の精度が上がったのか下がったのか、評価データに対して回さない限り判断できません。それでも「動いているっぽいから出す」運用が定着すると、リグレッションがしばらく経ってから本番ログで発覚します。
第二に、最適化のループが手作業であること。「Few-shot を増やしてみる」「Chain-of-Thought を足してみる」「JSON 形式を強制してみる」といった改善は、ほぼすべて人間がアイデアを出して試行する流れになります。これは複数の改善を組み合わせると組合せ爆発を起こし、再現も難しくなります。
第三に、モデルが変わると全部やり直しになること。Gemini 2.5 用に磨いたプロンプトが、コスト最適化のために 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(2026 年時点で主流)は指示文と Few-shot を協調的に最適化します。
この3要素を Antigravity の中で扱うとき、エディタが提供する補完・差分プレビュー・チェックポイント機能との相性がとても良く、私自身は LangGraph と組み合わせる前段の「素材作り」フェーズで多用しています。
Antigravity プロジェクトに DSPy を入れる初期セットアップ
具体的なセットアップから始めましょう。Python 3.11 以上の仮想環境を Antigravity のターミナルで作り、依存を入れます。
# Antigravity のターミナルから実行
python -m venv .venv
source .venv/bin/activate
# DSPy 本体と評価用ユーティリティ
pip install "dspy-ai>=2.6.0" "litellm>=1.50.0" python-dotenv
# 構造化出力の検証用
pip install pydantic
.env に LLM プロバイダーの API キーを入れておき、Antigravity の Custom Rules で「本ファイル内の os.getenv は必ず .env 経由を前提」と明文化しておくと、AI が誤って実キーを書き込むリスクを減らせます。Custom Rules の設計については、Antigravity Custom Rules でプロジェクト規約を AI に守らせる方法 で詳しく扱っているので、未設定の方は先にそちらを参照してください。
最初に、最小構成で疎通を確認します。
# scripts/dspy_smoke.py
import os
from dotenv import load_dotenv
import dspy
load_dotenv()
# Gemini 2.5 Flash を使う例(コスト最適化用)
lm = dspy.LM(
"gemini/gemini-2.5-flash",
api_key=os.environ["GEMINI_API_KEY"],
temperature=0.0,
max_tokens=1024,
)
dspy.settings.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 は「全部同じ重要度」と勘違いして最適化します。
評価データセットの作り方は、Antigravity でAIエージェント評価フレームワークを本番投入する方法 でデータ設計の落とし穴を含めて扱っているので、本番投入前に併読してください。
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=False,
)
# 成果物を JSON で保存し、本番ではこれをロードする
optimized.save("artifacts/invoice_v3.json")
print("compiled:", optimized.score)
実務上は、auto="light" から始めて改善幅が頭打ちになったら medium → heavy と上げるのが安全です。heavy はトークンコストが跳ね上がるので、私はメトリクスがプラトーに達したか CI のサンプル評価で確認してから回しています。
Antigravity からデバッグする — トレーシングと差分
最適化前後の挙動差を可視化したい場面では、DSPy の inspect_history() と Antigravity のチェックポイント機能の組み合わせが効きます。最適化後のプログラムを保存する直前に、評価セットの一部に対して inspect を回し、結果を Markdown としてリポジトリにコミットしておくと、後から見返したときの判断材料になります。
# scripts/diff_optimized.py
import dspy
from pipelines.invoice_extraction import InvoiceExtractor
baseline = InvoiceExtractor()
optimized = dspy.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 を運用できる気がしてくると思いますが、私が実際にハマった落とし穴を共有します。
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.settings.configure(rm=...) でキャッシュを有効化して再現性を確保します。
5. コンパイル済み成果物のスキーマを変更してそのままロードする: Signature にフィールドを追加した状態で旧バージョンの JSON をロードすると、初期化時にではなく実行時にエラーが出ます。スキーマ変更時は必ず再コンパイルし、バージョンタグを上げる運用にしておきましょう。
6. ログを取らずに本番に出す: DSPy のトレース(inspect_history)は本番では巨大になるので絞る必要がありますが、完全に切るとデバッグができません。私はサンプリング率を 1% 程度にして OpenTelemetry に流しています。可観測性については Antigravity で AIエージェントの分散トレーシングを実装する設計図 を参考にしてみてください。
個人開発者の視点から(実体験メモ)
次のアクション — まずは1つの Signature を切り出す
ここまで読んでくださった方への提案は1つだけです。明日、自分のプロダクトの中で「同じプロンプトを繰り返し書き直しているコード」を1か所だけ探し、その入出力を dspy.Signature として書き出してみてください。それだけで、これまで暗黙だった意図が型として可視化され、評価データを集める前段として大きな前進になります。
DSPy は銀の弾丸ではなく、評価データを集めて回すループそのものを支える道具です。私自身は、Antigravity を「DSPy プログラムを育てる温室」として使うことで、プロンプトに振り回される夜が確実に減ったと感じています。同じ感覚を、皆さんも明日からのコミットで掴んでいただけたら嬉しいです。