☰
Agent-Reach:为大模型Agent构建统一触达层,解决工具调用与治理难题
2026/10/25 18:25:55 网站建设 项目流程

大概半年前,我开始把手里几个基于大模型的Agent从"能聊天"往"真干活"方向推。第一批接入的公司内部系统只有七八个,跑了一个月很顺利。等接到二十多个系统的时候,问题就来了——同一个查询,Agent常常需要连续调三四个工具,每个工具返回的格式都不一样,有的还要轮询等异步结果。最夸张的一次,一个Agent因为等一个慢接口超时,把整条任务链卡了将近四分钟。我那时一度怀疑自己是不是把Agent做成了个"只会说不会做"的半成品。

后来我把触达层单独抽了出来,做了个叫Agent-Reach的中间件。简单说,它就是夹在LLM和外部系统之间的一层统一触达协议:无论背后是内部API、SaaS服务、数据库还是老的遗留系统,Agent都通过同一套方式去"够到"它们。这篇文章就把这段过程完整拆一遍:它到底解决什么问题、架构怎么设计、关键实现长什么样、实测效果如何,以及路上踩过的几个大坑。如果你也在做智能体编排、Function Calling集成,或者准备把Agent接到更多业务系统上,这里面的教训应该能帮你省掉不少弯路。

1. 为什么我决定做Agent-Reach:Agent触达外部系统时的三座大山

1.1 工具散落异构:每个系统都有自己的"方言"

做Agent的首选方案很直接:给LLM配几十个Function,让它自己选。一开始确实能跑,但很快我就发现,真正困难的不是让模型理解用户意图,而是让它稳定地触达那些外部系统。

拿我手头的系统举例:报销系统是老的SOAP接口,知识库用得是GraphQL,监控平台是REST带WebSocket推送,身份信息在LDAP里,还有一个团队自研的管理后台走的是内部RPC。Agent要触达这些系统,就得面对各不相同的鉴权方式、参数格式、返回结构、错误码语义。有的系统用Bearer Token,有的用签名头,还有的直接需要在Header里塞一个内部票据。为了让Agent"够到"这些服务,我一开始在代码里写了几十种分支逻辑,每接入一个新系统就要复制粘贴一堆适配代码。任何一个系统升级接口,Agent这边就要跟着返工。

这种散落异构带来的直接后果是:Agent的核心逻辑被无关的适配细节淹没。一个负责"查询报销单"的函数的真实业务逻辑可能只有十行,但为了兼容返回格式、处理超时、拼接鉴权头,代码膨胀到两百行。这种代码写多了以后,Agent的扩展速度会急剧下降,连我自己都不想再往里加新东西。Agent-Reach的第一个目标,就是把"触达"这件事从业务逻辑里剥离开,让Agent只关心"要什么",不关心"怎么连"。

1.2 上下文被工具描述吃光:30个工具之后的Prompt失控

第二个让我头疼的问题来自LLM本身。做Agent的人都绕不过Function Calling,而Function Calling需要把工具的描述塞进Prompt。刚开始只有10个工具时,工具的JSON描述加起来也就几千个Token,模型选得很准。等工具数量涨到30个以上时,问题开始出现:工具描述在Prompt里占了将近两万Token,不仅挤占了用户对话和上下文的空间,更严重的是,模型开始"忽略"一些工具,甚至频繁选错工具。

我印象很深的一次调试:用户问"把上周的发布报告发给王工",模型没有调用联系人查询工具,而是直接调用了"创建工单"工具,因为它觉得关键词"发布报告"和某工单模板更相关。这就是典型的工具描述过载导致的"指令漂移"。后来我分析,当工具清单过长时,模型对每个工具真实语义的注意力会被稀释,它更倾向于根据描述里最显眼的那些词做判断。两万Token的工具描述还会让后续的每次对话都背负巨大的计算开销,单轮延迟跟着涨。

要解决这个问题,最优雅的做法不是逼模型在几十个工具里做选择题,而是让Agent在调用前先做一次工具筛选,只把相关的少数几个工具暴露给模型。这个思路如果靠业务代码自己实现,每个场景都要写特殊的筛选逻辑,而且很难通用。Agent-Reach把这件事做成了目录服务的默认行为,后面我会详细说。

