DSPyをElixirに移したImp、Elixirを書かない人が持ち帰れるものは何か?

gen
gen

@gennnn ・ 38本

サムネイル

プロンプトを1文いじるたびに、前は通っていたケースが壊れていないか目で確かめ直す作業、ストレスになりますよね。DSPyをElixirへ移植したImpのREADMEとソースを読むと、その作業を仕組みに渡すための部品が、言語に依らないものとElixirでしか成り立たないものにきれいに分かれて見えてきます。しかもREADMEで「監督付き」と書かれた実行の仕組みは、ソースを開くと想像と違う動きをしていました。

Impは、1行のシグネチャで何を引き受けるのか?

Impは、DSPyを「BEAM(ErlangやElixirが動く仮想マシン)へ完全に移植した」と名乗るMITライセンスのライブラリです。執筆時点のmix.exsのバージョンは0.7.0で、READMEは「0.7は実験的で、APIはまだ変わりうる」と明記しています。スターは執筆時点で227、動かすにはElixir 1.19以上とC/C++コンパイラが要ります。

中心にあるのは、READMEに載っている次の1行です。

"issue -> kind: enum[bug,feature,question], summary"
|> Imp.signature("Triage a GitHub issue.")
|> Imp.predict(lm: lm)

GitHubのissueを受け取り、種類と要約を返す。書いてあるのはそれだけで、プロンプトの文面もパーサもありません。Impがシグネチャからプロンプトを組み立て、返答を型と照らし合わせます。kindは必ず3つの値のどれかになり、外れたら呼び出し自体がエラーを返す、とREADMEは説明しています。

持ち帰れるもの、プロンプトより先に入出力を宣言する

この発想は言語を選びません。Hacker NewsのImpのスレッドには「ツール呼び出しはenumを縛らない。スキーマはhnなのにhackernewsが返ってきた」というコメントがありました。構造化出力やツール呼び出しに任せても、値の範囲まで守られるとは限らないわけです。

入出力を先に宣言しておくと、「プロンプトを直す」作業が「契約を満たさない返答を数える」作業に変わります。何件がenumから外れたか、何件の要約が空だったか。数えられるものは、あとで機械に直させられます。

評価関数と例がなければ、自動改善は1歩も動かない

Impでの改善は、Imp.evaluate/3で採点してImp.optimize!/3で直す流れです。例はImp.example/1で用意し、採点にはImp.exact_match/1のような関数を渡します。

看板のGEPAは、READMEの言葉を借りると「プログラムを走らせ、失敗した箇所を読み、指示文を書き直す」オプティマイザです。lib/imp/optimizer/gepa.exの説明では、評価関数、学習用と検証用の例、書き直し案を考えるモデル(:reflection_lmなど)の3つが必須になっています。「今のプロンプトから指示をでっち上げることはしない」という一文まであります。

「プロンプトを自動で直す」と聞くと魔法っぽいですよね。中身は、正解の例と採点ルールを持っている人の試行錯誤を、機械が代わりに回す仕組みです。採点できる例が手元に1件もないなら、先に作るべきはオプティマイザより例のほうです。

GEPAの修正履歴に、エージェントを直す難しさが出ている

2026年9月27日にマージされたPR #191で、GEPAの既定の動かし方がDSPy準拠の:gepa_v0_1_4_mergeに切り替わりました。Imp独自の探索は、execution_profile: :beam_nativeと名指ししないと使われません。

個人的に一番面白かったのは、同じPRのもう1つの修正です。変更前は、何度もモデルを呼ぶプログラムでも、振り返りには常に最初の呼び出しが使われていました。エージェントに適用すると、空の履歴と最初の1ステップが見えるだけで、ツールの結果も後続のステップも最終的な答えも、振り返り役のモデルに届いていなかったんです。

修正後は、例ごとに呼び出しを1つ無作為に選び(:seedで固定できます)、そのステップまでの思考、ツール呼び出しと結果、答えを見せます。エージェントのプロンプトを直すなら、1ターン目だけ眺めても原因は分からない。自前で振り返りの仕組みを組むときにも、そのまま効く教訓です。

「監督付き」の中身は、自動復旧より持ち主管理だった

READMEはImp.start_run/3を「OTPの下で監督されたプロセスとしてプログラムを動かす」と説明しています。Elixirで監督と聞くと、落ちたプロセスをSupervisorが再起動してくれる姿を思い浮かべますよね。

lib/imp/run.exを読むと、実行を管理するControlはGenServer.startで単独に起動され、Supervisorの子にはなっていません。本体の処理はTask.Supervisor.async_nolinkでタスク用のSupervisorの下に置かれますが、タスクが落ちると:run_failedイベントを記録して終わり、再起動はされません。一方、start_runを呼んだプロセス(持ち主)が死ぬと、Controlは登録された取り消し処理を呼び、タスクを強制終了して自分も止まります。

ここでの監督は、落ちても生き返らせる仕組みより、持ち主がいなくなれば一緒に止まり、外からも止められる仕組みに近いんです。手すりはほかにも並んでいます。

  • authorize:で、ReActV2やRLMがツールを実行する前に:allow、{:deny, reason}、{:cancel, reason}のどれかを返せる。30秒以内に返らなければ拒否扱い
  • deadline:で、実行中のLLMリクエストを残り時間で打ち切る。すでに動いているツールは止めないので、そちらはcancel/2を使う
  • admission:で、同時に走る実行の数を絞る。枠が埋まっていれば{:error, :busy}をすぐ返す

プロセスの監視や持ち主の概念はBEAMの流儀なので、実装はPythonへそのまま持ち込めません。持ち帰れるのは、夜通し動かすエージェントに「誰が持ち主か」「ツールごとに誰が許可するか」「何秒で諦めるか」を決めておく、という設計の問いのほうです。

0.7.0のImpに、効果の数字はまだない

READMEは自ら「オプティマイザには大規模なベンチマークが必要」と書いています。Imp上でGEPAを回して何%良くなった、と言える検証結果はまだ出ていない、というのが作者自身の位置づけです。

Hacker Newsでは「構造化デコードからツール呼び出しとエージェントに移って久しい」とDSPy不要論が出る一方、「DSPyの手法は今も価値がある。直接のやり方が効かないときのフィードバック制御のようなもの」という反論もありました。僕は後者寄りです。enumの外れ値がツール呼び出しでも起きる以上、「採点して直す」ループは形を変えて残ると考えています。

Elixirを書かない人は、Impから何を持ち帰るのか?

Elixirを学ぶ必要はありません。ImpはDSPyを別の言語で書き直したぶん、言語に依らない考え方とランタイム頼みの部分の境目が見えやすい教材です。

持ち帰るのは、プロンプトの前に入出力を1行で宣言すること、採点ルールと例を先に用意すること、エージェントの実行に持ち主と止め方を決めておくことです。まずは今いちばん手を入れているプロンプトを1つ選び、入力と出力をissue -> kind, summaryの形で書き出してみてください。1行で書けなければ、直すべきはプロンプトの文面より、そのタスクの定義のほうです。