做了几年多Agent系统,说实话,一开始我根本没把通信协议当回事。当年想得很简单:Agent之间能发消息、能互相调用不就行了。直到系统从三五个Agent扩展到十几个,从单机挪到容器集群,我才意识到消息格式、服务发现、RPC这三个环节没做扎实,整个协作系统的可靠性和性能会一起崩掉。这篇文章就围绕多Agent协作系统的通信协议,讲清楚消息格式怎么设计、服务发现怎么做、RPC调用怎么选型和调优,目标是帮你构建一套高效可靠的Agent通信网络。如果你正在搭多Agent系统,或者想让现有Agent之间的通信更稳、更快,这篇应该对你有用。
1. 先把问题拆开:多Agent通信到底在难什么
1.1 通信协议是整个协作系统的语言中枢
多Agent系统的本质是多个独立运行的智能体协同完成任务。既然每个Agent可以有自己的运行环境、生命周期,甚至不同的技术栈,那一个用Python写的规划Agent和一个用Java或者Go写的执行Agent怎么交换信息?只能靠一套双方都能理解和遵守的约定,也就是通信协议。
我把这套协议拆成三个层面来理解。消息格式解决“怎么说”的问题,服务发现解决“找谁说话”的问题,RPC调用解决“怎么把话说完并且拿到结果”的问题。这三点不是孤立的,而是层层叠加。很多团队把精力全放在Agent的算法和提示词调优上,结果一到联调就卡住:消息解析不了、服务地址找不到、调用超时到崩溃。说到底,是通信这层地基没打好。
有个很直观的类比是嵌入式开发里的I2C和SPI。做过单片机通信的朋友都知道,I2C的速率虽然只有几百K,但胜在协议简单、接线少、多设备挂在同一条总线上也能稳定工作。Agent系统的通信协议其实也一样,信息密度高不高是一回事,但首先要稳定、可预期、可排查。你连消息格式都没统一,就好比I2C总线上一个设备用7位地址、另一个用10位地址,谁都找不着谁。
1.2 消息格式、服务发现、RPC三者相互牵连
三个层面不是先做完A再做B再做C的关系,而是设计时要一起考虑。举个例子,服务发现返回的实例地址、端口、协议版本,要不要带进消息的元数据?RPC请求的traceId,是不是也要在消息信封里跟着走,才能贯穿整条调用链?
我见过不少系统,消息格式用的是简单JSON,服务发现用的是固定配置文件,RPC则是自己拿HTTP加个路由硬拼。单体跑的时候没问题,一旦Agent数量上来、容器频繁重启,问题就全暴露了:配置文件里的IP早就失效了,消息里没有traceId导致链路追踪要人工拼日志,RPC超时了也没有统一的重试策略,每层各写各的,最后故障恢复全靠人肉盯。这一步的核心认知是:通信协议是一套整体设计,不是三个独立模块。下面三节分别拆开讲,但你真正落地时,要始终当作一个体系来考虑。
2. 消息格式设计:Agent之间交换信息的通用语
2.1 序列化格式怎么选
消息格式最底层的一个问题是序列化。Agent运行时,对象在内存里是一堆结构体,但通过网络发出去必须变成字节流。序列化格式决定了字节流的体积、解析性能、跨语言能力,以及最重要的——两端对字段变更的容忍度。
常用的几种格式我基本都在生产环境里折腾过,直接说结论。如果团队快、要快速验证想法,JSON是起步的选择,可读性强、调试方便、任何语言都有库。但JSON的问题也很明显:体积偏大、没有强类型约束、协议演进全靠自觉。比如一个Agent把用户意图字段从字符串改成嵌套对象,另一端的解析逻辑可能直接挂。
MessagePack和CBOR都是二进制化的JSON,目标都是在保留动态结构的同时压缩体积、提升解析速度。它们比JSON省流量,但一样不强约束字段类型,适合对payload大小敏感、但还没到需要强schema的阶段。CBOR有RFC 8949标准,在一些IoT嵌入式场景里更常见,Agent系统里也能直接用。
如果Agent数量多、接口会长期演进,Protobuf通常是最稳的选择。它有明确的schema定义文件(.proto),生成各语言的代码,序列化体积小、解析快、强类型校验。代价是要引入编译流程,每次改字段都要重新生成代码,学习成本比JSON高不少。我个人的习惯是:核心服务之间的内部RPC用Protobuf,边缘的调试接口和外部对接用JSON。两者不冲突,按场景切分就好。
做一个简单的对比:
| 序列化格式 | 体积 | 解析性能 | 强类型 | 跨语言 | 字段演进 | 适用场景 |
|---|---|---|---|---|---|---|
| JSON | 大 | 中等 | 弱 | 极好 | 需人工维护 | 调试、外部对接 |
| MessagePack | 中等 | 较快 | 弱 | 好 | 需人工维护 | 动态负载、嵌入式 |
| CBOR | 中等 | 较快 | 弱 | 好 | 需人工维护 | 标准化IoT场景 |
| Protobuf | 小 | 快 | 强 | 好 | schema支持 | 内部核心RPC |
选型的时候我总结了一条原则:先想清楚消息的生命周期。如果一个消息只是发出去然后被消费一次,怎么便宜怎么来;如果它要进消息队列、被多个Agent消费、可能未来还要变更字段,那最好一开始就用带schema的格式,省得后面再做数据迁移。
2.2 消息信封和核心字段
序列化格式定了之后,接下来要设计消息的整体结构。我习惯把消息分成信封和负载两层。信封是每个Agent都会读的通用部分,负载是业务数据。信封装得越规范,后面做路由、追踪、限流就越省事。
信封里我必带的字段有这么几个。messageId是全局唯一的消息ID,用来去重和追踪,比如执行Agent结果回调时通过messageId关联任务,就非常方便。topic或者type字段表示消息的语义类型,比如task_submit、task_result、heartbeat,路由和消费者可以靠它做匹配。timestamp是消息产生时间,很多排序和分析场景都依赖它。ttl是存活时间,尤其消息经过队列或者缓存时,超过ttl的消息可以直接丢弃,避免旧消息堆积。traceId更是不能少,一次完整的Agent协作任务会跨多个服务和网络跳转,traceId把整条链路串起来,排查问题全靠它。
我自己遇到过最典型的翻车:早期做Agent系统时偷懒,消息体直接塞了个大JSON,连版本号都没有,更别提traceId。结果生产环境里一个任务从规划Agent到执行Agent再到反馈回路,中间出现了循环调用,日志刷了几万行,但没法定位是哪一条消息触发的。后来补了traceId,一条命令就能看完整调用链路,效率完全不一样。
负载部分倒是相对自由,但建议至少包含payload和error两个子段。很多初版设计只考虑正常流程,忽略错误返回。结果Agent一旦处理失败,另一端面对的是空响应,只能瞎猜。显式在负载里带上错误码和错误消息,是分布式系统里很便宜的防御性设计。
2.3 版本兼容与字段演进
消息格式设计得再好,挡不住业务调整。需求一变,消息字段就要增删改。最痛的是线上有多个版本的Agent同时运行,你没办法让所有实例同时升级。这就需要格式在演进过程中保持兼容。
几条基本原则值得刻在脑子里。第一,只能增加字段,不要修改已有字段的类型,更不要删除字段。加字段是老客户端解析新消息时容易忽略的,但至少不会崩;改类型则是直接破坏解析。第二,如果是带schema的格式,新增字段要标记为可选,别在加字段时顺手设置为必填,否则旧代码解析时校验不过。第三,即便通信双方都是你控制的代码,消息里也一定要放版本信息。等线上出了"两边代码都是最新的但还是对不上"的问题时,版本号能救你一命。
实际操作里,我还会在加载消息schema时做一次前后兼容性检查,把新增字段按版本号记录在文档里。虽然麻烦点,但当接口超过几十个版本时,这套记录就是整个团队的保命文档。
3. 服务发现:让Agent在集群里找到彼此
3.1 集中式注册中心还是去中心化
消息格式解决的是"消息长什么样",但Agent要通信,得先知道对方的IP和端口。小规模系统里可以用配置文件写死,但Agent是动态的,容器重启、水平扩缩容都会让地址漂移。服务发现就是解决动态寻址这件事。
常见的方案分两大类。一类是集中式注册中心,所有Agent启动时向中心注册自己的地址、端口、能力标签,消费者发现服务时去中心里查。etcd、Consul、ZooKeeper都是这类。另一类去中心化方案,比如基于DNS、基于gossip协议,Agent之间互相通告对方。Agent系统里我建议先上集中式,理由很简单:一致性容易保证、排查方便、生态成熟。DNS结合Consul也能实现服务发现,但TTL缓存带来的更新延迟在某些场景下会影响故障转移速度。
集中式里,etcd和Consul我用得比较多。etcd的watch机制很适合服务发现,Agent监听某个前缀的key变化,注册中心有增删时立刻收到通知;租约机制则能让宕机的Agent实例在一段时间内自动被清理。Consul的优势是多数据中心、自带健康检查和DNS接口,但重一点。ZooKeeper在Java生态里常见,不过它的CP模型和会话机制有时候比etcd繁琐,非强需求我不会首选。
3.2 注册、心跳和健康检查
服务发现不能只做一个"在线列表",它还要保证列表里的地址都是真正能用的。这就是健康检查的作用。
最基础的做法是租约加心跳。Agent启动后向注册中心put一个带租约的key,比如TTL是10秒,Agent每个5秒续约一次。如果Agent进程挂了,续约停止,租约过期后注册中心自动删除key。这里有个很实际的经验:租约TTL不要设太短,也别设太长。太短,网络抖动或者GC停顿一次,Agent就被误杀摘除了;太长,Agent真的挂了要半天才能从列表里消失,调用方会一直往死地址上发请求。我的经验值CP里把TTL设在10到15秒,心跳间隔5秒,既避免了误杀,也能在十几秒内完成故障感知。
除了租约,注册中心还要做主动健康检查,常见三种:TCP连通性检查、HTTP健康接口检查、gRPC健康检查。TCP最基础,但只要端口通就认为服务健康,不够精确。HTTP接口通常是/health或/healthz,可以自定义更多检查逻辑。如果用了gRPC,直接用标准healthCheck协议,省去额外的HTTP端口。我一般组合使用:注册中心做TCP加HTTP检查,Agent自己内部再做组件级健康状态上报。
3.3 容器场景下的服务发现,躲不开的几个问题
容器化部署几乎成了Agent系统的标配。容器带来便利,也让服务发现的坑变多了。第一个问题是IP漂移,容器重建后IP就变了,服务发现必须能快速感知并更新。用etcd的watch机制通常几百毫秒内能通知到客户端,但前提是客户端没缓存太旧的数据。第二个问题是容器编排平台的负载均衡和服务发现是两层概念,Kubernetes的Service是给外部流量用的,Agent内部的gRPC调用则更推荐直连Pod地址,配合客户端负载均衡,减少中间转发延迟。
这里多说一个坑:有些Agent容器是带状态的,尤其在消息处理中不能简单重启。这时服务发现不只是找地址,还要约定好实例的唯一标识和会话亲和性。不要把容器名当唯一标识用,因为重建后名字可能变;给Agent实例分配一个持久ID并注册到元数据里,比什么都可靠。
4. RPC调用:让Agent之间的协作像函数调用一样自然
4.1 RPC的本质和框架选型
消息格式和服务发现都有了,Agent之间真正的调用动作靠RPC完成。RPC的目标是让一个Agent调用另一个Agent的能力,像调用本地函数一样自然。听起来简单,但跨网络、跨进程之后,延迟、重试、超时、参数传递、错误处理全都变了。
主流的RPC框架我对比过几个。gRPC是当前多Agent系统里最常见的选择,基于HTTP/2,支持多路复用、流式传输、Protobuf强类型定义,生态完善。Thrift在数据交换场景里比较出名,但Agent调用这种服务间通信场景,使用率相对低。JSON-RPC简单直接,跟JSON消息格式同构,适合轻量级交互和高频调试。
| RPC框架 | 传输层 | 序列化 | 流式 | 强类型 | 生态 | 推荐场景 |
|---|---|---|---|---|---|---|
| gRPC | HTTP/2 | Protobuf | 支持 | 强 | 极好 | Agent内部高频调用 |
| Thrift | TCP/HTTP | Thrift IDL | 支持 | 强 | 好 | 跨语言数据服务 |
| JSON-RPC | HTTP/TCP | JSON | 弱 | 弱 | 中等 | 轻量调试、跨系统对接 |
gRPC的HTTP/2多路复用我很喜欢。传统HTTP/1.1每个请求一个连接,并发高时连接数爆炸。HTTP/2是一条连接复用多个流,Agent之间如果调用频繁,可以显著减少握手开销和端口占用。这在容器集群里特别重要,因为每个Pod的连接数上限和文件句柄都是实打实的资源。
4.2 同步、异步与流式调用怎么选
RPC的调用模式直接决定Agent协作的体验。同步调用是一端发请求,阻塞等待结果,最简单的模式。如果Agent B需要调用Agent C的规划结果才能继续,那就适合同步。同步模式对超时最敏感,一旦滞后就会拖慢整条链路。异步调用是一端发完请求立刻返回,结果通过回调或者轮询获取返回,适合"能把任务扔出去就不用管"的场景,比如定时任务分发。流式调用则是持续的流,gRPC单向流和双向流都支持,适合大段文本生成、上下文持续交互相这类场景。
Agent协作系统里,三个模式都会用到。比如执行Agent跑一个耗时的分析任务,规划Agent不可能一直傻等,这时异步加回调最合适。但如果两个Agent在做紧密的协商式推理,需要持续交换中间结果,双端流式反而更顺手。选型的核心是别过度设计,同步能解决问题就先同步,从同步改成异步容易,从异步改回同步会让你怀疑人生。
4.3 超时、重试与熔断的三重防线
RPC调用一旦跨网络,失败的维度就变得复杂。网络丢包、服务端过载、处理超时、进程重启,每一种都会让调用方处于不确定状态。要用超时、重试、熔断组成三道防线。
超时要设置并传播。gRPC里的deadline概念是整条调用链共享的,调用方设置一个总超时时间,每个中间节点消耗一部分,而不是让每一跳各自设一个固定超时。很多系统最初只在最外层设超时,结果内部服务各等各的,整体延迟直接失控。合理的做法是类似预算分配:最外层30秒,每层传递时递减,形成一条时间预算链。
重试不能简单“多打几次”。如果没有幂等保障,同一个任务被重复执行,可能造成灾难性后果。重试间隔要使用指数退避加随机抖动,避免所有客户端同步重试导致服务端雪崩。比如第一次失败等1秒,第二次等2秒,第三次等4秒,再随机加减最多100毫秒。重试次数也要限制,一般两到三次就够了,不要无限重试。这里有个血泪教训:我见过一个Agent调用链,因为反复重试,把一个本来延迟300毫秒的单查询放大成了后端1分钟的高负载,整个集群被打挂。
熔断则是把失败控制在源头。连续失败次数达到阈值,熔断器打开,接下来的请求直接快速失败,不再排队等超时,给后端恢复的时间。等后端恢复了,熔断器再半开,放几个试探请求,成功后就关闭。这套机制配合超时和重试,才算完整的一整套RPC可靠性方案。
5. 实战实录:一个多容器Agent系统的通信落地
5.1 拓扑设计与容器编排
前面说的概念,落到实际场景里更容易理解。拿一个常见的多Agent部署来说:3个容器,分别是etcd注册中心、agent-core核心调度容器、agent-executor执行容器。我需要让它们之间通过前面讲的通信协议安全稳定地协作。
拓扑很清晰:agent-core启动时把自己的能力注册到etcd,agent-executor也注册进去。agent-core通过服务发现找到executor实例,然后用gRPC把任务分发给它,executor处理完通过RPC回调结果。用docker compose来编排,一个简单但完整的通信网络就这样搭起来了。
docker-compose.yml的核心片段大致是这样:
services: etcd: image: quay.io/coreos/etcd:v3.5.5 ports: ["2379:2379", "2380:2380"] command: etcd --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd:2379 agent-core: image: agent-core:latest depends_on: [etcd] environment: ETCD_ENDPOINTS: etcd:2379 AGENT_ROLE: core ports: ["8080:8080"] agent-executor: image: agent-executor:latest depends_on: [etcd] environment: ETCD_ENDPOINTS: etcd:2379 AGENT_ROLE: executor这里最关键的配置是让所有Agent统一通过etcd这个服务名来发现注册中心,而不是硬编码容器IP。容器重建后IP会变,但服务名不变,这本身就解决了一大半的地址漂移问题。
5.2 5条命令验证整张通信网络
架构搭好之后,可以先不用写业务代码,用5条命令把通信网络本身验证一遍,非常高效地暴露问题。
第一条,拉起整个环境:
docker compose up -d第二条,确认三个容器都活着:
docker compose ps正常输出里,3个服务的STATUS都是Up。第三步才是关键,验证服务发现是否生效。直接在etcd里看注册了哪些Agent节点:
docker compose exec etcd etcdctl get /agents --prefix --keys-only如果agent-core和agent-executor都正确注册,这里能看到两个带租约的key,比如/agents/agent-core和/agents/agent-executor。看不到或者只有其中一个,就要查Agent的注册代码了。
第四步,验证RPC调用。用grpcurl直接调agent-core的接口:
grpcurl -plaintext -d '{"task_id":"001","type":"analysis"}' agent-core:8080 agentproto.Planner/Plan返回里能看到调用成功的结果。第五步,观察执行端日志:
docker compose logs -f agent-executor这里能看到executor收到了通过etcd服务发现找到并用gRPC调过来的任务请求,处理完成后再把结果回调回core。
有这套验证方法,通信网络是否正常在5分钟内就能判断出来。我每次新加一个Agent容器,都会先用这个流程跑一遍,确认它注册了、能被找到、能响应RPC,然后再开始写业务逻辑。
5.3 踩过的坑和排查方法
这套架构跑起来之后,坑是一点一点填平的。说几个有代表性的。
第一个是RPC超时问题。线上出现过“cannot finish rpc call in 30 seconds”的报错,一眼看到是RPC调用超过30秒被中断。排查下来发现,agent-executor要执行一个耗时分析任务,但调用方的deadline是全局30秒,后端处理需要40秒,所以执行到一半就被取消了。这个问题的根源不是机器慢,而是超时预算设置没考虑任务实际耗时。后来我把耗时任务改成异步模式:任务接口先返回这次的执行ID,真正的结果通过回调或查询方式获取,问题就解决了。
第二个是连接被服务端关闭。日志里出现类似server closed abruptly的错误,字符流读一半连接断了。这类问题在容器环境里极其常见,多数是负载均衡器或网络代理的空闲连接超时导致连接被回收,客户端还在用同一个连接发请求,自然失败。解决方式是让客户端定期重建连接、对断连的RPC做一次安全重试、同时把gRPC的keepalive打开,在连接空闲时也发送保活ping。
第三个坑在服务发现的缓存上。Agent客户端从etcd watch到变更通知有很大的延迟,可能拿到已经失效的地址。后来我在客户端加了定时全量刷新,比如每60秒强制重新拉一次节点列表,同时保存上一次请求的失败地址,短时间内不再把请求发过去。这个改动看着土,但效果立竿见影,故障转移时间从分钟级降到了秒级。
整体来看,通信网络的调优是个持续逼近的过程,你无法一次做到完美,但可以设计出能够快速排查、快速恢复的协议结构,这是最值得投入的部分。
6. 写在最后:通信设计里我最看重的三件事
做了这么多Agent系统的通信改造,让我挑三件最重要的事,一定是这三件。
第一件是消息格式的schema化和版本管理尽早做。业务刚起步时,多个Agent消息直接传JSON很爽,但Agent种类超过三四个、接口开始互相依赖时,没有schema的代价就是你改一个字段,所有接收方都要人肉排查一遍。Protobuf带来的额外工作,会在中后期百倍地赚回来。
第二件是可观测性是通信设计的标配,不是可选项。traceId从第一天就要打上,日志、消息、RPC调用全链路贯穿。我见过太多团队排查Agent协作问题时,根本不知道一条消息从哪来、到哪去,全凭猜。有一个贯穿的traceId,至少能把故障排查时间缩短一个量级。
第三件是超时、重试和熔断永远要一起设计。只设超时不设重试,临时故障时任务就白白失败;只设重试不设熔断,后端一抖动整个集群就被重试打垮。这三者是一套互相关联的机制,必须在协议设计时就一起考虑,而不是上线之后再补救。
通信协议是Agent协作系统的骨架,虽然不像算法和Agent能力那样光鲜,但它是所有上层智能稳定运行的前提。希望这篇整理能帮你把这块骨架搭得更牢靠,少走一段我当年走过的弯路。