1.3 故障扩散:全联通不等于高可用

第三个问题,是在Agent触达的系统多了以后才暴露出来的。Agent一次任务往往要串行或并行调用多个工具,链路拉长后,任何一环慢下来都会被成倍放大。

举个例子:某个查询需要先查组织架构,拿到团队ID后再查项目列表,最后再去监控系统拿接口调用数据。这个过程在模型层面可能只要几秒钟的"思考",但实际执行会因为三四个外部请求的往返而显著变慢。有一次,监控平台一个接口响应正常,但它的网关偶发卡顿,导致每次Agent执行到那一步都要卡住十几秒。用户体感就是从"秒回"变成了"不知道什么时候会回"。

更糟的是,这类问题很难定位。因为Agent是"自由发挥"地调用工具,每次调用的路径都不一样,传统的告警规则很难覆盖所有可能的链路组合。我们需要的是一个能统一做超时控制、重试、限流、熔断和链路追踪的触达层,而不是让每个连接器自己各管各的。Agent-Reach把所有这些治理能力下沉到了中间层,连接器只负责一件事:接上目标系统,做完一次请求,返回结果。

2. 核心架构:把"触达"抽象成可插拔的协议层

2.1 四个核心组件,各管一摊

Agent-Reach不是一个Agent框架,也不做模型调度。它只负责Agent触达外部系统这一段。整个设计围绕四个组件展开,职责非常清楚:

组件职责核心产出
Access Protocol统一定义一次触达请求和响应的数据结构标准的AccessRequest/AccessResponse
Asset Catalog工具注册、能力描述、动态发现、健康检查分组后的工具Schema列表
Router按模型的调用意图路由到具体连接器一次实际的外部系统调用
Guard限流、超时预算、重试、熔断、审计每次调用的治理决策与日志

这四个组件加在一起,目标就一句话:Agent只需要说"我要触达某件事",Agent-Reach负责完成"如何触达"。模型侧不用再关心目标系统在哪、用什么协议、要不要鉴权,这些全部由中间层吞掉。

在设计上,我把Access Protocol放在最底层,因为它是整个方案的"共同语言"。只要连接器都遵守这套协议,Agent-Reach的Router和Guard就能对任意连接器做统一治理。这也是为什么我坚持所有连接器必须继承同一个基类,而不是各自返回自由格式的对象。

2.2 为什么选择"自描述工具"而不是直接把OpenAPI交给模型

你可能会有疑问:现在很多系统都提供OpenAPI文档,为什么不把这些文档直接交给模型,让它自己去调呢?我试过,踩过坑,才最终回到"自描述工具"这条路上来。

问题出在两点。第一,OpenAPI文档是为开发者设计的,里面包含了大量的枚举、错误码、分页参数、鉴权说明,一个稍微复杂点的系统文档可能有几百KB,直接塞给LLM既不现实也会严重污染上下文。第二,模型的Function Calling能力面对的是"精简、明确、单用途"的工具描述,而不是完整的企业级API面。把OpenAPI原样交给模型,模型很大概率会在参数映射上出错,比如把客户ID和订单ID搞混。

Agent-Reach的做法是让每个连接器在Asset Catalog里注册一份"高度精炼的工具描述",只留下模型真正需要的信息:工具名、一句话说明、参数结构和返回值结构。这些描述由接入方维护,但它不是从OpenAPI自动生成的,而是基于实际Agent调用场景手工提炼的。听起来麻烦,但这是保证模型调用准确率的关键一步。工具描述写得越聚焦,模型选得越准。

我在内部还规定:一个工具的description不超过64个Token,参数说明里每个字段的description不超过20个Token。超了就要求精简。这个约束一开始团队成员觉得太苛刻,后来大家发现这反而倒逼出了更好的Schema设计——如果你没法用一句话讲清楚这个工具是干什么的,大概率是你对这个工具的功能定位还没想清楚。

2.3 目录服务与动态发现:让连接器即插即用

