{"slug": "modeling-one-llm-agent-three-ways-python-clojure-elixir", "title": "Modeling One LLM Agent Three Ways: Python, Clojure, Elixir", "summary": "Freshcode, a software development company, compared building LLM agents in Python, Clojure, and Elixir, finding that while Python offers mature frameworks like LangChain, functional languages provide advantages in state management and testability. The company's analysis, based on their experience, highlights that Clojure's immutable data structures prevent unintended state mutations, whereas Python's mutable structures can lead to hidden changes. The article details how each language handles the core agent loop, tools, and state, with Python using dictionaries and frameworks, Clojure using immutable maps, and Elixir leveraging OTP for concurrency.", "body_md": "# Elixir, Clojure, or Python for LLM Agents? Our Experience with All Three\n\nMost agent tooling is Python-first. LangChain, AutoGen, CrewAI, and LangGraph all target Python. Given that Python is the [second-most-popular programming language](https://survey.stackoverflow.co/2025/technology#most-popular-technologies-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.\n\nAs ambassadors of functional programming, we [have been toying with the agentic systems](https://www.freshcodeit.com/blog/llm-agents-in-clojure) 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.\n\n**Agents? What are those?**\n\nBut 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.\n\n[Anthropic distinguishes](https://www.anthropic.com/engineering/building-effective-agents) 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.\n\nWhat varies across languages is how you represent tools, state and the loop itself.\n\n**The stub agent in three languages**\n\nWe’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.\n\n**Python**\n\nPython provides us with ready frameworks for spinning up agents. We cannot omit them, though we will also write Python agents from scratch.\n\n**LangChain**\n\npython\n\nfrom langchain_openai import ChatOpenAI\n\nfrom langchain.agents import initialize_agent, Tool\n\ndef run_sql(query: str):\n\n    ...\n\nllm = ChatOpenAI(model=\"gpt-4.1-mini\")\n\ntools = [\n\n    Tool(name=\"run_sql\", func=run_sql,\n\n         description=\"Run an SQL query on the analytics db.\")\n\n]\n\nagent = initialize_agent(\n\n    tools=tools, llm=llm,\n\n    agent=\"zero-shot-react-description\", verbose=True,\n\n)\n\nresult = agent.run(\"How many active users did we have last week?\")\n\nThe agent loop runs inside `initialize_agent`. State and trace are accessed through framework APIs. Tools are `Tool` class instances.\n\n**Without a framework**\n\npython\n\nTOOLS = {\n\n    \"run_sql\": {\"run\": run_sql},\n\n    \"render_chart\": {\"run\": render_chart},\n\n}\n\ndef run_agent(question: str) -> dict:\n\n    state = {\n\n        \"conversation\": [{\"role\": \"user\", \"content\": question}],\n\n        \"trace\": [],\n\n    }\n\n    decision = call_llm(state[\"conversation\"], TOOLS)\n\n    if decision[\"type\"] == \"tool_call\":\n\n        tool_name = decision[\"tool\"]\n\n        params = decision[\"params\"]\n\n        result = TOOLS[tool_name][\"run\"](params)\n\n        state[\"conversation\"].append({\n\n            \"role\": \"tool\", \"name\": tool_name,\n\n            \"content\": repr({\"params\": params, \"result\": result}),\n\n        })\n\n        state[\"trace\"].append({\n\n            \"step\": 1, \"tool\": tool_name,\n\n            \"params\": params, \"result\": result\n\n        })\n\n    return state\n\nTools 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.\n\n**Clojure**\n\nClojure represents the agent as data transformations on immutable maps.\n\n#### Tool definitions\n\nclojure\n\n(def run-sql-tool\n\n  {:name \"run_sql\"\n\n   :description \"Run an SQL query on the analytics db\"\n\n   :params [:map [:query string?]]\n\n   :run (fn [{:keys [query]}]\n\n          (db/run-sql query))})\n\n(def tools\n\n  {\"run_sql\"      run-sql-tool\n\n   \"render_chart\" render-chart-tool})\n\nTools 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.\n\n**The agent loop**\n\nclojure\n\n(defn run-agent-once [state config]\n\n  (let [decision (llm/call-llm-with-tools\n\n                   (:model config) (:api-key config)\n\n                   tools/tools (:conversation state))]\n\n    (case (:type decision)\n\n      :message\n\n      {:state (append-message state \"assistant\" (:content decision))\n\n       :done? true}\n\n      :tool-call\n\n      (let [{:keys [tool params]} decision\n\n            tool-def (get tools/tools tool)\n\n            params'  (tools/validate-params tool-def params)\n\n            result   ((:run tool-def) params')]\n\n        {:state (append-tool-result state tool params' result)\n\n         :done? false}))))\n\n(defn run-agent [user-question config]\n\n  (loop [state (initial-state user-question)\n\n         steps 0]\n\n    (let [{:keys [state done?]} (run-agent-once state config)]\n\n      (if (or done? (>= steps (:max-steps config 8)))\n\n        state\n\n        (recur state (inc steps))))))\n\nEach 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.\n\n**Testing**\n\nclojure\n\n(deftest agent-produces-trace\n\n  (let [state (core/run-agent \"How many active users?\" config)]\n\n    (is (= 1 (count (:trace state))))\n\n    (is (= \"run_sql\" (-> state :trace first :tool)))))\n\nYou 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.\n\n**Elixir**\n\nElixir 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.\n\n**Agent as a GenServer**\n\nelixir\n\ndefmodule AnalyticsAgent do\n\n  use GenServer\n\n  def start_link(opts) do\n\n    GenServer.start_link(__MODULE__, opts)\n\n  end\n\n  def init(opts) do\n\n    {:ok, %{\n\n      conversation: [],\n\n      trace: [],\n\n      tools: %{\n\n        \"run_sql\" => &Tools.run_sql/1,\n\n        \"render_chart\" => &Tools.render_chart/1\n\n      }\n\n    }}\n\n  end\n\n  def handle_call({:ask, question}, _from, state) do\n\n    state = update_in(state.conversation, &[%{role: \"user\", content: question} | &1])\n\n    {result, new_state} = run_loop(state, max_steps: 8)\n\n    {:reply, result, new_state}\n\n  end\n\n  defp run_loop(state, opts) do\n\n    case LLM.call_with_tools(state.conversation, state.tools) do\n\n      {:message, content} ->\n\n        {content, append_message(state, \"assistant\", content)}\n\n      {:tool_call, tool, params} ->\n\n        result = state.tools[tool].(params)\n\n        new_state = append_tool_result(state, tool, params, result)\n\n        run_loop(new_state, opts)\n\n    end\n\n  end\n\nend\n\nThe 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.\n\n**Supervision**\n\nelixir\n\ndefmodule AgentSupervisor do\n\n  use Supervisor\n\n  def init(_opts) do\n\n    children = [\n\n      {AnalyticsAgent, name: :analytics},\n\n      {CodeGenAgent, name: :codegen},\n\n      {ReviewAgent, name: :review}\n\n    ]\n\n    Supervisor.init(children, strategy: :one_for_one)\n\n  end\n\nend\n\nIf 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.\n\n**How each runtime handles production requirements**\n\n**Parallel Processing**\n\nPython 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.\n\nClojure 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.\n\nElixir 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.\n\n**State management**\n\nPython 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.\n\nClojure 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.\n\nElixir 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.\n\n**Fault tolerance**\n\nPython 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.\n\nClojure inherits JVM exception handling. Supervision patterns can be built using libraries, but the language and runtime don’t provide them natively.\n\nElixir 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.\n\n**Distribution**\n\nPython requires external infrastructure (Kubernetes, Celery, Ray) for distributing agents across machines. Coordination protocols must be added separately.\n\nClojure 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.\n\nElixir 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.\n\n**Ecosystem and library support**\n\nPython 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.\n\nClojure 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.\n\nElixir 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.\n\n**Testing**\n\nPython 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.\n\nClojure 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.\n\nElixir 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.\n\n**Documentation and AI Context**\n\nAgents need structured information about the functions they can call and the data types they work with.\n\nElixir 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.\n\nClojure has docstrings and specs (clojure.spec). Malli schemas serve both as validation and as documentation. Since schemas are data, agents can inspect them programmatically.\n\nPython 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.\n\n**When to use which**\n\nPython 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.\n\nClojure 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.\n\nElixir 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.\n\n##### 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.\n\n**SD Times Q&A:**\n\n##### How does Elixir’s GenServer pattern work for LLM agent loops?\n\nEach 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.\n\n##### What are the tradeoffs of using Clojure for AI agent state management vs. Python?\n\nClojure’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.\n\n##### Does Python’s GIL affect LLM agent performance?\n\nFor 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.\n\n##### Which language is best for running many LLM agents concurrently in production?\n\nElixir 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.", "url": "https://wpnews.pro/news/modeling-one-llm-agent-three-ways-python-clojure-elixir", "canonical_source": "https://sdtimes.com/programming-languages/elixir-clojure-or-python-for-llm-agents-our-experience-with-all-three/", "published_at": "2026-08-13 17:38:59+00:00", "updated_at": "2026-08-13 17:43:35.158634+00:00", "lang": "en", "topics": ["large-language-models", "ai-agents", "developer-tools"], "entities": ["Freshcode", "Python", "Clojure", "Elixir", "LangChain", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/modeling-one-llm-agent-three-ways-python-clojure-elixir", "markdown": "https://wpnews.pro/news/modeling-one-llm-agent-three-ways-python-clojure-elixir.md", "text": "https://wpnews.pro/news/modeling-one-llm-agent-three-ways-python-clojure-elixir.txt", "jsonld": "https://wpnews.pro/news/modeling-one-llm-agent-three-ways-python-clojure-elixir.jsonld"}}