
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。下面是核心依赖清单可直接抄fastapi0.115.6 uvicorn[standard]0.32.1 pydantic2.9.2 httpx0.27.2 redis5.2.1 apscheduler3.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(okrow is not None, datarow) 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(okTrue, datarows) return Envelope(okFalse, error_codeETOOLNOTFOUND)注意几个细节第一连接器里没有做超时控制超时是在策略层统一处理的连接器只负责干活第二每个工具都在 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而在连接器的 SQLcustomer_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 timeoutschema 校验通过但连接器报参数错误上游字段漂移对比注册表版本与上游契约更新连接器 schema 并升级版本熔断频繁误触发窗口内请求数太少失败率失真检查最小请求数阈值提高 min_requests 到 20 以上重试导致上游负载翻倍对不可重试错误也做了重试查看错误码分类严格维护 retryable 错误码白名单响应数据太大Agent prompt 被撑爆未设置返回条数上限检查信封大小连接器层强制 max_rows 与截断策略最后分享两个小技巧先说一个可能和主流直觉相反的经验给 Agent 的工具不是越多越好而是“够得着且稳得住”才好。我后来在 Agent-Reach 里加了一个“工具可达性评分”根据近期成功率、时延、错误码分布给每个工具打分低于阈值的工具会自动从 Agent 的可见工具列表中降级。这个设计让模型不会总去打那些半死不活的工具也倒逼上游团队把接口质量做上去。另一个技巧是Agent-Reach 的注册表里我总会给每个工具额外塞一条“常见失败示例”比如参数传错格式时返回的具体错误样例这样模型在面对模糊输入时更容易自我纠偏少走一次错误调用。这些都不是什么高深理论但都是我在实际项目里反复验证后留下来的东西希望对正在做 Agent 连接层的你有用。