深夜写代码的人里,十有八九都幻想过自己的程序能永不停机。我这里说的不只是服务器不宕机,而是你那套 AI 应用,能在你睡觉的时候自己派活儿、自己干活、自己汇报。我最近打造了一个 24 小时不停工作的多 Agent 集群,从最开始只有一个 Agent 来回跑,到后来拆成一组带调度、带故障转移的集群,前前后后踩了不少坑。这篇文章就是我的实践记录和复盘总结,如果你是搞 Agent 开发、想让自己的 AI 应用从“单机玩具”走向“生产可用”的开发者,这篇内容应该能帮你少走不少弯路。
先说结论:把 Agent 从单个进程挪到集群里,真正的难点不在“连起来”,而在“怎么让一堆 Agent 协同工作还不打架、不丢任务、不崩溃”。我用了主控节点加 Worker 节点的结构,用 Docker Compose 拉起基础组件,中间接消息队列做任务分发,再配合心跳检测和自动拉起机制来实现故障转移。整套系统跑起来之后,最大的变化就是我再也不用半夜起来重启脚本了,任务挂了它会自己重试,节点死掉它会自动剔除,日志和状态也都能集中看到。
1. 为什么非要把 Agent 做成集群
1.1 单个 Agent 的瓶颈在哪里
我们刚开始做 Agent 应用的时候,大多数人都是从单进程开始的,就是一个 Python 脚本,或者一个 FastAPI 服务,里面挂着 LangChain 或者自研的 Agent 循环。单 Agent 跑起来很简单,处理一些简单的“给我总结一下这篇文章”、“帮我写个周报”这种任务完全够用。但你一旦把任务量提上来,问题就全暴露了。
第一个瓶颈是上下文窗口。一个大模型 Agent 的上下文是有限的,如果你让它连续处理几十个任务,上下文会被各种中间结果塞满,后面的任务质量会肉眼可见地下降。第二个问题是单点故障,一个进程崩了,所有的任务就全停了,没有任何容错。第三个问题更隐蔽——效率。单个 Agent 只能顺序干活,前一个任务在等 LLM 响应的时候,后面的任务就只能排队,而这时候你的 CPU、磁盘、甚至网络带宽其实都在闲着。
我记得当时我跑了一个批量处理任务,大概要处理 2000 多条数据,每个任务要调用好几轮 LLM,估算下来要跑十几个小时。中间只要有一次 API 超时没处理好,整个流程就断在那儿了。后来我实在受不了了,才下决心把 Agent 改成集群架构。说白了,单个 Agent 就像一个人开了一家小店,生意一多你就发现你既当收银员又当厨师又当保洁,忙不过来的时候只能干着急。
1.2 集群带来的三项核心价值
多 Agent 集群解决的核心问题,用大白话讲就三个词:并行、冗余、分工。
并行最好理解。多个 Worker 节点同时拉任务,原来十几个小时才能跑完的批量任务,在三个节点上可能四五个小时就跑完了。而且由于每个 Agent 有独立的上下文,它们互不干扰,每个任务都能拿到“干净的”上下文窗口。
冗余是集群存在的真正理由。24 小时不停工作的系统,最怕的就是夜里三点某个节点因为内存泄漏或者网络波动挂掉。有了集群架构,主控节点会实时检测 Worker 的心跳,一旦发现某个节点不响应,就自动把它的任务重新分发到其他健康的节点上。这个就是我们常说的故障转移,也是“24 小时不停工”这句话背后的核心保障。
分工则是让集群效率最大化的关键。不同 Agent 可以承担不同角色——有的负责数据抓取,有的负责内容生成,有的负责质检。还可以针对不同任务类型调配不同的模型,比如简单分类用轻量模型跑,复杂推理才调用大参数模型。这样整个系统的资源利用率会高很多,成本也能压下来不少。
2. 系统架构与关键组件的选型实战
2.1 主控与 Worker 的职责划分
我先讲清楚我这套集群的基本结构。整个系统分成两类节点:主控节点和 Worker 节点。主控节点不干活,它只负责三件事:接收任务、调度任务、监控各 Worker 的状态。Worker 节点才是真正跑 Agent 的地方,一个 Worker 可以并行跑多个 Agent 实例,每个实例从队列里拉取任务执行。
为什么要把“派活”和“干活”分开?原因很简单:Agent 执行任务的时候,会调用 LLM、处理工具、读写数据库,非常消耗内存和 CPU。如果让同一个进程既做调度又执行任务,一旦任务量上来,调度的响应速度就会变慢,整个系统会变成“调度器先卡死,然后所有 Worker 一起卡死”的连锁故障。分开之后,主控节点非常轻量,哪怕 Worker 全部挂掉,主控也还活着,新任务还能继续进队列,不会丢。
我自己用的是 Python 写的调度器,本质上就是一个长运行的异步服务。它订阅任务请求接口,把任务塞进消息队列,然后维护一张 Worker 注册表,记录每个节点的存活状态和当前负载。还有个细节值得注意:主控节点本身要做成无状态的,这样万一主控也挂了,可以快速起一个新的,从队列里继续干活,不会出现“调度器丢了”这种灾难。
2.2 消息队列选型:Kafka 还是 Redis
任务队列是集群的通信中枢。我这里聊一个很多初学者都会纠结的问题:到底用 Kafka 还是 Redis。两种我都实际用过,直接说结论。
Redis Stream 适合中小规模集群。它部署极其简单,一个容器就搞定了,而且性能足够好,单机就可以支撑每秒几千条任务的吞吐。如果你只是想在多个 Worker 之间做一个简单的公平队列,Redis Stream 或者 Redis List 完全够用。我在项目早期就是用 Redis Stream 实现任务分发的,代码量很少,调试也方便。
Kafka 则适合任务量大、需要持久化和回放能力的场景。它的优势是日志机制非常强,所有消息都有持久化,消费者挂了可以从上一次的位置继续消费,不会漏消息。缺点就是重——部署 Kafka 至少要搭 zookeeper(或者 KRaft 模式下的 controller),运维成本明显高一个量级。
这里有个非常实际的经验:如果你的 Worker 在执行任务时是“边消费边标记完成”的模式,那 Redis 就够了;但如果你需要“任务发出去之后还能追溯整个生命周期”,那 Kafka 的日志结构会省心很多。两种方案的取舍本质上是“重可靠”还是“轻运维”的问题。目前我这套集群用的是 Kafka,因为我比较需要消费位移和重放能力,后期排查问题的时候能少很多麻烦。不过说实话,如果你的场景没到每天百万级任务,直接用 Redis 上,别折腾 Kafka 了。
2.3 框架选择:LangChain、CrewAI、Dify 谁更合适
Agent 框架的选择直接影响开发和运维体验。很多人一上来就问 LangChain、CrewAI、Dify 应该选哪个,我的回答是:先搞清楚你到底想要什么。这三者定位完全不同。
LangChain 是底层框架,它给你的是积木而不是成品。你可以用它的 Tool、AgentExecutor、Memory 等组件自由拼装你自己的 Agent 系统。灵活性最高,但学习曲线也最陡。如果你要做的集群 Agent 有大量自定义逻辑——比如复杂的工具调用、动态 Prompt、自定义记忆策略——LangChain 是合理的底子。
CrewAI 是偏上层的多 Agent 编排框架,它的设计哲学就是让多个 Agent 像团队一样协作,有“角色”和“任务”的概念,写起来非常直观。我当时在原型阶段用过一个星期,体验是很爽的,但问题在于它对底层运行时的屏蔽比较多,一旦要接入自己的集群调度逻辑,反而感觉它的抽象在碍事。CrewAI 的优势在于快速验证多 Agent 协作的流程,但它更像“帮你把流程搭好”,而不是“帮你构建一套稳定运行的系统”。
Dify 则是更完整的平台型产品,提供可视化编排、知识库、工作流管理这些能力。如果你不想写代码,只想通过拖拽构建一套 Agent 应用,Dify 确实很香。但是,它是个平台,不是库,你很难把它拆开来嵌进自己的集群架构里。我最终的方案是:自己写 Agent 核心逻辑,用 LangChain 只做工具调用部分,调度层完全自研,这样既能保持灵活性,又能完全掌控集群行为。
注意:框架只是脚手架,集群的核心价值在于调度、容错和可观测性,这些框架帮不了你太多。不要把框架当成全部。
2.4 Agent 记忆与状态共享的设计
集群环境里最容易被忽略的就是“记忆”。单个 Agent 跑的时候,你可以把历史消息存在内存里,方便得很。但集群里有多个 Worker,每个 Worker 都可能处理同一个用户的不同任务,那这个用户的上下文在哪个节点上?我是不是每次都要把历史聊天记录从头带一遍?
这里需要区分两种记忆:会话级记忆和任务级记忆。会话级记忆用 Redis 存就行,按 session_id 做 key,Agent 每次处理任务之前先从 Redis 拉历史摘要。摘要不用太长,能把关键信息带上就行。任务级记忆则要看任务生命周期,如果一个任务需要多轮 Agent 协作才能完成,那中间状态最好也放到共享存储里,避免某个 Worker 执行到一半挂掉之后进度全丢。
另外一个经验是:别把完整的对话历史都塞给 Agent。用一个“记忆压缩”策略——把历史总结成要点,比如“用户是做跨境电商的,上次聊过物流时效问题”,这样既省 token 又避免上下文被无关信息稀释。我在集群里跑过一阵子之后发现,很多时候 Agent 回答质量下降,不是模型问题,而是上下文里塞了太多不相干的历史记录,记忆设计的优先级非常高。
3. 实操从零搭建一个可运行的 Agent 集群
3.1 基础环境准备:用 Docker Compose 快速拉起依赖
我不喜欢把时间浪费在装环境上,所以整套集群的基础组件全用 Docker Compose 编排。你需要拉起来的东西一般包含:消息队列(我用 Kafka,也可以用 Redis)、分布式缓存(Redis)、以及可选的向量数据库用于长期记忆。
先给出一个最简的 Compose 编排。注意我这里省略了具体镜像版本,你自己按需替换即可。
version: '3.8' services: redis: image: redis:7-alpine ports: - "6379:6379" command: redis-server --appendonly yes zookeeper: image: bitnami/zookeeper:3.9 environment: - ALLOW_ANONYMOUS_LOGIN=yes ports: - "2181:2181" kafka: image: bitnami/kafka:3.6 ports: - "9092:9092" environment: - KAFKA_BROKER_ID=1 - KAFKA_LISTENERS=PLAINTEXT://:9092 - KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 depends_on: - zookeeper这个 Compose 文件启动之后,你就有了一套可以随时重启、清空重来的基础环境。测试阶段一定要保证这些组件是“可丢弃的”,别把生产数据跟测试环境混在一起,否则后面你连排查问题的入口都找不到。
配合容器化的好处是,任何机器上都可以快速复现同一套环境。我有一次在另一台新服务器上部署,只需要把 Compose 文件复制过去,一条docker compose up -d就搞定了所有依赖。这就是容器对于集群项目的意义——它把“环境准备”这个最无聊但最容易出错的环节变成了一句命令。
3.2 Worker 注册与心跳探活
集群里所有 Worker 都需要向主控节点注册。这个机制很简单:每个 Worker 启动的时候,先调用主控的注册接口,告诉主控“我上线了,我的 ID 是什么,我能跑什么类型的任务”。主控会把这个信息记在内存里,同时用一个键值对结构存到 Redis,保证主控重启后还能恢复节点列表。
注册之后就是心跳探活。Worker 每隔几秒发一个心跳包,主控收到心跳就更新对应节点的最后活跃时间。如果某个节点超过一定时间没有心跳,主控就把它标记为“失联”,同时把它正在执行的、尚未提交结果的任务重新放回待处理队列。
我用的心跳方式是向 Redis 写入一个带过期时间的 key:heartbeat:{worker_id},每次写入时设置 TTL 为 10 秒。主控检查的时候,只要 key 还存在,就代表节点活着;key 过期了,就认为节点挂了。这个方案比你自己维护时间戳要简单得多,也不依赖复杂的第三方组件,实测非常稳定。
Worker 注册表里还应该记录每个节点的当前并发能力。比如某台机器内存大,可以同时跑 5 个 Agent;另一台比较弱,只允许并发 2 个。调度器在派发任务的时候,根据这些负载信息决定往哪些节点投递,避免一台机器忙死、其他机器闲死。
3.3 任务分发、重试与结果回收
任务分发的流程是这样的:用户提交任务后,主控节点把任务封装成一个标准消息,发到对应的 Kafka topic 上。每个 Worker 进程作为一个消费者组的一员,从 topic 拉取消息。这里有个关键点:同一个任务只让一个 Worker 消费,不能让多个 Worker 同时处理,否则会导致重复执行和资源浪费。Kafka 的消费者组机制天然解决了这个问题,这也是我说 Kafka 在该场景下比 Redis 省心的原因之一。
拿执行日志举例。每个 Worker 在执行任务时,会周期性地把中间状态写入 Redis:task:{task_id}:progress和task:{task_id}:status。这样主控或者前端面板随时能查到一个任务的进度,比如“正在调用 LLM”、“工具执行中”、“已完成”。
结果回收的逻辑也很直白。Worker 执行完任务后,把最终结果写回 Redis,并在 Kafka 的结果 topic 里发一条完成通知。主控收到通知后,把任务状态更新为“完成”,然后将结果回传给调用方。这里我专门设计了一个结果 TTL,比如默认存 7 天,超过时间就删掉,避免 Redis 被历史任务结果填满。
重试机制是我的血泪经验。任务重试绝不能简单地把消息重新丢回原 topic,否则一旦任务本身有问题,它会无限循环把队列“堵死”。我的做法是:每个消息带上retry_count字段,如果任务失败,判断重试次数是否超过阈值。没有超过就发到重试 topic,带上递增的计数;超过阈值就把任务标记为“失败终态”,扔进死信队列等待人工处理。
3.4 故障转移让集群真正 7x24
故障转移这块是我踩坑最多的区域,主要问题集中在“半死节点”上。什么叫半死?就是进程还活着,心跳还在发,但它的 Agent 因为某些原因卡死了——比如调用外部 API 超时,或者死锁在某个工具调用上。这种情况心跳完全正常,任务却一直不出结果。
解决方案是给每次任务执行加上超时控制。主控在派发任务时会在消息里带上一个timeout字段,Worker 端如果超过这个时间还没结束当前 Agent 执行,就会被强制终止,并把任务标记为“执行超时”。主控再把超时任务分配给另一个 Worker 从头执行。这样就保证了没有一个任务可以永久占用资源。
另外还要针对“节点彻底挂掉”做防护。我上面提到的心跳机制只能解决节点不可达的情况,真正的问题在于:挂掉的节点上那些还没完成的任务怎么办?我的做法是把每个正在执行的执行记录都放 Redis,主控通过扫描executing:{ task_id}的锁信息来判断一个执行是否正常结束。如果节点失联,对应锁过期之后主控会把这些任务重新入队。这里需要注意设置合理的锁过期时间,太短会导致任务重复执行,太长会导致故障恢复变慢。我一般把锁时间设成任务预设超时时间的两倍。
这些机制都完善之后,我发现我基本上可以“开摆”了。以前半夜系统崩溃,我要爬起来看日志、重启进程,现在集群会自动把任务转移到其他节点,顶多有个别任务重跑一遍。这里有一个必须接受的现实:容错的代价是“至少一次执行”,不是“正好一次执行”。你的业务要对重复执行有容忍度,否则就得在业务层面额外做幂等。
4. 常见问题与排查技巧实录
4.1 任务为什么全部卡在排队中
我遇到过最吓人的问题:某天早上一看监控,所有任务的状态都是“排队中”,一个都没有被执行。查了半天发现,Kafka 消费者组的 rebalance 出了问题——某个 Worker 节点异常退出后,消费者组一直在重新分配分区,但协调器那边迟迟没有拉起来新的消费者,整个组就进入了一种“假死”状态。
排查思路是:先看任务是不是真的没有被消费,然后看消费者状态。我最后的解决方法是给消费者组设置了固定的 session.timeout 和 heartbeat.interval,并且保证每个 Worker 退出时会主动调用consumer.close(),让消费者组快速重新分配。还有一个细节:消费端的并发度不能一味地调高,Kafka 消费者组里一个分区只能被一个消费者线程消费,如果你的 Worker 数量大于分区数,部分 Worker 会空转,任务还是卡在队列里。
这类问题的本质是集群里最容易被忽略的“时间同步问题”——不光是时钟同步,还有超时参数之间的互相纠缠。配置超时参数时,别只用默认值,最好在预发环境模拟节点挂掉,观察 rebalance 到底要花多久,再反过来调整参数。
4.2 多 Agent 上下文互相污染
集群化之后我遇到过一个非常隐蔽的问题:Agent A 生成的中间结果,不知怎么跑到了 Agent B 的上下文里。最终展示出来的回答经常“串味”,比如上一单客户的需求,出现在下一单客户的回复里。这个问题直接把我的信任度打没了。
后来追踪发现是共享记忆模块的 key 设计有问题。我在写 Redis 时只用了 session_id 做 key,但同一个 session 的任务可能同时被多个 Worker 并发处理,不同的任务阶段会把中间状态覆盖进同一个 key。解决方案有两个:一是给记忆 key 加上 task_id 维度,确保不同任务读写互不干扰;二是在 Worker 拉取上下文的时候,再做一次数据隔离校验,确保自己读到的历史记录的时间线和当前任务是匹配的。
这个问题的教训是:不要相信“内存里那点状态没问题”,只要系统涉及并发,就一定要显式地设计数据隔离边界。Agent 的记忆在集群环境下不是“共享变量”,而是“有主键的数据”。
4.3 内存泄漏与长尾任务
集群跑久了之后,最容易出现的就是单个 Worker 内存持续走高。我一开始以为是模型调用的问题,后来用memory_profiler跑了一遍才发现,是 LangChain 工具调用里某个 HTTP 客户端的连接没有及时关闭,导致连接池里的连接越积越多,最终把内存撑爆。
排查内存泄漏的方法就三板斧:第一,给每个 Worker 加内存监控,设定的阈值,超过就自动重启,先保住整体稳定性;第二,用 objgraph 或者 tracemalloc 定位具体是哪些对象没有被回收;第三,针对常驻对象做代码审查,重点关注全局变量、静态缓存、网络连接池这类“易积累”资源。
长尾任务则是另一个坑。所谓长尾任务,就是个别任务因为依赖的服务特别慢,执行时间远超平均水平。这些任务虽然数量少,却会长期占用 Worker 的并发额度,拖慢后面所有任务。我现在会在调度层做任务时长分级,把预计耗时短的任务优先派发,长时间任务走独立的低优先级队列,并且限制同时执行的数量,避免被长尾任务堵塞。
4.4 没有监控就是瞎跑
24 小时系统没有监控,等于在雾里开车。我这里说的监控不一定要上特别重的 Prometheus + Grafana 全家桶,你可以用更轻量的方式起步:日志集中化 + 关键指标记录。
我最初给每个 Worker 打日志,但日志分散在不同容器里,排查问题的时候要挨个进容器翻,效率极低。后来我把所有日志统一通过logging输出到标准输出,让 Docker 的日志驱动收集起来,再用一个轻量的日志采集服务把这些日志集中到一个地方。这样我可以用一条命令查看所有 Worker 的日志,再配合关键词过滤,排查效率翻了好几倍。
关键指标我建议至少记录这几项:每个 Worker 的实时并发数、任务排队长度、任务执行耗时分布、失败率、重试次数。这些指标不需要依赖复杂工具,可以每 10 秒写一条 json 到日志中。当你怀疑系统出问题时,第一件事不是看代码,而是先看这些指标曲线,定位是调度问题、执行问题还是外部依赖问题。
5. 这个项目带给我的几个经验
最后说点不太好量化但特别重要的体会。第一,集群化不是银弹。如果你的 Agent 应用本身逻辑混乱、任务定义模糊,搬到集群上只会把问题放大十倍。先在一个 Worker 上跑通完整流程,再谈扩展。
第二,Agent 集群的运维成本远比想象中高。它不是一个“写完就完事”的系统,你要持续处理队列堆积、节点失联、内存增长这些乱七八糟的事。我个人觉得,如果任务量没有达到一定规模,单 Agent 加合理重试完全够用,不必为了“技术炫酷”而上集群。我当时上集群的契机,是被批量任务折磨到不行了才做的,而不是因为“别人都这么搞”。
第三,也是最重要的:多 Agent 集群的系统设计,核心从来不是“AI 有多聪明”,而是“任务能不能被可靠地分发、执行和回收”。模型能力是变量,基础设施才是常量。你把调度、容错、监控这些基础打牢了,后面换再强的模型,系统都能无缝跑起来。反过来说,模型再强,调度一团乱麻,系统还是会两天一小崩、三天一大崩。
这套集群我到现在还在跑,也不断往里加新的技能和工具。如果让我给还在犹豫要不要上集群的人一个建议,我会说:先用单 Agent 把你的业务逻辑验证清楚,再上集群解决规模问题。顺序反了,等着你的就是无穷无尽的返工。