
做Agent项目最头疼的不是模型调用也不是Prompt怎么调而是多个Agent之间怎么互相找到对方、怎么传话、怎么把活儿派下去。我组里上个月做的内部服务就是卡在这——每个Agent都是独立部署的HTTP服务互相之间的调用全靠人肉记录IP和端口加一个节点要改一圈配置某个实例挂了自己都不知道。后来我把这套东西抽出来做成了现在这个叫Agent-Reach的轻量级触达框架专门解决Agent之间的发现、通信和任务派发问题。如果你正在搭多Agent系统或者想把现有的单Agent服务升级成可以互相协作的架构这篇应该能帮你省掉不少弯路。Agent-Reach不是什么大而全的分布式中间件它做的事情很聚焦维护一份所有Agent实例的注册信息提供一套统一的消息投递接口再按策略把任务路由到合适的节点上。用下来最大的感受是——它把“谁在哪、怎么找、怎么叫得动”这三个问题一次性解决了而且代码量不多接入成本很低。1. 整体设计与思路拆解先聊设计思路。Agent-Reach这个名字里有两个词Agent代表接入系统的智能体单元Reach强调的是“触达”——意思是无论在什么网络位置、什么部署形态消息最终都能可靠地送达到目标Agent手上。这个理念贯穿了整个框架的设计。1.1 为什么需要这样一个中间层我在实际开发中踩过的最深的一个坑是Agent之间的调用关系一旦多起来很快就变成一张蜘蛛网。A要调BB要调CC又要调A每个服务都得知道对方的地址改一个端口就要通知所有关联方。后来演进到用K8s部署Pod的IP是动态的这种静态配置方案就彻底玩不转了。这个问题的本质是“寻址”和“路由”的职责放错了地方。每个Agent不应当关心其他Agent的网络位置它只需要知道自己要发出的消息类型和预期的接收方。至于对方在哪、有几个实例、该选哪个这些是基础设施该管的事。Agent-Reach就是把这部分职责收拢到了统一的注册与路由层。看个具体的场景一个客服系统里有意图识别Agent、情感分析Agent、知识库检索Agent和工单生成Agent。用户进来一句话需要按顺序或按条件调用其中几个。没有中间层的时候编排逻辑里全是硬编码的地址和手工写的重试逻辑有了Agent-Reach编排层只需要按Agent名称发消息具体投递给哪个实例、如果失败了换哪个节点再试都交给框架处理。另一点考虑是异构系统的兼容。我们的Agent有的是Python写的有的是Go写的还有两个老服务是Java的。如果每个Agent之间直接互通等于要在每种语言组合之间都实现一遍协议。有了Agent-Reach做收敛各语言只需要实现一套与注册中心交互的SDKAgent之间的数据格式统一走JSON或Protobuf跨语言通信的成本一下子就降下来了。1.2 核心角色与协作模型Agent-Reach的架构里一共三个角色注册中心、消息路由器和Agent节点。注册中心维护了一份实时的节点表记录每个Agent的名称、版本、实例地址、健康状态和自定义元数据。它相当于整张网的中心通讯录。节点接入时上报自己的信息随后周期性发送心跳。注册中心如果在指定时间内没有收到某个实例的心跳就把它的状态标记为离线不再把新消息路由给它。消息路由器是消息进出的门户。所有发给Agent的消息都先到达路由器由它根据目标Agent名称查找可用实例列表然后按照配置好的策略选出一个实例完成投递。路由器只做消息的中转不持有业务状态所以它是可以水平扩展的——多个路由器实例共享同一份注册表本质上没有单点瓶颈。Agent节点是实际的业务承载者。对外它向注册中心上报状态、接收路由器发来的消息对内它把收到的消息转交给业务逻辑处理再把结果异步或同步地回传给调用方。协作模型也很直观。调用方发给Agent-Reach一个请求附上目标Agent名称和载荷数据路由器从注册表取出目标Agent的可用实例按路由策略选出目标将请求转发过去目标Agent处理完后如果调用方设置了回调地址或请求ID就通过结果通道返回。整个过程调用方感知不到背后有多个实例的存在这对业务代码的侵入性是最小的。另外我还设计了一个回调机制请求中带一个replyTo字段目标Agent处理完可以把结果异步回调给发起方这样能把一些需要在多个Agent之间连续协作的长流程解耦成一条异步消息链。这个机制在后面的实操部分会详细演示。2. 核心细节解析与实操要点框架的骨架搭好之后真正有技术含量的是细节。这里逐个拆解实现过程中最关键的几个部分附带一些我踩过的坑。2.1 注册协议与心跳策略Agent启动后的第一件事是向注册中心发起注册请求上报以下信息Agent名称、实例ID、主机地址、监听端口、处理的消息类型列表、负载权重。注册中心收到后返回确认并把这个实例标记为在线。这里有个容易被忽略的点Agent名称和实例ID是两个不同的概念。Agent名称是逻辑上的服务标识比如“intent-recognizer”实例ID是物理上的唯一标识比如“intent-recognizer-node-3”。注册表里允许同一Agent名称下有多个实例ID路由的时候就要从这多个实例里挑一个。如果只记录名称不区分实例之后做版本升级或定向排查问题时会非常痛苦。心跳策略我采用的是最简单也最稳的方案每5秒发一次心跳连续3次心跳未收到就判定离线。为什么不把心跳频率调低我试过30秒一次确实能减少注册中心的压力但节点异常时发现故障的延迟太长——用户端会先感受到超时框架后面再怎么补救都显得慢了。5秒对绝大多数系统来说产生的负载可忽略不计故障发现的时间却能在15秒内完成属于性价比很高的参数区间。心跳包的设计也做了优化。早期我传了完整的注册信息后来发现没必要改成只传实例ID加一个时间戳即可。注册中心只需要确认“你还活着”详细的元数据只在注册和更新时发送。注意心跳的超时判定不要只看绝对时间建议结合分布式时钟的偏差。多机部署时如果某台机器的时间慢了半分钟它的心跳时间戳会永远落后于预期导致明明健康的节点被误杀。我的做法是在判定离线的代码里加一个时间偏差校准——每收到一次心跳动态记录与本地时钟的差值。2.2 消息封装与路由策略路由器接收的消息会经过一次统一封装。每个消息的头部包含几个关键字段目标Agent名称、消息类型、请求ID、发起方标识、超时时间、优先级。把这些字段放到头部而不是直接混在消息体里是为了让路由器不解析具体业务数据就能完成路由决策——这既是性能考虑也是安全考虑。路由策略是我经过多次试验后确定的三层递进第一层按消息类型过滤。一个Agent往往只处理特定类型的消息路由器先根据注册信息里的“支持的消息类型列表”把不匹配的实例全部排除。比如有个任务是文本分类那就只路由给声明支持“text-classify”消息类型的实例。第二层按负载权重选择。剩余的候选实例里按照注册时上报的权重做加权随机。默认权重是平均分配但如果某台机器配置更高可以给它加权重——这个逻辑和Nginx的加权轮询是一个原理。第三层按粘性会话绑定。如果消息头上带了sessionId并且之前在某个实例上处理过同一个会话的消息优先路由到那个实例保证会话状态的连续性。这一点在做多轮对话类Agent时非常重要——如果两轮对话被路由到不同实例上而实例又没有共享会话状态那用户会感觉机器人“失忆”了。消息超时控制也经历过调整。早期按全局统一设置超时时间结果发现耗时差异太大意图识别可能需要200毫秒知识库检索可能要800毫秒如果统一设300毫秒检索类请求会频繁超时。后来改成了注册信息里每个Agent声明自己预期的处理耗时路由器按这个值动态决定等待时间。2.3 可靠性设计失败重试与消息轨迹Agent之间的通信没有可靠保障就是纸上谈兵。我在实现中把失败场景分成了几类分别处理。一类是节点不存在或全部离线。这种情况直接返回错误码不该尝试重试因为重试一万次也不会成功。另一类是目标节点存在但投递超时。这种情况不能立刻下结论说节点坏了可能是对方在处理慢或网络抖动。此时路由器会从可用实例列表里排除当前节点挑另一个实例重试一次。如果第二次也失败就返回失败响应并把错误信息记录到日志里。重试的次数不要多我的经验是1到2次封顶。重试生效的前提是目标服务是幂等的——它重复处理同一个请求不会产生副作用。如果你的Agent业务不是幂等的比如它会生成工单或扣减库存那重试必须谨慎或者业务侧要自己实现去重逻辑。为了排查问题我给Agent-Reach加了一个简单的消息轨迹记录每条从路由器进出的消息都会打上一行结构化日志包含请求ID、目标Agent、路由的实例ID、耗时、结果状态。多个节点之间通过请求ID可以把整个调用链串联起来。这个功能在压测和线上问题定位时帮了大忙——没有它你只能对着两端日志胶水般地拼凑调用关系。实际操作中还发现有些Agent在处理消息时会抛出异常。这类异常会被SDK捕获然后包装成带有原始异常信息的错误响应回传给调用方。这里有个细节异常信息里往往会带有堆栈直接透传给调用方存在两方面问题——一是信息量太大二是可能泄露内部代码结构。我的做法是默认只在响应里带错误类型和提示信息堆栈只打印在目标节点本地日志里。排查的时候通过请求ID去目标的日志里查堆栈。3. 实操过程与核心环节实现这一节把Agent-Reach的完整接入过程展开讲一遍从初始化到业务调用再到联合调优按实际操作的顺序来。3.1 最小可运行环境的搭建Agent-Reach的核心不需要外部依赖我用Go实现了注册中心和路由器的参考实现Agent SDK则提供了Python和Go两个版本。在你自己的环境里用它之前需要准备三样东西一台能跑注册中心的机器1核1G就够起步至少两个Agent实例做互调测试一个简单的调用方程序可以是curl启动注册中心的命令很直接。假设你下载了release包解压后执行./agent-reach-server --port 7788 --data-dir ./data这里7788是服务端口--data-dir用来存注册表和消息轨迹的本地持久化文件。启动后注册中心会监听两个端口一个是给Agent SDK用的注册与心跳端口另一个是给调用方发消息用的HTTP端口。如果看到日志输出类似agent-reach server started on :7788说明服务起来了。验证一下注册中心是否正常运行可以请求它的健康检查接口curl http://localhost:7788/health返回{status:ok}就算通了。3.2 编写并接入一个Agent有了注册中心接下来写一个最简单的Agent示例。我这里用Python SDK演示其他语言SDK的接口思路是一样的。from agent_reach import Agent, Message agent Agent( nameecho-agent, port9001, register_tolocalhost:7788, message_types[echo], weight1 ) agent.handle(echo) def handle_echo(msg: Message): payload msg.get_payload() return { echo: payload.get(text, ), received_at: msg.get_timestamp() } if __name__ __main__: agent.start()这段代码做了四件事初始化了一个名为echo-agent的Agent声明它监听9001端口向注册中心注册并且能处理echo类型的消息。定义的处理函数接收一个Message对象取出载荷里的text字段原样返回。注意register_tolocalhost:7788这个参数它告诉SDK要把自己的地址信息上报给谁。如果你有多台机器这里就填注册中心所在机器的IP。启动这个Agent后在注册中心的节点列表里应该能看到一条新记录。通过HTTP接口查询curl http://localhost:7788/nodes?agentecho-agent返回值里包含实例ID、地址、端口、状态和注册时间。看到status:online就说明注册成功了。3.3 发送一条消息到AgentAgent接入完成后从外部调用它有两种方式一种是直接构造HTTP请求发给路由器的投递接口另一种是通过SDK的调用方法。先看直接HTTP调用的方式这样能直观地理解消息流转的路径。curl -X POST http://localhost:7788/messages \ -H Content-Type: application/json \ -d { target: echo-agent, type: echo, payload: {text: hello agent-reach}, timeout_ms: 3000 }这条命令做的事是向路由器发一个消息目标是echo-agent消息类型是echo载荷里带了一段文本。路由器收到后从注册表里查到有实例在线的echo-agent把请求转发给那个实例的9001端口。目标实例执行处理函数后返回结果路由器再把这个结果原样返回给调用方。响应里会包含这样几个字段请求ID、目标实例ID、处理耗时、以及业务返回值。如果你一次启动了多个echo-agent实例重复发请求会看到target_instance_id在多个值之间变化这说明负载均衡策略在工作。调用方是业务系统时更推荐用SDK少写不少样板代码from agent_reach import Client client Client(serverlocalhost:7788) result client.send_sync( targetecho-agent, message_typeecho, payload{text: hello}, timeout_ms3000, ) print(result)这个send_sync是同步发送会阻塞等待结果返回。如果业务里不想等可以用send_async拿到一个用于后续查询结果的任务ID。这个在后面会用到。3.4 参数调优与实例水平扩展Agent-Reach的一个实用价值在于扩容非常方便。新启动一个echo-agent实例只要把端口换掉名字保持echo-agent不变它会自动注册到同一个逻辑名称下。注册表里出现两个在线实例后路由器的负载均衡会自动生效不需要改动任何调用方代码。再聊调优参数。我这里总结几个上线前一定要过一遍的配置项给出的值都是在我自己的压测环境中实测过比较合适的起点心跳间隔5秒离线判定阈值3次心跳同步调用的默认超时3000毫秒失败重试次数1次消息轨迹日志保留最近14天如果你发现一个Agent实例的处理时间经常超过3000毫秒那同步调用的超时要相应调大或者这个实例需要拆分成多个并行处理的小任务。最忌讳的做法是盲目调大全局超时来掩盖单个节点的性能瓶颈——时间长了整个调用链的延迟都会涨上去。要验证水平扩展是否生效可以这样压一下wrk -t4 -c50 -d30s -s post.lua http://localhost:7788/messages如果只用一个在线实例QPS天花板很明显启动第二个实例后QPS应该有不小的上升同时单实例的CPU占用率不会一直顶着上限。这里post.lua是wrk的Lua脚本用来构造POST请求数据你本地需要提前准备好。3.5 异步回调链路的实现处理一个耗时需求时我碰到了同步调用不合适的场景某次任务要从知识库检索并生成摘要整个过程要好几秒。如果调用方一直是阻塞等待体验和资源占用都不理想。后来就把这条路改成了异步消息链。异步调用的核心是回调机制实现起来分成三段第一段调用方发异步消息带一个replyTo字段指向自己的消息接收地址client.send_async( targetsearch-agent, message_typesearch, payload{query: 分布式系统设计}, reply_toorchestrator, correlation_idtask-001 )第二段search-agent处理完把结果封装成响应消息按照replyTo字段发到orchestrator这个Agentfrom agent_reach import Agent, Message search_agent Agent(..., namesearch-agent) search_agent.handle(search) def handle_search(msg: Message): result do_search(msg.get_payload()[query]) # 注意这里是调用Agent-Reach的回调API系统会把消息发到reply_to指定的Agent agent.reply( msgmsg, payload{result: result} ) return {status: accepted}第三段orchestrator里注册一个处理器接收并继续后续流程from agent_reach import Agent orchestrator Agent(..., nameorchestrator) orchestrator.handle(search.result) def on_search_result(msg: Message): result msg.get_payload()[result] # 拿到结果后做下一步处理比如调用生成Agent这里最关键的细节是correlation_id。异步环境下响应消息可能来自任意一个实例顺序也可能错乱。用correlation_id把请求和响应关联起来才能在回调时正确匹配上下文。我在早期实现里没用关联ID结果同时发三个异步请求时回调回来的数据完全对不上号排查了很久才发现是这个环节的设计缺陷。4. 常见问题与排查技巧实录这个部分记录我在实际使用Agent-Reach过程中遇到过的典型问题以及对应的排查方法和解决思路。有一些是代码层面的bug有一些是使用姿势的问题整理成下面的速查表。现象可能原因排查方法解决建议Agent注册不上注册中心日志提示超时防火墙拦了注册端口在Agent机器上执行telnet server_ip 7788测试连通性放行端口或检查注册地址是否写错注册成功但消息路由不过去心跳中断导致被标记离线在注册中心节点列表里查状态是否为online检查Agent进程是否稳定心跳间隔是否过短部分请求超时但不是每次都超时某个实例响应慢但还没离线查看消息轨迹日志中该实例的平均耗时排查该实例的资源占用或持续处理能力A发的消息B收不到但B的实例在线消息类型不匹配核对注册信息里B支持的消息类型列表在Agent注册时正确声明所有支持的类型两个Agent之间循环调用导致死循环业务设计缺陷检查消息轨迹日志中是否有重复的请求ID链在编排层增加调用深度限制或人工确认环节下面把两个场景单独展开说因为它们太常见了而且很容易误判。4.1 节点“假死”问题碰到过一次很诡异的现象注册中心显示实例状态是online消息也路由过去了但业务方反馈响应特别慢偶尔还会超时。查了一通发现那个节点上的Agent进程还在但业务线程池里堆积了大量任务处理不过来。写代码里配置的消息处理线程数上限是2而这个节点不知什么时候被分配了大量请求线程池处理速度跟不上新请求排队严重看起来就像挂着了一样。这个问题的本质是“进程在线”和“能处理消息”不是一回事。后来我在Agent的API里增加了一个可选的处理能力上报——SDK会周期性地检查线程池的队列长度和最近请求的平均耗时如果发现积压超过阈值就在心跳包里附带一个degraded标记。路由器收到这个标记后会降低该实例的权重尽量把新消息分给空闲的节点。这个机制虽然简单但很有效。如果你的场景里存在明显的流量倾斜可以考虑在SDK层加上类似的报障逻辑。4.2 注册中心重启后Agent失联注册中心重启会造成所有Agent的心跳连接中断。这本身不是大问题SDK如果发现注册中心连不上会启动重连逻辑以指数退避的方式每3秒、6秒、12秒...递增重试直到恢复。实际遇到的问题是注册中心的持久化文件因为格式化或磁盘清理丢了重启后注册表是空白的。这种情况恢复起来比较费劲。Agent侧如果心跳正常会在下一个心跳周期把自己的信息重新上报所以恢复只是时间问题。但如果在重启后立即调用了Agent路由器会发现查不到目标直接报错。给个实用建议确保你调用的Agent都设置过持久化的注册表路径或者做好定期备份。Agent-Reach的--data-dir参数就是干这个的别为了省事不设。4.3 多实例场景下会话状态丢失这个前面提过一嘴但值得单独说。做多轮对话Agent时同一个用户的多轮对话如果被路由到不同实例而实例各自维护着会话状态对话就会“断片”——用户上一轮提供的信息下一轮Agent完全没印象。第一道防线是粘性路由让同一个sessionId始终打到同一个实例。这个策略我已经在路由层实现了。第二道防线是状态外部化不要把会话状态只存在Agent的内存里而要存到Redis这类共享存储中。这样即使节点重启或流量迁移新接手请求的实例也能从共享存储里加载完整上下文。两道防线配合使用才算是真正解决了会话连续性问题。5. 使用场景与扩展方向到这里Agent-Reach的核心能力已经全部覆盖了。最后聊聊它在实际项目里的几个典型用法和一些可以继续扩展的方向。5.1 典型落地场景第一个场景是多智能体协作系统。多个专职Agent各自负责一种能力比如做意图分类的、做实体抽取的、做知识检索的由一个编排Agent根据用户请求动态组织调用链。Agent-Reach在这里解决了服务发现和任务路由的问题编排层不再需要写死每个子服务的地址新加一个Agent的能力时也不会污染现有代码。第二个场景是异构系统消息桥接。公司内部可能同时存在老系统和新系统老系统没有Agent封装新系统也不想直接对接老系统的私有协议。可以让老系统通过一个适配Agent接入Agent-Reach等价于给老系统包了一层标准接口其他新Agent调用它就和使用普通Agent一样不需要知道背后的协议差异。第三个场景是本地多模型路由。如果你的应用里同时接入了多个模型服务比如轻量模型处理简单请求、大模型处理复杂请求可以用Agent-Reach做一层流量分发。目标Agent名称为“llm-router”可用的实例分别指向不同的模型服务通过权重或者消息头里的模型选择字段来决定走哪个模型。这个模式下上游业务无需关心模型部署在哪个服务器上也不需要知道模型版本号。第四个场景是内部工具链的自动化。我们的运维团队用Agent-Reach接了一个日志收集Agent和一个异常告警Agent。日志Agent持续分析服务日志发现异常时发消息给告警Agent告警Agent再调用通知渠道。整个过程没有人工参与链路短出现问题时也容易追踪。5.2 可以继续扩展的方向现在的版本是我为了自己团队的需求做的不少地方可以做更深度的增强。一个是灰度发布。当前注册信息里的元数据字段是预留了的但路由器还没有消费这些字段。如果把版本号写进元数据路由器就可以根据请求头里携带的版本偏好把流量按比例导到不同版本的实例上。这个逻辑实现起来并不复杂但需要业务侧对版本管理有统一规范。另一个是消息轨迹的可视化。目前只有结构化日志虽然有请求ID排查时还是要去多个日志文件里搜。后续可以导出一个标准的调用链数据格式配合开源的可观测性系统做成拓扑视图一眼就能看出消息是怎么在全网流动的。还有一个是Agent之间基于内容的路由。目前路由完全依赖消息类型做不到根据消息内容动态决定目标。比如同样是“订单”类型消息有些要发给国内业务组有些要发给海外业务组按内容动态路由就更有意义。这部分可以做但谨慎评估收益——内容解析本身就是不稳定的如果规则写错消息容易发错地方比固定路由更难查问题。5.3 个人使用体会整套框架从零到落地我最大的体会是“基础设施一定要克制”。刚开始我也冲动着想加很多功能比如分布式事务、消息持久化、复杂编排引擎后来都砍掉了。Agent-Reach只做触达和路由这一件事其他都留给上层的业务编排去处理。这样带来的好处是每次出问题都能很快定位到具体层次不会出现一个bug牵扯出好几个模块的情况。如果你要自己实现类似的东西建议把代码控制在单文件可以读完的规模。核心逻辑其实就三块注册表管理、心跳检查、路由选择。代码不需要多复杂能跑稳定就是胜利。未来的方向先不管把第一版接进生产环境转起来你自然知道哪些功能是真正需要的。最后分享一个有用的小技巧上线Agent-Reach时先跑一个带真实负载但流量可控的“影子模式”。也就是把路由器部署上但消息先不发往真实业务Agent而是打到日志里。跑一周看看注册、心跳、路由各环节是否稳定确认没问题再切真实流量。我因为这个习惯避免了好几次上线事故建议你也试试。