Asset Catalog在实现上借鉴了微服务里的注册中心思路。每个连接器在进程启动时向Catalog注册,注册信息包含工具Schema、健康检查地址、限流配置、鉴权类型和依赖标签。Catalog对外提供两类查询接口:

  • 全量查询:给运维和调试用的,能看到当前注册了哪些工具、各自状态如何。
  • 按相关性查询:给Agent编排用的,传入用户请求和上下文,返回一组合适的工具子集。

按相关性查询是规避"工具描述过载"的关键。具体做法是先按权限过滤(用户没有权限的工具直接不返回,这也能防止模型尝试调用不该调的东西),再做语义检索。我给每个工具描述离线生成了向量索引,查询时用用户请求的embedding做topK召回,再叠加关键词加权。召回数量设了上限,默认12个。这样无论底层已经注册了50个还是200个工具,模型每次看到的都只是经过筛选的、和当前请求真正相关的一小部分。

动态发现还有个好处是支持的灰度发布。新工具接进来后,可以先把状态置为"影子模式",只记录不生效;链路验证通过后再切到"正式模式"。这个能力在传统系统对接时代需要额外的开关和发布流程,在Agent-Reach里就只是Catalog里的一个状态字段。

3. 工程实现:从Tool Schema到连接器适配的关键细节

3.1 用一份YAML描述一个工具的"全生命周期"

Agent-Reach的每个工具在Catalog里都是以YAML配置形式存在的。这里我放一个真实示例,是某个工具接入时的配置:

version: "1.0" id: "crm_search_contacts" name: "search_contacts" description: "根据姓名、部门或邮箱搜索CRM客户联系人信息" type: "tool" input_schema: type: "object" properties: keyword: type: "string" description: "联系人姓名、邮箱或手机号,支持模糊匹配" department: type: "string" description: "可选,按部门过滤" required: ["keyword"] output_format: type: "json" fields: - contact_id: "string" - name: "string" - department: "string" max_output_size: 2048 runtime: connector: "crm.http" endpoint: "/api/v1/contacts/search" method: "POST" timeout_ms: 3000 retry: 2 rate_limit: capacity: 20 refill_per_sec: 5 auth: type: "gateway_sso" scopes: ["crm:read"] fallback: - type: "mock" data_path: "/etc/agent-reach/mock/crm_search_contacts.json"

这份YAML覆盖了一个触达调用的完整生命周期。模型侧只需要看到name、description、input_schema这部分;Router要的是runtime;Guard要的是rate_limit、timeout_ms、retry;运维要的是fallback和auth。所有角色都从同一份配置里取自己关心的字段,不需要各自维护一份"真相源"。

有个细节我要强调下:max_output_size这个字段一开始我没加,后来被大规模返回打爆过上下文才补上的。有的工具返回的列表动辄几百条记录,如果全部塞进模型上下文,既浪费Token又干扰判断。加了上限之后,连接器可以对返回结果做截断或者汇总,保证模型拿到的始终是"精华版"数据。这个字段对控制成本帮助非常大。

3.2 连接器基类:把后端适配收敛到统一接口

所有接入Agent-Reach的工具都必须实现这样一个基类:

import asyncio from abc import ABC, abstractmethod from dataclasses import dataclass @dataclass class AccessRequest: action: str params: dict trace_id: str auth_context: dict deadline: float @dataclass class AccessResponse: success: bool data: object = None error_code: str = "" message: str = "" class BaseConnector(ABC): def __init__(self, name: str, config: dict): self.name = name self.config = config @abstractmethod async def execute(self, request: AccessRequest) -> AccessResponse: """执行一次触达调用,必须全程异步,并且遵守 request.deadline。""" @abstractmethod def health_check(self) -> bool: """返回当前连接器是否可用。"""

AccessRequest里的deadline是Agent-Reach超时控制的关键。Router在派发请求时会根据Guard算好的超时预算设置一个绝对时间戳,连接器在执行过程中必须主动检查这个deadline。这样,一次触达调用不会因为某个第三方SDK内部的重试机制而无限制地拖下去。

异步是另一条硬性要求。所有连接器的execute都必须是async函数,因为Agent一次任务常常会并行触达多个工具,如果某个连接器用了同步阻塞调用,整个事件循环就会被卡住。这也是我在评审连接器代码时最先检查的部分。

3.3 把目录动态编译成Function Calling参数

