直接分享一个我最近在团队内部落地的小项目:Agent-Reach。如果你正在做多智能体系统,或者被几个Agent协同时的"互相找不到、互相等"折磨过,这篇值得看完。简单说,Agent-Reach是一个面向智能体的轻量路由触达层,核心就做三件事:让Agent互相发现、让任务精准投递、让失败调用有兜底。我把它定位成"多Agent时代的服务网关",在真实业务里压过一轮流量,今天把完整设计和踩坑记录都拿出来。
1. 项目背景:多Agent系统为什么需要一个"触达层"
这两年大家聊Agent,最多的是单Agent的推理能力、工具调用能力。但真正把Agent放到生产环境,你会发现单点再强都不够,问题几乎全出在"多个Agent怎么配合"上。我自己遇到的场景就很典型:团队里同时维护了文案Agent、数据分析Agent、图片审核Agent,以及一个负责对外服务的协调Agent。一开始是硬编码互相调HTTP接口,API地址写死在配置文件里,Agent稍微多起来就乱成一锅粥。
最痛的一次线上事故是这样的:数据分析Agent升级到新版本,换了服务的端口,但协调Agent里的配置还是旧的,结果所有需要数据支撑的任务全部超时。排查花了整整一个下午,最后发现只是地址没同步。那一刻我意识到,多个Agent协作的基础不是推理能力,而是"彼此能不能找到对方、能不能把任务准确交到对方手里"。这个能力,就是Agent的可达性,也是Agent-Reach这个名字的由来。
我当时梳理了这类系统里最常碰到的四个问题:
- 服务发现缺失:Agent实例地址变更时,调用方无感知,配置靠人肉同步。
- 能力描述混乱:只知道对方有个"审核Agent",但不知道它具体能处理什么输入输出格式、支持什么参数。
- 路由策略简单:所有任务无差别轮询或者随机分发,根本做不到"按能力、按负载"投递。
- 失败没有兜底:目标Agent挂了,调用方只能自己try-catch,任务就直接断了。
这几个问题叠加在一起,系统规模一上来就是灾难。Agent-Reach要做的,就是把传统微服务里的注册中心、路由网关、熔断降级,重新按Agent的语义做一遍。不是简单的HTTP转发,而是"把任务投给最合适的Agent,并且处理调用的全生命周期"。
这个方案适合谁?如果你的系统已经有三个以上Agent在协作,或者你准备从单体Agent往多Agent架构演进,又或者你只是想给现有Agent加一个可观测的调用层,Agent-Reach都能直接套用。它不依赖特定的Agent框架,底层就是通用的注册与路由逻辑。
2. 整体设计思路:以"能力描述"为中心,而不是以"接口地址"为中心
Agent-Reach和传统网关最大的区别,在于它的元数据是"能力"而不是"路径"。传统网关根据URL决定转发目标,而Agent-Reach根据"任务的能力标签"决定投递给谁。这个思维转变是整个设计的核心。
2.1 核心概念:Agent元数据与能力声明
每个接入Agent-Reach的Agent节点,必须向外声明两部分信息:基础信息和能力信息。基础信息包括Agent的ID、服务地址、健康检查接口、权重等;能力信息则描述"这个Agent能干什么",包括能力名称、输入参数Schema、输出格式、超时建议、并发上限等。
我举个例子,一个"文案润色Agent"的能力声明是这样的:
{ "agent_id": "agent-writer-01", "service": { "host": "10.0.8.12", "port": 8120, "health_check": "/healthz" }, "capabilities": [ { "name": "text_polish", "description": "对中文文本进行风格润色,支持正式与口语风格切换", "input_schema": { "type": "object", "properties": { "content": { "type": "string", "maxLength": 5000 }, "style": { "type": "string", "enum": ["formal", "casual"] } } }, "timeout_ms": 30000, "max_concurrency": 20, "weight": 80 } ] }这套声明里有几个值得注意的点。timeout_ms不是随便写的,它由Agent调用方和提供方一起定,同时会在Reach层做兜底拦截;max_concurrency和weight则是路由评分的重要依据。传统网关从不关心"目标服务现在能承受多少请求",但在Agent场景里,一个Agent实例通常只部署一到两个副本,并发能力比传统微服务弱很多,必须提前感知。
2.2 触达流程:一次任务请求的完整路径
Agent-Reach的调用流程,我分成六个阶段:
- 调用方提交任务,带上能力标签target_capability和任务参数。
- Reach层从本地缓存里找所有声明过该能力的活跃Agent列表。
- 按路由策略打分排序,选出候选实例。
- 向候选实例发起调用,同时开启超时控制。
- 如果首选实例失败,自动按顺序尝试下一个候选。
- 全程记录调用链日志,输出任务ID和节点耗时。
这个流程看起来和普通的负载均衡很像,但真正的差异在第二步和第三步。第二步的"活跃Agent列表"不是简单的心跳保活,而是Reach层主动做健康检查之后的结果;第三步的路由策略也不是随机,而是一套综合了权重、负载、亲和性的打分机制。后面我会专门展开讲路由评分。
2.3 为什么不做点对点直连
还有个问题我纠结过一阵子:既然Agent数量不多,为什么不干脆点对点直连,省掉中间这一层?后来实践下来发现不行。点对点的调用方和提供方一旦超过五个,就会形成网格状的复杂依赖,每个调用方都要维护一份目标列表,版本的兼容、重试策略、超时配置散落在各处,根本没法统一治理。有中间层的好处是,调用方只需要知道"要发什么能力任务",至于哪里能接这个任务,Reach层会搞定。事实也证明,把复杂网络收敛成星型结构之后,排障和灰度都轻松很多。
3. 路由与调度核心:从"能用"到"好用"的关键设计
很多自研路由框架死掉的原因,是把能力发现做出来了,但路由策略太粗糙。Agent-Reach里我重点打磨了这块。
3.1 路由评分公式
路由决策我不是用简单的轮询,而是给每个候选Agent算一个综合得分:
score = weight_factor * (agent_weight / max_weight) + health_factor * health_score - load_factor * (current_load / max_concurrency) + affinity_factor * affinity_score这四项含义分别是:
- weight_factor:权重项,对应能力声明里的weight,用于让高性能Agent承担更多任务。
- health_factor:健康项,Reach层每10秒做一次主动探活,健康实例得满分,连续两次探活失败的实例得零分。
- load_factor:负载项,Reach层维护每个实例最近一分钟的请求量,用当前并发量和max_concurrency的比例做惩罚项。
- affinity_factor:亲和项,如果调用方和某个Agent在同一个网络区域,加分,避免跨区调用的延迟。
实际打分的时候,四个因子的系数是可配置的,默认是0.4、0.3、0.2、0.1。比例不是拍脑袋定的,是从结果倒推的:一开始我把负载项权重调得很高,结果发现新上线的Agent几乎接不到流量,因为它的历史负载是0,评分反而最高,流量瞬间灌满;后来降下来,再把健康项优先级提上去,整体就稳定了。
3.2 动态权重调整与"热实例"问题
动态权重调整我建议不要做得太激进。最早我参考过负载均衡里的EMA平滑算法,想根据实时响应时间动态调整权重,但Agent的响应时间波动比普通API大得多——思考型Agent可能一条消息要处理20秒,简单任务却只要2秒。如果把响应时间作为权重信号,路由会变得非常不稳定。最终我只保留了按并发度和失败率两个硬指标做微调:连续三个请求失败,暂时摘除节点;并发度超过max_concurrency的80%,降低权重。这套机制已经足够稳健。
还有一个容易踩的坑是"热实例"。在Agent场景里,同一个Agent的不同实例往往共享同一套底层模型或者数据库,如果流量被平均分发到每个实例,反而可能导致共享资源被打爆。所以Agent-Reach里引入了负载偏置参数bias,允许管理员给某些实例配置更高的权重上限,人为地把流量压到少数实例上。这在传统网关里是个很怪的操作,但在Agent世界非常常见,因为Agent实例没有无状态一说,它们背后大多是共享的有状态资源。
3.3 重试与幂等:Agent调用比API调用更危险
重试策略在Agent场景里需要格外谨慎。普通接口重试,最多担心重复扣款;Agent任务重试,如果目标Agent不是幂等的,任务可能被执行两次,产生两份结果。我在Agent-Reach里给每个任务生成一个全局唯一task_id,调用方在提交时带上这个ID,Reach层透传给目标Agent,并要求Agent在接收端做去重。去重逻辑不一定由Agent本身实现,通常我会在Reach层维护一个最近5分钟task_id的布隆过滤器,重放请求直接返回上一次的结果,而不是再次投递。
这部分的配置里有两个参数很关键:
- max_retries:重试次数,默认2次,不要超过3次。超过3次,整个链路的时间会膨胀到不可接受。
- retry_backoff_ms:重试间隔,采用指数退避,初始200ms,每次翻倍。
我用一组实际数字说明下为什么不建议调大。假设一个Agent平均处理时间是8秒,一次任务最多重试3次,最坏情况下耗时约为8 + 8 + 16 + 32 = 64秒,这还不包括排队时间。如果重试次数调到5,最坏就是8 + 8 + 16 + 32 + 64 + 128 = 256秒。一个任务超过4分钟,用户的耐心基本耗尽。所以控制总时长比提高成功率更重要。
4. 实操全过程:从零搭建Agent-Reach最小可用环境
前面把设计聊透了,接下来是实操。我会用Python快速搭一个Agent-Reach的最小版本,包括注册中心、路由引擎、两个模拟Agent和一个调用方。全部代码可以跑在本地,不需要额外的中间件。
4.1 目录结构与依赖
我建议的项目结构是这样的:
agent-reach/ ├── reach_server.py # Reach核心服务,负责注册与路由 ├── agent_worker.py # Agent节点通用启动脚本 ├── client_demo.py # 调用方示例 ├── agent_catalog.json # 注册的Agent元数据 └── requirements.txt # 依赖依赖只有FastAPI和httpx,两个就够。安装命令:
pip install fastapi uvicorn httpx我没有用消息队列,也没用Redis做注册中心,刻意保持轻量。因为Agent-Reach的定位是内部协作层,不是高并发网关,Redis反而增加部署成本。注册信息直接放在Reach进程内的字典里,配合持久化到JSON文件,对中小规模完全够用。
4.2 实现注册中心与健康检查
Reach服务端我先实现Agent的注册和心跳接口。每个Agent启动时调用/register接口上报元数据,然后每5秒上报一次心跳。Reach端维护两份状态:agent_meta存放能力声明,agent_health存放健康状态。
# reach_server.py 核心片段 from fastapi import FastAPI, Request import time, asyncio, json app = FastAPI() agent_meta = {} # agent_id -> 元数据 agent_health = {} # agent_id -> {"last_heartbeat": float, "fail_count": int} @app.post("/register") async def register(req: Request): data = await req.json() agent_id = data["agent_id"] agent_meta[agent_id] = data agent_health[agent_id] = {"last_heartbeat": time.time(), "fail_count": 0} return {"code": 0, "msg": "registered"} @app.post("/heartbeat") async def heartbeat(req: Request): data = await req.json() agent_id = data["agent_id"] if agent_id not in agent_meta: return {"code": 1, "msg": "agent not found, please register first"} agent_health[agent_id]["last_heartbeat"] = time.time() agent_health[agent_id]["fail_count"] = 0 return {"code": 0, "msg": "ok"} @app.get("/catalog") async def catalog(): # 返回当前所有健康Agent的能力列表 now = time.time() result = [] for aid, meta in agent_meta.items(): if now - agent_health.get(aid, {}).get("last_heartbeat", 0) < 15: result.append(meta) return {"agents": result}这里有个细节要说一下:健康判断的标准不是"有心跳就健康",而是"心跳超时15秒才算不健康"。因为Agent每5秒上一次心跳,允许丢2到3个心跳包。实际运行中网络抖动经常让心跳延迟,如果阈值设得太紧,会出现误杀。
健康检查的主动探活,我是用asyncio.create_task在后台每10秒对所有已注册Agent的/healthz接口发一次HTTP请求。注意这里要加超时,我设置为2秒。一旦主动探活也失败,立刻把该Agent的fail_count加1;连续fail_count大于等于2,路由时就排除该节点。
4.3 实现路由分发接口
最关键的是/route接口。调用方把任务参数直接POST到这里,Reach层完成能力匹配、打分、调用和重试。
@app.post("/route") async def route(req: Request): data = await req.json() task_id = data.get("task_id") capability = data.get("capability") payload = data.get("payload", {}) max_retries = min(int(data.get("max_retries", 2)), 3) candidates = find_candidates(capability) if not candidates: return {"code": 404, "msg": f"no available agent for capability: {capability}"} last_error = None for attempt in range(max_retries + 1): selected = select_best(candidates) if selected is None: break try: result = await call_agent(selected, task_id, payload) return {"code": 0, "task_id": task_id, "agent": selected["agent_id"], "result": result} except Exception as e: last_error = str(e) mark_failure(selected["agent_id"]) candidates.remove(selected) await asyncio.sleep(0.2 * (2 ** attempt)) return {"code": 500, "task_id": task_id, "msg": f"all attempts failed: {last_error}"}find_candidates函数做的事情是:遍历所有健康Agent,解析其capabilities列表,找到与请求capability名称匹配的实例。select_best则按上一节讲的评分公式打分排序。
call_agent里我用了httpx的AsyncClient,并设置了Agent声明的超时时间。这里必须用Agent自己报的超时,而不是一个统一值。因为不同的Agent处理效率差异巨大,统一超时要么导致慢Agent永远超时,要么让快Agent的调用方白等。
4.4 模拟Agent节点
每个Agent节点启动时做两件事:注册自己的能力,启动一个服务进程等待任务。下面是一个模拟的"文本情感分析Agent":
# agent_worker.py 片段 import httpx, json, time, asyncio from fastapi import FastAPI, Request app = FastAPI() AGENT_ID = "agent-sentiment-01" REACH_SERVER = "http://127.0.0.1:8000" AGENT_META = { "agent_id": AGENT_ID, "service": {"host": "127.0.0.1", "port": 9001, "health_check": "/healthz"}, "capabilities": [ { "name": "sentiment_analyze", "input_schema": {"type": "object", "properties": {"text": {"type": "string"}}}, "timeout_ms": 12000, "max_concurrency": 10, "weight": 50 } ] } @app.get("/healthz") async def healthz(): return {"status": "ok"} @app.post("/invoke") async def invoke(req: Request): body = await req.json() # 模拟AI推理耗时 await asyncio.sleep(1.5) text = body.get("payload", {}).get("text", "") # 简易情感打分,真实场景这里会调用模型 score = round(min(len(text) % 10 / 10, 1.0), 2) return {"sentiment": "positive" if score > 0.5 else "negative", "score": score} async def register_and_heartbeat(): async with httpx.AsyncClient() as client: await client.post(f"{REACH_SERVER}/register", json=AGENT_META) while True: await asyncio.sleep(5) try: await client.post(f"{REACH_SERVER}/heartbeat", json={"agent_id": AGENT_ID}) except Exception: print("heartbeat failed")多开几个端口9001、9002、9003就模拟出多个Agent实例,把AGENT_ID和端口改一下即可。
4.5 调用方示例与联调验证
调用方就简单了:
# client_demo.py import httpx, uuid async def submit_task(): task_id = str(uuid.uuid4()) payload = { "task_id": task_id, "capability": "sentiment_analyze", "payload": {"text": "这部电影太好看了,强烈推荐"} } async with httpx.AsyncClient() as client: resp = await client.post("http://127.0.0.1:8000/route", json=payload) print(resp.json()) if __name__ == "__main__": import asyncio asyncio.run(submit_task())运行顺序很关键:先启动reach_server.py,再启动两三个agent_worker.py,最后跑client_demo.py。首次联调时有一个常见现象:调用方报"no available agent for capability"。99%的原因是Agent还没来得及向Reach注册完就发请求了。我自己的调试办法是启动Agent后先访问/register接口检查返回,再去调用/route。
联调通过之后,我建议做一次"杀节点"演练:正在跑任务的时候,手动Ctrl+C杀掉其中一个Agent,观察Reach层是否自动把后续任务路由到另一个实例。这个演练我每次都做,比看100行日志都管用。
5. 真实环境中的常见问题与排查技巧
Agent-Reach在内部跑了几个月,我整理了一批高频问题和排查思路,按"现象-原因-解法"的格式列出来,可以直接当速查表用。
5.1 任务全部超时但Agent明明在运行
这是最迷惑人的问题。你去看Agent进程,活着;去访问它的健康检查接口,也通;但是所有任务都超时。我最终发现原因在于Agent的调用接口只是"接受请求",不代表"能快速处理请求"。很多Agent用了同步阻塞的模型推理,当并发请求超过它的处理能力时,请求会在Agent端排队。此时健康检查仍然秒回,但实际任务处理时间已经爆炸。
排查办法:在任务日志里看"网络传输耗时"和"Agent处理耗时"两个指标。如果网络耗时低但整体耗时长,问题出在Agent内部;如果网络耗时本身大,才去查Reach路由和网络链路。千万不要把两者混在一起。
针对Agent端排队的问题,我加了一个"处理中计数"字段,Agent在每次调用真正开始时递增。Reach层不允许路由给"处理中计数超过max_concurrency"的实例。这个改造比任何超时调参都有效。
5.2 一个Agent异常却被反复打满重试
默认重试2次的策略在大多数时候够用,但遇到目标Agent的接口抛异常时,会出现"请求4秒失败,重试,又4秒失败,再重试,总计12秒"的惨状。原因很简单:异常Agent没有及时从候选列表移除。
我的处理方式是,每次路由失败,把fail_count加1,一旦超过阈值,直接进入30秒的隔离期。隔离期内的节点不参与任何路由,隔离结束后自动重新参与。这就避免了"一个坏节点拖垮所有任务"的最坏情况。隔离期的阈值我调成2,也就是连续失败两次即隔离,这个参数很保守,但稳妥。
5.3 新上线Agent接不到流量
新Agent启动后,注册成功、心跳正常,但流量半天上不去。原因是评分公式里的健康项和负载项对新手不友好。新节点历史负载是0,load_factor反而会给出靠近满分的评价,但weight_factor偏低。问题往往出在Affinity不太匹配或者weight配置过低。
我建议新Agent接入时的策略是:先把weight设置成平均值以上,观察半小时,确认稳定后再调低。另外,不要指望新节点上线后马上承担大流量,给它一个预热期,让Reach层的缓存把它的健康数据累积起来,路由才会信任它。
5.4 调用链路上缺少任务ID导致排障困难
我刚开始做的版本里,调用链路只记录API地址,出了问题根本对不上是哪次任务。后来强制所有调用环节携带task_id,Reach层、Agent层、日志系统统一按task_id索引。一次完整调用的日志长这样:
[task_id: 8f2a1c94] route -> selected agent-sentiment-02 (score=82.5) [task_id: 8f2a1c94] invoke agent-sentiment-02 success, cost=1572ms [task_id: 8f2a1c94] attempt=1 success没有task_id的调用链,排查一次线上故障可能要半小时;有task_id,五分钟内能把每个环节的时延看穿。这算是我踩过的最值的一个坑。
5.5 Agent能力版本兼容问题
还有一类问题,平时不出事,一升级就炸:能力名没变,但输入输出结构变了。旧Agent支持text字段,新Agent要求text和lang字段,Reach层不做校验,直接把老格式的任务丢给新Agent,结果解析失败。
我的解法很简单,也很土:在能力声明里加一个version字段。每次能力结构变动,version递增。调用方和目标Agent都必须在任务里带上version,Reach层只路由版本完全匹配的任务。这个规则虽然笨,但能挡住90%以上的兼容事故。
6. 后续扩展方向:让Agent-Reach从"能用"走向"好用"
截至目前,Agent-Reach已经帮我们稳定支撑了十几个Agent节点的日均调用。这个规模不大,但足够验证设计本身是对的。接下来我有三个明确的扩展计划,如果你准备拿这套思路做更长远的规划,可以参考。
第一个是增加"编排链"支持。现在的Reach层每次只做一件事:把任务投给一个Agent。但真实业务往往是串行链路:先生成文稿,再审核,再配图。我打算在Reach层引入简单的DAG定义,让调用方提交一个"编排描述文件",Reach层按依赖顺序自动执行整条链,而不是由调用方自己串。这会部分变成工作流引擎,但触达路由的本质不变。
第二个是更精确的"能力协商"。现在的capability匹配是精确匹配,将来Agent的能力越来越细,同一个"文案生成"能力可能有很多变体。我想把匹配规则升级成"语义匹配+参数约束检查",比如根据input_schema自动检查参数合法性,不匹配的直接拒绝,而不是把坏参数发给Agent,浪费一次调用。
第三个是接入观测指标。我计划把每条路由的决策原因、评分明细输出成Prometheus指标,这样定位问题就不需要看日志猜了。一个"为什么这个任务没有投给那个Agent"的问题,在指标里一眼就能看到是健康分不够还是负载惩罚过重。
不过这些都是后话。如果你现在正面临多个Agent协作混乱的问题,不用等这些高级特性,把我前面讲的最小闭环搭起来,先把"互相找得到、任务投得准、失败兜得住"这三件事做到,系统的稳定性就会有一个质的提升。我自己实际运行下来的体会是,Agent-Reach这类触达层,解决的不是算法问题,而是工程治理问题。有时候最不起眼的"路由逻辑",反而是整个Agent系统的定海神针。