1. 项目概述:Agent 为什么需要“可达性”
先说个我自己的判断:2025 年做 AI Agent,最大的瓶颈早就不是模型推理能力,而是 Agent 的“手”伸得不够远。Agent-Reach 这个项目,就是我在一线实践中沉淀出来的一个词——Agent 的可达性问题。简单说,一个 Agent 要真正干活,就得去够数据库、HTTP 服务、内部工单系统、消息队列、搜索接口……但每次“够一下”背后都藏着协议不统一、鉴权方式不一致、schema 频繁漂移、超时重试没人管这些破事。Agent-Reach 是一层薄薄的连接中间层,它做的事情就是把“Agent 能调用哪些外部系统、怎么调用、调用失败怎么办”收敛成一套工程规范。
这项目适合谁参考?两类人。一类是正在把 LLM 接进业务系统的后端工程师,你现在多半在怼各种 tool 定义、API gateway、prompt 里塞函数描述,你大概率会遇到我下面写的所有坑。另一类是把多 Agent 系统落地到生产环境的技术负责人,你需要一个清晰的连接治理模型,而不是让每个 Agent 各自用 requests 乱连。这篇文章既讲 Agent-Reach 的核心设计思路,也会展开我当时定的连接超时参数、重试策略、schema 校验规则,最后把踩过的坑整理成排查表。我不卖关子,直接进正文。
我做的 Agent-Reach 定位不是“又一个 agent 框架”,而是 agent 框架与外部世界之间的那层胶水。你可以把它理解为快递中转站:Agent 是下单的人,外部系统是收货地址,Agent-Reach 负责把每一件“调用请求”封装成标准包裹,按路由规则送出去,再在约定时间内把回执带回来。之所以要做这一层,是因为当 Agent 需要触达的系统超过十个以后,直接在 agent 代码里写工具调用会立刻失控——每个上游服务都有自己的鉴权、限流、数据格式和故障表现,这些横切关注点不该堆在 prompt 或 agent 的业务逻辑里。
从实际效果看,Agent-Reach 解决的问题可以拆成四块:一是协议归一,屏蔽 REST、gRPC、数据库直连、消息队列等底层差异;二是连接治理,统一负责超时、重试、熔断、限流;三是 schema 管理,让工具描述与上游 API 契约保持同步,避免模型拿到过期的函数签名;四是可观测,每一次“伸手”都有 trace 可查。这四块做完,Agent 团队才能真正把注意力放回任务编排本身,而不是天天救火。
2. 架构设计:把“能连”变成一门工程
2.1 三层结构:连接器、路由、策略
Agent-Reach 的内部结构我一开始就确定为三层,而不是一个散装工具箱。第一层是连接器层(Connector),负责跟具体的外部系统打交道;第二层是路由层(Router),负责根据工具名、租户、目标环境决定走哪个连接器;第三层是策略层(Policy),负责在调用前后执行超时、重试、熔断、限流、脱敏这些横切逻辑。
这个分层不是拍脑袋。你想象一下如果没有路由层,Agent 想查“订单状态”时,代码里写死一个 order-db 连接器,这在新环境、新数据源出现时就得改 Agent 本身,那 Agent 的稳定性就绑死在上游拓扑上了。有了路由层,Agent 只知道“我调用 query_order 这个工具”,至于这个工具背后是 MySQL 还是新迁移的 TiDB,是走直连还是走公司内部的 API 网关,都由路由规则在运行时决定。这个抽象非常值钱,因为我见过太多团队因为一个数据源切换,把 Prompt 和工具定义改了三个版本。
策略层放在这里也大有讲究。重试、熔断、限流这些逻辑如果写在连接器里,每个连接器都得重复实现一遍,而且很容易出现“A 连接器重试 3 次、B 连接器重试 0 次”这种混乱。Agent-Reach 把策略收敛成一组可组合的 Policy 对象,按顺序套在调用链上。我实际写代码时,策略层用的就是典型的装饰器链模式,每个策略只管一件事,比如 RetryPolicy 只负责判断“这次失败能不能重试”,CircuitBreakerPolicy 只负责统计失败率并决定放行还是拒绝。
2.2 为什么不做成“万能适配器”
这里我必须说一个反模式。市面上很多工具喜欢做“万能适配器”,声称一个 SDK 连接所有数据源。我的经验是,所谓万能适配器最后一定退化成“什么都支持、什么都不好用”的状态。Agent-Reach 的连接器接口故意做得很薄,只有四个核心方法:health_check、describe_tools、call、close。每个连接器只向 Agent-Reach 承诺自己能执行任务并返回标准信封,至于内部是拼 SQL、组 HTTP 请求还是发 MQ 消息,连接器自己决定。
薄接口的好处是接入成本低。新接一个系统,你只需要实现四个方法,而不是去理解某个框架的复杂抽象。我在项目里接的第一个连接器是公司内部的工单系统,它的 API 非常反人类——需要先登录拿令牌,再调用查询接口拿列表,最后逐个查详情。这些逻辑全部封装在连接器内部,对路由层和 Agent 来说,它就是一个叫 search_tickets 的工具,参数只有 keyword 和 limit。这就是我想要的效果:Agent 侧永远面对简单稳定的工具契约,复杂留给连接器。
同时我也刻意没有把 Agent-Reach 做成“把外部系统直接映射成 LLM 工具”的傻瓜工具。原因很现实:很多生产系统的接口不是给 LLM 设计的,它们的鉴权、分页、限流头、错误码都各有脾气,直接暴露给模型只会换来一大堆幻觉和无效调用。所以连接器层承担了“语义转换”的职责——把业务 API 的原始响应整理成模型容易消费的结构化结果,把模糊的错误翻译成清晰的错误码。这是 Agent 生产化里经常被忽略、但实际价值极高的一块工作。
2.3 关键抽象:标准信封与工具注册表
Agent-Reach 里所有调用的出口统一走一个“标准信封”:
{ "ok": true, "data": { "...": "..." }, "meta": { "attempts": 1, "duration_ms": 120, "connector": "order-db", "tool": "query_order", "trace_id": "a1b2c3d4" } }失败时信封略有不同,data 字段变成 null,增加 error_code 字段。我要求所有连接器都必须返回这个信封,即使底层接口挂了也不能裸抛异常。这样做的直接好处是 Agent 侧处理结果变得非常简单,模型只需要看 ok 字段就知道这次调用成没成;如果失败,error_code 能告诉它是超时、权限不足还是上游 500,这比让它去猜一段笔误乱码的异常字符串靠谱得多。
工具注册表是另一个核心抽象。每个连接器启动后会调用 describe_tools 把自己支持的工具清单上报给中心注册表,注册表里存的不只是工具名,还有 JSON Schema 格式的入参定义。这个注册表有两个消费方:一是路由层,用来做工具名到连接器的映射;二是 Agent 平台,在做 tool calling 时把这些 schema 转成模型需要的 function 描述。我在项目里是把注册表的数据同时落一份到本地 SQLite 和一份到 Redis,本地那份是给路由层短平快查询用的,Redis 那份是给多个 Agent 实例共享用的。这个设计在只有一个 Agent 实例时有点过度,但一旦横向扩容,你立刻会发现共享注册表是必须的。
3. 核心细节解析与实操要点
3.1 工具 Schema 的写法决定 Agent 的智商上限
这是我做 Agent-Reach 过程中最大的感悟:agent 能不能正确使用一个工具,80% 取决于工具 schema 写得够不够好,而不是模型有多聪明。很多团队在定义工具参数时偷懒,比如给一个搜索工具只写一个模糊的 query 参数,结果模型调用时要么把整段自然语言塞进去,要么漏掉必要字段。
我总结的 schema 三原则:第一,参数描述要写“模型视角”的话,不要写“接口视角”的话。比如 GetOrder 接口的原始参数是 userId,但模型其实只知道对话里出现了“客户的手机号”,所以 schema 里最好同时暴露 phone 和 user_id 两个字段,并在描述里说明如何从自然语言中提取。第二,尽量用 enum 和 format 约束死取值范围,比如 status 字段显式枚举 open、closed、cancelled,日期参数标注 format: date-time,这样模型不容易编造非法值。第三,必填项要跟业务依赖严格对应,该必填就必填,不打折扣。
下面是我在 Agent-Reach 里实际用过的一个工具定义片段,接的是订单查询。注意我用了 oneOf 来表达“订单号和客户 ID 二选一”的语义,这个细节看似不起眼,但对模型的命中率影响很大:
{ "type": "function", "function": { "name": "query_order", "description": "查询订单详情,支持通过订单号查询,或通过客户标识查询近30天订单", "parameters": { "type": "object", "properties": { "order_id": {"type": "string", "description": "完整的订单号,形如 ORD-2025-001234"}, "customer_phone": {"type": "string", "description": "客户手机号,11位数字"}, "limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5} }, "oneOf": [ {"required": ["order_id"]}, {"required": ["customer_phone"]} ] } } }3.2 路由规则的粒度怎么拿捏
路由层不是简单的工具名 match,我后来把路由规则拆成了两级。第一级叫“工具级路由”,就是 tool_name 前缀匹配;第二级叫“上下文路由”,是根据请求的 metadata 来分流,比如租户 ID、当前环境(dev/staging/prod)、用户身份。这两级匹配缺一不可。
工具级路由好理解,query_order 前缀的请求一律走 order-db 连接器就好。但上下文路由经常被忽略,直到出事故才追悔莫及。我印象最深的一次是:一个开发中的 Agent 实例错误地对生产数据库发起了查询,因为两套环境的工具名完全一样,路由层没有区分运行时环境,直接把请求打到了 prod 数据源。从那以后,我在路由规则里强加了 env 条件,dev 环境的 Agent 请求永远匹配到 dev 数据源,即便工具名相同也过不去。这个规则用一句话描述就是:路由规则本质上是一张访问白名单,越界宁可拒绝,也不要放行。
路由规则我是用 YAML 维护的,在项目里长这样:
router: rules: - name: "order-query-toolchain" tool_pattern: "query_*" env: "prod" tenant: "*" connector: "order-db-prod" policies: ["timeout-8s", "retry-2x", "circuit-breaker-strict"] - name: "dev-sandbox" tool_pattern: "*" env: "dev" tenant: "*" connector: "sandbox-stub" policies: ["timeout-2s", "no-retry"]这份配置暴露了一个设计原则:默认走“兜底沙箱”。凡是规则没匹配到的高危工具,在开发环境一律落到一个返回固定样例数据的 stub 连接器,而不是让请求裸奔。这个设计让新手 Agent 在开发环境里怎么折腾都不出事。
3.3 超时、重试与熔断参数的一次成型
这些策略参数我前前后后调了快两周,最后稳定下来的方案是这样:超时分为连接超时和总超时两类,连接超时默认 2000ms,总超时按工具特性分三档——读类工具 8000ms,写类工具 15000ms,人工审批类工具 30000ms。总超时一定要把重试时间也计算在内,我算过一个反面例子:某工具单次调用 3 秒超时,重试 3 次,理论上最坏情况 12 秒,但因为每次重试前还要排队等连接池,实际最长等到了 20 秒。所以我在策略层做总超时时,用的是“单次调用超时 × 最大重试次数 + 缓冲时间”这个公式。
重试策略我用的指数退避,但加了抖动。具体参数是:初始退避 500ms,退避因子 2.0,最大退避 5000ms,抖动系数 0.3。我解释一下为什么要抖动:如果 50 个并发请求同时失败并同时重试,固定退避会导致它们在同一时刻再次打爆上游,抖动可以在时间上把这些重试打散。另外一个关键原则:只对可重试错误重试。我在错误码规范里明确区分了 retryable 和 non-retryable 两类错误码,超时、网关 502、连接拒绝是可重试的,参数校验失败、403 权限错误、404 资源不存在是不可重试的。盲目重试不可重试错误只会放大问题。
熔断参数看起来简单,实际是统计口径最容易出错的地方。我用的指标是滑动窗口内的失败率,窗口 30 秒,窗口内请求数不少于 20 时才计算失败率,失败率超过 50% 则打开熔断,进入半开状态 10 秒后放行一个探测请求。为什么强调“窗口内请求数不少于 20”?因为流量低的时候,一次失败就可能把失败率拉到 100%,导致熔断误触发,反而让原本健康的上游被无谓地断路。这个坑我踩过以后,顶了一个注释在配置里:熔断器的职责是保护脆弱的上游,不是惩罚偶发故障。
# 策略参数配置,agent_reach/configs/policies.yaml 节选 policies: timeout-8s: type: timeout connect_timeout_ms: 2000 total_timeout_ms: 8000 retry-2x: type: retry max_attempts: 3 initial_backoff_ms: 500 backoff_factor: 2.0 max_backoff_ms: 5000 jitter: 0.3 retryable_error_codes: ["ETIMEOUT", "EBADGATEWAY", "ECONNRESET"] circuit-breaker-strict: type: circuit_breaker window_seconds: 30 min_requests: 20 failure_threshold: 0.5 half_open_timeout_s: 104. 实操过程:把一个订单查询接到 Agent-Reach 上
4.1 环境与依赖的准备
Agent-Reach 本身是用 Python 3.11 写的,主要依赖 FastAPI、pydantic v2、httpx 和 redis。选择 FastAPI 是看中它的异步能力和自动文档,这在给 Agent 提供调用入口时非常省事;pydantic v2 负责 schema 校验,因为工具入参在校验阶段拦截掉大多数非法请求,比让模型事后发现错误划算得多。
我的建议是,最小可运行环境不必上 Docker Compose 全家桶,开发期用 SQLite 存注册表、用一个简易的内存版连接池就够了,等要部署多实例再切换到 Redis 和 PostgreSQL。下面是核心依赖清单,可直接抄:
fastapi==0.115.6 uvicorn[standard]==0.32.1 pydantic==2.9.2 httpx==0.27.2 redis==5.2.1 apscheduler==3.10.4这里说一个选择逻辑:连接器层我全用 httpx 而不是 requests,因为 Agent-Reach 的核心路径是异步的,connections 混用同步和异步会非常痛苦。你可能觉得小项目无所谓的,但一旦几十个连接器同时跑,同步阻塞会在事件循环里堆积成灾难。所以从一开始就把连接器接口设计成 async 的。
4.2 写一个 PostgreSQL 连接器
我拿最常见的业务场景举例——把订单库接进来。这个连接器实现四个方法,核心逻辑在 call 方法里。为了安全,我没有让 Agent 直接传任意 SQL,而是内置几个白名单查询模板:查订单详情、查客户近 30 天订单、按状态统计订单数。参数经过 schema 校验后拼进模板,且只允许 SELECT 语句。这在工程上叫“防护性连接器”——Agent 再聪明,也不该拥有执行 DROP TABLE 的权限。
# connectors/postgres_connector.py import asyncpg from agent_reach import Connector, Envelope class PostgresConnector(Connector): def __init__(self, config): self.dsn = config["dsn"] self.max_rows_limit = config.get("max_rows_limit", 50) self._pool = None async def health_check(self) -> bool: conn = await self._get_conn() try: await conn.fetchval("SELECT 1") return True finally: await self._release(conn) async def describe_tools(self) -> list[dict]: return [ { "name": "query_order_detail", "parameters": {...}, "timeout_ms": 8000, }, { "name": "query_customer_recent_orders", "parameters": {...}, "timeout_ms": 8000, } ] async def call(self, tool_name: str, params: dict, metadata: dict) -> Envelope: if tool_name == "query_order_detail": sql = "SELECT * FROM orders WHERE order_id = $1" row = await self._fetch_row(sql, params["order_id"]) return Envelope(ok=row is not None, data=row) if tool_name == "query_customer_recent_orders": sql = """ SELECT order_id, status, created_at FROM orders WHERE customer_phone = $1 AND created_at > now() - interval '30 days' ORDER BY created_at DESC LIMIT $2 """ rows = await self._fetch_rows(sql, params["customer_phone"], params.get("limit", 5)) return Envelope(ok=True, data=rows) return Envelope(ok=False, error_code="ETOOLNOTFOUND")注意几个细节:第一,连接器里没有做超时控制,超时是在策略层统一处理的,连接器只负责干活;第二,每个工具都在 describe_tools 里声明了自己的预期超时时间,路由层会把这个值传给策略层,方便做一些自适应判断;第三,数据库连接池要单独调优,我见过最典型的错误是连接池太小,五个 Agent 实例每个开 15 个连接就能把 PostgreSQL 的连接数打满。
4.3 把工具暴露给 Agent 平台
连接器写完后,需要把工具注册到 Agent-Reach 的注册中心,再同步给 Agent 平台。Agent 平台的接入方式因平台而异,但主流都支持 OpenAI 风格的 function calling。Agent-Reach 在这里负责把注册表的 JSON Schema 转成平台需要的格式,这个转换是自动化的,避免两边手工维护导致漂移。
我这里给一个真实的转换输出示例,这是 Agent 平台那边最终拿到的工具定义:
{ "name": "query_customer_recent_orders", "description": "查询客户最近30天的订单列表,支持按limit控制返回条数", "parameters": { "type": "object", "properties": { "customer_phone": {"type": "string", "pattern": "^1\\d{10}$"}, "limit": {"type": "integer", "minimum": 1, "maximum": 20} }, "required": ["customer_phone"] } }当 Agent 端发出一个 tool call,请求先落到 Agent-Reach 的 HTTP 入口,入口解析出 tool_name 和参数,交给策略层做校验和熔断检查,再走到路由层匹配连接器,最后连接器执行并返回信封。整个链路我在中间埋了 trace_id,每一跳都记录时延。这个小机制在排查时帮了大忙,后面问题排查章节我会展开讲。
4.4 首次联调的现场
第一次联调我印象很深。当时我用一个简单的对话测试:“帮我查一下手机号 138****8888 最近买了什么。”预期是 Agent 调用 query_customer_recent_orders,然后返回订单列表。实际跑的时候,第一次调用模型给出了一个我以为很完美的 tool call,参数是 customer_phone,值是 138...,但结果返回了空。
排查发现,坑不在 Agent-Reach,而在连接器的 SQL:customer_phone 在表里带空格和脱敏格式,比如 138 8888 8888,而模型提取的字符串是不带空格的。这个数据格式不一致是连接器层要处理的典型问题。我在连接器内部加了一个标准化函数,把传入的手机号先去掉空格、横线,再跟数据库里脱敏后的存储格式做匹配。这个教训我记下了:连接器不只是转发请求,它要对数据做必要的适配,尤其是用户输入和系统存储之间经常存在不可见的格式鸿沟。
5. 常见问题与排查实录
5.1 连接池被打满,Agent 集体卡死
这是我遇到的第一个生产级问题。现象:三个 Agent 实例同时处理客服会话,数据库连接池设置的是每实例 10 个连接,理论上 30 个连接足够,但因为某个工具没有设置合理的超时,大量查询卡在慢 SQL 上,连接池耗尽,后来进来的请求全部排队,最终触发全局超时雪崩。
排查方式:我先把 Agent-Reach 的监控面板打开,看到 query_order_detail 的 P99 时延从 200ms 飙到 8000ms,连接池的 wait 曲线同步爬升。定位到是一条全表扫描的 SQL 导致——订单表没有在 customer_phone 上建索引。这个问题的修复有两个层面:索引是根因,但要靠连接池参数兜底。我在连接池上加了 acquire_timeout,超过 3000ms 拿不到连接就直接返回 ETIMEOUT,而不是无限排队。这个兜底设计很重要,因为 Agent 侧最怕不确定性,明确的超时错误码能让模型快速决定下一步,而无限等待只会让整个会话僵住。
5.2 上游接口改了字段名,Agent 开始“瞎编”
这是第二个高频问题:上游系统升级,把原来的 userEmail 改成 primaryEmail,连接器没同步更新,导致调用返回 null。结果模型拿不到真实数据后,居然在回复里“编造”了一个订单状态。这不是模型的问题,是工具契约失效后模型被逼到了墙角——它宁可圆一个谎也不愿意承认自己没拿到数据。
我后来的对策是两件事:第一,连接器在 describe_tools 里返回的 schema 增加 versions 字段,每次上游契约变化都要升版本,旧版本工具保留 90 天并打 deprecated 标记;第二,Agent-Reach 在信封里增加了 schema_age 字段,表示这个工具 schema 距离上次上游同步过去了多久,一旦超过 7 天,监控告警会自动拉起。这个机制听起来简单,但它把“上游契约漂移”这件事从不可见变成了可观测。
5.3 权限过大与凭证散落
Agent 要访问那么多系统,凭据管理早晚会出问题。我的项目里曾经发生过一次事故:一个 Agent 的工具不小心被赋予了生产数据库的写权限,虽然这次没出事,但光是“可能出事”这个事实就够让人夜不能寐了。Agent-Reach 后来强制要求每个工具声明权限等级:read-only、read-write、admin。路由层会在运行时校验,Agent 的调用请求里会携带来源身份标识,凡是不匹配的调用直接拒绝并记录审计日志。
凭证这块我的建议是全部集中到密钥管理服务,不要散落在连接器配置文件里。Agent-Reach 的连接器只持有密钥的引用名,启动时统一从密钥服务拉取。这样一个 Agent 被攻破时,攻击者拿不到任何明文凭证,只是拿到一堆“怎么去取凭证”的路径。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 推荐的修复路 |
|---|---|---|---|
| Agent 频繁调用同一工具导致限流误伤 | 路由规则未收敛,多 Agent 共享配额 | 查看配额监控与调用方标识 | 在路由层按租户/实例拆分配额桶 |
| 工具返回超时错误但上游日志显示正常 | 连接池排队时间过长 | 检查连接池 wait 曲线 | 调大连接池或加 acquire timeout |
| schema 校验通过但连接器报参数错误 | 上游字段漂移 | 对比注册表版本与上游契约 | 更新连接器 schema 并升级版本 |
| 熔断频繁误触发 | 窗口内请求数太少,失败率失真 | 检查最小请求数阈值 | 提高 min_requests 到 20 以上 |
| 重试导致上游负载翻倍 | 对不可重试错误也做了重试 | 查看错误码分类 | 严格维护 retryable 错误码白名单 |
| 响应数据太大,Agent prompt 被撑爆 | 未设置返回条数上限 | 检查信封大小 | 连接器层强制 max_rows 与截断策略 |
最后分享两个小技巧
先说一个可能和主流直觉相反的经验:给 Agent 的工具不是越多越好,而是“够得着且稳得住”才好。我后来在 Agent-Reach 里加了一个“工具可达性评分”,根据近期成功率、时延、错误码分布给每个工具打分,低于阈值的工具会自动从 Agent 的可见工具列表中降级。这个设计让模型不会总去打那些半死不活的工具,也倒逼上游团队把接口质量做上去。另一个技巧是,Agent-Reach 的注册表里我总会给每个工具额外塞一条“常见失败示例”,比如参数传错格式时返回的具体错误样例,这样模型在面对模糊输入时更容易自我纠偏,少走一次错误调用。这些都不是什么高深理论,但都是我在实际项目里反复验证后留下来的东西,希望对正在做 Agent 连接层的你有用。