还没开始动笔前,先给这个项目定个调。大家做 Agent 都在拼模型、拼提示词,我却花了大半年时间在一个很不起眼的方向上:让 Agent 能稳定地“够到”外部系统。团队里既有做算法的,也有做后端的,平时互不搭话,但这个项目把我们拧到了一起。这篇文章就把 Agent-Reach 的设计思路、核心实现、实测数据和踩坑记录完整复盘一遍,代码和配置都能直接拿走改。
1. Agent-Reach 是什么:一个专治“够不着”的连接层
大概从 2023 年底开始,我所在的小组一直在折腾 AI Agent。一轮一轮的内测下来,最常见的抱怨不是“模型不够聪明”,而是“Agent 够不着东西”。它要读数据库,得有人先写接口;它要调内部 OA,得有人先申请权限;它想跟另一个 Agent 协作,两边协议对不上,只能各自为政。这个问题有个专门的说法,叫 agent reach,也就是智能体对外部世界的可达范围。Agent-Reach 这个项目就是为了解决“够不着”而生的。
先说清楚它能干什么:Agent-Reach 是一个部署在 Agent 与外部资源之间的连接层,统一处理服务发现、路由、鉴权、协议转换和数据格式对齐。换句话说,它让 Agent 不再需要针对每个系统单独写胶水代码,而是通过一套声明式配置告诉连接层“我要什么”,连接层负责找到路径、完成握手、把结果翻译回 Agent 能懂的结构。项目适合三类人:正在做多 Agent 协作的工程团队、被各种内部系统 API 折磨的 RAG 应用开发者、以及想给 Agent 加工具调用能力但不想维护一堆硬编码函数的个人开发者。
这篇文章我会从设计思路、核心实现、实测踩坑三个角度完整复盘整个项目,结尾附一份常见问题速查。代码片段都是可以直接抄走改的,配置说明也按生产环境的真实情况写,不是教学 demo。
1.1 为什么需要一层“中间人”
很多团队的第一反应是:直接让 Agent 调 API 不就行了?问题是,Agent 的调用方式和真实系统的接口方式之间存在几条很难绕开的鸿沟。
第一道鸿沟是鉴权。企业内部系统动辄 OAuth2、LDAP、token 轮换、内外网隔离,Agent 每次要调一个系统,就得在 prompt 里塞一堆密钥,或者依赖一个长链接池。第二道鸿沟是输出格式。同一个“查询订单状态”的操作,在订单系统里返回的是 JSON 嵌套结构,在 CRM 里返回的是 XML,在报表系统里可能是一段 Markdown。模型要在这几种格式之间来回猜,出错率极高。第三道鸿沟最隐蔽:服务的位置在变。今天服务跑在 A 集群,明天迁移到 B 集群,Agent 的 system prompt 里写死的 endpoint 就失效了。
Agent-Reach 本质上就是把这三道鸿沟统一收口:鉴权在连接层完成、格式转换在连接层完成、服务寻址也在连接层完成。Agent 只需要记住一件事——向 Reach 网关声明自己的意图,剩下的事由连接层去办。这个定位和 API 网关有点像,但区别在于 API 网关处理的是“机器到机器”的固定调用,Reach 处理的是“模型到世界”的动态意图匹配。
1.2 名字里的两层意思
“Agent-Reach” 这个名字里有两个词。Agent 不用多说,重点在 Reach。Reach 在英文里有“伸手够到”的意思,也有“影响范围”的意思。我当时起这个名字,就是想表达两层:一是让 Agent 能“够到”更多外部系统,二是让开发者能一眼看出这套系统的度量维度——你到底覆盖了多少服务、多少协议、多少种鉴权方式。后面我们会看到,Reach 里的所有设计都在围绕“扩大可达范围”这个目标展开。
2. 整体设计与选型:为什么是声明式路由
项目立项的时候,我们首先排除掉的方案是“继续堆函数”。之前团队里已经攒了 60 多个 @tool 装饰器定义的工具函数,每个函数都绑定一个具体的 URL 和认证方式,结果就是每换一个环境就要改代码、重发布。这次设计的第一原则就是:绝不把环境相关的信息写在代码里。
2.1 核心架构三件套
Agent-Reach 的整体架构可以拆成三个部分:Reach Gateway(连接网关)、Reach Registry(服务注册中心)、Reach Runtime(运行时执行器)。
网关负责接收 Agent 的请求,做意图识别和服务发现,相当于整个系统的“前台”。注册中心维护一份服务目录,里面记录了每个服务的调用协议、参数 schema、鉴权要求和当前健康状态,相当于“通讯录”。运行时负责真正执行调用,做协议适配、数据转换和错误处理,相当于“翻译官加司机”。
三个部分之间通过一个轻量级的内部消息通道通讯,不引入额外的消息队列依赖。为什么不用 Kafka?因为我们这里的调用模式是短连接、低并发、强时效,单机模式下内部服务平均耗时 200 毫秒,引入消息队列之后延迟反而会翻三倍,而且运维成本完全不成比例。这是典型的“杀鸡用牛刀”场景,直接否掉。
2.2 声明式配置长什么样
服务注册中心的核心是一份 YAML 配置。每一个外部系统被抽象成一个 service,service 下面定义 actions,也就是“这个服务能对世界提供什么操作”。下面是一个真实配置的简化版:
service: crm_system version: 2.1.0 base_url: ${CRM_BASE_URL} auth: type: oauth2_client_credentials token_url: ${CRM_TOKEN_URL} client_id_env: CRM_CLIENT_ID client_secret_env: CRM_CLIENT_SECRET actions: query_customer: method: GET path: /api/v2/customers/{customer_id} params: customer_id: string response_schema: | { "customer_id": "string", "name": "string", "level": "string" } create_follow_up_task: method: POST path: /api/v2/tasks body_schema: | { "customer_id": "string", "task_type": "string", "due_date": "date" }这段配置解决了一个很实际的问题:Agent 的 system prompt 里不再需要出现任何 URL,也不再需要关心 CRM 系统用的是 OAuth2 还是 Basic Auth。它只需要知道“有一个操作叫 query_customer,参数有 customer_id”,剩下的事情 Reach 网关来处理。配置里用了环境变量占位,比如 ${CRM_BASE_URL},这样同一份配置可以安全地提交到代码仓库,不会泄露密钥。
2.3 为什么选了 Python 而不是 Go
这个选择在团队里争论过。Go 在并发和部署上有优势,但我们的核心诉求是快速迭代和与 LLM 生态深度集成。Python 生态里已经有现成的 JSON Schema 校验库、OpenAPI 解析库,而且主流 Agent 框架的 SDK 都是 Python 优先。Reach 的关键路径是模型调用和协议转换,瓶颈在 LLM 推理和外部 IO 上,Go 的并发优势在这里发挥不出来。
后来实测也印证了这一点:同样一个查询动作,Python 版的 Reach 网关 P95 延迟是 380 毫秒,其中 260 毫秒花在外部 API 调用上,真正属于 Reach 自身的开销只有 120 毫秒。如果要追求极限性能,可以把网关节点的数量横向扩到 3 个,用 Nginx 做最简单的负载均衡,实测 P95 可以从 380 降到 290。这个数字对于 Agent 场景来说已经非常够用,毕竟模型推理本身的延迟通常在 1 秒以上。
3. 核心实现:从意图到调用的完整链路
有了整体设计,接下来就是把每个环节落地的过程。这一节按照“请求进来 → 意图识别 → 服务发现 → 协议适配 → 返回翻译”的顺序,把每个模块的实现细节和当时踩坑的地方都过一遍。
3.1 意图识别模块
Reach 网关拿到 Agent 的请求后,第一件事不是去查服务,而是做意图解析。这里说的意图解析不是让模型自由发挥,而是用一个轻量级的分类器把自然语言请求映射到注册中心里的某个 action 上。
我们试过两种方案。第一种是纯 LLM 方案:把服务目录作为 few-shot 示例塞给模型,让模型输出匹配的 action。优点是灵活,缺点是每次请求都要多一次模型调用,成本和延迟都不可控。第二种是结构化匹配方案:先提取请求中的实体和动词,再跟 action 的名称、描述、参数名做相似度匹配。我们最终用的是混合策略——大多数请求走结构化匹配,匹配置信度低于 0.7 时再兜底调一次 LLM。
结构化匹配的实现其实不复杂。先把每个 action 的 name 和 description 用预训练 embedding 模型编码成向量存在内存里,请求进来后同样做一次编码,然后算余弦相似度。因为服务目录通常只有几十到几百个 action,全量暴力搜索完全够用,毫秒级返回。我写过一版基于 SQLite FTS5 的关键词匹配方案,准确率差了十几个点,后来还是换回向量匹配。
这里有个细节值得说一下:action 描述的重要性被大多数人低估了。描述写得敷衍,匹配准确率就惨不忍睹。我后来制定了一个模板:动作动词 + 操作对象 + 关键参数 + 可选场景。比如“创建跟进任务”这个 action,描述写“给指定客户在 CRM 中创建一条跟进任务,需要客户编号和任务类型”,比“create task”的匹配效果好得多。建议做 Agent 工具注册的团队都按这个模板重写一遍工具描述。
3.2 服务发现与动态路由
服务注册中心不是只存静态配置,它还承担着一个关键职责:健康检查。Reach Runtime 会以 30 秒为周期对每个 service 的 base_url 发送探测请求(一个轻量的 HEAD 请求),如果连续三次失败,就把该服务标记为 unhealthy,路由时自动跳过。
这个机制帮我们避免了一个很尴尬的故障:之前有一次 CRM 系统发版,网关地址变了,服务没挂,但所有请求都打到旧地址上,Agent 一直在报“系统繁忙”。加了健康检查之后,服务名称和实际地址解耦,地址迁移对 Agent 完全透明。配置里 service name 是逻辑标识,实际 URL 由注册中心动态返回,这就实现了前面说的“环境与代码分离”。
路由策略我们提供两种:第一个可用和加权轮询。默认用第一个可用,也就是按注册中心的健康状态排序,优先选择健康的节点。加权轮询用于多活部署的场合,权重在配置里指定。实测中 99% 的场景用第一个可用就够了,加权轮询更多是给那些“觉得必须有个轮询”的团队一个心理安慰。
3.3 协议适配层:统一入参出参
这一层是 Agent-Reach 代码量最大、最容易出 bug 的地方。外部系统的接口格式五花八门,Reach Runtime 要做的是把外部格式翻译成 Agent 能理解的标准结构。
标准结构目前定义为三种类型:json_object(结构化数据)、text(文本内容)、binary(文件内容)。外部接口返回的数据先经过一个轻量的转换管道,管道由几步组成:状态码检查 → 响应体解析 → 字段映射 → 类型归一化。
举个例子,订单系统返回的创建结果是这样:
{ "code": 0, "data": { "orderId": "SO12345", "status": 1, "createdAt": "2024-06-11T08:30:00Z" } }Reach 的字段映射规则会把它改写成:
{ "order_id": "SO12345", "status": "created", "created_at": "2024-06-11 16:30:00" }这里做了三件事:把 orderId 换成 snake_case、把 status 的数字枚举换成可读字符串、把 UTC 时间转成本地时区。这三个转换看着简单,实际都是踩坑踩出来的。数字枚举如果不翻译,模型很容易把 status: 1 理解成“成功”,但业务含义是“已创建”。字段命名不统一,模型就需要在多个命名风格之间猜。时间时区不转,Agent 推算截止时间就会出错。
3.4 鉴权中间件的实现
鉴权是 Agent-Reach 里最“无聊”但最重要的模块。配置里声明 auth 类型后,网关会自动生成对应的鉴权处理器,请求发出前先把 token 挂上。目前支持五种:
| 鉴权类型 | 配置关键字 | 适用场景 | 安全要点 |
|---|---|---|---|
| 无需鉴权 | none | 公开只读接口 | 不推荐在公网使用 |
| Basic Auth | basic | 内部简单服务 | 必须配合 HTTPS |
| API Key | api_key | 第三方 SaaS | Key 存环境变量 |
| OAuth2 client credentials | oauth2_client_credentials | 企业内部系统 | token 内存缓存 + 过期刷新 |
| 自定义 header | custom_header | 有特殊握手流程的系统 | 支持模板变量注入 |
OAuth2 client credentials 的实现值得展开说一下,因为很多内部系统都用的它。Reach 会在首次调用某个服务时向 token_url 发起一次 token 请求,拿到的 access token 缓存在内存里,同时记录过期时间。后续请求直接复用缓存中的 token,只有发现距离过期不足 60 秒时才重新获取。这样做有几个好处:避免每次请求都打 token 接口;避免用过期 token 请求导致 401;token 不会出现在 Agent 的 prompt 或日志里。
这里有一个安全上的细节:Reach 网关在启动时会把配置里的环境变量读入内存,启动完成后立即从环境变量中移除这些值。虽然听起来有点过度设计,但实测确实遇到过 Agent 的日志被拉去分析时包含了环境变量 dump 的情况,移除之后这个风险就没了。
3.5 返回结果的后处理
最后一步是结果后处理。Agent 拿到的结果不能原样返回,至少要做三件事:长度控制、敏感信息过滤、调用链信息附加。
长度控制上,我们把默认响应的最大长度限制在 4000 token 以内,超出部分做截断并在结果中标记 truncated 字段。为什么是 4000?因为主流模型的上下文窗口虽然越来越大,但给工具调用结果留的空间通常只有 2000 到 6000 token,给多了反而挤占对话内容的配额。敏感信息过滤用的是正则加白名单双层策略,身份证号、手机号、银行卡号会被自动替换成脱敏形式。调用链信息则是在返回结果里附加一个 reach_meta 对象,里面记录了实际调用的服务、action、耗时和重试次数,这些信息对排查问题特别有用。
4. 实测效果:三个场景下的数据复盘
写代码一时爽,上线跑起来才知道深浅。这一节分享的是我们在三个真实业务场景里的实测数据,以及为了拿到这些数据做的压测过程。
4.1 场景一:多 Agent 协作下的服务编排
第一个场景是部署两个 Agent——一个负责客户意向分析,一个负责跟进任务执行。没有 Reach 之前,两个 Agent 通过共享一个 Redis 队列通信,消息格式两边各写一套解析,每当任一边升级 prompt,另一边就要跟着改协议。
接入 Reach 之后,两个 Agent 不再直接通信,而是各自声明自己的“可达服务”。分析 Agent 声明了 query_customer_insight 和 recommend_next_action 两个 action;执行 Agent 声明了 create_follow_up_task 和 send_reminder。协调层通过 Reach 的服务目录把两个 Agent 的能力统一暴露给调度器,调度器只负责编排,不关心实现细节。
这个改动的最大收益不是性能,而是解耦。实测数据:任务编排的端到端成功率从 82% 提升到 96%,平均耗时从 8.2 秒降到 5.4 秒。失败率下降的原因很简单——之前通信协议错位导致的消息解析失败占了总失败的一半以上,Reach 的统一格式把这个因素直接消除了。
4.2 场景二:RAG 应用把数据库查询交给 Agent
第二个场景是一个内部知识库 RAG 应用,原本所有查询都走向量检索。用户问“上个月华东区的销售额是多少”,RAG 只能返回文档片段,不能直接给数字。接入 Reach 后,Agent 把这个请求识别成 query_sales_summary action,运行时去 BI 系统把数据拉回来,再作为检索结果的上文补充,模型最终能给出准确数字。
这里我特别想强调一下数据格式的价值。BI 系统返回的原始数据是 CSV 格式,一行行数字模型很难直接看懂。Reach 在协议适配层把 CSV 转成了带有字段说明的 Markdown 表格,模型引用起来轻松许多。实测这个场景的答案准确率从 61% 提升到 89%,表格化是关键因素。
4.3 场景三:批量任务处理的稳定性
第三个场景是批量处理短信通知的发送状态回写。每天大约有 5 万条状态回写请求,集中在晚上 8 点到 10 点之间,峰值 QPS 大概在 30 左右。这个量级对网关来说毫无压力,真正的挑战是外部通道的偶发超时。
Reach Runtime 内置了三档重试策略:快速重试(500ms)、退避重试(1s、2s、4s)、放弃并记录。配合健康检查的熔断机制,统计下来这个场景的最终成功率达到 99.93%,未被成功处理的请求在重试耗尽后全部进入死信队列,不会静默丢失。死信队列是这个场景额外加的一个组件,它不在最初设计里,是在第一次压测发现“有 0.1% 的请求丢了但没人知道”之后补上的。
4.4 关于并发和性能的补充说明
有人可能会问,Reach 网关能扛多大并发?我们做过一次简单的压测:8 核 16G 的容器,部署 2 个网关节点,模拟 100 并发持续压 5 分钟。在服务目录 85 个 action、每个请求都走完整意图识别链路的条件下,网关自身(不含外部 API 时间)的平均处理耗时是 34 毫秒,P99 是 78 毫秒,内存没有明显增长。对于绝大多数 Agent 场景来说,这个性能足够,真正的瓶颈永远在 LLM 推理和外部服务响应上。所以别把调优精力花在网关上,先看邻居。
5. 常见问题与排查技巧实录
开发 Agent-Reach 的过程中我攒了一堆“早知道就好了”的教训。这一节挑六个最有代表性的问题,按照“症状 → 原因 → 解法”的方式记录。
5.1 问题一:意图识别总把请求路由到错误的 action
症状:Agent 说要创建任务,结果调了查询接口,或者匹配到完全无关的工具。
排查思路:先看意图识别模块的置信度日志。如果置信度低于 0.7,问题出在描述写得太泛;如果置信度很高但路由还是错,问题多半出在 action 名称太相似。
解法:重写 action 描述,遵循“动作动词 + 操作对象 + 关键参数 + 可选场景”的模板;对名称相似度高的 action,在描述里加入排除性说明,比如“这个操作只负责创建,不负责查询”。
5.2 问题二:外部接口返回 401,但配置里的密钥明明是对的
这是最常见的配置坑。排查顺序:先看网关日志里的 token 获取记录——如果是首次请求,看 token_url 返回的状态码;如果 token 获取成功,再看触发的服务请求里 Authorization header 是否带上了。
一个隐蔽的原因是时钟漂移。OAuth2 的 token 校验依赖时间,如果网关所在容器和认证服务器所在机器的时间偏差超过 5 分钟,即使 token 没过期也会被拒绝。解法是给网关容器配置 NTP 同步,并在 token 缓存策略里把提前刷新时间从 60 秒放宽到 120 秒。
5.3 问题三:外部服务偶尔慢得像爬
症状:Agent 的响应时延经常飙到 10 秒以上,单看网关日志发现外部 API 的耗时却正常。
排查后发现是连接池配置的问题。默认的 HTTP 连接池把最大连接数设成了 10,峰值时连接被占满,请求全部排队。解法是根据外部服务的 QPS 调整连接池参数,并开启 keep-alive 复用。实测把最大连接数调到 50 之后,P95 延迟从 8 秒降到 900 毫秒。
5.4 问题四:Agent 返回的内容里出现外部系统的内部字段
这类问题发生在字段映射规则没生效的情况下。最常见的原因是外部接口改版,response 里的字段变更,但 Reach 的 mapping 配置没同步更新。解法不是“下次注意”,而是给 response_schema 加字段版本号,并写一个启动自检脚本:网关启动时对比注册中心的最新 schema 和本地缓存的 mapping 规则,发现不一致就打告警。
5.5 问题五:模型不按标准结构输出
这个问题的背景是:我们允许 Agent 在特殊情况下不走意图识别,直接传入结构化调用指令。有时候模型生成的 JSON 不合法,或者字段名跟 schema 对不上。
解决方法是加一层“宽容解析器”:把模型输出先做一次 JSON 修复(比如补括号、去尾逗号),再做一个字段别名映射(把 orderId 和 order_id 都识别成同一个字段)。这两步听着朴素,但把调用成功率从 71% 拉到了 95% 以上,是投入产出比最高的两段代码。
5.6 问题六:多环境切换时配置错乱
开发、测试、生产三个环境共用一套配置仓库,经常出现有人改错环境的问题。解法是在配置里强行引入 environment 字段,并且环境切换时必须显式传参,不允许默认值。这项约束是血的教训换来的——有一回发布脚本没传 environment,默认走了 dev 配置,结果生产环境调了半小时才开始报错。
6. 边界把控:别把连接层做成“万能层”
Agent-Reach 在团队里运行三个月后,我收到最多的需求不是“加新功能”,而是“把功能删掉”。原因是连接层做得太顺手,什么逻辑都想往里面塞,逐渐变成一个超级中间件。网关里开始出现业务判断、数据清洗、甚至简单的指标计算,代码量膨胀到原来的三倍,排查问题也越来越费劲。
6.1 连接层最容易越界的三个地方
根据我们踩过的坑,给三条边界建议。第一,连接层只做协议转换,不做业务决策。不要在网关里判断“这个客户该不该发短信”,那不是连接层该管的事。第二,连接层只做通用鉴权,不做细粒度权限管理,那是业务系统自己的职责,Reach 只要保证“有权限的请求能通行,没权限的请求被拦截”,至于这个用户能不能看某个字段,让业务系统去管。第三,连接层的日志要精简,只记录链路和错误,不记录业务数据。我们在日志里吃过亏,一旦把业务数据写进去,日志就变成敏感信息的聚集地,审计和安全团队天天找上门。
6.2 我个人的一点体会
这个项目做下来,我最深的感受是:连接层的价值不在于“能连多少个系统”,而在于“能让连上的系统稳定跑多久”。Agent 的能力边界由它的 reach 决定,而 reach 的可靠性决定了 Agent 能不能真正走出 demo 环境。如果以后再让我重做一遍,我会把健康检查、死信队列和字段版本自检这三件事放到第一版就做,它们是连接层在长期运行中真正保命的东西。