今天想聊一个偏“设计”的话题:如何用 Python、Clojure、Elixir 去实现同一个 LLM Agent 模型。
网上关于 LLM Agent 的文章并不少,但很多内容都默认使用 Python,而且一上来就接某个框架。框架固然方便,可一旦换语言、换团队、换运行环境,如果脑子里没有一个“Agent 到底是什么结构”的清晰模型,代码就会越写越绕。本文会选择三个切入点:
- 先定义一个简单统一的 Agent 抽象;
- 用 Python 的面向对象风格实现;
- 用 Clojure 的不可变数据风格实现;
- 用 Elixir 的进程与 GenServer 风格实现。
这篇文章适合两类读者:一类是有 LLM 应用开发经验、想横向比较不同语言建模方式的开发者;另一类是刚接触 Agent 开发、想从底层结构理解“LLM 工具调用循环”的新手。文中的所有示例代码都不依赖特定模型厂商 SDK,重点放在 Agent 自身的结构设计上,因此你可以把“模型决策”部分替换成任意真实的 LLM 接口。
1. LLM Agent 到底在建模什么?
1.1 为什么不是“调一次大模型”那么简单
很多初学者会把 LLM Agent 理解成“调用大模型接口”,也就是把用户问题发给模型,再把模型返回内容展示出来。如果是这种场景,我们确实不需要复杂建模。
但 Agent 的目标是让模型具备“观察、决策、行动”的能力。外部任务往往无法通过一次生成就完成,例如:
- 用户问某个城市的天气,但当前模型并不知道实时天气;
- 用户要求对一段文本做数据库查询、邮件发送或计算器计算;
- 用户连续抛出多个问题,Agent 需要保留上下文;
- Agent 必须判断何时调用工具,何时停止调用,直接把答案给用户。
这些需求意味着:我们不能只写“传入 prompt,拿到 completion”,而需要把 Agent 当成一个可迭代运行、能保存状态、能调用外部工具的“小系统”。
1.2 Agent 的核心结构:状态、工具、执行循环
把复杂概念拆开,一个 LLM Agent 的最小结构通常包含三部分:
| 组成部分 | 作用 | 典型问题 |
|---|---|---|
| 状态 | 保存 system prompt、历史消息、用户当前输入 | 状态放哪里、会不会并发写坏 |
| 工具 | 以可描述、可调用接口提供给模型的函数 | 工具如何注册、如何分发、如何校验参数 |
| 执行循环 | 让模型观察上下文、输出决策、执行工具、更新上下文 | 循环会不会死循环、如何限制步数 |
这三部分在不同语言里可以对应到不同语法机制:
- Python 里可能是一组类;
- Clojure 里可能是一组不可变 map 与多方法;
- Elixir 里可能是 GenServer 态状态机。
如果你的代码已经能解决这三个问题,无论你用不用框架,Agent 骨架都是成立的。
1.3 不要把“实现代码”直接等同于 Agent 模型
这里有一个容易踩的认知误区:很多同学用过 LangChain 或自研工具后,会把其中一个类名、一个 API 函数当作 Agent 模型。
但 Agent 本质上是一种控制流抽象。工具调用只是其中的动作之一,模型也不一定只能“调工具”,它也可能直接返回最终回答。因此本文后续所有代码都会围绕同一个控制流来写,只是语法和状态组织方式不同。
2. 统一 Agent 模型:先定规则再写代码
2.1 统一控制流
为了让三种语言具备可比性,先定义一个简单的执行流程。
- 外部用户输入一句话;
- Agent 将这句话作为一条
user消息写进历史; - Agent 让“模型决策层”分析当前输入,返回一个动作:
- 如果是
finish,表示可以直接给最终回答; - 如果是某个工具名称,则表示需要调用哪个工具、参数是什么;
- 如果是
- 如果动作是工具调用,执行工具,把结果作为一条新消息保存;
- 回到第 3 步,继续让模型观察;
- 设置最大步数,防止死循环。
这个流程和主流 Agent 框架是兼容的。区别只在于:真实项目中第 3 步动辄由大模型生成 JSON 或 tool_calls 来驱动,而本文示例中用一个可读的占位函数代替,避免引入特定厂商 SDK 和网络依赖。
2.2 定义一个通用消息格式
所有 LLM Agent 都会保存消息历史,统一消息结构比较简单:
消息 = {角色: user / assistant / system, 内容: 字符串}消息列表则会成为 Agent 的长期上下文。不同语言对“消息”这个结构的建模方式差异很大:
- Python 倾向于使用
@dataclass定义强类型消息; - Clojure 倾向于直接使用 map 或 record;
- Elixir 则倾向于使用 struct。
在后面的示例中,你会看到同一条消息在三种语言里分别长什么样。
3. 环境准备与工程边界
这篇文章不指定固定版本,因为 LLM 生态变化很快,目标不是复刻某个版本号,而是带你理解建模思路。建议你使用长期维护的稳定版本:
# Python python --version # Clojure clojure --version # Elixir elixir --version本文示例代码的逻辑要求如下:
- Python 示例需要 Python 3.10 以上,以便使用
dataclass的slots、更清晰的类型标注; - Clojure 示例没有引入第三方库,只需要 Clojure 环境;
- Elixir 示例使用
GenServer,属于内置 OTP 行为,不需要额外依赖。
建议本地目录按语言拆分:
llm-agent-three-ways/ ├── python_agent.py ├── clojure_agent/src/llm_agent/core.clj └── elixir_agent.exs需要特别说明:示例中的工具是“纯逻辑函数”,不会真正请求远程服务器。真实场景中,你需要把 LLM 决策函数换成自己的模型调用逻辑,但 Agent 外部结构基本可以保持不变。
4. Python 实现:用类型与对象封装 Agent
4.1 为什么 Python 适合这样建模
Python 是当前 LLM Agent 开发最普及的语言,生态最完善。它的优点在于:
dataclass可以快速定义消息、工具、Agent 等数据结构;- 类型标注让代码可读性更强;
- 面向对象方式便于把系统 prompt、工具列表、消息历史打包到一个对象里。
在 Python 中建模 LLM Agent,并不一定需要类。完全可以用字典加函数,但类的好处是能把状态和行为放在一起,避免散落的全局变量。
4.2 完整代码示例
下面是一个最小但完整的 Python Agent。
# 文件路径:llm-agent-three-ways/python_agent.py from dataclasses import dataclass, field from typing import Callable, Dict, Tuple @dataclass class Message: role: str content: str @dataclass class Tool: name: str description: str handler: Callable[[str], str] def execute(self, arg: str) -> str: return self.handler(arg) @dataclass class AutoAgent: system_prompt: str tools: Dict[str, Tool] messages: list[Message] = field(default_factory=list) max_steps: int = 5 def add_message(self, role: str, content: str) -> None: self.messages.append(Message(role=role, content=content)) def llm_plan(self, text: str) -> Tuple[str, str]: """ 真正的项目中,这里应该把 text 和其他上下文一起发给大模型, 让模型返回动作名和参数。为了做到无网络依赖可运行, 先用本地规则模拟模型的决策。 """ # 如果工具返回结果中包含天气信息,就不再继续调用工具 if text.startswith("TOOL_RESULT:"): return "finish", text[len("TOOL_RESULT:"):] if "天气" in text: if "北京" in text: return "weather", "北京" if "上海" in text: return "weather", "上海" return "weather", "北京" # 默认直接结束 return "finish", f"模拟回答:{text}" def run(self, user_input: str) -> str: self.add_message("user", user_input) current_input = user_input final_answer = "" for _ in range(self.max_steps): action, param = self.llm_plan(current_input) if action == "finish": final_answer = param break tool = self.tools.get(action) if tool is None: final_answer = f"没有找到工具:{action}" break # 执行工具,得到观测结果 observation = tool.execute(param) current_input = f"TOOL_RESULT:{observation}" self.add_message("assistant", f"调用工具 {action}({param}),得到 {observation}") if not final_answer: final_answer = "达到最大迭代步数,提前结束。" self.add_message("assistant", final_answer) return final_answer def weather_handler(city: str) -> str: # 真实场景中这里可以调用天气服务 API return f"{city} 晴,气温 20℃" def build_agent() -> AutoAgent: tools = { "weather": Tool( name="weather", description="查询天气,参数为城市名", handler=weather_handler, ) } return AutoAgent( system_prompt="你是一个有工具调用能力的助手。", tools=tools, ) if __name__ == "__main__": agent = build_agent() answer = agent.run("北京今天天气怎么样?") print("回答:", answer) print("--- 消息历史 ---") for msg in agent.messages: print(f"[{msg.role}] {msg.content}")4.3 运行与预期输出
直接在终端执行:
cd llm-agent-three-ways python python_agent.py预期输出大致如下:
回答: 北京 晴,气温 20℃ --- 消息历史 --- [user] 北京今天天气怎么样? [assistant] 调用工具 weather(北京),得到 北京 晴,气温 20℃ [assistant] 北京 晴,气温 20℃注意,这里第一轮模型决策会把“北京今天天气怎么样”解析成调用weather工具;工具结果返回之后,第二轮的llm_plan看到TOOL_RESULT:前缀,就说明该停止循环并输出最终答案。
之所以需要TOOL_RESULT:前缀,是为了让模拟决策函数知道“这是工具观测结果,不是新的用户问题”。真实项目中,模型是否继续调用工具,通常由模型自己根据 tool_calls 判断。
4.4 Python 模型的关键点
Python 版建模重点不在“能不能用类”,而在于:
- 通过
Message对象统一历史消息格式; - 通过
Tool对象把工具名称、描述、执行函数绑定在一起; - 把
messages作为 Agent 实例的核心状态; - 用
for循环控制执行步数,避免无限循环。
真实项目中,llm_plan应当替换成类似 OpenAI Chat Completions 或其他兼容接口的调用。对大多数开发者来说,最难的部分不是写一个 LLM 请求,而是把上面的状态管理、工具注册、步数控制写好。
5. Clojure 实现:不可变数据与多方法
5.1 Clojure 的建模视角
Clojure 是一门运行在 JVM 上的 Lisp 方言,强调不可变数据与函数式编程。相比 Python 用类封装 Agent,Clojure 更自然的做法是:
- 用不可变 map 表示 Agent 状态;
- 用普通函数操作状态;
- 用多方法或