
今天想聊一个偏“设计”的话题如何用 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 或 recordElixir 则倾向于使用 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 实现用类型与对象封装 Agent4.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_factorylist) max_steps: int 5 def add_message(self, role: str, content: str) - None: self.messages.append(Message(rolerole, contentcontent)) 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 fTOOL_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( nameweather, description查询天气参数为城市名, handlerweather_handler, ) } return AutoAgent( system_prompt你是一个有工具调用能力的助手。, toolstools, ) 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 用类封装 AgentClojure 更自然的做法是用不可变 map 表示 Agent 状态用普通函数操作状态用多方法或