1. 从一场联调事故说起Agent 之间为什么总失联上个月我们内部做一次多 Agent 协作的压测场景很简单一个规划 Agent 接到用户需求后拆出三个子任务分发给检索 Agent、代码生成 Agent 和质检 Agent。听起来很常规对吧结果第一次跑通就翻了车。规划 Agent 拿着子任务的执行结果去做汇总发现三个子 Agent 的响应里有两个超时一个返回了别的主线的中间结果。日志里全是Connection refused和Task not found。当时我们几个工程师对着监控面板面面相觑每个 Agent 单测都是好的服务也都活着为什么一协作就变成一锅粥问题出在一个被绝大多数人忽视的环节Agent 之间的寻址和投递完全是裸奔的。单体 Agent 内部怎么调函数都行一旦拆成多个 Agent 独立部署、各自维护状态你面对的就是一个分布式系统最经典的三个难题——服务发现、可靠投递、失败恢复。当时市面上有不少编排框架但要么太重强制你上全套工作流引擎要么太轻只做 HTTP 转发真正适合中小团队自建 Agent 网络的通信底座几乎是空白。我花了三周时间写了一个叫 Agent-Reach 的协调层组件用来解决上面这堆破事。这篇文章就把它的设计思路、核心实现、以及我在真实压测中踩过的坑全部摊开讲。适合正在搭多 Agent 系统、被互联互通折磨得想骂人的同学参考。它不是银弹但能让你从天天救火变成睡个安稳觉。2. 设计之前先把Agent 通信这件事拆明白2.1 三种通信模式你混用了吗动手写代码之前我先把 Agent 之间的通信模式梳理了一遍。几乎所有协作场景都逃不出这三种第一种是请求-响应规划 Agent 派活给执行 Agent等它干完把结果拿回来。这是最常见的模式也是最容易出问题的因为一次请求的完整链路包含派活、执行、回传、确认任何一环断了对调用方来说都是超时。第二种是事件广播比如一个 Agent 监测到配置变更需要通知所有相关 Agent 刷新缓存。这种模式对送达的要求没那么严格漏一两个订阅方可以接受但你不能把关键事件也做成广播——那纯粹是给自己埋雷。第三种是流式推送适合长时间运行的任务比如代码生成 Agent 每产出一个文件就往仓库 Agent 推一条消息。流式场景里真正难的不是推而是断点续传和消费位点管理。Agent-Reach 的核心思路很简单所有模式统一抽象成消息只不过消息的投递语义不同。请求-响应是request类型广播是event类型流式推送是stream类型再加上一个ack类型专门做确认回执。类型一收拢后面的存储、路由、重试逻辑就都能统一处理了。当时我自己的踩坑经历是第一版把所有通信都 HTTP 同步调以为最简单。结果广播一多Agent 之间互相等链路一长就雪崩。后来才意识到同步调用只能用于请求-响应事件广播和流式推送必须走异步队列这是架构层面的红线不是优化层面的喜好。2.2 注册中心不Agent-Reach 用路由表 租约很多人的第一反应是上 ZooKeeper 或者 etcd 做服务发现。但我做 Agent-Reach 时的约束条件很明确不想引入额外的基础设施团队里没人愿意专门维护一个注册中心集群。所以我把服务发现做成了基于租约lease的轻量路由表直接嵌在协调节点进程里。每个 Agent 启动时向 Agent-Reach 的协调节点注册自己注册信息包括Agent 编号、服务名、能力标签比如capability:codegen、回调地址、以及一个 TTL 租约。协调节点维护一张内存路由表Agent 每隔 TTL/2 的时间续租一次。租约过期没续上路由表就把这个 Agent 标记为不可用再等一个宽限期就彻底剔除。这套机制跟注册中心比牺牲了跨节点容灾和历史变更追溯换来了零依赖、毫秒级感知故障对小规模 Agent 网络几十个节点以内完全够用。你要问我什么时候该上 etcd当 Agent 数量超过一百、协调节点需要多活、或者你需要严格的版本兼容管理的时候再考虑不迟。路由表里还有一个容易被忽略的设计能力路由。调用方不指定具体 Agent 编号只声明我要一个能做代码生成的 Agent。协调节点会根据能力标签、当前负载、租约新鲜度三个维度打分选一个最合适的投递目标。这个设计让 Agent 网络具备了一定的弹性——某个 Agent 挂了能力路由会自动把任务分给同能力的另一台。2.3 为什么必须引入收件箱我见过不少团队做 Agent 通信直接在回调地址上做 POST然后把任务 ID 挂在 URL 上。表面看能跑但有一个致命问题Agent 执行任务期间可能重启、可能断网重启之后回调地址可能都变了。这时候任务状态就丢了你根本不知道它执行到哪一步。Agent-Reach 给每个 Agent 维护一个持久化收件箱。所有发往该 Agent 的消息先进收件箱Agent 处理完一条弹出一条。收件箱的存储我用的是本地嵌入式数据库SQLite 就够用单机场景没必要上 PostgreSQL每条消息记录message_id、type、payload、status、create_time、attempts六个字段。消息的流转状态机是pending → processing → done或者pending → processing → failed → pending待重试。收件箱的价值在于Agent 的消费进度不依赖进程内存重启后从数据库里接着干。这本质上就是 Outbox 模式在 Agent 场景的应用。我当时想明白这一点之后很多任务神秘消失的问题都迎刃而解了。3. Agent-Reach 的核心实现注册、路由、投递、回执3.1 注册与租约续期的代码骨架Agent 端的启动注册逻辑我用 Python 写了个示例实际项目里不管什么语言套路是一样的import asyncio import time import httpx class AgentClient: def __init__(self, agent_id, coordinator_url, callback_url, ttl30): self.agent_id agent_id self.coordinator_url coordinator_url self.callback_url callback_url self.ttl ttl self.lease_task None async def register(self, capabilities): payload { agent_id: self.agent_id, callback_url: self.callback_url, capabilities: capabilities, ttl: self.ttl } async with httpx.AsyncClient() as client: resp await client.post(f{self.coordinator_url}/register, jsonpayload) if resp.status_code 200: self.lease_task asyncio.create_task(self._keep_lease()) async def _keep_lease(self): interval self.ttl / 2 while True: await asyncio.sleep(interval) async with httpx.AsyncClient() as client: await client.post( f{self.coordinator_url}/renew, json{agent_id: self.agent_id} )这里有一个细节续租间隔我特意设为 TTL 的一半而不是 TTL。网络抖动是常态如果间隔设成接近 TTL一次偶发超时就会导致 Agent 被误判下线然后能力路由把任务发给别的 Agent造成重复执行。TTL30 秒、每 15 秒续租一次既能容忍一定网络波动又能让故障感知控制在 30 秒内算是一个经过压测验证的平衡值。协调节点那端维护租约的数据结构也很直接class LeaseRegistry: def __init__(self): # agent_id - {capabilities, callback_url, last_renew, status} self.agents {} def renew(self, agent_id): if agent_id in self.agents: self.agents[agent_id][last_renew] time.time() self.agents[agent_id][status] online def sweep(self, now, ttl, grace_period): # 定期清扫过期租约 for agent_id in list(self.agents.keys()): last self.agents[agent_id][last_renew] if now - last ttl: self.agents[agent_id][status] suspect if now - last ttl grace_period: del self.agents[agent_id]grace_period这个参数我吃过亏后面专门讲。现在先记住一个原则判定下线要慢标记可疑要快真正剔除要更慢。宁可让一个 Agent 被短暂重复投递也不能让一个活着的 Agent 被永久剔除。3.2 消息投递的三段式入队、分拣、回执消息从调用方发出到被 Agent 消费Agent-Reach 的处理管线是三段式的第一段入队。调用方调用POST /dispatch协调节点把消息写入目标 Agent 的收件箱状态置为pending然后立刻返回一个message_id。注意这里不是同步转发无论目标 Agent 是否在线消息都已经落库了。这个设计保证调用方不需要等目标 Agent 就绪消息一定不会丢。第二段分拣。Agent 端有一个长轮询的消费者线程每隔几百毫秒向协调节点拉取自己收件箱里statuspending的消息。为什么用拉而不是推因为推模式下 Agent 重启期间推送的消息会堆积在协调节点的内存缓冲里Agent 恢复后容易重复消费或漏消费。拉模式天然支持断点续传Agent 重启后从数据库里恢复自己的消费位点再继续拉。这就是前面说收件箱的价值落地。第三段回执。Agent 处理完消息后调用POST /ack传message_id和处理结果。协调节点把消息状态从processing改为done。如果 Agent 处理失败调用POST /nack协调节点根据attempts字段判断是重试还是进入死信。调用方怎么拿到最终结果每个request类型消息都有一个reply_to字段指向调用方自己的收件箱。目标 Agent 处理完结果作为一条reply消息进调用方的收件箱调用方拉取即可。你会发现每个 Agent 既是消息的生产者也是消费者整个网络是对称的。这个设计让 Agent-Reach 天然支持多级流水线Agent A 派活给 BB 的回复进 A 的收件箱A 继续往下派。3.3 重试策略指数退避与死信队列消息重试是最容易拍脑袋设计的部分。我第一版写的是固定间隔重试 3 次每次间隔 5 秒。实测下来问题很大如果目标服务因为过载而拒绝固定 5 秒重试三次等于在人家最忙的时候又捅了三刀。正确姿势是指数退避加抖动。重试间隔 base_interval * 2^attempt再乘以一个随机因子比如在 0.8 到 1.2 之间浮动。base_interval 建议 2 秒起最大间隔封顶 120 秒。同时设置max_attempts超过之后进死信队列由人工或其他兜底 Agent 处理。这是我实测下来的重试参数表可以直接抄参数值说明base_interval2s首次重试的基础等待时间max_interval120s重试间隔封顶避免无限拉长backoff_factor2指数增长的底数jitter0.8 ~ 1.2随机抖动避免惊群max_attempts5超过进死信队列dead_letter_ttl7d死信消息保留时间还有一个坑重试要尽量在协调节点做不要在 Agent 端做。如果你让 Agent 自己重试Agent 重启后重试状态就丢了而且无法全局控制重试频率。协调节点统一管重试才能和路由表联动——目标 Agent 已经下线了就直接把消息重新路由给另一个能力相同的 Agent而不是傻等。4. 压测中踩过的三个坑你以为稳了其实全是隐患4.1 坑一租约宽限期设置不当导致的脑裂式重复消费第一次压测跑 50 个并发任务时我观察到一个诡异现象同一份任务被执行了两遍而且两个执行 Agent 都不认为自己有问题。查下来的根因是租约宽限期设太短。我当时 TTL 设 15 秒宽限期设 5 秒。某次压测时协调节点的 GC 停顿了 8 秒期间所有 Agent 的续租请求都没处理。等 GC 结束协调节点认为所有 Agent 都过期了把任务重新路由给备用 Agent。原 Agent 其实只是没续上租任务还在正常执行于是两边同时处理产生重复结果。修复方案分两层。第一层宽限期改成 TTL 的两倍也就是至少 30 秒让 GC 之类的偶发停顿不至于引起大规模误判。第二层投递时带上execution_tokenAgent 每次真正开始执行前要把 token 从pending抢到processing这个操作是原子的。谁抢到 token 谁执行抢不到的自动放弃。这相当于给消息投递加了一把分布式锁重复路由的问题从根上被堵住了。4.2 坑二Agent 间循环调用引发的消息风暴Agent 协作系统有一个特别隐蔽的陷阱循环调用。A 给 B 派活B 处理不了转给 CC 觉得应该找 A于是一圈一圈转下去。由于每条消息都在收件箱里有记录这个循环不会造成传统意义上的死循环——它是会结束的但每一步都在产生新消息最终把协调节点的消息表撑到几十万行。我在压测里设计了一个查不到数据就转给上一个 Agent 补充的流程结果三条消息在四个 Agent 之间转了几十轮消息表膨胀到十几万条SQLite 的写入性能崩了。解法有两个缺一不可。第一个是跳数上限每条消息加一个max_hops字段默认 10每经过一次转发减一归零就把消息丢进死信。第二个是转发轨迹追踪协调节点在消息的trace字段里记录所有经过的 Agent 编号每次转发前检查 trace如果发现目标 Agent 已经在自己这条轨迹里说明出现了环直接终止并告警。这两个机制加起来循环调用最多造成一条死信消息不会拖垮整个系统。4.3 坑三回调地址漂移收件箱成了幽灵信筒第三个坑来自一个很日常的操作某个 Agent 因为要扩容用同样的 Agent ID 在新机器上重新部署。新机器 IP 变了回调地址自然变了。老机器的进程没完全退干净还在续租于是路由表里出现了两个相同 Agent ID 的条目一个指向新地址一个指向旧地址。旧地址的收件箱消息全部投递失败新地址的 Agent 却完全不知道有人给它发过消息。排查了很久才发现问题出在注册协议太宽松Agent ID 相同就直接覆盖注册信息不做版本校验。修复方案是给注册请求加一个instance_id机器级唯一 ID协调节点发现同一 Agent ID 重复注册时强制要求新的注册带上epoch字段epoch 必须大于旧实例否则拒绝。老实例的租约续期请求也会被拒绝因为它携带的 instance_id 已经不是当前活跃实例了。这个坑给我的教训是Agent ID 只能标识逻辑身份不能标识物理实例。凡是涉及物理实例的注册、续租、回调都必须以 instance_id 为准。有点类似 HTTP 的 ETag 机制——光靠 URL 寻址不够还得校验版本。5. 稳定性之外的进阶优化负载感知、局部分片、可观测性5.1 负载感知路由别把所有任务堆给同一个 Agent能力路由如果只看在线有对应能力压测时就会暴露一个问题所有任务被均匀地其实是轮询地分发给所有 Agent但每个任务的复杂度差别巨大。处理简单任务只要 200 毫秒的 Agent 和处理 5 分钟重度任务的 Agent 被同样对待忙的忙死闲的闲死。我在 Agent-Reach 里加了一个体重指标每个 Agent 在续租时上报自己的实时负载包括队列深度收件箱 pending 消息数、最近一分钟处理耗时 P95、当前并发数。协调节点在能力路由打分时这三个指标各占权重 30%、40%、30%选得分最优的 Agent 投递。这个优化上线后压测里的 P95 延迟直接降了 37%。原理不复杂从谁有空转给谁变成谁最快把活干完转给谁。负载感知本质上做的是全局调度而不是简单的负载均衡。5.2 收件箱局部分片单 Agent 收件箱的一百万条消息问题压力测试做到单个 Agent 收件箱超过 100 万条消息时SQLite 的select * from inbox where statuspending limit N开始出现明显的性能退化。原因是 pending 消息和已处理消息混在同一张表里没有索引覆盖全表扫描越来越慢。修复方法很粗暴但很有效按status做局部分区。pending消息存一张只有几百条记录的热表done消息定期归档到冷表failed消息单独存放。热表因为体积小查询永远是毫秒级冷表只做历史追溯不在乎慢。这个优化带出一个通用经验任何消息系统热数据和冷数据必须物理分离。不要因为数据量还没那么大就偷懒等到 100 万条再处理代价是几小时的迁移和一堆线上事故。5.3 消息轨迹的三种监控视图最后一个大家容易忽略的模块是可观测性。Agent 通信链路比单体接口长得多出问题的时候如果没有轨迹追踪排查一天都不一定有结果。Agent-Reach 里我实现了三种监控视图都是在消息的trace字段基础上做的链路视图输入一个message_id画出它从发出、经过哪些 Agent、最终完成的完整时间线每一跳的耗时都标出来。排查任务到底卡在哪一步时这张图是救命稻草。队列视图每个 Agent 的收件箱 pending 数量、处理速率、平均等待时间实时展示。队列深度暴涨往往比接口延迟更能提前暴露系统瓶颈。失败归因视图所有 failed 消息按失败类型聚类看看是超时、连接拒绝、还是处理异常。如果连续五分钟超时占比超过 20%自动告警。这三种视图加起来让我从事后看日志变成了事前看趋势。有一次压测中发现某个 Agent 的队列深度缓慢上涨但接口延迟正常顺着队列视图往下查发现是这个 Agent 在处理某个特定类型的消息时出现线性重试每次都要等满 120 秒。如果没有队列视图这个问题要等用户投诉才能发现。6. 收尾Agent-Reach 适用的边界以及我最后的建议写了这么多得说点实在话收个尾。Agent-Reach 不是什么高大上的框架它的定位就是自建多 Agent 系统时的一块通信拼图。我最初做它是因为实在受不了 Agent 之间裸连 HTTP 导致的各种玄学问题其实一点都不玄全是分布式系统的基本问题只是分散在 Agent 场景里显得特别难查。适用边界说一下Agent 数量在几十个量级、部署在同一个内网、不需要跨数据中心强一致性的团队用它最合适。如果你的场景是几百上千个 Agent、要求多区域容灾、需要严格的消息事务语义那么建议直接考虑成熟的 MQ 或工作流引擎Agent-Reach 的能力模型会变成你的限制。最后分享几个我实际用下来的体会第一先把失败场景想清楚再写正逻辑。Agent 通信 90% 的代码都是在处理消息丢了怎么办Agent 挂了怎么办重复消费怎么办。我第一版把正逻辑写得很漂亮后来 80% 的时间都在补失败路径。如果重来我会先写故障注入测试。第二给每条消息留下足够的元数据。message_id、trace、max_hops、execution_token这些字段看似废话但每一个都在实际事故里救过我。别嫌消息头重宁多重十个字段不要在排查时因为没有 trace 而看半天。第三把重试、超时、路由这些逻辑统一收敛在协调节点。Agent 端保持无状态是最好的这样你随便怎么缩扩容都不用担心 Agent 端的本地状态丢失。Agent-Reach 的核心价值就是把稳做到了协调层而不是指望每个 Agent 自己自觉。如果你也在搭 Agent 网络希望这篇分享能帮你少踩几个坑。通信底座稳了Agent 的智能才能真正发挥出来——不然再聪明的 Agent消息都送不到对方手里那不叫协作叫互相折磨。