Agent-Reach这个名字,最终被写在了我们团队多智能体协作层的第一份架构文档上。当时的情况挺拧巴:团队里已经跑了四十多个Agent,有做代码分析的、做报表的、写客服话术的、调度内部流程的,单个拎出来都很能干,可一旦要让它们互相配合,就开始搬文档、对接口、等排期。更尴尬的是,很多Agent的能力其实高度互补——报表Agent发现销售额异常,理应由归因Agent继续往下查,再由库存Agent核验是否缺货,但现实里这三条链路要靠人肉编排,跑一次全流程动辄一两个星期。于是我们决定做一个叫Agent-Reach的中间层,核心就一件事:让Agent之间像人一样能"触达"对方的能力,而不是永远隔着一层工程师维护的对接表。
这篇内容适合正在搭建多Agent协作平台的架构师、后端工程师和AI应用工程师。我会把Agent-Reach从需求拆解、架构设计、最小闭环代码,到上线后踩过的大坑、补生产化安全措施的完整过程都翻出来。不吹概念,直接给能落地的方案和代码,以及那些真跑起来才会发现的细节。
1. 从"Agent孤岛"说起:为什么需要一个叫Reach的中间层
1.1 当Agent数量突破两位数,问题不再是单个Agent的质量
单个Agent的评测指标再好看,也只说明它在自己的封闭域里干得不错。可一旦Agent之间要协作,最原始的方式就是让工程师互相拉群:"你们的Agent能不能提供一个接口?参数长什么样?鉴权怎么弄?"一个Agent还好,十个Agent靠人肉对接勉强能忍,四十个Agent的时候,两两组合理论上有一千六百条链路,靠Excel根本维护不过来。
我们当时实际上就是这么干的。每个人都维护了一份自己的"谁有哪个能力、怎么调用"的文档,结果文档每天都在过期。某天BI Agent要调库存Agent的capacity接口,发现字段名变了,对方已经改成availability了——这种破事每周都在消耗集体耐心。真正的转折点是有一次线上运营活动需要六个Agent协同,技术侧排期两周半,运营直接找了业务负责人投诉。这时候我们才意识到,问题早就不是"单个Agent智能不够",而是Agent之间没有一套自动发现、自动履约的协作基础设施。
1.2 MCP解决了工具调用,却没解决Agent之间的对等协作
业界聊得很多的MCP这类协议,解决的是"让Agent调用外部工具"的问题,本质上是主从关系:Agent是客户端,工具是服务端。Agent发起请求,工具执行完返回结果。这种模式非常好用,也确实把工具层统一了。但Agent与Agent之间不是主从,而是对等关系。A调用B,B可能还要调用C,C甚至反过来需要A的某些数据。这种动态的、多跳的、互相依赖的调用图,不是给每个Agent配一套MCP工具就能覆盖的。
打个比方:MCP像是给每个Agent发了一个更好的工具箱,让它知道怎么用锯子、锤子、电钻;而Agent-Reach要解决的是,你拿着锯子想去隔壁工位找师傅借个角度规,你得知道隔壁师傅在不在、他擅长什么、怎么和他说话、借完东西怎么记账。工具箱解决不了人际关系,Agent-Reach做的恰恰是这层"关系网络"。
当然,市面上也有不少Agent互联协议在做类似的事,但我们没有迷信标准。标准给了我们语言和规范,工程落地才是大头:注册信息存哪里、路由怎么选、超时怎么设、消息怎么去重、权限怎么控制。Agent-Reach不是重新发明协议,而是把这些工程问题稳稳兜住,同时对外保留一张足够开放的接口。
1.3 Agent-Reach想清楚的三件事:发现、触达、追踪
做这个项目之前,我逼着团队所有人先用一句话说清楚Agent-Reach到底要解决什么。最后收敛下来就三个词:发现、触达、追踪。
发现的意思是,Agent上线后自动注册自己和自己的能力,下线时自动摘除,不需要人工改配置。触达的意思是,调用方不需要关心目标Agent的地址、密钥、鉴权方式,只要说清楚"我要什么能力、参数是什么",网关负责找对人、发对请求。追踪的意思是,任何一次跨Agent调用,都能顺着一个请求ID把整条链路捞出来:从A到B到C,每一步花了多久、消耗了多少token、谁负责执行的。
听起来简单,但实际操作中这三个词背后全都是坑。比如"发现",Agent的注册信息里到底该放什么?放一个自然语言描述"我能做数据统计",路由时根本没法用;放一份字段清晰的JSON Schema,匹配准确率才上得去。再比如"触达",Agent的能力可能有多个副本,路由到哪个实例?副本挂了要不要重试?重试会不会导致两边都执行一遍修改操作?这些细节我在后面的章节里逐个展开。
2. Agent-Reach的分层设计:注册、路由与消息通道怎么分工
2.1 注册中心:Agent带着能力清单入住
每个Agent接入Agent-Reach时,首先要做的是住进注册中心。注册的数据不是简单一个"我在这里"的地址,而是一份结构化的AgentProfile,包含Agent唯一ID、对外endpoint、监听的消息类型、能力列表、处理模式、运行状态。能力列表是整个注册信息的核心,每条能力至少要包括能力ID、能力名称、所属域、描述、入参JSON Schema、出参JSON Schema。
这样说太抽象,我直接给一个当时我们定下来的注册信息样子:
{ "agent_id": "bi_report_agent_v3", "endpoint": "http://internal.agent.svc/report/agent/v3", "transport": "http", "capabilities": [ { "capability_id": "cap.report.generate", "name": "生成经营报表", "domain": ["bi", "reporting"], "description": "根据指定指标和日期范围生成标准经营分析报表", "input_schema": { "type": "object", "required": ["metrics", "date_range"], "properties": { "metrics": {"type": "array", "items": {"type": "string"}}, "date_range": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}:\\d{4}-\\d{2}-\\d{2}$"} } }, "output_schema": { "type": "object", "properties": { "report_id": {"type": "string"}, "summary": {"type": "string"}, "data": {"type": "array"} } }, "mode": "sync" } ], "status": "ready" }这里有两个关键选择值得解释。第一,为什么用能力ID而不是直接用名称?因为名称有歧义。"销售分析"在BI Agent里是"生成报表",在归因Agent里是"定位因素",在同一个注册中心里很容易撞车。用capability_id统一标识,再配上domain字段做一级分类,路由时就能先粗筛再细筛。第二,为什么输入输出都要JSON Schema?因为路由匹配不仅要选Agent,还要在转发前做参数校验。一个不符合schema的请求如果直接修正ML里,要么报错要么返回乱糟糟的数据,不如在网关层直接挡掉。
存储层面,我们选了etcd而不是Redis或者MySQL。etcd自带lease机制,天然适合做注册心跳——Agent定期续约,续约失败就自动过期删除,比Redis手动设置TTL再加过期回调省心得多。同时etcd的watch机制能让网关实时感知Agent上下线,不用一遍遍轮询。如果团队已经重度依赖Redis图省事也不是不行,但我们实测下来,用etcd的lease + watch组合,整套注册逻辑能少写一半代码。
2.2 路由器:为什么不能只用关键词匹配
注册信息是清楚了,接下来是路由。最早我们做过一版"关键词匹配",效果相当灾难。原因是Agent的描述里同义词太多了:用户说"查一下仓库还能装多少货",库存Agent的能力描述写的是"查询可用库存容量",仓储Agent写的却是"获取库位剩余空间",关键词匹配算法根本分不清它们在说同一件事。用分片、加权、同义词表硬调,调了小半个月,还是动不动就把请求发给错误的Agent。
后来我们改为两级匹配。第一级是硬过滤:根据请求里带上的domain和必要的输入字段,把完全不符合的Agent直接排除掉。比如请求参数里有"sku列表、库房ID",大概率该走供应链域,硬过滤结果就是剩下两三个候选。第二级是语义排序:把请求的意图和候选Agent的能力描述都转成向量,用embedding算余弦相似度,取分最高的那个作为路由目标。同时设一个最低阈值,比如相似度低于0.55就拒绝路由,提示调用方"未找到匹配能力",而不是硬选一个错的过去。
这套两级匹配看起来简单,但有两个很重要的工程细节。一个是阈值怎么定。我们一开始按直觉设0.8,结果大量正常请求因为描述不够严谨被拒;后来取了200条业务真实请求做了人工标注,画分布图发现0.55~0.60之间是分水岭,再低就开始出现明显错配。另一个是缓存。embedding推理不是免费的,每次都重新对全部Agent算一遍开销太大,我们在网关里对"请求域+参数schema结构"做了哈希缓存,命中缓存就不做向量匹配,直接返回最近一次结果。缓存TTL设5分钟,既保证时效,又不至于打爆推理服务。
2.3 消息通道:请求-响应之外的异步事件
早期Agent-Reach只支持同步请求-响应,调用方发一个HTTP请求,等着结果。跑了一阵发现很多场景根本不适合同步:比如客服Agent让数据分析Agent跑一个全量用户分群统计,哪怕再快也要几分钟,让调用方一直挂着连接等结果,既不现实也浪费资源。于是我们在消息通道里加了异步事件模型,核心有三种消息类型:
- request:调用方发起的跨Agent请求,网关路由到目标Agent。
- response:目标Agent执行完毕回传的结果,关联原始request_id。
- event:Agent主动对外发布的业务事件,例如"某报表已生成""某库存低于阈值",订阅该事件的Agent会收到通知。
同步调用走request和response两条消息就够了,异步长任务则可以配合event做回调。比如数据分析Agent受理请求后立即返回一个task_id,执行完成后发一条report.ready事件,客服Agent这边订阅到事件后主动来取结果。这个事件通道我们直接用内部已有的消息中间件承载,Agent-Reach只负责定义消息的schema和路由规则,不重新造一套总线。
这里一定要提幂等设计。跨Agent调用的网络断连、超时重试都可能导致同一条请求被执行两次,如果是单纯的查询还好,碰到"发券""写配置""改库存"这类操作,重复执行就是事故。我们的方案是强制每个请求携带全局唯一的request_id,网关转发时透传,执行端收到重复request_id直接返回上一次结果。这个字段从第一版就定了,后面所有排障也都受益于它。
3. 一个能跑起来的最小闭环:从注册到触达的完整代码
3.1 目录结构与运行依赖
讲完设计,我们来搞点实际的。这部分我给出的是一套可复现的最小闭环,包含一个网关、两个Agent,跑通"调用方请求网关 → 网关路由 → Agent执行 → 回传结果"的完整链路。项目结构是这样:
reach/ ├── gateway/ │ ├── main.py # 网关入口 │ ├── registry.py # 与etcd交互的注册模块 │ └── router.py # 路由匹配与转发 ├── agent/ │ ├── weather_agent.py # 天气Agent │ └── report_agent.py # 报表Agent ├── common/ │ └── context.py # 请求上下文、trace_id处理 └── requirements.txt依赖比较简单:Python 3.10+、fastapi、uvicorn、httpx、etcd3,再加一个用于向量匹配的轻量接口服务,你本地可以用任何embedding模型替代。网关用FastAPI是因为同时要处理HTTP入口和SSE流式输出,生态成熟,样板代码少。注册中心起一个单机etcd即可,docker一行命令:
docker run -d -p 2379:2379 --name reach-etcd quay.io/coreos/etcd:v3.5.5 \ /usr/local/bin/etcd --advertise-client-urls http://0.0.0.0:2379 \ --listen-client-urls http://0.0.0.0:23793.2 注册与心跳:让Agent"在线"且"可被发现"
Agent启动后第一件事就是把自己写进etcd。我们封装了一个register_agent函数,逻辑很简单:向etcd写入AgentProfile,再用lease建立心跳。这里有个关键点——key的名字一定要带agent_id,并且value里必须带上endpoint和注册版本号。版本号的作用后面排障章节会细讲,先记住这个习惯。
# registry.py import json import etcd3 def register_agent(agent_id: str, profile: dict, ttl: int = 30): client = etcd3.client(host="127.0.0.1", port=2379) lease = client.lease(ttl) key = f"/agent-reach/agents/{agent_id}/profile" client.put(key, json.dumps(profile), lease=lease) # 开启后台协程续约 lease.refresh() return leaseTTL设多长算合理?我们用的30秒。太短会导致Agent一次GC停顿或者网络抖动就掉线,太长又会让真正已经故障的Agent继续挂在注册中心里"误导路由"。30秒是大多集群Agent心跳的最通用配置,配合网关本地缓存30~60秒的过期时间,基本能达到"故障感知不超过一分钟"的目标。
Agent侧还需要在一分钟内反复调用lease的refresh接口,相当于告诉注册中心"我还活着"。
# agent common code async def heartbeat_loop(lease, stop_event): while not stop_event.is_set(): await asyncio.sleep(10) # 每10秒续一次 try: lease.refresh() except Exception as e: print(f"[heartbeat] refresh failed: {e}") # 失败一定次数后主动注销并报警这里特别提醒一下:我们在第一版曾忽略"主动注销"。进程被kill -9后,etcd的lease自然是过期的,不用管;但如果是优雅停机,你最好显式调lease.revoke(),并且发送一条offline事件给网关,让网关立即摘掉缓存,别等30秒过期。优雅下线差那几十秒,线上请求就可能多几十个失败。
3.3 网关侧的路由匹配与转发实现
网关启动时会先拉一次etcd里的全部Agent列表,再通过watch持续监听变化,把AgentProfile缓存到本地内存。收到调用方请求后,先解析目标能力,走两级匹配,然后用httpx把请求转发到目标Agent的endpoint。
# router.py async def route_and_invoke(request: InvocationRequest): candidates = match_capabilities(request) if not candidates: raise HTTPException(status_code=404, detail="no suitable agent found") target = candidates[0] payload = { "request_id": request.request_id, "capability_id": request.capability_id, "params": request.params, "trace_id": request.trace_id, "max_hops": request.max_hops - 1, } # 超时控制在30秒,连接直接抛超时异常 async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post(target.endpoint + "/rpc", json=payload) return resp.json()路由匹配函数match_capabilities是核心,它不做embedding计算,先做硬过滤和缓存查询:
# router.py def match_capabilities(request: InvocationRequest): cached = cache.get(f"{request.domain}:{request.capability_id}") if cached: return cached agents = registry_cache.all_agents() # 一级过滤:domain与schema字段兼容性 candidates = [ a for a in agents if request.domain in a["domains"] and schema_compatible(a, request.params) ] if not candidates: return [] # 二级排序:调用embedding服务获取相似度 candidates.sort(key=lambda a: embedding_score(request, a), reverse=True) if candidates[0]["score"] < SIMILARITY_THRESHOLD: return [] cache.set(f"{request.domain}:{request.capability_id}", candidates[:1], ttl=300) return candidates[:1]这里面有个容易被忽视的点:路由匹配的是"能力",不是"Agent名"。调用方在请求里填capability_id,网关就按能力找Agent。一个Agent可以注册多个能力,一个能力也可能被多个Agent声明的副本承载,网关要做负载均衡和故障转移。最小闭环里先贪心选评分最高的那个,够用。
3.4 Agent侧的执行入口与回调验证
Agent侧实现就简单很多,一个FastAPI应用,监听/rpc路径,收到请求后从body里取出capability_id和params,分发给对应的handler。这里我故意用一份最普通的dict查表,演示时足够直观;生产里你可能会换成依赖注入或者函数注册装饰器,思路没区别。
# weather_agent.py from fastapi import FastAPI, Request import asyncio, uuid app = FastAPI() HANDLERS = { "cap.weather.current": handle_current_weather, "cap.weather.forecast": handle_forecast, } @app.post("/rpc") async def rpc_endpoint(request: Request): body = await request.json() req_id = body["request_id"] # 幂等去重:如果已执行过直接返回结果 if req_id in executed_set: return {"ok": True, "duplicated": True, "result": executed_set[req_id]} handler = HANDLERS[body["capability_id"]] result = await handler(body["params"]) executed_set[req_id] = result return { "ok": True, "result": result, "usage": {"tokens": estimate_tokens(body["params"])}, "exec_time_ms": 42, }验证整个闭环时,我一般先启动两个Agent,再直接写一个小脚本调用网关:
curl -X POST http://127.0.0.1:8000/invoke \ -H "Content-Type: application/json" \ -d '{ "request_id": "abc-123", "capability_id": "cap.weather.current", "params": {"city": "beijing"}, "domain": "weather" }'首次跑通时最有可能卡住的是Agent的endpoint写成了localhost。网关和Agent如果不在同一个容器或者同一台机器,localhost只会连到网关自己。这个错误我们团队的新人犯过不止一次,排查时优先怀疑它。
4. 排障实录:Agent调用链路上最常见的三个故障根因
4.1 故障一:注册信息过了有效期,网关还在往旧地址转发
上线没多久我们就碰到一次诡异故障:某个BI任务持续报timeout,可对应的Agent进程明明活着,打开监控看CPU和内存也很正常。一开始我们以为是Agent处理能力不足,把超时时间一路调高,结果依旧超时。最终查看了网关日志,发现所有请求都被转发到了一个已经不存在的endpoint上。
root cause出在注册信息没有跟着Agent重启而更新。那台Agent机器经历了重启,新的Agent实例监听在新的端口上,但旧实例在死亡前没能从etcd里摘除自己,因为重启太快,旧的lease还没来得及过期。网关watch到etcd里的变化有延迟,加上本地缓存TTL没到,所以继续向旧地址转发。
这个坑的教训有两条。第一,Agent实例必须在启动时主动把endpoint和版本号上报,而不是由运维手工改etcd。第二,网关在没有收到Agent心跳确认的情况下,不应该让缓存里的Agent条目或endpoint存活太久。我们在网关侧加了一个后台巡检任务,每15秒把本地缓存和etcd实际状态比对一遍,发现endpoint变更立即清缓存。那次之后,类似问题再没出现过。
4.2 故障二:一次A调用B、B调用C的链路,日志对不上号
另一个高频问题是日志链路断裂。某天客服Agent反馈,下游归因Agent经常报错,但运维想定位是哪一次客服会话触发的,翻客服Agent的日志和归因Agent的日志却对不上——两边的时间戳都对不上,请求内容也连不起来。
排查过程花了大半天,最后发现根因非常简单:客服Agent调用归因Agent时只传了业务参数,没有传递自己的会话ID,也没有生成全局trace_id。归因Agent收到请求后,内部框架自己new了一个request_id,这个ID只存在归因Agent的单次日志里。两边日志各写各的,当然衔接不上。
我们在common/context.py里统一定义了跨Agent上下文协议,每个请求都必须携带trace_id、parent_span_id、user_id、tenant_id四个标准字段。网关入口一旦发现trace_id缺失,会自动生成新的,但无论如何,这个ID会贯穿整条调用链。同时所有日志输出改成JSON格式,每个span都有trace_id、span_id、service、event_time。从那以后,任何跨Agent的报错我们都能在分钟级别定位到"哪个Agent在哪一步出了问题"。
4.3 故障三:两个Agent互相调用,形成了没人察觉的循环
最惊悚的故障是循环调用。某天下午网关的请求量突然暴涨,监控曲线出现了一个非常漂亮的指数上升趋势,同时平均响应时间飙升。我们第一反应是流量攻击,结果查下来发现是两个Agent自己打起来了:一个分析Agent在无法确认数据口径时,会调用库表校验Agent复核;校验Agent发现偏差后,又回过来问分析Agent"你原来的口径是什么",分析Agent又被触发去校验——如此反复,一次请求的调用深度直接膨出几十层。
循环的可怕之处在于它很难靠单体代码review发现。你觉得A调B算一次很合理,B调A也各有各的道理,但组合起来就是无底洞。我们最终用两个机制兜住了这个问题:第一,请求上下文里维护max_hops递减计数,网关每转发一次就减一,减到0直接拒绝并返回"调用深度超限";第二,request_id全文透传,执行端看到同一个request_id已经在链路里出现过,直接不再重复处理。这两张网一收,循环调用基本能在三跳内被切断。
坦白讲,循环调用的威胁不只是无限递归。就算用max_hops限制深度,每一跳都在消耗token和算力,几十层下来费用可观。所以我们在网关入口按调用方维度配了token预算,单次请求聚合消耗超过预算就熔断。这一块后面安全章节会细说。
5. 上线前被反复拷问的五个问题:鉴权、限流、审计与安全
5.1 认证:比"内网可信"更稳妥的双向签名
早期我们在内网环境跑,论证过"反正内网,防火墙挡一下就行了"。没有通过。理由是Agent-Reach一旦接入更多团队、更多环境,内网边界早就模糊了——某个Agent可能跑在开发者的笔记本电脑上,某个可能跑在云上,指望所有调用方都在一个干净的内网环境里完全不现实。
最后采用的是每个Agent一对client_id和client_secret,请求签名后网关验签。签名算法用的HMAC-SHA256,对请求体里的关键字段和时间戳做摘要,网关收到后用该Agent的secret重新算一遍,匹配则放行。时间戳窗口设为±300秒,防止重放攻击。
这里有个容易被忽略的点:secret绝不能直接明文存储在Agent进程的环境变量里。我们统一放到内部密钥服务里,Agent启动时按client_id拉取,运行时只在内存中持有。凡是把密钥写进代码仓库的,一律打回。
5.2 权限:能力级别的细粒度授权,而不是Agent级别的粗放授权
认证解决了"你是谁",权限解决"你凭什么能调这个能力"。我们踩过一个设计弯路:一开始按Agent维度做授权,给每个Agent一张白名单,写上它能调用哪些目标Agent。结果没过两周就发现不行——同样是"数据导出"能力,普通分析Agent应该能用,但生产环境的数据写入Agent绝对不能碰;同一个目标Agent下,不同能力的敏感级别完全不一样。
所以Agent-Reach的权限模型固定在"主体+能力+动作"三元组上。ACL表大概长这样:
subject_type: agent subject_id: bi_report_agent capability_id: cap.report.generate effect: allow subject_type: agent subject_id: bi_report_agent capability_id: cap.report.delete effect: deny路由转发之前,网关先查ACL表做鉴权。deny优先,allow放行,没匹配到默认拒绝。这样做的好处是,新增一个Agent不需要为一堆目标Agent挨个打点,只要在能力注册时声明权限范围;给某个Agent授权"调用某能力"也是一条策略的事。同时能力名统一走capability_id,不会因为叫"update_user"还是"modify_user"造成鉴权漏洞。
5.3 限流与预算:给失控的循环调用一个刹车
限流不新鲜,但Agent场景里的限流有个特殊点:不仅看请求速率,还要看token消耗和外部依赖费用。我们做了两层限流。
第一层是请求速率限流,按client_id + capability_id维度做令牌桶。第二层是成本预算层,每一次跨Agent调用都会统计消耗的token数,网关累加到全局budget里。我们在配置里给每个调用方设了阈值,比如"单小时token消耗超过100万就熔断该调用方的后续请求",而且必须人工确认才能恢复,避免反复触发。
这个设计在上线第二天就救了我们一次。一个测试Agent的循环调用漏过了限流,但预算熔断在几秒内就把它按住了,成本损失几乎可以忽略。所以"预算"这个维度,我强烈建议所有做多Agent协作的朋友都补上。
5.4 审计:不记录原始Prompt,但把敏感数据流转记清楚
审计是个容易被低估的环节。我们一开始只顾着功能跑通,没补审计,结果审计能力审查时被卡了很久,最后补得比设计还痛苦。审计设计有几个硬性要求:一是可追溯,谁在什么时间调用了哪个能力,传了哪些实体ID(用户ID、订单ID),消耗了多少token;二是保留完整的父请求ID和子链路关系;三是不能因为审计引入新的数据风险。
所以Agent-Reach的审计日志明确不记录原始prompt全文,只记录脱敏后的摘要和实体引用。比如记录"用户#1234的客服会话触发归因Agent,调用cap.attribution.run",但不会记录这个会话里用户具体说了什么。脱敏逻辑在日志出口统一处理,各Agent侧不做任何敏感信息落盘。这样做既对上合规审查,又避免把审计系统变成另一个数据泄露面。
6. 重做一个Agent-Reach,我会在哪些设计上让步
6.1 流式响应应该在第一天就支持
第一个让步是流式响应。第一版Agent-Reach只支持同步HTTP,结果很多Agent一跑就是几十秒,调用方挂在连接上干等,体验很差。后来为了支持SSE流式输出,我们在网关里补了长连接管理和流式转发,前后花了两周多。如果重来一次,我会在消息协议设计初期就把流式响应作为一等公民,同步响应只作为它的特例。这个改动牵涉到网关的缓冲策略、超时控制和调用方SDK,越晚补代价越大。
6.2 能力描述用结构化Schema,而不是自然语言大作文
第二个让步是能力描述。我们第一版AgentProfile里的description字段全是自然语言,写的时候觉得挺友好,路由匹配时发现全是灾难。一份描述二三十个字,embedding匹配只能靠运气。后来把每条能力拆成JSON Schema、枚举值、示例参数,匹配准确率立刻上一个台阶,而且网关还能在校验阶段就过滤掉一半的非法请求。现在我的原则是:自然语言描述只作为辅助说明,路由和校验的主依据永远是结构化schema。
6.3 日志链路和审计要从第一行代码开始
第三个让步是日志和审计。当初只想着先把闭环跑通,日志随便打了打,审计完全没接。结果Agent数量一多,排查一次跨Agent问题要把几个系统的日志手工黏在一起,痛苦不堪。如果重来,我会在第一个可运行版本出现之前,就把trace_id透传、统一日志格式、审计事件输出这三件事做好。原因很简单:事后补审计,你要去所有Agent侧改代码;事前做,只是多写一个公共依赖的问题。成本差了五倍不止。
6.4 我仍然保留的三条原则
虽然让步不少,有三条原则我坚持到底。第一,Agent-Reach网关只做路由、状态管理和安全控制,绝不侵入Agent内部的业务逻辑。第二,所有跨Agent交互必须经过注册发现,禁止任何绕过网关的直连——哪怕只是临时调试,也不能破例。第三,能力描述变更必须有版本号,不兼容变更要提前在网关侧隔离。这三条看着像是约束,实际帮我挡住了很多"为了方便留个后门"导致的故障。
最后再分享一个我个人的判断:Agent-Reach这类中间层的价值,不在任何酷炫的AI能力,而在于把无序的Agent调用变成有序、可观测、可运营的工程系统。我们团队现在每天有几百次跨Agent调用通过它流转,我反而觉得最骄傲的不是跑通了多少大模型推理,而是有一次线上故障定位只花了十分钟——换作以前,这种链路问题够我们排查一个通宵。如果你正在为Agent协作发愁,照着这个思路先搭一个最小闭环,再慢慢补安全、预算和排障能力,能少走不少弯路。