Agent工具链路治理:统一注册与路由网关的设计与落地 写 Agent 这块儿久了你会发现真正卡脖子的往往不是模型推理能力而是模型知道该做什么但手伸不出去。我们在内部搭客服 Agent 的时候最早靠手写 Function Calling 挨个接工具工具从 5 个涨到 80 个之后代码维护成本直接失控。后来我们以Agent-Reach为代号重新做了一层统一的能力注册与路由网关才算把这条链路理顺。这篇就把 Agent-Reach 的设计思路、落地过程、踩坑记录都摊开说写给正在搭 Agent 工具链路、却又被工程细节折磨的团队。1. 先说我为什么不再手写 Function Calling1.1 工具从 5 个涨到 80 个之后代码先撑不住了刚开始接触 LLM API 的时候大家都会先试 Function Calling。给模型声明两个工具让它在合适的时候调用比如查天气、查余额体感非常惊艳。但请注意两个工具惊艳和八十个工具可用之间隔着一条巨大的工程鸿沟。我踩过的第一个坑是工具描述膨胀。每个工具都要写一段自然语言描述、参数 schema、枚举值、示例一个工具平均要占 600 到 800 token。八十个工具就意味着 5 万 token 左右的系统提示词。这个量级对上下文窗口不是很友好模型的选择准确率也开始下降经常在相似的工具名之间徘徊比如get_order_info和query_order_detail它分不清哪个该用。第二个坑是错误处理。手写 Function Calling 时每个工具调用失败都要单独写 catch 分支。有的工单系统超时有的内部 API 返回 500有的第三方接口需要重试。每个分支写一次代码里全是if error xxx的垃圾逻辑。等到工具从 5 个涨到 80 个你已经不是在写功能代码而是在写一个混乱的错误路由表。第三个坑最隐蔽你能读代码但模型不能。传统 API 网关的文档是给人看的可大模型只能看到你在 prompt 里拼进去的工具描述。团队里每个后端同学都按自己的风格写 description有的人写得详细有的人只写一句话。模型碰到工具描述不清晰的时候会开始自主发挥编参数调用后果就是线上出现奇奇怪怪的工单和查询。1.2 模型是客人工具是服务生中间不能没有调度台我自己想了一个类比挺好用的。你把大模型想象成餐厅里的客人他只会说我要一份少盐的红烧肉但他不知道后厨有几个灶、哪个灶归谁管、哪个厨师今天休假。工具就是服务生每个服务生只负责一道菜。客人越喊越响但是没有人去后厨调度那结果就是所有服务生一拥而上厨房乱成菜市场。手写 Function Calling 就是在给每个客人配一套独立的传话系统这本质上不靠谱。真正需要的是一个中间调度台客人的需求进来调度台根据菜单决定叫哪个服务生、按什么顺序上菜、失败了换哪个备选服务生。这个调度台就是 Agent-Reach 的核心定位一个面向大模型的、统一的能力触达网关。我们当时定下的目标是这样的前端模型侧只看到一张精简后的工具清单而非 80 个原始工具后端业务侧只负责实现 handler 和声明 manifest不用关心模型怎么选工具中间路由层统一做参数校验、鉴权、超时控制、失败回退。有了这三条整个工具链路的职责才算真正收拢。2. Agent-Reach 把哪三件事收拢成了一条线2.1 注册把人看的接口文档翻译成模型看的 manifestAgent-Reach 的第一个核心设计是能力注册。每个后端系统接入时不是直接扔一个 OpenAPI 文档过来而是提供一个 handler 加一份 manifest。manifest 是一段结构化的 JSON描述这个工具是干什么的、需要什么参数、什么权限、超时多久、失败后有没有备用方案。我拿查余额这个场景做一个 manifest 示例{ name: billing.query_balance, description: 查询用户账户的当前余额。适用于用户追问余额、扣费明细、话费疑问等场景。, params: { customer_id: { type: string, required: true, description: 用户在业务系统中的唯一标识 }, currency: { type: string, enum: [CNY, USD], default: CNY, description: 币种 } }, auth: service_token, timeout_ms: 3000, fallback: [billing.query_balance_cache] }这份 manifest 和普通接口文档最大的区别是什么它的描述语序是站在模型选择的角度写的。我见过很多团队写工具描述时直接复制后端注释比如 get user balance from billing system这种描述对模型来说信息量太稀薄。更好的写法是先说触发场景再说能力边界这样模型在意图匹配时命中率明显更高。参数校验上Agent-Reach 不是手写 if-else而是从 manifest 的params字段自动生成校验逻辑。枚举值、必填项、最大长度全部配置化。这样后端同学注册新工具时只需要写 handler校验逻辑由网关统一接管省掉了每接入一个工具就写一遍胶水代码的重复劳动。2.2 路由一层薄薄的分发但要有清晰的失败语义注册完之后模型侧看到的是一份精简指令集。在内部路由层收到模型发出的tool_call请求后按 manifest 做四件事查注册表、校验参数、检查权限、分发到 handler。查注册表这步很简单但有一点特别容易被忽略工具名冲突。我们一开始用业务方的命名直接注册结果两个团队都起了order.create路由层根本不知道该发给谁。后来强制所有工具名加模块前缀billing.query_balance、ticket.create、slack.send_message这样冲突问题彻底解决。失败语义这块我多说两句。Agent-Reach 的路由层统一封装了三种失败类型unknown_tool模型调用了不存在的工具通常是工具裁剪或者描述不一致导致的invalid_params参数校验失败返回值里会带上具体哪个字段有问题handler_errorhandler 内部跑挂了会带上可读的错误码和提示信息。这三种失败信息有一个共同要求必须能被大模型直接理解。模型收到错误后还要决定下一步动作所以错误消息绝不能是500 Internal Server Error这种人类调试信息而应该像billing service timeout, you can tell the user that balance query is temporarily unavailable。很多团队忽视这个细节导致模型一次次重试同样失败的调用浪费 token 又伤体验。2.3 回退主路径崩了备选路径顶上单个工具调用失败已经够烦了如果一次任务要调用链路里的三个工具任何一个挂了都会拖垮整条链路。Agent-Reach 在 manifest 里增加了一个fallback字段允许你为工具声明备选实现。举个例子我们的客服机器人要查实时余额主实现是调计费系统实时接口但计费系统每周三凌晨会有十五分钟的维护窗口。没有回退机制的时候这个窗口里的客服机器人就是半个残废用户问啥都是暂时查不了。加了回退之后manifest 里声明fallback: [billing.query_balance_cache]维护窗口内的请求会自动降级到缓存接口虽然数据有五分钟延迟但用户侧完全无感。回退链还可以串联多级。比如主接口超时 - 降级到备选缓存接口 - 缓存也没有 - 路由层返回一个可理解的错误提示给模型让模型用话术安抚用户。这个多级回退的设计把我们最头疼的第三方不可用问题从一个异常分支变成了一个可配置项。3. 我如何用 Agent-Reach 接起一条完整的客服链路3.1 选定场景客服 Agent 需要同时触达三个系统空谈设计没意思我直接拆一个真实落地的场景。我们做的客服 Agent 需要同时触达三个内部系统账户计费系统查余额、工单系统创建投诉、知识库查 FAQs。在接入 Agent-Reach 之前代码里是三个独立的 SDK 调用块全部硬编码在 Agent 主逻辑里。模型一旦聊到需要跨系统查询再开单的场景就得写一长串胶水代码手动串联调用顺序效果差、调试难。接入 Agent-Reach 之后我们把三套系统全部注册为独立工具工具名所属系统核心用途超时回退billing.query_balance计费系统查实时余额3sbilling.query_balance_cacheticket.create工单系统提交投诉工单5sticket.create_escalationknowledge.search知识库检索 FAQ 答案2sknowledge.search_local这张表的好处是Agent 主逻辑完全不感知后端的实现差异它只知道有这些工具可用。底层是计费系统还是工单 API对模型来说都是平等的真正实现了工具层面的解耦。3.2 从零注册第一个连接器代码与配置下面用一个最小化的 Python 实现来演示 Agent-Reach 注册一个连接器的核心思路。首先是 handler 部分业务方只需要实现一个接受参数、返回 dict 的函数# handlers/billing.py def query_balance(customer_id: str, currency: str CNY): # 这里放真实的计费系统调用逻辑 balance billing_client.get_balance(customer_id, currency) return {balance: balance, currency: currency}然后是注册动作在 Agent-Reach 的注册中心里把 handler 和 manifest 绑定起来。# registry.py from handlers.billing import query_balance register( manifest_pathmanifests/billing.query_balance.json, handlerquery_balance, ) # 启动路由时网关会加载所有 manifest 并生成模型侧的压缩描述 router ReachRouter.load_registry(manifests/) prompt_tools router.export_tools_for_llm(max_tokens1200)这里的export_tools_for_llm是整个设计里最妙的一步。它不是把全部 manifest 直接塞给模型而是根据配置的上限自动压缩工具描述——保留工具名、一句话用途、必填参数名删掉冗长的枚举说明和示例。模型看到的工具清单是精简过的决策负担小很多。router 的核心分发逻辑长这样class ReachRouter: def __init__(self, registry): self.registry registry def dispatch(self, tool_call: dict) - dict: name tool_call.get(name) params tool_call.get(arguments, {}) manifest self.registry.lookup(name) if not manifest: return {status: error, error_type: unknown_tool, message: ftool {name} is not registered} errors validate_params(params, manifest.params) if errors: return {status: error, error_type: invalid_params, message: missing or invalid fields: , .join(errors)} if not check_auth(manifest.auth): return {status: error, error_type: auth_denied, message: permission denied for this tool} return self._dispatch_with_fallback(manifest, params) def _dispatch_with_fallback(self, manifest, params): chain [manifest.name] manifest.fallback for name in chain: handler self.registry.lookup_handler(name) try: result handler(**params) return {status: ok, result: result} except TimeoutError: continue except Exception as e: return {status: error, error_type: handler_error, message: get_readable_error(name, e)} return {status: error, error_type: all_fallback_failed, message: all available implementations failed}这段代码看着不复杂但它替 Agent 主逻辑扛掉了三类麻烦工具不存在、参数错误、服务超时回退。原先这些逻辑散落在 Agent 代码的各个角落现在全部收进了一个dispatch调用里。3.3 联调检查清单记得把错误消息翻译成模型能听懂的话把 Agent-Reach 接入客服机器人之后我整理了一份联调清单这里直接分享给你用 3 到 5 个真实对话样本跑一遍确认模型能够正确选择工具不会因为描述模糊而选错类似工具故意让一个工具超时确认回退链触发且返回给模型的错误消息是能读懂的话而非堆栈查看日志里每个 tool_call 的耗时分布确认路由层的分发开销在 5ms 以内不会成为链路瓶颈检查模型是否尝试调用未被授权的工具比如客户直接问帮我看看别人的订单Agent 不能暴露越权工具。这个联调清单不是一次性的。每接入一个新工具我都会把前两条重新跑一遍因为新工具的描述格式如果偏离约定很容易再次诱发模型选择错误。4. 真正磨人的是这几个地方超时、上下文与权限4.1 超时预算LLM 和工具调用共享同一根秒表我在接入初期犯过一个错只给 handler 设超时没从整个 Agent 会话的角度算时间账。用户问你一个问题LLM 生成响应用了 3 秒模型决定调用工具工具自己花了 5 秒再把工具结果喂回模型重新生成回答又花 3 秒。一整套流程下来11 秒就没了。用户侧感知就是这机器人转圈圈转了一万年。后来我们引入了超时预算的概念。在 Agent-Reach 的路由参数里每次外部调用都带一个deadline这个 deadline 由会话层的全局预算倒推出来。阶段时间预算模型首字延迟3s单次工具调用3s含重试工具结果回传后二次生成3s链路整体兜底8s如果工具调用阶段发现剩余预算不够多路由层会直接跳过慢接口优先走回退缓存路径。这并不是降级体验而是保障整体响应的有就比没有强策略。用户宁可拿到一个五秒内的确定性回答也不愿意等一次三秒后超时的空白。4.2 上下文窗口工具描述从 6000 token 压到 900工具描述太长的问题前面提过我再展开讲讲 Agent-Reach 是怎么解决的。我们最初 80 个工具的全量描述有将近 6000 token不加裁剪地拼进系统提示词不仅贵而且容易让模型看花眼。我们的方案是两层裁剪。第一层是静态裁剪为每个会话场景准备一个工具子集比如订单咨询场景只暴露订单相关的 15 个工具发票场景只暴露 8 个。第二层是动态裁剪从用户的第一句话里提取关键词按 embedding 相似度召回候选工具最终只把相关性最高的 5 到 8 个工具拼进 prompt。动态裁剪的伪逻辑长这样def select_tools(user_query: str, scene: str, candidates: list[Manifest]) - list[Manifest]: # scene 过滤 scoped [m for m in candidates if m.scene scene] # 关键词召回 query_vec embed(user_query) scored [(m, cosine(query_vec, m.embedding)) for m in scoped] scored.sort(keylambda x: x[1], reverseTrue) # 保留前 8 个核心工具 return [m for m, _ in scored[:8]]这套裁剪下来模型看到的统一工具描述只需要 900 token 左右。实际效果是选择准确率明显回升调用耗时也低了因为模型不用在几十个工具里反复横跳了。我建议任何团队在 Agent 工具超过 20 个时都必须上动态裁剪这几乎不是可选项而是必选项。4.3 权限与安全不要让模型拿着管理员的钥匙到处试工具多了以后权限问题变得很尖锐。有一次我们给客服机器人接了一个内部查询工具没有做权限隔离结果模型在对话中猜到了参数的含义直接调出了不在当前会话权限范围内的数据。这件事让我意识到传统接口权限的最小单位是用户Agent 场景权限的最小单位是模型的一次调用。Agent-Reach 在权限设计上做了一件很落地的事把权限校验和 manifest 绑死。每个工具都声明auth字段路由层在分发前先验权限。客服场景的会话 token 只能调用ticket.create和billing.query_balance不能调用user.reset_password这类高危管理工具。另外我强烈建议在路由层做一次参数白名单校验。比如customer_id如果应该是当前会话中的用户 ID那就不能允许模型直接传入一个会话外的 ID。做不了太复杂的规则没关系至少加一层参数里的用户标识必须与会话用户一致的校验能堵住绝大多数越权风险。5. 说句公道话Agent-Reach 不是银弹5.1 什么时候你不该上 Agent-Reach我虽然把它夸了一通但必须说清楚边界。如果你的项目只有三五个工具、调用链很短、团队也没有专门的 Agent 基础设施维护精力那直接手写 Function Calling 完全够用。强行上 Agent-Reach 这类路由层反而会引入额外的抽象复杂度让本来简单的事变绕。我的判断标准很简单工具数量超过 15 个或者需要在多个调用之间做回退、鉴权、串联处理再考虑接 Agent-Reach。低于这个阈值先别折腾。抽象不是越早越好而是疼了再上。我们当初也是手写 Function Calling 写到痛不欲生的时候才动工重构的那个节点才是合理时机。5.2 和 Function Calling、MCP 的关系很多人问我 Agent-Reach 是不是要替代 Function Calling 或者 MCP。我的回答是不是替代关系而是处于不同层级。Function Calling 是模型原生能力负责让模型决定调用哪个工具MCP 是工具协议标准负责定义工具传输和调用格式Agent-Reach 是编排与路由层关心的是模型决策之后怎么可靠地把这一调用落到业务系统上并且把失败兜住。如果你已经在用 MCP 的工具协议Agent-Reach 完全可以坐在 MCP 之上兼容现有 server补上动态裁剪和回退这一层。我们内部甚至建议后续把 MCP 的 server 直接包装成 Agent-Reach 的 handler两者共存不冲突。5.3 我现在的使用心得这个项目跑了大半年最让我欣慰的不是模型调用工具的准确率而是新工具接入的节奏变了。以前每接一个工具Agent 代码要改一轮、发一次版、回归一遍。现在后端团队把 handler 写好、manifest 配上在注册中心点一下发布就行。前端的 Agent 逻辑一行都没动。这种接入工具像插插头的体验才是 Agent 工程化该有的样子。我在实际使用中还有个受益很多的小技巧也分享出来。Agent-Reach 的 manifest 里description字段不要写这是查余额的工具这种废话而要写当用户提到余额、扣费或欠费时使用此工具查询。把触发条件前移模型的选择准确率能再提升几个点。这个技巧看起来小但在几十个工具的场景里效果差异非常明显。