最近在重构团队内部的 Agent 底座时,我们把很多精力花在了“模型能说出来什么”上,直到被生产环境里一堆乱七八糟的工具调用问题逼疯,才意识到一个被反复忽视的短板:模型知道哪些工具存在、能拿到哪些能力、调得了哪些接口,这个“可达范围”才是真正决定 Agent 上限的东西。于是我们花了三周时间做了一个专门解决“可达性”的组件,内部代号就叫 Agent-Reach,这篇文章就把它的设计思路、核心实现和踩坑过程完整记录下来。
Agent-Reach 本质上是一个位于 Agent 与外部工具/数据服务之间的“能力通道层”。它不负责生成模型回复,也不存储业务数据,它只做一件事:在正确的时间,把正确且有限的工具描述、调用路径和权限边界摆在模型面前。适合正在做 AI 应用、智能体框架,尤其被“工具越来越多、模型越调越傻”这个问题困扰的工程师和架构师参考。
1. Agent-Reach 到底要解决什么问题
1.1 工具越多,Agent 越傻
先说一个很反直觉的实践现象:给 Agent 暴露 5 个工具的时候,它调用得又准又稳;给暴露到 20 个,开始偶尔选错;给暴露到 50 个以上,错误率会明显上升,甚至出现“明知有正确工具却偏要硬编一个参数凑给另一个工具”的迷惑行为。
问题不在模型本身,而在于当前的实现方式太粗暴。大多数 Agent 框架会把所有工具的 name、description、parameters JSON Schema 一股脑塞进 system prompt。模型注意力窗口是有限的,工具描述越长越多,彼此干扰就越严重。尤其两个工具描述长得像、功能有重叠时,模型经常会陷入“选择困难症”,或者被某个更靠前、描述更花哨的工具带偏。
我当时总结出一个经验:工具的可达性不等于工具列表的长度,模型最需要的永远是被压缩过的、跟当前任务最相关的几项能力。
1.2 从单机小工具到企业级场景的需求变化
早期做一个客服问答 Agent,工具就三四个:查订单、查物流、查优惠券,全量注入完全没问题。但当我们把 Agent 接到团队内部的多个系统上时,情况立刻失控了。
这些系统的 API 数量不小,而且归属不同团队维护。有的团队今天加了一个新接口,明天改了一个参数名;有的接口只在特定业务线有意义;有的工具是高权限操作,绝不能在任何对话场景下都开放。如果还靠人工维护一个大而全的工具清单,让 Agent 每次对话都拿全量去选,不仅模型负担大,维护人员也会疯掉。
所以 Agent-Reach 最开始的目标不是做一个花哨的 Agent,而是解决一个很实际的问题:如何在服务数量动态变化、权限层级复杂、模型不能吃下全部上下文的情况下,仍然保证 Agent 能稳定、安全、低延迟地触达它该用的能力。
1.3 我给自己定的四个设计目标
在动手写代码之前,我先把需求收拢成了四条硬性设计目标,后面的架构都是围绕这四条展开的:
- 延迟敏感性:工具筛选不能让用户多等,路由决策最好控制在毫秒级别,不能在 Agent 主链路里塞一个重量级大模型 rerank。
- 故障隔离:某个外部服务挂掉,不能拖着整个 Agent 一起超时,必须能快速熔断并把错误变成模型可读的提示。
- 权限收敛:工具能否被调用,不只取决于模型“想不想”,还要取决于当前用户、当前会话、当前上下文允许不允许。
- 可组装性:不能和具体某个 Agent 框架强绑定,不管是自研框架、LangChain 还是直接调 OpenAI Function Calling,都能以类似的接入方式使用。
这四个目标基本决定了 Agent-Reach 不会去碰“生成回复”这件事,它只做接入、筛选、路由、调用、兜底,像一个基础设施层。
2. 整体架构与核心设计
2.1 五种角色的拆分
在 Agent-Reach 内部,我按职责拆了五个角色,这个拆分不是拍脑袋想出来的,是源于线上排查时的真实痛点:以前的工具调用代码把“选哪个工具”和“怎么调工具”耦合在一起,出了问题根本分不清是模型选错了,还是接口传参错了。
- Registry(工具注册中心):管理所有工具的元数据、版本、可见范围。谁注册了谁、什么时候上线,都由它说了算。
- Router(路由选择器):基于当前对话语义和提前建立的工具索引,选出候选工具列表。
- Resolver(解析执行器):负责把模型的参数补全、校验、映射成真实 API 请求。
- Guard(权限守门员):拦截所有调用请求,校验用户身份、会话权限、操作风险等级。
- Observer(观测器):记录每一次“从意图到工具调用”的完整链路,包括耗时、命中工具、失败原因。
这样的五个角色摆出来以后,一个请求从进来出去的路径非常清晰:先走 Guard 做前置校验,再走 Router 做工具筛选,Resolver 负责跑通真实调用,Observer 在旁边全程记录。
2.2 为什么是“注册 + 语义路由”,而不是全量注入
我先说为什么不能继续用全量注入。全量注入本身不是绝对错误,在工具少于 10 个、描述差异很大的场景下,它简单直接。但一旦工具数量超过几十个,全量注入会带来两个很难接受的问题:
- 模型把大量注意力花在不相关的工具描述上,导致对用户意图本身的理解变弱。
- 某些工具的参数 Schema 特别长(比如一个接口有 30 个字段),全量塞进去直接吃掉宝贵的上下文空间。
Agent-Reach 的设计思路是“预选 + 精选”。先用轻量级的语义匹配,把几十个甚至上百个工具快速筛到 3 到 5 个候选;再把这几个候选的完整描述注入模型。本质上相当于给模型做了一次“信息降噪”。这里有一个常见的误区:很多人会认为筛选应该直接用大模型来做,让模型自己选工具。这个我强烈不建议在主链路里做,因为大模型一次的延迟都在数百毫秒甚至几秒,再做一轮筛选,用户体感直接就垮了。更合理的做法是提前对工具描述做向量化索引,查询时用用户问题做一次快速的向量召回,再用关键词规则或轻量分类模型做兜底。
2.3 与现有框架的兼容策略
Agent-Reach 的定位不是一个“新框架”,而是一个可以插进现有框架的模块。对外它暴露的东西很简单:输入是一个包含用户意图和上下文的请求,输出是一组可执行工具调用。
因此在接入 LangChain 时,可以把 Agent-Reach 包装成一个自定义 Tool Retriever;接入 OpenAI Function Calling 时,可以把”候选工具列表“直接拼进 functions 参数;接入自研框架时,也只需要在调度层加一个中间调用。
这样做有一个明显好处:团队未来就算换了 Agent 框架,Agent-Reach 这一层基本不用动,工具注册信息、权限配置、路由索引都照样生效。这也是我为什么坚持把“工具管理”和“Agent 对话逻辑”拆开的原因。这两个东西的生命周期完全不同,不该被绑死在同一个类里面。
3. 核心实现:让工具真正“可达”
3.1 三个核心抽象:Catalog、Endpoint、Policy
技术落地时,我把 Agent-Reach 内部最核心的抽象收敛成三个:
- Catalog:工具目录。一个工具本质上是一条带元数据的记录,里面包括唯一名称、描述、可见范围、版本、所属域。
- Endpoint:真实调用入口。描述协议类型(HTTP/gRPC/本地函数)、地址、参数映射、认证方式。
- Policy:调用策略。包括谁能调用、什么上下文允许调、是否需要人工审批、失败后的动作是什么。
这三个抽象是递进的关系:Catalog 决定“Agent 知不知道有这个能力”,Endpoint 决定“调得通调不通”,Policy 决定“该不该调”。以前很多项目只做了 Endpoint,忽视了 Catalog 和 Policy,所以工具一多就乱。
3.2 工具注册表的数据结构
实际写代码的时候,我没有搞特别复杂的东西,直接用 Pydantic 定义了一个带校验的工具模型。关键是name 和 description 的写法在入 Catalog 之前就要规范化,因为后面 Route 依赖的就是这两个字段。
from pydantic import BaseModel, Field, field_validator from typing import Any, Dict, List class ToolSchema(BaseModel): """Agent-Reach 的工具元数据模型""" name: str = Field(description="工具唯一名称,如 order.query") description: str = Field(description="工具职责描述,控制在120字以内,避免和其他工具语义重叠") domain: str = Field(description="所属业务域,如 order / logistics / coupon") input_schema: Dict[str, Any] = Field(description="JSON Schema 参数定义") endpoint_id: str = Field(description="绑定的端点ID") visibility: str = Field(default="internal", description="internal / restricted / public") version: str = Field(default="1.0.0") enabled: bool = Field(default=True) tags: List[str] = Field(default_factory=list) @field_validator("name") def validate_name(cls, v): if len(v.split(".")) < 2: raise ValueError("工具名建议命名空间格式:域.动作") return v @field_validator("description") def validate_desc(cls, v): if len(v) > 120: raise ValueError("工具描述过长,请压缩到120字以内") return v这里有两个容易踩的细节。第一个是工具名必须带命名空间,比如“order.query”、“logistics.track”,而不是一个笼统的“query”。因为 Router 做规则兜底的时候,需要靠前缀快速过滤掉无关工具。第二个是描述不能超过 120 字,太长的描述不仅浪费 token,还会导致 embedding 向量区分度下降,经常把两个不同功能的工具揉到同一个语义区域里。
3.3 路由选择:向量召回 + 规则兜底 + Top-K 微调
路由流程我分成三步:
- 把用户当前输入(包括最近几轮对话摘要)转成一个查询向量。
- 在工具描述向量库里做相似度召回,取 top 10。
- 再用一个非常轻量级的规则集对 top 10 做过滤和排序,最终给模型 3 到 5 个候选。
为什么要做二次过滤?因为纯向量召回经常有两个毛病:召回的相似度普遍都很低时,说明没有特别匹配的工具,这时候宁可让 Agent 诚实回答“没有这个能力”,也不要硬选一个最接近的上去乱调;另外向量召回偶尔会被同义描述误导,比如“物流轨迹”和“订单揽收状态”在语义上很像,但实际是两个系统,规则集在这里能帮忙做硬性纠偏。
Top-K 的选择也有讲究。我一开始把 K 设为 5,结果发现模型偶尔会忽略最相关的工具,反而选择列表里描述更“诱人”的。后来我把工具列表的排序规则从“按相似度降序”改成“相似度 + 日志调用频次的加权排序”,效果好了很多。实现起来就是维护一个最近 7 天的工具调用成功次数计数,用对数形式把高频工具稍微往前挪一点。
def rerank(candidates: list[dict], usage_stats: dict[str, int]) -> list[dict]: """对召回结果做二次排序:相似度为主,调用频次微调""" from math import log1p scored = [] for c in candidates: freq = usage_stats.get(c["tool_name"], 0) # 相似度占 80% 权重,调用频次占 20% 权重,避免冷门工具完全没有曝光 hybrid = 0.8 * c["score"] + 0.2 * min(0.1 * log1p(freq), 1.0) scored.append((hybrid, c)) scored.sort(key=lambda x: x[0], reverse=True) return [c for _, c in scored[:5]]这个加权逻辑是我自己在实践中反复调过参数的,不一定适合所有场景,但它解决了一个特别真实的问题:新上线的工具由于还没有历史调用记录,会被模型略微冷落;但也不能让新工具直接排最前,因为可能描述得不够准确,先让它出现在候选区,等用户用几次之后再自然上位。
3.4 执行链路设计:把失败变成模型可读的信息
工具被选中之后,剩下的执行链路同样不能掉链子。传统做法是模型输出一个 JSON,里面带 tool_name 和 arguments,Agent 框架直接拿着参数去调接口。这里有个很大的隐患:外部接口返回的错误五花八门,很多框架不会对这些错误做语义包装,直接把底层报错抛回模型,模型很容易被误导。
Agent-Reach 在处理上做了三层包装:
- 第一层,网络错误统一翻译成“服务暂时不可用,请告知用户稍后重试”。
- 第二层,业务错误保留状态码和业务 message,拼成紧凑字符串返回模型。
- 第三层,如果连续失败次数超过阈值,直接触发熔断,并在这一轮对话里主动不再给模型推荐这个工具。
这层处理极大提升了线上稳定性。有一次下游物流服务半夜升级,接口返回了大量 502,如果直接把这些原始报错丢给模型,模型会一本正经地跟用户说“物流系统出现了 com.xxx.Exception”,简直灾难。包装之后,模型只会说“暂时查不到,晚点再问我”,并且下次路由时不再把这个工具排进候选。
4. 实操:把 Agent-Reach 接入现有 Agent
4.1 准备工作:定义你的第一个工具
我先拿最常见的“查天气”来演示,虽然简单,但整条链路是完整的。在 Agent-Reach 里,工具定义有两种方式:一种是在工程代码里用 Pydantic 模型定义,另一种是通过 YAML 配置文件热加载。我个人更推荐先用 YAML 做原型,稳定后再固化到代码里,因为改配置比改代码快得多。
# tools/weather.yaml name: weather.query description: 查询指定城市的实时天气情况,包括温度、湿度、风力预报 domain: weather visibility: public version: "1.0.0" endpoint: protocol: http url: "https://api.example.com/weather" method: GET auth: type: apikey header_name: X-Api-Key input_schema: type: object properties: city: type: string description: 城市中文名或拼音 days: type: integer description: 预报天数,1-3 required: - city additionalProperties: false你可能会发现这里没有直接把 API Key 写在文件里,而是用 auth 引用了一个凭证 ID。这是刻意为之:配置文件要能进 Git,但不能把真实密钥带进去。Agent-Reach 在启动时会从环境变量或密钥管理服务里解析认证信息,这样即使配置文件泄露,也只是暴露一个占位符。
4.2 建立索引并快速验证路由
配置文件写好后,需要把它注册进 Catalog 并写入向量索引。这一步在 Agent-Reach 里是一条命令:
agent-reach-cli register --config tools/weather.yaml agent-reach-cli rebuild-index --model text-embedding-3-small索引重建这个操作我特意设计成显式命令,而不是每次启动自动做。原因很简单:embedding 模型调用有成本,而且批量重建时如果工具数量在几千,耗时能到几十秒,放 Agent 启动链路里会拖慢发布速度。所以平时只在新增或修改工具描述后执行一次。
验证路由效果也很直接,跑一个交互式查询:
agent-reach-cli route --query "明天杭州热不热"期望输出:
repo root: tools/weather.yaml tool candidates: 1. weather.query (score=0.913, source=vector) 2. weather.alarm (score=0.731, source=vector)这里“source=vector”表示是向量召回命中的。如果某个工具是通过关键词规则命中的,会标成 source=rule,这在排查时候很好用。
4.3 在 Agent 主链路里调用
Agent-Reach 在 Agent 主链路里非常轻。下面这段是把它接入一个最简单 OpenAI Function Calling 流程的示意:
import openai from agent_reach import ReachClient client = ReachClient.from_config("./reach_config.yaml") # 1. 输入用户消息,Agent-Reach 返回候选工具 candidates = client.route("明天杭州热不热") # 2. 把候选工具转换成 OpenAI Functions 格式 functions = [c.to_openai_function() for c in candidates] # 3. 正常走模型调用 response = openai.chat.completions.create( model="gpt-4o", messages=history, functions=functions, function_call="auto", )这里最核心的一点是:传给 OpenAI 的 functions 列表不再是几十个,而是 Agent-Reach 精选出的三五个。请求体大小直接小了一个数量级,模型输出的准确率肉眼可见地提升。我在一次测试里,同样问题下工具调用错误率从原来的 18% 降到了 4% 左右。
很多现成框架的用户会纠结“要不要让 Agent-Reach 帮我直接执行函数”,我的答案很明确:不要。Agent-Reach 负责把“模型应该考虑什么”这件事做好,至于真正的执行,最后还是要 Agent 框架自己完成。因为 Agent 框架通常有自己的消息历史管理、human-in-the-loop 机制,如果 Agent-Reach 强制接管执行,反而破坏现有生态。
5. 踩坑记录与排查速查表
5.1 工具描述太长导致语义漂移
这是我遇到的第一个坑。一开始写工具描述时,总想把所有细节都写进去,比如“查询天气,支持国内主要城市,数据来自某某气象平台,返回格式包括温度、湿度、气压、紫外线指数、7 天预报……”。结果 embedding 做出来之后,这个工具和“查询水质”、“查询空气质量”几乎分不开。
后来我把描述改成类似“查询指定城市实时天气(温度、湿度、风、降水)”,并且把额外信息全部塞进参数项的 description 里。向量召回效果立刻变好。工具描述的核心是职责边界,不是实现细节。
5.2 两个工具语义重叠,模型反复选错
有一个真实案例:我们有“订单查询”和“订单列表查询”两个工具。前者按订单号查单件详情,后者按时间范围查订单列表。从业务上讲它们完全不同,但模型经常用错。
我最后用了两个手段解决。首先修改 description,分别强调“只能按订单号查询单笔订单”和“只能按用户和时间范围查询列表”;其次在 Catalog 里加了 disable_dependency 限制,当路由候选同时出现这两个工具时,强制靠参数类型过滤:用户输入包含订单号,则只保留前者;用户输入包含日期范围,则只保留后者。
5.3 外部服务超时拖垮整个 Agent
这个问题几乎每家做 Agent 的公司都会遇到。某个下游接口偶尔响应 5 秒,导致模型调用这一个工具时,整个用户请求就定格了 5 秒。这几乎是不可接受的体感。
我在 Agent-Reach 的 Endpoint 层做了三件事:设置默认超时 2.5 秒;超过一次直接返回“服务繁忙”包装消息;连续 3 次失败则把该工具状态置为 degraded。degraded 状态下,Router 会主动把该工具从候选里移除或者放到最后。下一次 Agent 再遇到同样问题,就直接告诉用户现在查不了,而不会傻等。
5.4 参数 Schema 频繁变化
下游团队经常加接口参数,比如原来“创建订单”只要一个商品 ID,后来又要求必须传门店 ID。如果 Agent-Reach 的 Catalog 里 Schema 没更新,模型就会持续少传参数,然后接口校验报错。
我建议给每个工具维护一个 version 字段,并且把 Schema 变更当成一次发布行为,变更后必须重建索引。为了防止“改完工具忘了重建”,我写了一个 git pre-push 钩子,检测到 tools/ 目录下有文件变更时,强制要求本地重建索引并跑一次 route 冒烟测试。
我把这些排查经验整理成一张速查表,团队新同学基本能照着定位:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 模型选错工具 | 描述重叠、排序干扰 | 检查候选分数、对比工具描述向量相似度 |
| 候选列表为空 | 索引未重建 | 执行 rebuild-index 验证索引数量 |
| 调用接口报错 | 参数缺失或 Schema 过期 | 对比 Catalog 版本与实际接口 OpenAPI 文件 |
| 工具一直不被选中 | 无历史调用记录导致排序靠后 | 查看 routed 日志,考虑手动提高初始权重 |
| 响应延迟过高 | 下游接口慢 | 查看 observer 输出的 p99 耗时,检查熔断状态 |
这张表可能看着简单,但每一条背后都是线上真实事故换来的教训。特别是“候选列表为空”这个情况,第一次遇到时我排查了很久,最后发现是部署新环境时忘了执行索引重建命令。
6. 可观测性与安全实践:没有被记录的能力等于不存在
6.1 每一次“可达”都要有痕迹
Agent-Reach 的 Observer 从设计之初就不是一个可有可无的日志模块,而是把它当成一个独立的数据源来做。每次路由请求输出一条结构化事件,内容包括:会话 ID、用户 ID、输入摘要、候选工具列表及各工具分数、最终选中工具、调用结果状态、耗时。
这些数据最大的价值不是给开发人员看控制台日志,而是用来做两件事:
- 分析工具覆盖情况。每周跑一次“所有注册工具的被选次数和被成功调用次数”,能快速看出哪些工具是僵尸工具、哪些工具虽然被频繁选中但成功率极低。
- 反推模型偏见。如果观测到某个工具出现次数异常高,但用户反馈并不好,很可能不是工具好用,而是描述写得过于显眼,把模型带偏了。
有一次我通过观测数据发现,“订单催单”工具被选中的概率是其他类似工具的三倍,点进去看上下文才发现,用户只是说“帮我看看快递怎么还没到”,模型就自动动了催单接口。这在没有观测数据的情况下几乎无法察觉,因为从单次对话看,模型调用的工具和用户意图似乎沾边。
6.2 可达范围的边界就是风险边界
Agent-Reach 里每个工具都可以设置一个 visibility 和 policy,我强烈建议把“高权限工具”默认设为 restricted。比如“退款审批”、“删除用户”、“发送营销短信”这类的操作,即使是模型认为最合适的工具,也必须经过 Guard 的二次校验。
具体实现上,我在 Guard 里加了一个简单的上下文检查器,如果当前工具是 restricted 状态,就强制走一轮“用户确认”动作,而不是让模型直接执行。别觉得这个流程重,实际经历一次“模型误调用发券接口导致资损”的事故之后,你会明白一个硬性的“Permission denied”返回是多么让人安心。
6.3 后续演进:从单 Agent 到多 Agent 协作
Agent-Reach 短期内解决的是单 Agent 的工具可达性问题,但长期来看,它完全可以扩展成一个多 Agent 服务发现层。当系统里有多个 Agent 分别负责订单、售后、营销时,它们之间的能力也可以像“工具”一样注册进 Catalog,Router 路由到的不再是一个 API 而是一个 Agent。
我已经在内部做了这个方向的实验:把另一个团队的“售后分析 Agent”作为一个带特殊 endpoint 的工具注册进来,模型在对话中可以通过 Agent-Reach 触发它并拿到结果摘要。这种做法的好处是各个 Agent 的边界仍然清晰,又不会把对话系统变成一个层层调用的混乱网状结构。
基于这几次实战,我个人对做这一类基础组件的最大体会是:不要贪多,把每一条调用链路都做透明,把每一个失败都变成模型能理解的话。Agent-Reach 不是什么玄乎的设计,它只是把“模型该知道什么”这件事从感觉变成了工程,这比在后端堆一大堆漂亮但说不清用途的抽象有用得多。