智能体框架在重复造轮子?用Elixir/OTP重构生产级并发
发布时间:2026/10/31 19:10:20 锦皓数字建站

1. 一个被忽视的真相智能体框架在重复造轮子1.1 从一条社区吐槽说起前阵子在一个技术群里有位做分布式系统的老哥扔出一句话“现在这些智能体框架说白了就是 Elixir 的糟糕克隆。”群里先是安静了几秒然后炸了锅。做 AI 应用的人觉得被冒犯做电信出身的人却在疯狂点头。我恰好两边都沾过早年在电信领域写过基于 Erlang/OTP 的信令处理系统后来也跟风搭过各种智能体编排框架。两边一对比那种“既视感”强烈到让人不适——智能体框架正在重新发明电信业 1986 年就解决的问题。1986 年是什么概念那时候 Erlang 刚在爱立信内部萌芽目标就是解决电信交换机里成千上万个并发进程的调度、容错和热更新问题。四十年过去这套东西被磨成了 BEAM 虚拟机和 OTP 框架支撑着人类历史上对可用性要求最苛刻的系统。而今天很多智能体框架用 Python 或 TypeScript 从零开始磕磕绊绊地实现消息传递、状态管理、失败重启然后管这叫“创新”。这篇文章不是要贬低谁而是想把那套被验证了四十年的生产级并发经验翻译成智能体开发者能直接用的知识。如果你正在设计多智能体系统或者被框架里的状态同步、超时、重试搞得头大那这篇内容就是写给你的。1.2 智能体框架到底在解决什么问题先把话说清楚一个“智能体”在工程上到底是什么剥掉大模型那层光环它就是一个能接收消息、维护内部状态、做出决策、发出消息、并且可能失败的长期运行实体。你把这个定义里的“大模型”换成“业务逻辑”它就是一个电信网络里的进程换成“订单处理”它就是一个电商后台的 worker。本质上智能体框架要解决的核心问题就那么几个并发同时有几千上万个智能体在跑怎么调度不崩通信智能体之间怎么发消息同步还是异步消息丢了怎么办状态每个智能体的记忆、上下文存在哪进程重启后怎么恢复容错某个智能体调用模型超时了、报错了谁来管重试还是重启编排多个智能体怎么协作谁先谁后失败了整条链怎么回滚你看这五个问题电信业在 1986 到 1996 这十年里全部给出了工业级答案。而今天的智能体框架很多还在用“全局字典 线程池 try/except”硬扛。1.3 为什么是 Elixir/BEAM 而不是别的有人会问并发方案那么多Go 的 goroutine、Java 的线程池、Node 的事件循环凭什么说 BEAM 是标杆关键区别在于失败模型和状态隔离。Go 的 goroutine 挂了整个进程可能跟着崩Java 线程池里一个任务抛异常你得自己 catch 否则线程就废了。而 BEAM 的设计哲学是进程极其廉价进程之间完全隔离一个进程崩溃绝不影响其他进程而且崩溃是可观测、可管理的。这恰好就是智能体系统最需要的特性。你想想一个负责调用外部 API 的智能体因为网络抖动超时了它凭什么把整个编排流程拖垮在 BEAM 里这个进程直接崩掉监督者Supervisor根据预设策略决定是重启它、还是重启整组、还是放弃。这套机制不需要你写一行 try/except。所以标题里说“Elixir 的一个糟糕克隆”不是说智能体框架抄了 Elixir 的代码而是说它们在用更笨重的方式重新实现 BEAM 四十年前就免费提供的能力。2. 核心概念对照Actor 模型与智能体的一一映射2.1 Actor 模型智能体的理论祖先Actor 模型是 1973 年 Carl Hewitt 提出的计算模型核心思想极其简单万物皆 ActorActor 之间只通过消息通信每个 Actor 有自己的邮箱和私有状态。你把这个定义和智能体对比一下Actor 模型概念智能体对应概念说明ActorAgent独立的计算实体邮箱Mailbox消息队列 / 上下文接收待处理的消息私有状态记忆 / Memory不共享只能通过消息影响消息传递Agent 间通信异步、解耦监督树编排层管理失败与生命周期看出来了吗智能体框架里的每一个核心抽象Actor 模型里都有对应物而且早了整整五十年。Erlang 是 Actor 模型最成功的工业实现OTP 则是把这套模型工程化、产品化的框架。2.2 BEAM 虚拟机被低估的并发引擎BEAM 是 Erlang 的虚拟机也是 Elixir 运行的底座。它的几个特性放到今天的智能体场景里简直是量身定做第一进程极其轻量。一个 BEAM 进程初始内存只有几百字节到几 KB一台普通服务器跑几十万个进程毫无压力。这意味着你可以给每个智能体分配一个独立进程而不是共享线程池。独立进程带来的好处是状态天然隔离不需要加锁。第二抢占式调度。BEAM 的调度器会给每个进程分配“缩减数”reductions用完就切换。这保证了没有哪个智能体能“霸占”CPU。对比之下Python 的 asyncio 里一个死循环的协程能把整个事件循环卡死。第三软实时垃圾回收。每个进程独立 GC不会出现全局 Stop-The-World。对于需要长时间在线的智能体这一点至关重要——你总不希望一个智能体处理完大上下文后把其他所有智能体都卡住吧。第四位置透明。在 BEAM 里给本地进程发消息和给远程节点进程发消息代码写法几乎一样。这意味着你的智能体系统从单机扩展到集群改动成本极低。2.3 OTP智能体编排的现成答案OTPOpen Telecom Platform是 Erlang/Elixir 的一套设计模式和库它把并发系统的常见需求抽象成了几个行为BehaviourGenServer通用服务器封装了状态管理和消息处理循环。一个智能体的“接收消息-处理-更新状态-回复”流程就是一个 GenServer。Supervisor监督者负责监控子进程根据策略重启。这就是智能体编排里的“失败恢复”层。Application应用把一组进程打包成可启动、可停止的单元。GenStateMachine状态机适合有明确状态流转的智能体。我当年写电信系统时一个信令处理节点就是一棵监督树顶层 Supervisor 管着若干 GenServer每个 GenServer 处理特定类型的消息。某个 GenServer 崩了Supervisor 按策略重启其他进程毫无感知。这套东西直接搬到智能体系统几乎不用改。2.4 为什么智能体框架“重新发明”了这些那问题来了既然 OTP 这么好为什么智能体框架不用原因很现实。第一生态惯性。AI 领域的开发者大多在 Python/TypeScript 生态里对 Erlang/Elixir 陌生。第二大模型调用是 IO 密集型。很多人觉得“等 API 返回”用异步 IO 就够了不需要 BEAM 这种重武器。第三框架作者要快速出活。用 Python 写个asyncio加字典两天能出 demo用 Elixir 得先学语法和 OTP。但代价是什么代价是这些框架在生产环境里会暴露出一堆问题状态竞争、内存泄漏、失败传播、无法热更新、难以观测。这些问题OTP 在二十年前就解决了。3. 生产级并发的六个硬核经验3.1 经验一让进程崩溃而不是掩盖错误这是 BEAM 哲学里最反直觉、也最重要的一条不要用 try/except 包住一切让该崩的进程崩掉。在智能体系统里常见的错误做法是调用模型 API 失败catch 住返回一个默认值继续往下跑。结果就是错误被吞掉状态变得不一致最后在一个完全无关的地方爆出莫名其妙的 bug。正确做法是让这个智能体进程崩溃由监督者决定怎么办。监督策略有几种one_for_one只重启崩掉的那个子进程。one_for_all一个崩了重启所有子进程。rest_for_one一个崩了重启它和它之后启动的进程。对于智能体系统我通常建议无状态的智能体用 one_for_one有共享依赖的用 rest_for_one。比如一组智能体共享一个数据库连接池连接池崩了依赖它的智能体都得重启。注意让进程崩溃的前提是你的状态是可恢复的。如果智能体的记忆只存在进程内存里崩了就没了。所以关键状态必须持久化到外部存储进程重启后从存储恢复。3.2 经验二消息传递要设计成幂等的BEAM 的消息传递是“至多一次”at-most-once语义消息发出后如果接收进程崩了消息就丢了。这听起来很可怕但电信系统的做法是把幂等性做在业务层。什么意思就是让每条消息都带一个唯一 ID接收方处理前先检查这个 ID 是否处理过。如果处理过直接返回上次的结果。这样即使消息重发也不会产生副作用。在智能体场景里这对应的是“工具调用”的幂等性。比如一个智能体调用“下单”工具如果因为超时重试不能重复下单。解决方案是给每次工具调用生成一个幂等键服务端根据这个键去重。# 伪代码幂等工具调用 def call_tool(tool_name, params, idempotency_key): # 先查缓存看这个 key 是否已处理 cached cache.get(idempotency_key) if cached: return cached result actual_tool_call(tool_name, params) cache.set(idempotency_key, result, ttl3600) return result3.3 经验三用监督树代替全局异常处理很多智能体框架有一个“全局错误处理器”所有异常都往那里扔。这在小规模时能用规模一大就变成瓶颈和单点。OTP 的做法是分层监督每个子系统有自己的监督者监督者之上还有监督者形成一棵树。错误在离发生点最近的地方被处理处理不了的才往上抛。映射到智能体系统你可以这样设计最底层单个智能体进程处理自己的业务逻辑。中间层一组相关智能体的监督者比如“客服智能体组”。顶层整个应用的监督者负责启动和停止。这样客服组里某个智能体崩了客服组的监督者重启它不影响其他组。如果客服组整体崩了顶层监督者才介入。3.4 经验四状态外置进程无状态化BEAM 进程可以持有状态但生产系统里关键状态一定要外置。原因有三进程会崩、节点会重启、系统要扩容。电信系统的做法是把用户会话状态存在独立的数据库中信令进程本身尽量无状态。智能体系统也一样智能体的“记忆”应该存在向量数据库或 KV 存储里进程只持有当前对话的临时上下文。这样做的好处是智能体进程可以随时重启、随时迁移扩容时新起的进程能从存储里加载状态继续工作。3.5 经验五热更新不是奢侈品是必需品电信系统要求“五个九”可用性99.999%意味着一年停机时间不超过五分钟。所以 Erlang 支持热代码升级不停止系统的情况下替换代码。智能体系统其实也需要这个。你更新一个智能体的提示词或工具逻辑总不希望把整个服务重启吧在 BEAM 里你可以让 GenServer 在收到特定消息时切换到新版本的代码正在处理的消息继续用旧代码完成新消息用新代码。当然大多数智能体框架做不到这一点。但你可以借鉴思路把智能体的行为逻辑和运行时状态分离逻辑用配置或插件形式加载更新时只替换逻辑不重启进程。3.6 经验六可观测性要内建不是外挂BEAM 自带一套强大的可观测性工具进程监控、消息队列长度、内存占用、调度统计全都能实时拿到。OTP 还有:sys.get_state、:sys.get_status这样的调试接口。智能体系统最怕的就是“黑盒”你不知道某个智能体在干嘛、卡在哪、为什么慢。借鉴 BEAM 的思路你应该给每个智能体暴露当前状态在处理什么消息邮箱积压量有多少消息待处理最近一次操作的耗时失败次数和最后失败原因这些指标不需要多复杂一个 HTTP 端点或者一个内部消息查询就能搞定。关键是内建而不是等出了问题再去加日志。4. 从零搭建一个 OTP 风格的智能体原型4.1 环境准备与依赖说明这一节我用 Elixir 来演示因为它是 BEAM 上最现代的语法。如果你没装过 Elixir先装 Erlang/OTP 和 Elixir。这里有个常见坑Elixir 版本和 OTP 版本有对应关系装之前查一下兼容表。比如 Elixir 1.16 对应 OTP 26Elixir 1.17 对应 OTP 27。版本不匹配会出现各种奇怪的编译错误。安装完成后用elixir --version确认。然后创建一个 Mix 项目mix new agent_demo --sup cd agent_demo--sup参数会生成一个带监督树的骨架这正是我们需要的。4.2 定义一个 GenServer 智能体一个最简智能体核心就是handle_call或handle_cast。handle_call是同步调用调用方等结果handle_cast是异步发完就走。智能体之间通信我建议默认用 cast需要结果时才用 call这样能避免调用链上的阻塞。defmodule AgentDemo.Worker do use GenServer # 客户端 API def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: opts[:name]) end def process(pid, message) do GenServer.cast(pid, {:process, message}) end def get_state(pid) do GenServer.call(pid, :get_state) end # 服务端回调 impl true def init(opts) do state %{ name: opts[:name], memory: [], processed_count: 0 } {:ok, state} end impl true def handle_cast({:process, message}, state) do # 这里模拟调用大模型 result call_llm(message, state.memory) new_memory [message | state.memory] | Enum.take(10) new_state %{state | memory: new_memory, processed_count: state.processed_count 1} {:noreply, new_state} end impl true def handle_call(:get_state, _from, state) do {:reply, state, state} end defp call_llm(message, memory) do # 实际项目里替换成真实的模型调用 processed: #{message} end end这段代码里init初始化状态handle_cast处理异步消息并更新记忆handle_call用于查询状态。注意记忆只保留最近 10 条这是防止内存无限增长的常见做法。4.3 用 Supervisor 管理智能体组单个智能体不够我们要一组。Supervisor 的配置决定了失败恢复策略defmodule AgentDemo.Supervisor do use Supervisor def start_link(init_arg) do Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) end impl true def init(_init_arg) do children [ {AgentDemo.Worker, name: :agent_1}, {AgentDemo.Worker, name: :agent_2}, {AgentDemo.Worker, name: :agent_3} ] # one_for_one: 一个崩了只重启它自己 # max_restarts: 3 秒内最多重启 5 次超过就整体放弃 Supervisor.init(children, strategy: :one_for_one, max_restarts: 5, max_seconds: 3) end endmax_restarts和max_seconds这两个参数很关键。它们防止“崩溃-重启-再崩溃”的死循环。如果 3 秒内重启超过 5 次Supervisor 会放弃并终止把问题暴露出来而不是无限重启掩盖问题。4.4 智能体之间的消息路由多个智能体协作需要一个路由机制。最简单的做法是用注册名每个智能体启动时注册一个名字其他智能体通过名字发消息。# 给 agent_1 发消息 AgentDemo.Worker.process(:agent_1, 帮我查一下订单) # agent_1 处理完后可以转发给 agent_2 def handle_cast({:process, message}, state) do result call_llm(message, state.memory) # 根据结果决定是否转发 if needs_forward?(result) do AgentDemo.Worker.process(:agent_2, result) end {:noreply, %{state | memory: [message | state.memory]}} end这种点对点路由简单直接但耦合度高。更优雅的做法是引入一个“消息总线”进程所有智能体往总线发消息总线根据规则分发。这其实就是 RabbitMQ 这类消息队列的思路而 RabbitMQ 本身就是用 Erlang 写的。4.5 状态持久化与恢复前面说了状态要外置。在 Elixir 里可以用 ETSErlang Term Storage做进程内的高速缓存用数据库做持久化。ETS 的特点是读写极快但数据存在内存里节点重启就没了。# 在 init 里创建 ETS 表 def init(opts) do :ets.new(:agent_memory, [:named_table, :public, :set]) state %{name: opts[:name], memory: []} {:ok, state} end # 写入记忆 defp save_memory(agent_name, message) do :ets.insert(:agent_memory, {agent_name, message}) end # 读取记忆 defp load_memory(agent_name) do case :ets.lookup(:agent_memory, agent_name) do [{^agent_name, message}] - message [] - nil end end生产环境里ETS 只做缓存真正的持久化用 PostgreSQL 或专门的向量库。进程重启时从数据库加载最近的状态到 ETS然后继续服务。5. 常见问题与排查技巧实录5.1 消息积压邮箱爆了怎么办现象某个智能体响应越来越慢最后完全卡死。排查在 Elixir 里可以用Process.info(pid, :message_queue_len)查看邮箱积压量。如果这个数字持续增长说明消费速度跟不上生产速度。解决短期增加消费者数量或者给消息加优先级。长期检查是不是某个操作太慢比如模型调用没设超时或者消息生产速率本身就不合理。实操心得给所有外部调用设超时是防止邮箱积压的第一道防线。模型调用超时设 30 秒工具调用设 10 秒超过就崩让监督者处理。5.2 状态不一致重启后记忆错乱现象智能体重启后行为异常像是“失忆”或者“记错”。排查检查状态持久化逻辑。常见问题是状态写入是异步的进程崩溃时最后一次写入还没落盘。解决关键状态用同步写入或者用“写前日志”WAL模式先写日志再更新内存崩溃后从日志恢复。5.3 监督者重启风暴现象日志里大量“restarting child”系统反复重启。排查看max_restarts是否被触发。如果触发了说明子进程有持续性错误重启解决不了。解决不要盲目调大max_restarts那只会掩盖问题。应该让监督者放弃重启把错误暴露出来然后去修根因。5.4 常见问题速查表问题可能原因排查方法解决方向智能体无响应邮箱积压 / 死锁查 message_queue_len加消费者 / 设超时重启后状态丢失状态只在内存检查持久化代码状态外置 WAL反复重启持续性错误看重启日志修根因别调参数内存持续增长记忆无限累积监控进程内存限制记忆长度消息丢失接收方崩溃加消息 ID 追踪幂等 重发节点间通信失败网络分区查节点连接状态重连 降级5.5 独家避坑技巧技巧一给每个智能体设“最大记忆条数”。我见过太多系统因为把全部对话历史塞进上下文导致内存爆炸。经验值是保留最近 20 到 50 条更早的做摘要或存向量库。技巧二模型调用一定要设超时和重试上限。无限重试等于自杀。我的配置是超时 30 秒最多重试 2 次重试间隔指数退避。技巧三用:observer可视化监控。Erlang 自带的:observer.start()能图形化展示进程树、内存、调度排查问题时比看日志快十倍。技巧四智能体命名要有层次。比如:customer_service_agent_1、:order_agent_2而不是:agent1、:agent2。出问题时一眼能看出是哪个子系统。6. 这套思路能扩展到什么程度6.1 从单机到集群BEAM 的分布式能力是内建的。你只要启动节点时指定--sname或--name然后用Node.connect/1连接其他节点就能跨机器发消息。智能体系统从单机扩展到集群几乎不用改业务代码。当然分布式会带来新问题网络分区、节点失效、消息顺序。这些 OTP 也有对应方案比如:global模块做全局注册pg模块做进程组。6.2 与现有 AI 生态的桥接你不需要把所有东西都用 Elixir 重写。实际项目里我的做法是用 Elixir 做编排和并发层用 Python 做模型调用和工具实现。两者之间通过 HTTP 或消息队列通信。Elixir 这边负责智能体的生命周期、消息路由、失败恢复Python 那边就是一个无状态的“模型服务”接收请求返回结果。这样既享受了 BEAM 的并发优势又复用了 Python 的 AI 生态。6.3 什么场景不适合这套方案说句公道话不是所有智能体系统都需要 BEAM。如果你的系统只有几个智能体、QPS 很低、对可用性要求不高那用 Python 的 asyncio 完全够用没必要上 Elixir。BEAM 的价值在规模和可靠性当你有成百上千个智能体、要求 7x24 运行、不能接受状态丢失时它的优势才真正体现。选型要看场景别为了技术而技术。我自己踩过的坑是早期用 Python 写了一个多智能体编排单机跑得好好的一上生产就各种状态竞争和内存泄漏。后来把编排层用 Elixir 重写模型调用层保留 Python问题才彻底解决。这个迁移过程花了大概两周但换来的是半年零重启。这笔账我觉得划算。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。