有了Catalog里的工具Schema,下一步就是把它们动态编译成LLM能识别的Function Calling参数。核心代码其实很薄:

def build_tool_schemas(catalog_items: list[CatalogItem]) -> list[dict]: schemas = [] for item in catalog_items: schemas.append( { "type": "function", "function": { "name": item.function_name, "description": item.description, "parameters": item.input_schema, }, } ) return schemas

真正的难点在调用前的工具筛选。我在Router层实现了一个select_tools函数,它的输入是用户请求和用户上下文,输出是一组适合当前场景的CatalogItem:

async def select_tools(query: str, user_context: dict, catalog: Catalog) -> list[CatalogItem]: # 第一步:按权限过滤,用户没有权限的工具一律不给模型看到 permitted = catalog.filter_by_scope(user_context["scopes"]) # 第二步:如果过滤后的工具少于12个,直接全部返回 if len(permitted) <= 12: return permitted # 第三步:按语义相关性打分,取前12个 scores = [(item, scoring(query, item)) for item in permitted] scores.sort(key=lambda x: x[1], reverse=True) return [item for item, _ in scores[:12]]

请注意,我把权限过滤放在相关性检索之前。这背后的逻辑是:模型只能从给它看到的东西里做选择。如果一个无权限的工具出现在Prompt里,模型很可能因为描述匹配而尝试调用,然后被权限错误打回,浪费一整轮往返。在触达层把这种失败提前拦截掉,比事后再做权限校验要高效得多。

3.4 限流、超时与重试的组合拳

Guard组件的核心是三个策略的组合:令牌桶限流、全局超时预算、指数退避重试。

限流用的是令牌桶算法。每个连接器在Catalog里配置了capacity和refill_per_sec,Router派发前先尝试取令牌,取不到就直接返回"限流中"错误,并附带建议的等待时间。这里的关键是限流也要做成统一逻辑,不能每个连接器自己实现一套。

超时预算是Guard最微妙的部分。一个Agent任务可能先后触达5个工具,如果每个工具各设3秒超时,最坏情况就是串行等待15秒。我在Router层给整个任务链设了一个总预算,例如8秒。每次调用子工具前,Guard会把剩余预算分配下去,子工具的deadline就是当前时间加剩余预算。这样,即使中间某个工具异常慢,也不会拖垮整条任务链,因为它的deadline会随着时间推移越来越短,最终被强制打断。

重试默认开一次,且只在超时和网络类错误上重试,业务错误码一律不重试。重试时使用指数退避,基础间隔200毫秒,最多延迟1.6秒。这些参数都能在Catalog里按工具单独覆盖,但默认值已经能覆盖大部分场景了。

4. 实测数据:从10个工具扩展到50个工具,稳定性表现如何

4.1 三个月的观察:稳定性指标的明显变化

Agent-Reach稳定跑了三个月,我拿接入前后的关键指标做了对比。这里的"接入前"指Agent直接连各个系统、每个连接器自己管超时重试的阶段;"接入后"指全部被Agent-Reach统一治理的阶段。

指标接入前接入后
单次工具调用平均延迟2.1s1.2s
P99延迟8.7s2.4s
工具调用成功率94.2%99.1%
工具描述占用的上下文Token约26000约4500
新增一个工具的平均耗时约2天(含联调)约1小时

平均延迟下降的核心原因有两个:工具描述变短之后模型决策更快,这是第一层;慢接口不再拖垮整体链路,这是第二层。P99从8.7秒降到2.4秒是最直观的用户体感改善——之前用户经常投诉"转圈半天没结果",接入后这类投诉几乎绝迹。

成功率从94.2%提到99.1%,靠的不是某个单独的技巧,而是超时、重试、熔断、降级这些治理能力的合力。尤其是把"业务错误"和"系统错误"在AccessResponse里明确区分开之后,排错效率高了很多。系统错误可以安全重试或触发熔断,业务错误直接返回给模型让它调整策略,两者不再混为一谈。

4.2 一个完整的触达链路:从自然语言到结构化结论

我们看一个具体的例子。用户问:"本月团队报销单里有没有金额异常的条目?"

这个请求经过Agent-Reach的完整链路大概是这样的:

