Elixir, Clojure, or Python for LLM Agents? Our Experience with All Three

0
1
Elixir, Clojure, or Python for LLM Agents? Our Experience with All Three


Most agent tooling is Python-first. LangChain, AutoGen, CrewAI, and LangGraph all target Python. Given that Python is the second-most-popular programming language, the current ecosystem might work well for teams already using it. Still, organizations running JVM infrastructure or Erlang/OTP systems face the question of whether to move agents to Python or build them in the runtime they already operate. 

As ambassadors of functional programming, we have been toying with the agentic systems in our languages of choice, Elixir and Clojure. This article, which partially summarizes our previous endeavors, compares them with Python and examines how each handles the specific requirements of production agent systems.

Agents? What are those?

But let’s start with trivia for those who need it. An LLM agent combines a language model with the ability to call functions. The core loop — often called ReAct (Reasoning and Acting) — works like this: the LLM examines the conversation and available tools, decides whether to call a tool or respond, and if it calls a tool, the result gets fed back into the conversation. The loop continues until the agent produces a final answer or hits a step limit.

Anthropic distinguishes workflows (LLMs orchestrated through predefined code paths) and agents (LLMs that dynamically direct their own processes and tool usage). Both follow the same basic loop. The difference is in how much the LLM controls the sequencing.

What varies across languages is how you represent tools, state and the loop itself.

The stub agent in three languages

We’ll use a simple analytic agent as the comparison point. It will query the database to, let’s say, return statistics on weekly users, optionally generating charts if requested.

Python

Python provides us with ready frameworks for spinning up agents. We cannot omit them, though we will also write Python agents from scratch.

LangChain
```python

from langchain_openai import ChatOpenAI

from langchain.agents import initialize_agent, Tool




def run_sql(query: str):

    ...




llm = ChatOpenAI(model="gpt-4.1-mini")

tools = [

    Tool(name="run_sql", func=run_sql,

         description="Run an SQL query on the analytics db.")

]




agent = initialize_agent(

    tools=tools, llm=llm,

    agent="zero-shot-react-description", verbose=True,

)

result = agent.run("How many active users did we have last week?")

```

The agent loop runs inside `initialize_agent`. State and trace are accessed through framework APIs. Tools are `Tool` class instances.

Without a framework
```python

TOOLS = {

    "run_sql": {"run": run_sql},

    "render_chart": {"run": render_chart},

}




def run_agent(question: str) -> dict:

    state = {

        "conversation": [{"role": "user", "content": question}],

        "trace": [],

    }

    decision = call_llm(state["conversation"], TOOLS)




    if decision["type"] == "tool_call":

        tool_name = decision["tool"]

        params = decision["params"]

        result = TOOLS[tool_name]["run"](params)

        state["conversation"].append({

            "role": "tool", "name": tool_name,

            "content": repr({"params": params, "result": result}),

        })

        state["trace"].append({

            "step": 1, "tool": tool_name,

            "params": params, "result": result

        })

    return state

```

Tools are dictionaries. State is a dictionary. The control flow is visible. This version is testable in the same way as the Clojure version below. The trade-off here is that Python’s mutable data structures mean that a tool function can modify `state` through a reference without that modification showing up in the trace. Some would argue that such behaviour is a language flaw; we believe that it is a property to manage.

Clojure

Clojure represents the agent as data transformations on immutable maps.

Tool definitions

```clojure

(def run-sql-tool

  {:name "run_sql"

   :description "Run an SQL query on the analytics db"

   :params [:map [:query string?]]

   :run (fn [{:keys [query]}]

          (db/run-sql query))})




(def tools

  {"run_sql"      run-sql-tool

   "render_chart" render-chart-tool})

```

Tools are maps. Parameter schemas use Malli, which defines schemas as data structures rather than classes or decorators. It means schemas can be programmatically generated, serialized and transformed, which is useful when converting to the JSON format that LLM APIs expect.

The agent loop
```clojure

(defn run-agent-once [state config]

  (let [decision (llm/call-llm-with-tools

                   (:model config) (:api-key config)

                   tools/tools (:conversation state))]

    (case (:type decision)

      :message

      {:state (append-message state "assistant" (:content decision))

       :done? true}




      :tool-call

      (let [{:keys [tool params]} decision

            tool-def (get tools/tools tool)

            params'  (tools/validate-params tool-def params)

            result   ((:run tool-def) params')]

        {:state (append-tool-result state tool params' result)

         :done? false}))))




(defn run-agent [user-question config]

  (loop [state (initial-state user-question)

         steps 0]

    (let [{:keys [state done?]} (run-agent-once state config)]

      (if (or done? (>= steps (:max-steps config 8)))

        state

        (recur state (inc steps))))))

```

