去年我在做 AI 客服系统的时候,一开始只接了一个智能体,让它负责查订单、退换货。后来业务方觉得效果不错,又塞进来一个负责写营销文案的,一个负责做数据分析的,甚至还有个专门处理用户情绪安抚的。单看每个智能体都很能打,可一旦它们互相之间需要传递信息,问题就来了:A 拿到结果不知道该发给谁,B 需要的上下文被另一个智能体改得面目全非,C 的接口只认自己的 JSON 格式,D 直接超时崩掉。我当时的第一个念头是——我是不是在建一座巴别塔。
后来我整理了一个轻量的协作层,没想到一路用到现在,顺手给它起了个名字叫 Agent-Reach。Agent-Reach 本质上是一个面向 AI 代理的注册、发现、路由和协作框架,也可以叫多智能体编排中间层。它解决的问题很简单:当一个系统里有多个智能体同时存在时,如何让它们互相发现、按规则路由请求、在统一的上下文里协作,并且不把代码写成一团乱麻。这篇文章我就把当时的完整思路、技术选型、实操过程和踩坑记录都摊开来讲。如果你也在折腾多智能体,或者正准备把几个 AI Agent 接入同一个系统,这篇应该能给你省下不少时间。
1. Agent-Reach这个项目到底在干什么
1.1 它不是工作流引擎,是连接层
开始动手之前,我花了两个周末去看各种现成的编排方案。市面上很多框架强调的是“流程”:先让 Agent A 去做第一步,然后把结果喂给 Agent B 做第二步,最后汇总到 Agent C。这种思路适合业务链路非常固定的场景,比如订单审核、内容生成流水线。但一旦你的智能体数量变多、能力交叉,你真正缺的其实是另一层东西:连接层。
连接层负责三件事。
第一,让每个智能体都能被其他智能体“找到”。如果没有注册中心,Agent A 怎么知道 Agent B 还在不在运行?它的接口地址是什么?它的能力描述是什么?全靠写死配置是不现实的。Agent-Reach 做的就是服务注册与发现的活,只不过服务对象从普通微服务换成了 AI Agent。
第二,把请求按规则送到该去的地方。一个用户问题进来,可能涉及多个智能体。这时候需要一个路由层判断:这个问题该交给客服主 Agent,还是先交给数据分析 Agent 做个预处理?判断的依据可以是关键词、意图识别结果、当前负载、用户等级、甚至 Agent 返回的置信度。Agent-Reach 把这些都做成可配置的策略,而不是把路由判断塞进每个智能体的 Prompt 里。
第三,屏蔽掉不同 Agent 之间的协议差异。有的 Agent 是基于 OpenAI Function Calling 做的,有的自己实现了 ReAct 循环,有的干脆就是一个简单 HTTP 服务。它们传给下游的消息格式五花八门。Agent-Reach 规定了一套最小消息信封,所有内部通信都走统一格式,再在接入层做格式转换。这样新增一个 Agent 的时候,不需要改动其他 Agent 的代码。
我见过很多团队把多 Agent 协作写成了“神仙打架”:每个 Agent 直接调对方的接口,上下文互相污染,一个字段对不上就崩。Agent-Reach 的价值不是替你实现高阶智能,而是让协作这件事变得有秩序、可观测、可回滚。
1.2 真正适合 Agent-Reach 的场景
什么样的项目才值得引入类似的协作层?我总结了几类典型场景。
多任务分发的客服系统是最典型的。用户的问题往往不是一个 Agent 能搞定的,需要先意图识别,再分给售后、销售、技术支持等多个专用 Agent。Agent-Reach 可以在中间层完成意图判断、路由、结果汇总。
第二类是内部知识库问答。不同知识域由不同 Agent 负责,比如 HR 政策和报销流程是两套。通过 Agent-Reach 做“先路由后回答”,明显比塞进一个大 Prompt 更可控。
第三类是 AI 自动化工作流,需要多个 Agent 协作完成复杂任务,比如市场调研、竞品分析、合同初审。每个 Agent 只干自己擅长的事,由 Agent-Reach 负责编排调用顺序和上下文传递。
但也有不适合硬套的场景。如果你的系统里只有一个 Agent,或者所有逻辑都写在同一个 Prompt 里,那么引入独立的协作层就是过度设计。另外,如果任务链路极其固定,用传统工作流引擎比如 Airflow、Temporal 可能比 Agent 编排更稳定。Agent 的价值在于有自主性,如果完全不需要自主性,就别硬加。
1.3 “Reach”到底在说什么
“Reach”在英文里有“触达、延伸”的意思。我当时起这个名字,是想强调一个理念:单个 Agent 的能力边界是有限的,但通过协作层,它可以把能力触达给系统里的其他 Agent,也可以被其他 Agent 触达。这种双向触达才是多智能体系统真正难做的地方。
很多团队一开始都以为难点在单个 Agent 的推理能力上。调着调着就发现,真正的瓶颈往往是 Agent 之间的“可触达性”。上下文传递丢字段,路由规则互相覆盖,某个 Agent 挂了其他 Agent 还在傻乎乎地等结果。而 Agent-Reach 这类中间层,就是把“谁触达了谁、在什么条件下触达、触达的结果是否可靠”变成可管理、可观测的基础设施。
2. 设计 Agent-Reach 时的几个关键取舍
2.1 我为什么选了中心路由、星型拓扑
刚开始我考虑过完全去中心化的方案:每个 Agent 都维护一张“同伴目录”,自己决定把消息发给谁。听起来很自由,但我很快就否掉了。因为去中心化拓扑调试起来太痛苦,请求在 Agent A 和 Agent C 之间绕了三个弯,日志分散在各处,你根本不知道哪一跳出了问题。
Agent-Reach 采用的是星型拓扑:所有 Agent 都注册到中心路由器,Agent 之间的消息不直接互发,而是都经过路由器转发。这样做有三个明显好处。
一是集中的路由策略。你要调整“哪些问题应该发给数据分析 Agent”,只需要改一处规则配置,不用去改每个 Agent 的 Prompt 或者代码。
二是集中的可观测性。所有消息都会经过路由器,所以 trace_id 可以贯穿一整条调用链。出了问题时,我可以直接在路由器这一层拉出整条链路的日志,而不是去十台机器上翻。
三是生命周期管理方便。中心路由器可以定时做健康检查,发现某个 Agent 连续心跳失败,就把它标记为不可用,后续请求自动绕开它。
当然,星型拓扑的缺点是中心节点会成为瓶颈,也会成为单点故障。为了解决这个问题,我做了两个设计。第一,路由器本身不存业务状态,所有状态放到 Redis 和 PostgreSQL 里,这样路由器实例可以随时水平扩容,挂了直接拉起新实例。第二,健康检查和路由规则缓存都做了本地缓存,短暂断网不影响路由决策,只是转发可能收到“目标不可达”的错误。
2.2 统一消息信封:所有智能体只认一套协议
多智能体系统最容易踩的坑就是消息格式不统一。有的模型返回一个字符串,有的返回一个 JSON 对象,有的返回一个包含 metadata 的复杂结构。Agent-Reach 在协议层做了一个强制约束:所有内部流转的消息都必须符合一个标准信封。
这个信封分为三部分。头部是元信息,包括消息 ID、发送者、接收者、消息类型、trace_id、时间戳。负载是真正的业务数据,可以是字符串,也可以是 JSON 对象,但必须有明确的字段名。额外部分放权限和优先级标记,比如是否允许该消息触发工具调用、该消息的 priority 是 high 还是 normal。
为什么一定要统一信封?因为一旦多个 Agent 之间开始交换信息,你很快会遇到字段冲突。比如 Agent A 返回的结果里有个字段叫 status,Agent B 里 status 的含义完全不同。如果没有统一的字段语义,后面根本没法维护。Agent-Reach 信封里专门设计了 context 区块,用来存放 Agent 之间需要共享的语义化数据,同时保留 raw_payload 区块存放原始输出。这样既保留了原始信息,又提供了统一访问路径。
在实际使用时,Agent 往往只关心信封里的某个区块。比如路由只读头部和 status,最终答案拼接只读 payload。这样设计还有一个附带好处:每个 Agent 不需要了解其他 Agent 的内部细节,它只需要知道“我把消息交给 Agent-Reach,Agent-Reach 会给我返回一个标准信封”。
2.3 注册中心的健康状态与生命周期管理
Agent 不像普通服务那样可以随时拉起。一个 Agent 在启动时往往要做模型加载、工具初始化、Prompt 模板加载这些事情,比较耗时。所以 Agent-Reach 把每个 Agent 的生命周期分成了四个状态:注册中、在线、离线、故障。
Agent 启动后先向中心发送注册请求,携带自身 ID、能力描述、接口地址、支持的输入输出格式。中心收到后返回一个注册确认,同时给该 Agent 分配一个健康检查令牌。之后 Agent 要周期性发送心跳,默认是每 15 秒一次。连续三次心跳失败,Agent 就被置为离线;如果恢复心跳,则重新进入在线状态。
这里有一个值得注意的细节:Agent 的“能力描述”不只是给人看的自然语言,它是要参与路由匹配的。我建议把能力描述写成一组结构化的标签,比如 capability: order_query,scope: ecommerce,format: json。这样路由规则可以直接做标签匹配,而不需要每次都去做语义解析。你要是把能力描述写成一段长作文,路由的时候就麻烦了。
故障状态和离线状态的区别是:离线只是心跳丢失,Agent 可能还活着,只是网络抖动;故障状态则是 Agent 自己上报的错误,比如模型调用失败、内部状态异常。故障状态的 Agent 会被系统优先隔离,避免它继续接收新请求。这套生命周期管理虽然简单,但在实际运行中帮我少处理了大量“幽灵请求”。后来我还加了主动探测机制,每隔一段时间中心会向 Agent 发送一次 ping 级别的请求,以防 Agent 心跳正常但对实际请求毫无反应。
3. 从零到可用的实操记录
3.1 技术栈和最小依赖
Agent-Reach 对技术栈没有严格要求,但我自己实现的时候用了 Python 和 FastAPI,因为团队里大部分人写 Python,而且和 AI 生态的集成最方便。核心依赖只有三个:FastAPI 用来提供 HTTP 接口,Redis 用来做状态存储和分布式锁,PostgreSQL 用来持久化注册信息和路由规则。
如果你不需要分布式能力,最小版本其实只需要 FastAPI 就够了。Redis 可以先用内存变量替代,PostgreSQL 可以用 SQLite 替代。我建议新手先用最简单的方式跑通,再逐步加入状态存储和持久化。一上来就上全套分布式反而容易迷失。
推荐的目录结构大概是这样的:
agent_reach/ ├── core/ # 路由、注册、状态管理 ├── transports/ # HTTP、WebSocket、消息队列接入 ├── plugins/ # 针对不同Agent框架的适配插件 ├── config/ # YAML 配置文件 ├── agents/ # 示例Agent └── main.py # 启动入口Agent-Reach 本身不限制接入方式。只要你的 Agent 能通过 HTTP 或 WebSocket 收发消息,就可以接入。如果你用的是现成的 Agent 框架,通常只需要写一个很薄的适配层,把框架的输入输出转换成标准信封。
3.2 核心配置项解读
我在 config.yaml 里保留了最常用的一组配置。初次使用的人可以完全按下面这个模板来改:
server: port: 8000 host: 0.0.0.0 registry: heartbeat_interval: 15 heartbeat_timeout: 45 storage_backend: redis router: strategy: intent_priority default_timeout: 30 max_depth: 10 rules: - name: order_query match: labels: capability: order_query target: order_agent priority: 10 - name: data_analysis_fallback match: labels: scope: ecommerce target: data_agent priority: 1 agents: - id: order_agent endpoint: http://127.0.0.1:9001 capabilities: - order_query - refund_process format: json - id: data_agent endpoint: http://127.0.0.1:9002 capabilities: - data_analysis - report_generation format: json这里我重点说两个参数。
一个是 router.max_depth。这个参数用来限制一次请求最多经过多少个 Agent。默认值是 10,但实际我建议设置成 3 到 5。因为智能体协作的深度一旦超过三层,每一层都会丢失一部分上下文信息,最后生成的结果质量会明显下降。这个参数不是为了防死循环,而是防止你无意识地让多个 Agent 互相“接力”,绕来绕去把用户需求绕没了。
另一个是 router.strategy。这里我写的是 intent_priority,意思是先按语义意图匹配,再按优先级选择。实际实现时可以支持多种策略,比如随机、负载均衡、加权轮询。我通常在调试阶段用随机策略,因为每个 Agent 都能被均匀地调用到;生产环境再用 intint_priority 这类确定性策略,保证核心业务路由稳定。
3.3 把第一个 Agent 接进 Agent-Reach
接入 Agent 的核心工作是写一个适配层。下面我以一个最简单的“订单查询 Agent”为例。
这个 Agent 本身逻辑很简单:收到一个问题,判断是否包含订单号,然后返回一个标准信封。为了演示,我直接写成一个 FastAPI 服务:
from fastapi import FastAPI, Request from pydantic import BaseModel app = FastAPI() class Envelope(BaseModel): sender: str receiver: str msg_type: str trace_id: str payload: dict priority: str = "normal" @app.post("/agent/order") async def order_agent(req: Request): data = await req.json() # 这里简化处理,实际应解析 envelope payload = data.get("payload", {}) question = payload.get("question", "") order_id = extract_order_id(question) if not order_id: return { "sender": "order_agent", "receiver": data.get("sender", "router"), "msg_type": "response", "trace_id": data.get("trace_id", ""), "payload": {"answer": "没有找到订单号,请提供。", "status": "missing_param"}, } # 模拟查询订单 result = fake_query_order(order_id) return { "sender": "order_agent", "receiver": data.get("sender", "router"), "msg_type": "response", "trace_id": data.get("trace_id", ""), "payload": { "answer": f"订单 {order_id} 的状态是 {result['status']}", "order_id": order_id, "status": "ok", }, }接入 Agent-Reach 的时候,你需要写一个注册脚本,让 Agent 在启动后主动向中心注册自己的能力。下面的代码演示了注册流程:
import requests REGISTER_URL = "http://127.0.0.1:8000/register" def register_agent(agent_id, endpoint, capabilities, format="json"): body = { "id": agent_id, "endpoint": endpoint, "capabilities": capabilities, "format": format, } resp = requests.post(REGISTER_URL, json=body) resp.raise_for_status() print(f"{agent_id} 注册成功") register_agent( "order_agent", "http://127.0.0.1:9001/agent/order", ["order_query", "refund_process"], )跑起来之后,你可以通过 Agent-Reach 的路由接口发送一个请求,看看路由器会不会把问题分给 order_agent。这一步是验证整个链路是否通的关键。我当时第一次跑通的时候特别激动,因为终于不用再手工指定“把这条消息发给谁”了。
3.4 自定义路由规则和动态策略配置
路由规则是整个系统里最容易被玩坏的地方。因为业务方今天加一个关键词,明天加一个优先级,后天又提出“这个用户应该是 VIP 优先”,如果全写死在代码里,改一次发一次版,你会很崩溃。
Agent-Reach 的做法是把路由规则拆成“匹配条件”和“目标动作”两部分,存放在数据库里,运行时动态加载。匹配条件支持字段级匹配,包括 labels、sender、msg_type、payload 里的任意字段。目标动作可以是转发给某个 Agent,也可以是先调用一个函数做预处理,再进入下一步匹配。
举个例子,我设置了一条规则:凡是 payload 里包含 refund 关键词的消息,优先转发到售后 Agent,并且标记 priority 为 high。
{ "name": "refund_routing", "match": { "payload.keywords": ["refund", "退款", "退货"] }, "action": { "type": "forward", "target": "after_sale_agent" }, "priority": 20 }这里的关键是匹配条件里的 payload.keywords 是一个数组。实际匹配时,只要任意一个关键词命中就属于匹配成功。优先级数字越大越先被检查,所以到达 priority 20 的 refund_routing 会优先于其他规则执行。
我之前踩过一个坑:把关键词匹配和语义匹配放在同一个优先级里。结果有些用户消息同时触发了两条规则,路由器不知道该选哪个,白白增加延迟。后来我把规则设计成两阶段:先做硬匹配(关键词、标签、字段值),如果硬匹配没有结果,再触发语义匹配(调用一个轻量级分类模型)。这样既快又准。
如果你用的是 LLM 做意图识别,我建议把意图识别结果放在 envelope 的 metadata 里,而不是每次路由都现场调用模型。因为一次对话可能会有多次路由,每次都调一次模型,成本会指数级上涨。最好是入口处做一次全局意图识别,把结果缓存到 metadata,后续路由直接读缓存。
3.5 超时、重试与链路追踪怎么落地
多智能体系统一旦复杂起来,超时和重试就是双刃剑。超时设短了,稍微慢一点的模型调用就会误杀;超时设长了,用户等半天等不到结果。重试也一样,盲目重试可能把下游 Agent 打到崩溃。
Agent-Reach 里我对超时做了分层处理。第一层是 HTTP 连接超时,默认 5 秒;第二层是业务处理超时,默认 30 秒;第三层是整条链路的累计超时,默认 90 秒。每一层超时触发后都会向调用方返回一个明确错误码,而不是笼统地抛一个异常。这样可以快速区分是网络问题、Agent 处理慢,还是多个环节叠加导致超时。
重试策略我建议只在以下三种情况启用:连接失败、响应超时、下游返回明确的“临时不可用”状态。对于业务逻辑错误的响应不要重试,因为重试大概率还是得到同样的错误。Agent-Reach 里我做了指数退避重试,初始延迟 1 秒,退避因子 2,最大重试次数 3。这个参数组合是我在压测之后定下来的,既能缓解瞬时抖动,又不会让系统雪上加霜。
链路追踪方面,每个请求在进入 Agent-Reach 时都会生成一个 trace_id,随后所有子调用都带着这个 trace_id。我在日志里统一打印三样东西:trace_id、当前 Agent 名称、耗时。这样排查问题的时候就变成了“按 trace_id 搜日志,看哪个环节耗时异常”。如果你想更精细,还可以给每个 Agent 调用增加 span_id,但实际用下来,trace_id 加 Agent 名称这个粒度已经够用。
curl -X POST http://127.0.0.1:8000/route -H "Content-Type: application/json" \ -d '{"trace_id":"abc123","msg_type":"query","payload":{"question":"我的订单什么时候到?"}}'后端日志里会看到类似这样的输出:
[abc123] router received: msg_type=query [abc123] intent_match -> order_query [abc123] route matched: order_agent [abc123] order_agent response: status=ok, latency=812ms [abc123] final response sent这一行行日志,比任何调试器都好用。
4. 跑了一段时间之后踩过的坑和排查方法
4.1 智能体之间出现循环调用
多智能体系统最经典的故障就是两个 Agent 互相调用,最后谁也不干活,光在那传递消息了。我真实遇到过:售后 Agent 把用户的问题转给客服主管 Agent,客服主管 Agent 又觉得这个问题涉及订单,转给订单 Agent,订单 Agent 判断这不是订单问题,又转回售后 Agent。整个过程循环了十几次,直到超时。
后来我在 Agent-Reach 里加了两个防线。
一个是 max_depth 限制,转发深度到达设置的上限后直接终止并返回错误。这个方法简单粗暴,能防止最坏情况。另一个是环路检测:路由器会记录当前 trace_id 下已经访问过的 Agent 列表,如果目标 Agent 已经出现在访问列表里,就不再转发,而是返回一个“路由环路”错误。
实操中我发现,环路发生的原因往往不是路由规则配错了,而是某个 Agent 的职责边界太模糊。比如售后 Agent 的标签里既写了 order_query,又写了个 escalate,结果它不知道该自己处理还是转给别人。后来我要求每个 Agent 注册的时候必须填写 main_capability,每个 Agent 只能有一个核心能力标签。这个约束从源头上减少了循环调用。
4.2 上下文越长,回答越差
另一个让我头疼的问题是:多个 Agent 协作时,每个 Agent 都会往共享上下文里塞一段内容。几轮过后,上下文变得非常长,RAM 倒是没爆,但模型输出的质量明显下降,有时候还会把很早之前某个 Agent 的错误信息当成最新结果引用出来。
这个问题本质上是上下文污染。解决方法不是无限扩大上下文,而是做上下文治理。Agent-Reach 里我给每个 Agent 的上下文设置了配额:正常情况下,单个 Agent 最多携带 4000 字数的上下文进入模型,超过的部分必须做摘要压缩。摘要可以在 Agent 内部完成,也可以在路由器层通过一个轻量摘要 Agent 完成。
我个人的建议是:路由器层只负责传递“结论性摘要”和“关键原始字段”,不要传递完整对话历史。比如订单 Agent 只需要知道“用户问的是退款”,不需要知道用户前面和客服聊了 20 句废话。这就像公司里开跨部门会议,每个人只需要带自己部门的结论来,不必把 200 页会议记录全背过来。
4.3 路由规则越配越乱怎么办
路由规则一开始只有 3 条,后来变成 30 条,再后来连写规则的人都忘了某些规则为什么存在。我踩过这个坑之后,给 Agent-Reach 增加了一个规则可视化逻辑:把每条规则的匹配次数、命中率、平均耗时都记录下来,定期输出报表。
通过报表,我发现了大量“死规则”:有一些规则从来没有任何请求命中过,完全是因为某次临时排查问题加的,后来忘了删。还有一些规则之间存在重复匹配,两条规则条件相似,优先级高的那条把低的那条完全覆盖了。最后我定了一条规矩:每两个月集中清理一次路由规则,匹配率连续 10 天低于 0.1% 的规则自动置为停用状态。
另外一个治理技巧是:路由规则的名字必须能看懂。不要用 rule1、rule2 这种名字,而要用 order_query_v2、refund_with_vip_priority 这种带语义的名字。不然三个月后你自己都会怀疑人生。
4.4 并发一高,状态存储开始拖后腿
最开始我用 PostgreSQL 存注册信息和路由规则,用 Redis 存心跳和会话状态。后来发现高并发时 PostgreSQL 的连接数先扛不住了。排查之后发现,问题不在于 PostgreSQL 查询多慢,而在于我的代码在每次路由时都查了一次数据库加载全部规则,相当于把数据库当缓存用。
修复方案很简单:路由规则在启动时加载进本地内存,每隔 30 秒和数据库做一次同步。Redis 里的心跳数据做异步写入,只在状态变化时通知主库。改完之后,同样的并发量下 PostgreSQL 的负载降到了原来的十分之一。这个教训让我明白了一个道理:协调层本身必须是高性能的,它不能成为业务链路上的新瓶颈。
4.5 常见问题排查速查表
我把平时最容易遇到的几类问题整理成了一个表,供你快速定位:
| 症状 | 可能原因 | 排查动作 |
|---|---|---|
| Agent 一直返回超时 | 下游模型调用过慢 | 查看该 Agent 的耗时统计,考虑加大超时或换更快模型 |
| 两个 Agent 互相转发 | 路由规则重叠或职责模糊 | 检查命中规则列表,限制每个 Agent 的 main_capability |
| 回答内容张冠李戴 | 上下文被污染 | 开启上下文摘要开关,只传关键结论 |
| 路由规则不生效 | 规则缓存未刷新 | 手动触发规则同步,检查新规则是否真正写入库 |
| Agent 状态一直离线 | 心跳丢失或健康检查失败 | 检查 Agent 进程是否存活,网络是否稳定,端点是否可访问 |
| 请求量一高就慢 | 中心节点单点负载过高 | 水平扩容路由器,检查数据库连接池 |
这个表我直接写进了项目 README,后来团队新人接手后,不需要问我也能自己排查大部分问题。多智能体系统虽然听起来很酷,但维护起来跟传统分布式系统一样,拼的都是基本功:日志、超时、状态管理、规则治理。Agent-Reach 把这些基本功收拢到一个可观测的中间层里,让智能体之间既能各显神通,又能协作得井井有条。
最后再分享一点实际体会。不要指望一个框架能解决所有多 Agent 协作问题,Agent-Reach 能保证消息不乱跑、状态不丢、问题可查,但它不会替你想清楚每个 Agent 的职责边界。边界这种东西,需要你在跑业务的过程中一点点修正。所以我的建议是:先把一个最核心的场景跑通,再加第二个、第三个,不要一上来就接十个 Agent。现在这套框架内部还保留着当时那条最原始的路由规则,如今已经长成了包含几十条规则、十几个服务的协作网络。以后我大概率还会往里加更多东西,比如基于成本的路由、按模型速度自适应调度的策略,但核心的思路不会变——让每个 Agent 都能被找到、被路由、被信任,这才是多智能体系统能走远的地基。