第一步,Agent-Reach收到请求后先做工具筛选。它从Catalog里召回了4个工具:查组织架构、查报销单列表、查审批流信息、查核算规则。query这个请求的embedding和工具描述的相似度排序中,这4个排在最前面。其他四十六个工具完全没有出现在Prompt里,模型也不会被干扰。

第二步,Router按照工具间的依赖关系编排执行顺序。先查组织架构,拿到当前用户的团队ID;再用团队ID查报销单列表,拿到所有报销记录;接着并行触发审批流和核算规则两个查询,拿到审批节点和异常判定规则。

第三步,Guard在每次调用前都检查剩余超时预算。第一次调组织架构花了0.8秒,剩余预算6.2秒;第二次查报销单花了1.1秒,剩余5.1秒;两个并行调用各分配约2.5秒预算。如果其中一个慢接口超时了,它不会拖死另一个查询,因为两个调用各自有独立的deadline。

第四步,所有结果以统一格式组装成一个结构化JSON,交给模型生成最终答案:"本月报销单共37笔,发现2笔金额超出阈值,详情如下……"整个过程从请求到返回结论,耗时大概4秒。这在接入前是不可想象的。

4.3 数字之外的额外收益:接入和维护体验

比数字更让我满意的是开发体验的变化。接入一个新系统平均只需要一个小时,大部分时间花在写YAML配置和联调返回格式上,不再需要为了每个系统写一套自定义适配代码。团队内部的分工也清晰了:业务方只需要提供工具描述和接口文档,Agent-Reach的连接器模板由平台组维护,大家各司其职。

另一个意外收益是审计变得完整了。以前Agent调了个什么接口、用了谁的凭据、返回了什么数据,要查代码日志才能勉强拼出来。现在所有触达调用统一走Guard,每次调用都自动记录调用者身份、目标系统、参数摘要、返回状态和执行耗时。审计日志不再是额外工作,而是Agent-Reach的默认副作用,这对安全合规部门来说是个相当实用的能力。

5. 排错实录:三个让我头疼的问题与最终解法

5.1 工具描述太长导致的"指令幻觉"

最早把工具全量塞进Prompt的时候,我从20个工具增加到了30个,然后观察到一种诡异的现象:模型会频繁调用一些和用户请求关联很弱的工具。

比如用户说"查一下客户张明的订单",模型在调用search_customer之后,还会额外调用update_contact_tag,把张明的标签改成了"订单查询"。这类操作带有副作用,而且完全没有必要。我一开始以为是模型随机行为,排查完才发现是工具描述过载导致的"指令幻觉":当System Prompt里的工具定义太多、太冗长时,模型会错误地把一些描述片段理解为隐含指令。

定位过程比较繁琐。我先打印出了每次完整请求里tools参数的大小,发现光工具定义的JSON就有约8万字符,折合两万多Token。然后我做了个对照实验:只保留6个相关性最强的工具,同样的用户请求,模型行为完全恢复正常,不再出现多余调用。这个对照实验确认了根因。

最终解法就是我前面说的动态工具筛选,以及给工具描述设定Token上限。此外,我还在Prompt末尾加了一句硬性约束:"你只能调用上述工具列表中的函数,不要调用未列出的函数。"听起来很基础,但它显著减少了模型的"自由发挥"倾向。

5.2 一个慢接口拖垮整条链路

第二个坑是慢服务导致的级联阻塞。某天线上反馈,Agent处理报销类查询明显变慢,单轮耗时从3秒涨到了12秒。打开链路追踪的瀑布图一看,问题不在模型推理,而在一个遗留系统提供的"导出报销明细"接口上。那个接口的正常响应就要3-5秒,偶尔会拖到8秒以上。

更麻烦的是,Agent在调用这个慢接口时是并行发起多个子调用的。标准做法里,并行调用应该各自独立,但当时我的实现是:把多个子调用的await放在同一个TaskGroup里,任何一个子调用失败都必须等全部子调用结束才能继续。asyncio.TaskGroup在Python 3.11里的语义决定了一旦某个子任务抛异常,它会等待所有子任务结束再统一抛出。这就导致一个慢接口不仅自己慢,还绑架了其他所有子调用的返回。