Each iteration takes a state and returns a new state. The old state is unchanged. It means you can diff two states to see what a specific iteration changed. You can serialize the full state to EDN, save it and replay execution later. During development, the REPL lets you call `run-agent-once` with a captured state and step through execution manually.

Testing
```clojure

(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)))))

```

You call a function and assert on the returned map. The stub LLM makes behavior deterministic. No mocking libraries are needed because there are no framework internals to mock.

Elixir

Elixir models each agent as a process using the Actor Model. Processes are lightweight (kilobytes of memory), communicate through message passing, and are supervised for fault recovery.

Agent as a GenServer
```elixir

defmodule AnalyticsAgent do

  use GenServer




  def start_link(opts) do

    GenServer.start_link(__MODULE__, opts)

  end




  def init(opts) do

    {:ok, %{

      conversation: [],

      trace: [],

      tools: %{

        "run_sql" => &Tools.run_sql/1,

        "render_chart" => &Tools.render_chart/1

      }

    }}

  end




  def handle_call({:ask, question}, _from, state) do

    state = update_in(state.conversation, &[%{role: "user", content: question} | &1])

    {result, new_state} = run_loop(state, max_steps: 8)

    {:reply, result, new_state}

  end




  defp run_loop(state, opts) do

    case LLM.call_with_tools(state.conversation, state.tools) do

      {:message, content} ->

        {content, append_message(state, "assistant", content)}

      {:tool_call, tool, params} ->

        result = state.tools[tool].(params)

        new_state = append_tool_result(state, tool, params, result)

        run_loop(new_state, opts)

    end

  end

end

```

The message-passing model maps directly to standard agent workflow patterns. Prompt chaining is processes passing messages forward. Routing is a classifier process dispatching to specialized agent processes. An orchestrator process spawns and manages worker processes. Multiple agent processes run concurrently by default because that’s the core Elixir’s offer.

Supervision
```elixir

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

```

If one agent process crashes (due to bad LLM output, API timeout, or malformed tool result), the supervisor restarts it. The other agent processes are unaffected. In this way, Erlang/OTP has handled process failures since the 1980s; this approach applies to LLM agents without modification.

How each runtime handles production requirements
Parallel Processing

Python uses `asyncio`, threading, or multiprocessing. The GIL limits CPU-bound parallelism. For I/O-bound agent work (which most LLM API calls are), `asyncio` works adequately. For CPU-bound work or large numbers of concurrent agents, external tools like Ray or Celery are common.

Clojure has concurrency primitives (atoms, refs, agents, core.async) and runs on JVM threads. Running multiple agents concurrently requires explicit use of these primitives but is well-supported.

Elixir runs lightweight processes on the BEAM VM with preemptive scheduling. A single machine can run millions of processes distributed across all CPU cores. Running agents concurrently requires no special setup; you just start processes.

State management

Python state is mutable by default. In framework-based agents, state is typically internal to class instances. In plain-code agents, the state is in dictionaries that can be mutated from anywhere with a reference. Traceability depends on logging discipline.

Clojure state is immutable. Each agent iteration produces a new state map without modifying the previous one. States can be diffed, serialized, stored, and replayed. The REPL allows direct inspection of any intermediate state during development.

Elixir processes have an isolated state — each process maintains its own state that other processes cannot directly access. It prevents accidental state corruption across agents. Inspection is available through `:sys.get_state/1` and `:observer`, but the model is process-centric rather than data-centric.

Fault tolerance

Python provides try/except. Retry logic and circuit breakers are implemented manually or via libraries. Agent frameworks vary in how they handle failures — some have retry mechanisms, others leave it to the developer.

Clojure inherits JVM exception handling. Supervision patterns can be built using libraries, but the language and runtime don’t provide them natively.

Elixir has supervision trees as a core runtime feature. Supervisors monitor processes and restart them according to configurable strategies. This approach has been the standard in Erlang/OTP systems for decades and applies directly to agent processes.

Distribution

Python requires external infrastructure (Kubernetes, Celery, Ray) for distributing agents across machines. Coordination protocols must be added separately.

