powered by TechFeed
表示モード
Deep Dive

LLMエージェントをPython・Clojure・Elixirで実際に作り比べた — 「とりあえずPython」が最適解ではないケースとは

8月14日、SD Timesが「Elixir, Clojure, or Python for LLM Agents? Our Experience with All Three」と題した記事を公開した。この記事では、LLMエージェントの実装言語としてPython・Elixir・Clojureを実際に使い比べた経験をもとに、各言語の特性と適切な使いどころを解説している。「とりあえずPython」という選択が本当に正しいのか、現場の視点から問い直す内容だ。

8月14日、SD Timesが「Elixir, Clojure, or Python for LLM Agents? Our Experience with All Three」と題した記事を公開した。この記事では、LLMエージェントの実装言語としてPython・Elixir・Clojureを実際に使い比べた経験をもとに、各言語の特性と適切な使いどころを解説している。「とりあえずPython」という選択が本当に正しいのか、現場の視点から問い直す内容だ。


LLMエージェントの実装といえば、LangChainAutoGenCrewAILangGraphのようなPython-firstなフレームワークが市場を席巻している。しかし、JVMやErlang/OTPをすでに本番で動かしている組織にとっては「エージェントだけPythonに移すか、それとも使い慣れたランタイムで実装するか」という現実的な選択が迫られる。

この記事を書いたのは、関数型プログラミングのElixir・Clojureを業務で使うチームだ。「週次アクティブユーザー数をDBから取得し、必要に応じてグラフを生成する」シンプルな分析エージェントを三言語で実装しながら比較している。


Pythonの実装:フレームワークあり・なしで設計思想が変わる

Pythonはフレームワーク(LangChain等)を使う場合と、素のコードで書く場合で性格が大きく異なる。

LangChain版initialize_agent の中にループが隠蔽され、状態やトレースはフレームワークのAPIを通じてしか見えない。テストでフレームワーク内部をモックする必要が生じるのはこの構造による。

フレームワークなし版では、ツールも状態も辞書(dict)で表現する。たとえばツール定義を {"name": "run_sql", "description": "...", "fn": run_sql} のようなdictとして持ち、エージェントループ内でLLMのレスポンスを受け取ってdispatchする構造になる。制御フローが見える分、テストはClojure版と同じ感覚で書ける。ただし、Pythonのミュータブルなデータ構造は「ツール関数が参照経由で state を書き換えても、トレースに残らない」という問題をはらむ。記事はこれを「言語の欠陥ではなく、管理すべき特性」と表現している。


Clojureの強み:状態が不変なので、エージェントの挙動を後から追える

Clojureの実装で最も重要な特性はイミュータブルな状態管理だ。

;; 実際の実装では decision の取得・tool-def の解決なども含む。
;; ここでは構造把握のため主要な変換フローを示している
(defn run-agent-once [state config]
  (let [decision (llm/call-llm-with-tools ...)]  ; LLMにツール付きで問い合わせ
    (case (:type decision)
      :tool-call
      (let [result ((:run tool-def) params')]     ; ツールを実行して結果を取得
        {:state (append-tool-result state tool params' result)
         :done? false}))))

各イテレーションは「古いstateを受け取り、新しいstateを返す」純粋な変換だ。前のstateは変更されない。これにより以下が可能になる:

  • 2つのstateをdiffして、そのイテレーションで何が変わったかを確認できる
  • stateをEDNにシリアライズして保存し、実行を後からリプレイできる
  • 開発中はREPLで run-agent-once にキャプチャしたstateを渡し、手動でステップ実行できる

ツール定義にはMalliを使う。スキーマがクラスやデコレーターではなくデータ構造として表現されるため、LLM APIが期待するJSON形式への変換がプログラマティックに行える。

テストもシンプルだ。

(deftest agent-produces-trace
  (let [state (core/run-agent "How many active users?" config)]
    (is (= 1 (count (:trace state))))
    (is (= "run_sql" (-> state :trace first :tool)))))

関数を呼び出して返ってきたマップをassertするだけ。モックライブラリは不要だ。


Elixirの強み:並列処理とフォルト・トレランスがランタイムに組み込まれている

ElixirはActorモデルを採用し、各エージェントを独立したプロセス(GenServer)として動かす

defmodule AnalyticsAgent do
  use GenServer
  def handle_call({:ask, question}, _from, state) do
    {result, new_state} = run_loop(state, max_steps: 8)
    {:reply, result, new_state}
  end
end

プロセスはメモリ数KBと軽量で、1台のマシンで数百万プロセスを動かせる。複数のエージェントを並列実行するために特別なセットアップは不要で、プロセスを起動するだけで済む。

障害対応も組み込み済みだ。Supervisorを定義すれば、エージェントプロセスがクラッシュしても(LLMの不正出力、APIタイムアウト、ツール結果の破損など)自動で再起動し、他のエージェントには影響しない。

defmodule AgentSupervisor do
  use Supervisor
  def init(_opts) do
    children = [
      {AnalyticsAgent, name: :analytics},
      {CodeGenAgent, name: :codegen},
      {ReviewAgent, name: :review}
    ]
    Supervisor.init(children, strategy: :one_for_one)
  end
end

このSupervision Treeはエージェント専用に発明されたものではなく、1980年代からErlang/OTPが本番システムで使ってきた仕組みをそのまま適用したものだ。

分散についても、Elixirはプロセス間のメッセージパッシングが同一マシン上でも別マシン上でも同一のコードで動く。PythonがKubernetes・Celery・Rayといった外部インフラを必要とするのとは対照的だ。

機械学習・推論まわりのエコシステムとしては、数値計算ライブラリのNx、モデル推論を担うBumblebee、LLMの構造化出力を扱うInstructorといったライブラリが整備されており、Python依存なしにML周辺の処理をElixir内で完結させる素地が育ちつつある。


三言語の使い分け

観点 Python Clojure Elixir
並列処理 asyncio(GIL制約あり)、大規模はRay/Celery JVMスレッド・core.async等で対応 BEAMの軽量プロセスで標準対応
状態管理 ミュータブル、追跡は規律次第 イミュータブル、diff・リプレイ可 プロセス分離、外部から直接参照不可
障害対応 try/except、ライブラリ依存 JVM例外処理、スーパービジョンは外部ライブラリ Supervision Treeがランタイム組み込み
エコシステム 最大。全主要プロバイダーがSDK提供 小さい。JVM経由でJavaライブラリは使える 成長中。Nx・Bumblebee・Instructorあり

記事の結論として、三言語は排他的ではないとしている。プロンプトやツール設計の試行錯誤はPythonで素早く行い、本番のオーケストレーション層はElixir(並列性・障害耐性重視)またはClojure(追跡性・監査重視)で実装するという組み合わせも現実的な選択肢だ。


詳細はElixir, Clojure, or Python for LLM Agents? Our Experience with All Threeを参照していただきたい。