做AI应用这么久,我越来越觉得一个有意思的悖论:大家拼了命在卷单个Agent的模型能力和工具调用,结果好几个智商在线的Agent放到一起干活,反而乱成一锅粥。有人管这叫“多智能体协作”,实际体验更像是“多智能体吵架”——互相等通知、抢上下文、消息发到一半就断了。我最近花了大把时间梳理和重做这个环节,沉淀下来的方案叫 Agent-Reach,一句话概括就是:为多个AI Agent之间建立一套可靠、可观测、带边界的触达机制,让它们像一支配合默契的团队而不是一盘散沙。
这篇内容适合谁看?如果你正在做多Agent编排、搞Agent平台化,或者被“Agent之间怎么传话、怎么找人、怎么不让对话串味”这些问题折磨过,那这篇文章应该能帮你省下不少调试时间。我尽量把设计思路和实操细节都聊透,包括注册与发现、消息路由、会话隔离、权限边界,以及我在联调时踩过的一堆坑。
1. 项目整体思路:Agent-Reach 到底解决什么问题
1.1 多Agent协作的真实痛点
先把场景说清楚。我这边做的不是一个聊天机器人,而是一套带多个专业Agent的协作系统,里面有负责需求理解的入口Agent、负责SQL生成的数据Agent、负责工单流转的执行Agent,外加一个做质检的审查Agent。最初版本它们之间怎么通信?直接写死互相调API。结果就是:每个Agent都只知道自己的下一跳是谁,链路一长就没人说得清楚数据流走到哪了。
最痛的一个场景是这样的:入口Agent把用户请求转给数据Agent,数据Agent发现需要补一个参数,它得知道“该找谁要”,于是又调回入口Agent,入口Agent再回头问用户。这个“回头路”本身没问题,问题是当三个Agent同时在处理不同用户的请求时,回调时常串线——A用户的补充参数跑到了B用户的会话里。这就是极其经典的“上下文污染”。
Agent-Reach 的出发点很朴素:Agent之间本质上也是服务与服务的通信,那就把通信这件事从业务逻辑里剥离出来,做成独立一层。每个Agent只需要做两件事:宣告自己能干什么、订阅自己需要什么。至于消息怎么到达、会话怎么隔离、权限怎么校验,都交给Agent-Reach这一层处理。
1.2 为什么不能简单“把所有Agent接到一起”
早前我也试过简化方案:拉一个Message Bus,所有Agent都往总线上发消息,谁要谁订阅。听起来挺美好,实际跑起来很快就发现不对。
第一,总线模式没有“定向触达”能力。你给数据Agent发一条“查近30天订单量”,结果订阅了这个主题的质检Agent也收到了,虽然它不处理但至少会触发一轮没必要的推理,浪费token还引入噪声。第二,没有会话维度的路由。总线只知道消息的Topic,不知道这条消息属于哪个用户任务,下游Agent拿到消息后如果自己不带上下文ID,整个协作就崩了。第三,缺少背压机制。A任务还没做完,B任务的消息又来了,Agent的上下文窗口塞满后就开始胡言乱语。
所以Agent-Reach 在设计上坚持一个原则:触达必须带会话上下文、必须可路由、必须可拒绝。不是所有消息都要送到,也不是所有消息都必须被处理。Agent-Reach在中间扮演的是一个“智能调度台”,而不是简单的传声筒。
1.3 Agent-Reach的设计理念:触达优先,编排在后
这套系统的核心设计理念可以拆成三层:
- 触达层:解决“消息能不能可靠地送达目标Agent”的问题,包括注册发现、路由寻址、消息重试与超时。
- 会话层:解决“消息是不是属于同一个任务上下文”的问题,包括会话ID传递、上下文快照、分支隔离。
- 策略层:解决“这件事该不该让这个Agent做、能不能做”的问题,包括权限校验、负载约束、人工审批闸门。
我的经验是,很多团队死磕编排引擎(Orchestrator),试图用一套流程图把Agent的所有协作逻辑写死,结果业务一变流程图就重画。Agent-Reach不对上层业务编排做假设,它只承诺:你告诉我目标Agent是谁、带什么上下文、有什么权限凭证,我负责把话带到,并且带对了。至于Agent收到消息之后怎么决策,那是Agent自己的事。这样分工,职责边界清晰很多。
2. 核心机制拆解:触达链路的关键环节
2.1 智能体注册与动态发现机制
Agent-Reach 的第一步,是让每个Agent“报到”。我参考了微服务里面服务注册中心的思路,但针对Agent场景做了两个重要调整:一是注册信息里要带能力声明,二是要带受限上下文声明。
什么叫能力声明?不是写一句“本Agent擅长数据分析”,而是结构化描述:能处理哪些任务类型、需要哪些输入参数、会产生哪些输出事件、依赖哪些外部工具权限。这样一来,入口Agent在决定要不要把请求转给数据Agent时,可以先看它的能力声明匹配度,而不是闭着眼发过去等对方拒绝。
受限上下文声明这个点,是在踩了几次“把Agent撑爆”的坑之后加进去的。每个Agent能承担的并发任务数是不一样的,有的Agent内置的prompt比较长,留给用户输入的窗口就小。注册的时候就得如实上报:我这个Agent最大同时处理3个会话,超过的请排队或降级。Agent-Reach的调度器看到这个声明后,会做准入控制,不会盲目往一个Agent上灌流量。
2.2 消息路由与触达策略
路由策略决定了消息怎么找到Agent,以及怎么落地。Agent-Reach内部维护了一张路由表,每个Agent注册后都会生成唯一的Agent ID,同时绑定若干个入口地址(Endpoint)。路由表的更新是全量广播的,新Agent上线或者Agent能力调整,所有节点最终会拿到最新版本。
实际触达时,我用的是“先匹配能力、再就近调度”的策略。比如用户问“昨天订单为什么少了这么多”,入口Agent抽取出“订单查询+异常波动分析”两个意图,它先去路由表里筛出具备这两个能力的Agent列表,然后按当前负载、历史成功率、响应时延三个指标排序,选最优的那个,最后把请求发过去。
这里有个细节值得单独说:一次触达失败后该怎么办。我之前图省事,失败就立刻换人重试,结果两个Agent同时处理了同一个请求,用户收到了两遍回答。后来加上触达幂等键——每条消息带着全局唯一的Message ID,下游Agent处理完会把Message ID记进自己的已处理列表,重复到达的消息直接丢弃。这个设计很土,但是就是管用,至少让“重试”这个动作安全了很多。
2.3 会话状态管理与上下文隔离
这是Agent-Reach里我认为最值钱的部分,也是多数人会忽视的部分。多Agent协作时最麻烦的不是“消息怎么传过去”,而是“状态怎么保持”。 Agent A给Agent B传完消息,B处理完要回传结果,这时A可能已经去处理别的用户任务了,如果只靠内存变量保存状态,几乎百分百会串。
我的做法是给每个协作链路定义一个会话上下文对象(Context Object),它有三个核心组成部分:
- Session ID:全局唯一的任务标识,所有Agent触达消息里都带这个ID,用来路由回同一链路。
- Payload Snapshot:用户原始诉求的结构化摘要,每次Agent间转手时都要携带一份快照,防止中途丢失语义。
- Chain Trace:记录这条消息被哪些Agent处理过、每个Agent处理时用了什么模型和工具、耗了多少token。这个不仅是为了审计,更重要的是方便复现和定位问题。
上下文隔离还有一个容易被忽略的维度:不要把整个对话历史全部往下游传。我见过有人把入口Agent跟用户聊了50轮的原样文本拼在请求体里发给数据Agent,数据Agent的prompt被撑爆,回答质量直线下降。Agent-Reach 的做法是,在传给下游Agent之前做一次上下文压缩,把50轮对话浓缩成一个结构化的用户意图描述加上关键约束,这样下游Agent拿到的信息密度更高,处理起来也更快。
2.4 安全边界与权限控制
Agent触达链路里,权限控制经常被一句“内部服务,信任环境”带过。但真出了事,往往就是内部信任出的事。
我在这块梳理了三层控制:
- Agent级权限:每个Agent注册时绑定一个角色标签,比如“只读型数据分析Agent”不能触发写操作,“工单执行Agent”只允许访问工单域的工具。路由时强制校验发起方Agent的角色是否具备调用目标Agent的权限。
- 工具级权限:Agent希望调用外部工具(数据库、HTTP API、文件系统),必须先通过Agent-Reach的网关申请一个带作用域和时间戳的临时凭证。凭证过期自动失效,避免Agent的对话历史里藏着旧凭证被复用。
- 人工审批闸门:涉及敏感操作时,Agent-Reach会拦截触达请求,进入一个审批队列。审批类型可以配置,有的需要管理员一键放行,有的需要输入动态口令。没有人工确认,这类消息永远到不了下游Agent那里。
3. 实操过程:从零搭一个Agent触达链路
3.1 技术选型与基础框架
Agent-Reach 不是某个单一框架,而是一套逻辑结构,落地时你不需要闭源,也不需要把一个重型的BPM引擎引进来。我用的技术栈如下,仅供参考:
- 消息层:用Redis Stream 存待触达消息,利用它的消费组机制保证消息至少被某个Agent节点消费一次;不用Kafka是因为部署太重,Agent协作链路消息量没有那么大,Redis Stream足够,而且运维成本低。
- 路由与注册:用etcd存路由表,Agent上线时通过gRPC接口做心跳注册,路由表变化通过etcd的Watch机制推送到各个节点。
- Agent节点:每个Agent是一个独立的服务进程,对外暴露统一的“ReceiveMessage(gRPC接口)”方法,方法签名统一,内部实现随Agent职责不同而不同。
- 会话存储:用独立的Redis库存上下文对象,key是Session ID,value是序列化后的Context Object,TTL设为2小时。超过两小时的协作任务会被判定为超时,需要重新拉起会话。
这套组合的重量级比较合适。全部组件的单机版本我用Docker Compose就能起,整个环境的资源占用不到2GB内存,跑本地联调绰绰有余。
3.2 核心流程的逐步实现
我拿“用户查询异常订单”这个链路为例,完整跑一遍Agent-Reach的核心流程,顺便把每个环节的关键代码写出来。
第一步:Agent注册
每个Agent启动后,先向etcd注册自己的信息。注册时用的是PUT一个带TTL的key,表示这个Agent当前在线。关键代码是这样的:
def register_agent(agent_id: str, capabilities: dict, endpoints: dict): # 构造注册数据 agent_info = { "agent_id": agent_id, "capabilities": capabilities, # endpoint是接收消息的地址,比如 grpc://localhost:50051 "endpoint": endpoints["grpc"], "max_concurrent_sessions": capabilities.get("max_concurrent_sessions", 3), "roles": capabilities.get("roles", ["readonly"]), "status": "active" } key = f"/agent-registry/{agent_id}" # etcd的put方法里,lease_ttl设置的是租约有效期,Agent需要定期续约 etcd_client.put(key, json.dumps(agent_info), lease_ttl=30) # 开启心跳线程,每15秒续约一次 heartbeat_thread = threading.Thread(target=heartbeat_loop, args=(key,), daemon=True) heartbeat_thread.start()注册时声明好能力、负载上限、角色,后续路由和权限判断都依赖这份数据。注意max_concurrent_sessions这个字段,没写的话默认给1,因为我不知道你的Agent实际能扛多少并发,宁缺勿滥。
第二步:会话上下文初始化
入口Agent接收到用户消息后,先创建一个Session ID和Context Object,然后才考虑转给谁。
def create_session(user_message: str, user_id: str) -> str: session_id = f"session-{uuid.uuid4().hex[:16]}" context = { "session_id": session_id, "user_id": user_id, # 原始诉求摘要会在传给下游Agent前生成 "payload_snapshot": {"raw_query": user_message}, "chain_trace": [], } # TTL设为7200秒,即2小时 redis_client.set(f"ctx:{session_id}", json.dumps(context), ex=7200) return session_id这里有个心机:我先不生成结构化摘要,因为入口Agent还没理解完用户意图,过早摘要会丢失信息。等它调用意图识别模型之后,再更新payload_snapshot,把它从raw_query替换成更结构化的对象。
第三步:消息路由与触达
入口Agent完成意图识别后,去etcd读取路由表,筛选有“订单查询”能力的Agent;然后按负载排序选一个最优目标,发送消息。
def dispatch_message(session_id: str, target_agent_id: str, payload: dict, message_type: str): message = { "message_id": f"msg-{uuid.uuid4().hex[:12]}", "session_id": session_id, "target_agent_id": target_agent_id, "sender_agent_id": "entry-agent", # 幂等键,下游去重用 "message_type": message_type, "payload": payload, "created_at": int(time.time()), } # 先用HTTP调用目标Agent的健康检查接口,确认存活,失败则不发送 if check_agent_health(target_agent_id): stream_key = f"agent-inbox:{target_agent_id}" redis_client.xadd(stream_key, {"payload": json.dumps(message)}) # 消息排队后要通知目标Agent有新消息,简单做法:再打个HTTP通知接口 notify_agent(target_agent_id)这个函数里,我故意加了一个健康检查。Agent-Reach最忌讳的就是“坑位还空着就发货”。检查健康状态是为了避免消息发给了一个貌似在线但其实僵死的Agent,等它超时重试,白白增加耗时。
3.3 下游Agent的消费逻辑
下游Agent收到消息后,也要走一套固定的处理流程:
def receive_message(message: dict): message_id = message["message_id"] session_id = message["session_id"] # 第一步:幂等性检查,已经处理过的Message ID直接忽略 if redis_client.sismember(f"processed-msg:{session_id}", message_id): return # 第二步:拉取会话上下文 context = get_session_context(session_id) # 第三步:追加自己的处理记录到链路追踪 context["chain_trace"].append({ "agent_id": "data-agent", "action": "query_orders", "ts": int(time.time()) }) # 第四步:真正干活,执行SQL查询 query_result = execute_sql(payload_to_sql(context["payload_snapshot"])) # 第五步:回传结果给发起方,或者按需继续路由 dispatch_message(session_id, "entry-agent", query_result, "query_result") # 第六步:标记Message ID已处理 redis_client.sadd(f"processed-msg:{session_id}", message_id)这套消费逻辑强制了“先隔离后干活”的顺序,也保证了链路里每一步都可以被追踪。我建议所有Agent的接收端都统一用这个模板,否则你很快会在协作链路里找到三四种不同的消息处理风格,后面排查问题就变成猜谜游戏了。
3.4 关键参数的配置与调优
Agent-Reach 最让我头疼的其实是参数调优,没有标准答案,只有血泪教训。下面这几个参数我调了大概一周才绑到一个比较稳的基线:
- 触达超时:默认我设的是15秒。这个时间要考虑下游Agent的模型推理时长,如果模型本身就很慢,可以把超时放宽到30秒,但不能再长,否则用户侧早就感知到卡顿了。
- 幂等去重窗口:下游Agent维护的已处理Message ID列表,我设的是30分钟内有效,因为同一消息在30分钟内触发重试是正常行为,30分钟后的重复到达大概率是系统Bug,不再去重,反而应该告警。
- 会话TTL:2小时。实际业务里,一个复杂任务从发起到收尾很少超过1小时,2小时的余量足够应对慢链路;设太长会导致Redis里堆一堆僵尸会话。
- 最大重试次数:3次。超过3次触达失败,消息进入死信队列,由管理员手工干预。不要无限重试,否则一个坏Agent会拖垮半条链路。
这些参数没有通用标准,但思路是通用的:先给一个保守值,跑通链路之后再根据实测数据调整。重点是每个参数都要能通过配置中心动态修改,不要写死在代码里,否则每次调参都要发一次版,太痛苦。
4. 常见问题与排查技巧实录
4.1 会话串号问题
这个问题我踩得最狠。现象是用户A在问订单问题,回答里却出现了用户B的客户名。查了三天,最后定位到问题出在Agent内部的一个静态变量上。
业务Agent为了实现“记住用户诉求”,在类里写了一个self.current_context = get_context(...),结果这个Agent实例被多个线程共用,current_context被反复覆盖。这其实是个很基础的并发Bug,但在多Agent协作场景下暴露得特别明显:以前单Agent串号只会答非所问,现在串号会波及下游所有Agent,因为传过去的上下文本身就是错的。
排查方法我总结了三个步骤:
- 看链路追踪中会话ID有没有跳变。如果一条链路的ChainTrace里出现两个不同的Session ID,基本可以断定上游串了。
- 查Agent进程内是否用了全局可变的会话变量。Agents必须保证会话上下文只从入参里取,不能从类属性或者模块全局变量里读。
- 复查消息体的session_id字段在序列化时有没有被覆盖。有时候是框架层的消息体复用导致旧ID残留。
4.2 消息送达但Agent无响应
有一天运营反馈“工单创建了但下游Agent没反应”。我第一反应是排查网络,查完发现网络一切正常,消息也确实进了Redis的inbox,但Agent就是没有消费。
后来打了日志才发现,通知Agent的HTTP接口在Nginx层做了访问频率限制,因为下游Agent每次收到通知还要再轮询拉消息,双倍流量直接把Nginx的limit_req打爆了。请求被拒后Agent傻傻地没有重试机制,消息就躺在Redis里吃灰。
现在我的处理方式是:目的Agent收到通知后,如果发现inbox里没有对应消息,主动延迟500毫秒再拉一次;通知接口的限流阈值也调高了,并且通知本身带了指数退避重试。这个问题的本质是“消息管道有了,消费端却聋了”,所以排查时要同时盯消息队列的堆积量和Agent自身日志里的HTTP错误码。
4.3 上下文污染导致错误路由
还有一种比较隐蔽的坑,发生在路由阶段。我在入口Agent里做意图识别时,把上一个会话的意图特征残留在模型输入里,导致新用户的消息被错误路由到不相干的Agent。
比如上一个用户问的是“开发票”,当前用户问的是“查库存”,但意图分类模型因为上下文残渣,把当前请求也分到了“财务Agent”。链路上虽然正确传递了Session ID,但Agent选错了,整个流程走下去必然报错。
解决办法是加一道路由前置校验——在确定目标Agent之前,让另一个轻量Agent做一轮快速校验:“当前用户请求的意图是否匹配所选的Agent能力?”不匹配就回到路由环节重新选。虽然多花了一点token,但相比跑完整条错误链路再回头,成本低得多。
4.4 常见问题速查表
| 问题现象 | 排查方向 | 解决建议 |
|---|---|---|
| 两个Agent重复处理了同一任务 | 查看幂等Message ID是否生效 | 检查下游消费端是否在业务逻辑之前调用了去重标记 |
| 消息一直不进目标Agent的inbox | 查看路由表是否包含目标Agent | 检查etcd注册是否过期,Agent心跳是否正常 |
| 长对话丢语义 | 查看上下文快照的更新时机 | 在传给下游之前重新生成一份最新的payload_snapshot |
| 链路中一个Agent响应特别慢 | 看它是否有超负载迹象 | 核对注册信息里的max_concurrent_sessions是否合理 |
| 触达成功但结果没回传 | 检查回传消息的target_agent_id和会话ID | 确认消息类型是否匹配原链路上下文,避免回传时忘记带session_id |
| Agent间互相循环调用 | 拉取ChainTrace查看重复次数 | 在路由策略里加“已访问Agent列表”,超过3次环回就强制终止链路 |
5. 一点个人体会
Agent-Reach这个方案的轮廓,是很多次的深夜调试换来的。最深的感受是:多Agent链路的稳定性,七分靠机制,三分靠Agent本身的自觉。你没法指望每个Agent团队里的“队友”都高度自律,所以要在机制层面上把触达、路由、状态、权限这些底子打好。机制稳了之后,上层接新Agent就像插U盘——注册完能力、声明完负载,链路自然就转起来了。
最后再分享一个细节:不要把Agent-Reach的日志当摆设。我在开发阶段就养成了每一条链路都看ChainTrace的习惯,哪个Agent多花了几秒、哪个消息多跳了一环,一目了然。很多你觉得玄学的协作问题,翻翻链路追踪记录,往往就是一两行日志的事。你如果也正被多Agent协作搞得焦头烂额,可以先从今天这篇文章里的会话隔离和幂等机制动手改起,这两处一般会最先带来体感上的变化。