Clojure can use JVM clustering solutions. The Agent-o-Rama library provides distributed agent execution on Rama. Distribution isn’t built into the language but is available through the JVM ecosystem.

Elixir inherits Erlang’s clustering. Message passing between processes works the same way whether processes are on the same machine or different machines. You can develop on one machine and scale to a cluster without changing the agent code.

Ecosystem and library support

Python has the largest AI ecosystem. Every major LLM provider ships a Python SDK. Agent frameworks, embedding libraries, vector store integrations, and evaluation tools are all Python-first. If you need a specific integration, it’s probably already available in Python.

Clojure has a smaller ecosystem for AI-specific libraries. OpenAI and Anthropic API clients exist. The JVM gives access to Java libraries. For many integrations, you’ll write wrapper code.

Elixir has an emerging AI ecosystem—Nx for numerical computing, Bumblebee for model inference, Instructor for structured outputs. LLM API integrations exist but are less comprehensive than Python’s.

Testing

Python testing depends on the approach. Plain-code agents (tools as dictionaries, state as dictionaries) test the same way as any other Python code. Framework-based agents often require mocking framework internals, which couples tests to the framework’s implementation.

Clojure testing follows directly from the data-oriented design. Call the function and check the returned map. Swap in a stub LLM, run the agent, assert on the trace, and no special test infrastructure.

Elixir testing uses ExUnit with process-based isolation. Testing individual agents is straightforward. Testing interactions between concurrent agents requires more setup to handle asynchronous message passing.

Documentation and AI Context

Agents need structured information about the functions they can call and the data types they work with.

Elixir treats documentation as a first-class language feature. `@doc`, `@moduledoc`, and `@spec` annotations are part of the standard workflow. These provide type signatures, usage examples, and hierarchical descriptions that an AI agent can read to understand a module before using it. Documentation examples can be run as tests to keep them up to date.

Clojure has docstrings and specs (clojure.spec). Malli schemas serve both as validation and as documentation. Since schemas are data, agents can inspect them programmatically.

Python has docstrings and type hints. Type hints are optional and not enforced at runtime by default (tools like mypy add static checking). The information is available, but it is less consistently structured across the ecosystem.

When to use which

Python makes sense when you need specific AI library integrations, your team already works in Python, and you handle concurrency and fault tolerance through infrastructure or external tools.

Clojure makes sense when you’re on the JVM, you want agent state to be inspectable and replayable, and you prefer testing agents as pure data transformations. It fits when you need to understand and audit agent behavior after the fact.

Elixir makes sense when you need to run many agents concurrently with automatic fault recovery, and you want distribution as a built-in runtime capability. It fits systems where multiple agents coordinate in real time and where individual agent failures shouldn’t affect the rest of the system.

These are not mutually exclusive. An organization could prototype agents in Python for fast iteration on prompts and tool design, then implement the production orchestration layer in Elixir or Clojure, depending on whether the primary operational concern is concurrency or traceability.

SD Times Q&A:
How does Elixir’s GenServer pattern work for LLM agent loops?

Each LLM agent is modeled as an Elixir GenServer process with isolated state. The agent receives a question via message passing, runs a recursive tool-call loop using pattern matching, and returns the final result. If the process crashes due to a bad LLM response or API timeout, an OTP Supervisor automatically restarts it without affecting other agent processes.

What are the tradeoffs of using Clojure for AI agent state management vs. Python?

Clojure’s immutable data structures mean each agent iteration produces a new state map, leaving the previous one unchanged. This allows you to diff states, serialize them to EDN, and replay execution — useful for auditing agent behavior. Python’s mutable dictionaries are simpler but allow any function holding a reference to silently modify state, which can complicate debugging and tracing.

Does Python’s GIL affect LLM agent performance?

For most LLM agent workloads, which are I/O-bound (waiting on API responses), Python’s Global Interpreter Lock (GIL) has minimal impact and asyncio handles concurrency adequately. The GIL becomes a bottleneck for CPU-bound parallel work or very high numbers of concurrent agents, in which case external tools like Ray or Celery are typically used.

Which language is best for running many LLM agents concurrently in production?

Elixir is the strongest fit for high-concurrency agent systems. Its BEAM VM runs lightweight processes (on the order of kilobytes of memory each) with preemptive scheduling across all CPU cores, and distribution across machines works with the same message-passing model as local processes. Python and Clojure require additional infrastructure or explicit concurrency primitives to achieve comparable scale.

Artem Barmin