做 Agent 应用的人应该都有同感:真正卡住进度的往往不是模型选型,也不是 Prompt 怎么写,而是 Agent 想调用某个工具、想访问某个服务、想和另一个 Agent 协作时,连接这件事本身铺了一地的坑。我最初是在一个内部项目里被逼着做了个轻量的连接层,后来觉得思路值得整理,就把它独立成了一个叫 Agent-Reach 的项目。这篇文章把设计思路、核心实现、实测数据和个人踩坑都放在一起,给同样在折腾 Agent 应用的人一个参考。
Agent-Reach 本质上解决的是一个很朴素的问题:让 Agent "够得着"它需要的一切。够得着内部 API、够得着第三方服务、够得着 MCP 工具,也够得着其他 Agent。它不关心模型脑子里在想什么,只关心请求能不能稳定、安全、可控地从 Agent 出发,到达目标能力,再带着结果回来。下面按我实际的开发顺序来讲。
1. Agent 应用的连接困局:为什么我把目光从模型转向"最后一公里"
先说背景。我做的项目需要让 Agent 具备一组技能:查库存、下单前校验地址、调用内部推荐服务、偶尔还要调用外部天气或物流查询接口。一开始的做法很简单,每个技能写一个函数,在系统 Prompt 里把函数描述扔给模型,模型输出 JSON 参数,代码里用 if-else 分发调用。这种"裸调函数"的方式在小 Demo 里完全够用,但一旦技能数量超过二十个、调用链拉长,问题就集中爆发了。
1.1 胶水代码失控
每个技能背后往往不是一个函数,而是一串动作:鉴权、拼接请求、处理超时、重试、解析各种格式的返回值。这些逻辑如果散落在各个业务函数里,每次新增一个工具,我都要复制粘贴一大段相似代码。更麻烦的是,不同服务的协议完全不同:内部服务是 HTTP+JSON,推荐服务是 gRPC,两个第三方服务分别是 XML 和纯文本返回。Agent 不能直接理解这些差异,所有适配工作都得塞进那一堆胶水函数里。时间一长,代码里到处是"给 Agent 用的特殊分支",业务逻辑和连接逻辑搅在一起,改一个服务地址都要全局排查。
1.2 从工具调用到能力编排
另一个让我转变思路的契机是,需求从"单次工具调用"升级到了"多步编排"。比如"查一下用户常购商品的库存,如果不足就推荐替代品,并把结果发给客服 Agent"。这个流程要调用商品服务、推荐服务,再把结果投递给另一个 Agent。如果每个调用都各写各的,那编排层根本没法统一做超时管理和错误恢复。我当时就在想,Agent 需要的不是一堆散装函数,而是一个统一的"能力出口":无论底层是 HTTP、gRPC 还是 MCP 工具,对 Agent 来说都只是"一个可以触达的能力点"。
1.3 "够得着"是基础设施问题
想明白这一点之后,问题就被重新定义了。我需要一个基础层,它负责三件事:让每个能力有一个统一的描述和注册入口;让 Agent 的请求按能力名称路由到正确的后端;让所有底层协议的差异在适配层被消化掉。这个基础层后来就是 Agent-Reach 的雏形。它不是某个具体业务的工具,而是业务工具和 Agent 之间的基础设施。
2. Agent-Reach 的整体架构:注册中心、能力路由和协议适配如何分工
Agent-Reach 的核心结构很简洁,分三层:能力注册中心、能力路由、协议适配层。每一层只做一件事,边界划清楚之后,新增一个能力变得非常机械,基本不用再动业务代码。
2.1 能力注册中心:一切以"能力描述"为准
我做的第一件事是定义统一的能力描述格式。每个接入 Agent-Reach 的能力,都要提供一份 YAML 描述文件,包含:能力标识、用途说明、入参的 JSON Schema、出参格式、超时建议、所属服务方。下面是一个真实的例子:
capability: inventory.query version: 1.2.0 description: 查询商品库存数量,支持批量查询 owner: warehouse-team timeout_ms: 1500 input_schema: type: object required: [sku_ids] properties: sku_ids: type: array items: { type: string } maxItems: 50 output_schema: type: object properties: items: type: array items: type: object properties: sku_id: { type: string } stock: { type: integer } available: { type: boolean } auth: type: service_token scope: inventory:read注册中心实际上是一个带 TTL 的内存注册表,后端是 Redis。每个能力实例启动时把自己注册进来,定期续约。路由层拿到的不是静态配置,而是活的实例列表,哪个实例挂了,续约断了,路由层很快就能感知。为什么用 Redis 而不是数据库?因为能力实例的注册状态是典型的"短时、高频"数据,数据库的持久化在这里没有意义,反而会增加读写延迟。我用的是一个很轻的 Key 结构,key 是能力标识加实例 ID,value 是实例的地址和健康状态,TTL 设 30 秒,续约间隔 10 秒。
2.2 能力路由:按语义找服务,而不是按地址找服务
路由层的核心是"能力名到实例"的映射。Agent 发出请求时只需要声明要调用哪个能力,比如 inventory.query,路由层根据注册中心的数据把请求转发到具体的实例上。
路由策略我做了两种。默认是最简单的加权轮询,适合无状态的查询类能力。第二种是亲和路由,用于有本地缓存或需要保持会话状态的能力,同一个会话 ID 尽量打到同一个实例上。亲和的实现方式不复杂,对能力标识加会话 ID 做一致性哈希,就能把相同会话的请求稳定送进同一个实例。
这里有一个重要的取舍:路由层不解析业务参数。也就是说,它不会因为"库存不足"就把请求转到另一个能力。语义级的决策应该由 Agent 本身或上层的编排器来做,Agent-Reach 只保证"你说要调用 inventory.query,我就把它送到能提供这个能力的地方去"。把路由层做成哑管道,反而让系统更稳定,因为业务规则一旦进了路由层,升级和排障都会变得很痛苦。
2.3 协议适配层:消化所有底层差异
这一层是 Agent-Reach 里写得最痛苦,也最值得的部分。适配层面向的协议大致分三类:HTTP/JSON 服务、gRPC 服务、MCP 工具。
对 HTTP 服务,适配器负责把能力描述里的输入参数组装成请求体,补上鉴权头,按描述里的映射规则把响应字段转成统一格式。对 gRPC 服务,适配器把 JSON 参数转成 protobuf 消息,调用方法后再把响应转回 JSON。对 MCP 工具,适配器直接复用 MCP 客户端协议,按 MCP 的工具调用规范发起请求。
每个适配器都朝外输出同一种 InvocationRequest / InvocationResponse 结构,Agent 侧完全不需要关心底层是哪种协议。实际做下来,最麻烦的不是写适配器,而是维护对不同协议的预期。比如 HTTP 服务有的用蛇形命名,有的用驼峰命名;gRPC 的枚举值和 JSON 里的字符串要有一张映射表;MCP 工具的参数有时候喜欢嵌套一层。这些差异全部在适配层通过声明式映射解决,能力描述文件里加一个 transform 字段就可以搞定字段名的转换,不用写代码。
3. 关键实现拆解:一次工具调用的完整生命周期
架构定下来之后,最见功夫的就是一次调用从进来到出去的完整链路。这里把每一步展开讲,包括参数校验、鉴权、调度、容错和流式返回。
3.1 参数校验、鉴权和调度,一个都不能省
Agent 传进来的参数天然不可信。模型可能编造不存在的字段,也可能把类型搞错,所以参数校验必须放在入口,而不能等后端服务报错。Agent-Reach 在收到请求后会先用能力描述里的 JSON Schema 做一次完整校验,不合法直接返回结构化错误,附带具体是哪个字段不满足什么约束。这个设计的收益很大:上线以后很多诡异的问题,其实在入口就被拦下来了,根本不用查后端日志。
鉴权也放在链路的最前面。能力描述文件里声明了 auth 类型和 scope,注册中心维护一份"哪些 Agent 或服务有哪些 scope"的授权表。调用时校验两个维度:调用方有没有该能力的调用权限,以及本次请求的 scope 是否覆盖能力要求的最小权限。注意这里遵循最小权限原则,比如查询库存只需要 inventory:read,那就绝不给 inventory:write。
调度环节要处理的是并发和排队。同一个能力实例能承受的并发有限,路由层维护了一个简单的信号量来控制并发数,超限的请求要么排队要么快速失败。我的默认策略是排队,因为 Agent 场景下调用方对延迟的预期通常比普通 API 更宽容,排队几毫秒比直接返回错误更合理。但队列深度超过阈值时立即快速失败,避免雪崩。
3.2 超时、重试和熔断:容错三板斧的实测参数
Agent 应用里的超时设置非常讲究。大模型本身响应就慢,如果工具调用也慢,用户体验会变得难以忍受。我的实践是:能力描述文件里每个能力声明一个建议超时,路由层取 min(能力建议超时, 调用方超时)。为什么取较小值?因为如果调用方愿意等的总时长是 3 秒,而某个中间能力建议超时 2 秒,那重试余量只有 1 秒,不合理;取 min 可以保证至少留出一次重试的空间。
重试策略我踩过不少坑,最终采用的是"限次 + 递增退避 + 幂等保护"。查询类能力可以放心重试两到三次,但写入类能力必须看能力描述里的 idempotent 标记,只有标记为幂等的才自动重试。退避时间按 100ms、300ms、700ms 递增,加少量随机抖动,避免多个请求同时重试造成惊群。
熔断则是给每个能力实例维护一个滑动窗口,统计最近 60 秒内的失败率。失败率超过 50% 时熔断器打开,后续请求直接快速失败,不再打到后端。熔断器半开状态下放少量探测请求,成功了就关闭熔断,否则继续打开。这三个机制合起来的效果是:后端服务抖动的时候,Agent-Reach 会先通过重试吸收瞬时故障,再通过熔断保护后端不被拖垮,等后端恢复后自动回归正常。我见过太多 Agent 框架只做单次调用,后端一抖动整个对话就废了,熔断这一层真的值得好好做。
3.3 流式返回:给 Agent 的另一种"触达"方式
很多工具调用不是一次返回全部结果,而是慢慢吐数据,比如长文本生成、日志传输、大文件分析。Agent-Reach 对这类能力支持流式调用:路由层建立一条流式通道,调用方的请求进来后,适配器把后端的流式响应切成数据块向前转发,每一块都带独立的序号,客户端可以边收边处理。
实现上我用的是 gRPC 的双向流,内部约定 InvocationRequest 和 InvocationChunk 两种消息。HTTP 服务需要流式响应时,适配器会把 HTTP chunked 转成 InvocationChunk;MCP 工具的流式输出则通过 MCP 的采样/消息事件中转。这里有个细节:流式通道的超时策略和普通请求不一样,普通请求用的是总超时,流式请求用的是"空闲超时",即只要通道还在吐数据就不算超时,但停顿超过阈值就判定异常并主动断开。实测下来这个设计对 Agent 场景非常友好,模型可以基于部分结果提前做判断,不用傻等全部数据。
4. 实测效果与踩坑记录
架构和代码都落地之后,我在一个二十多个真实能力的内部环境里跑了两个月。这一节给一些实测数据,再把最有代表性的三个坑展开讲,都是文档里不会写的。
4.1 性能数据:连接层到底增加了多少开销
我最关心的指标是 Agent-Reach 纯转发带来的额外延迟。以下是同一批库存查询请求,直连后端和走 Agent-Reach 的对比数据:
| 指标 | 直连后端 | 经 Agent-Reach 转发 | 额外开销 |
|---|---|---|---|
| P50 延迟 | 42ms | 54ms | 12ms |
| P95 延迟 | 68ms | 83ms | 15ms |
| P99 延迟 | 120ms | 141ms | 21ms |
额外的 12 到 20 毫秒主要花在 JSON Schema 校验、鉴权查表和路由转发上。对一个完整 Agent 任务动辄几秒甚至十几秒的耗时来说,这个开销完全可以接受。压力测试下,单实例的 Agent-Reach 能稳定支撑每秒 2000 次调用,瓶颈出现在 JSON Schema 校验库上,而不是网络转发本身。如果你对极致性能有要求,可以在入口加一层参数校验的缓存,针对高频能力跳过重复的 Schema 编译,能再省几毫秒。
4.2 踩坑一:超时设太短,模型反而更容易幻觉
最初我把默认超时统一设成 800ms,理由是内部服务响应快,不想让 Agent 等太久。结果线上频繁出现 Agent 自行编造结果的情况——很多模型在工具没有及时返回时,并不会老老实实说"超时了",而是顺着语境生成一个看起来合理的答案。
这个问题的根因在于,Agent 编排层把"工具调用结果"和"模型生成内容"混在一起,超时错误被当成普通文本送进了上下文。修复方式是双管齐下:Agent-Reach 超时错误必须使用结构化错误码,比如 timeout,而不是自然语言描述;同时把默认超时提高到 1500ms,让大多数正常调用有充足时间完成。千万不要为了省几百毫秒牺牲正确性,模型幻觉在工具场景下的代价远高于那点延迟。
4.3 踩坑二:JSON Schema 太严格,把正常的模型输出拦死了
能力描述文件里我一开始把 inventory.query 的 sku_ids 字段设成了 maxItems 10。那段时间线上大量报参数错误,排查后发现是模型经常一次查询 15 个 SKU,被 Schema 拦下后,Agent 不是拆成多次调用,而是直接返回错误给用户。这类问题的本质是:Schema 校验规则应该反映后端服务的真实约束,而不是我拍脑袋的"合理限制"。把 maxItems 改成 50 之后,错误率立刻下降。经验是:校验规则宁可宽松一点,把明显非法的拒绝掉就好,边界情况交给后端业务校验去兜底,不要在教育模型的路上走太远。
4.4 踩坑三:重试放大故障,差点把后端打挂
熔断器上线之前,有一次内部服务发生缓慢退化,每次请求要五六秒才返回错误。因为响应慢,我的重试策略就不断触发,并发请求被放大到正常情况的三倍,后端服务被自己的重试流量压垮,故障时间反而拉长了。
这个问题的教训是我后来重点复盘的内容:重试必须有上限,且必须和超时、熔断联动。现在我的策略是"一退二进三熔断"——第一次失败后退避重试一次,第二次再失败就快速失败,如果这个能力在 60 秒窗口内失败率超过阈值,熔断器打开,后续请求不再进入后端。核心是不让重试和熔断各管各的,它们是一条防线上的不同环节,必须统一设计。
5. 安全边界与可观测性:连接层最容易忽视的两块
连接层做了一个"所有流量都经过"的位置之后,安全和观测就不再是可选配置,而是默认能力。
5.1 安全边界:不让 Agent 成为攻击入口
Agent 的请求来自对话场景,攻击面比传统 API 更复杂,因为输入的构造者是模型和用户的合谋产物。Agent-Reach 在安全上做了三层防护。
第一层是服务鉴权。所有请求必须带调用方的身份凭证,Route 层校验凭证与能力描述里声明的 scope 是否匹配。第二层是参数净化。JSON Schema 校验通过后,适配器会执行一层清洗,剔除所有不符合 schema 的额外字段,防止模型输出的意外字段被透传给后端。第三层是出网白名单。接入的外部能力必须声明允许访问的域名列表,Agent-Reach 在出网代理层强制校验,不在名单上的域名一律拦截。这一条非常有用,它保证了即使某个 Agent 环节被诱导去请求一个恶意地址,连接层也会把它拦下来。
5.2 可观测性:把每一次触达变成可追踪的记录
连接层是天然的观测点。Agent-Reach 对每次调用生成一个 trace_id,从请求进入、Schema 校验、路由决策、后端调用、重试行为到最终响应,全部打点。日志除了基本信息,还记录:使用的模型上下文里关于这次调用的描述摘要、参数是否被清洗、命中了哪个实例、熔断器状态等。
这些日志最大的价值是排查 Agent 行为问题时能直接对账。比如用户说"Agent 明明调用了库存接口却说没货",我可以通过 trace_id 找到真实的响应数据,看到底是库存真的为零,还是参数传错,还是模型把结果理解错了。这个"对账"能力在 Agent 应用里极其重要,否则出了问题只能在模型输出的黑盒里猜。我还做了一个简单的看板,展示各能力的调用量、失败率、熔断触发次数和平均延迟,每次发布新能力后观察一两天,基本就能判断出这个能力是否稳定。
6. 后续规划与个人体会
Agent-Reach 目前已经能满足我手头项目的需求,但后面还有几个想做的方向。一是能力注册中心的联邦化,让不同团队部署的 Agent-Reach 实例之间可以互相发现和调用能力,做成一个跨组织的 Agent 能力网络。二是引入更细粒度的成本控制,给每个能力调用打上价格标签,让编排层可以按预算做路由选择,比如优先调用便宜但足够用的能力。三是和主流 Agent 框架的深度集成,目前已经兼容 MCP 协议,但还想做更内嵌的适配器,让 LangGraph 或者自研编排器直接接入。
最后说点个人体会。做 Agent 应用的这两年,我越来越觉得基础设施比模型能力更决定体验的上限。模型再聪明,如果工具调用链路三天两头超时、出错、返错数据,用户得到的依然是一个"聪明但不可靠"的 Agent。Agent-Reach 没有用任何花哨的算法,它的全部价值就是老老实实把"够得着"这件事做成一条可靠、可观测、可控制的通道。
如果你也在做类似的事,我的建议是先不要急着追求大而全的编排能力,花时间把连接层的语义定义清楚、容错策略做扎实、日志打全,后面所有上层功能都会顺很多。等这三个基本功到位了,再回头看看以前那些手写的胶水代码,你可能也会有同样的感慨:早该把这一层单独做出来了。