Modeling One LLM Agent Three Ways: Python, Clojure, Elixir

ViktoriiaYarosh1 pts0 comments

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

Skip to content

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?")

```<br>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

```<br>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})

```<br>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?]}...

tool tools state agent params python

Related Articles