解法的核心是引入独立的任务隔离和全链路超时预算。每个子调用不再是"整体等齐"的语义,而是各自带上独立的deadline,谁先完成谁先返回,慢的那个单独超时,结果标记为失败或者走降级分支。同时用asyncio.timeout给整条任务链套一个总预算,避免多个子调用的超时相互叠加。改完之后,同样场景的P99延迟稳定在2.4秒左右,慢接口不再产生"一拖全拖"的效果。

5.3 权限模型不统一导致调用频繁失败

第三个坑在权限层。Agent接入的系统越多,权限模型的差异就越明显:有的系统要Basic Auth,有的要求JWT Bearer,还有内部系统直接认LDAP账号,甚至有一个遗留后台要求自己拼签名。最初的设计是每个连接器自己保存一套凭据,结果用户换岗位后,有的连接器还在用旧的账号调用,导致一批Agent任务在触达那几步时频繁报401。

排查过程很痛苦,因为错误发生在不同系统里,报错信息五花八门,有的是"Unauthorized",有的是"token expired",有的是干脆返回一个空列表,根本看不出权限问题。

后来我调整了设计:把所有外部系统的凭据从连接器里剥离出来,统一放到Agent-Reach的凭据仓库里管理。连接器在execute时不再接触任何密钥,只从auth_context里取一个user_ref,由网关层在做鉴权时统一映射到目标系统的凭据。用户在离职或变更权限时,只需要在凭据仓库里改一条映射,所有连接器立刻生效。

这个改动还顺带解决了一个安全隐患:连接器代码仓库里不需要再存放任何真实凭据,即使代码泄露,密钥也不会随之泄露。每天定时轮换凭据也变成了一件可以在网关层统一执行的事,而不是逐个系统去改。

6. 个人经验:落地Agent-Reach的几条实操建议

6.1 别贪多,先从三个高频但简单的场景切入

如果你也想在自己的Agent体系里引入类似的触达层,我的建议是千万别想着一次接入几十个工具。先选两三个高频、逻辑简单、结果可验证的场景跑通,这能帮你尽早验证架构方向对不对,又不会在排查问题上分走大量精力。

我当时先选了三个场景:查联系人、查报销单、查监控大盘。这三个场景覆盖了三种典型模式:简单查询、带依赖的查询、跨系统聚合查询。把这三个场景跑稳定之后,后续扩展更多工具就只是工作量问题,而不是架构问题。

6.2 把输出约束写进Tool Schema,比事后截断更省心

工具的max_output_size这个字段是我强烈建议保留的。它不只是一个数字,更是一种设计约束:接入方在写工具描述时就必须想清楚"这个工具最多返回多少数据",而不是等到运行时被大量返回撑爆了上下文才想起来做限制。

我在实际使用中发现,给max_output_size配上自动摘要效果更好。超过上限时,连接器先把返回结果压缩成关键字段,再返回给模型。比如查联系人列表,超过50条时自动聚合成"共找到137位联系人,其中前50位如下"这样的格式,既保住了关键信息,又不会污染上下文。

6.3 可观测性必须从第一天就做,而不是出了问题再补

Agent类系统的排错天然比普通Web服务难,因为模型行为带有不确定性。同一个用户请求,两次调用的工具可能都不一样。在这种系统里,没有链路追踪几乎等于盲人摸象。

我要求所有触达调用都必须带trace_id,一条完整链路里的所有外部调用都共享这一个ID。Guard每次介入限流、重试或者熔断都会记录事件,并和trace_id关联。这样排查一个问题时,我可以直接画出完整的时间线:用户在什么时候发出请求,模型选了哪些工具,每个工具的执行时长、成功与否、是否触发了重试、是否被限流。没有这套基础设施,那些偶发的Agent故障根本无从下手。

开发Agent-Reach这段经历,给我最大的一个感受是:Agent能不能在真实业务里落地,瓶颈往往不在模型聪明不聪明,而在于它能不能可靠地"够到"那些系统。触达层看起来是最不性感的部分,但它决定了Agent从Demo到生产的距离。每次新接一个系统只需要写一份YAML配置,而不是重新造一遍轮子的时候,我就觉得当时抽出来的这层没有白做。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询