做 AI Agent 的人,迟早都会碰到一个特别讨厌的问题:你明明给智能体挂了一大堆工具,它也真的能生成一段调工具的 JSON,可跑起来就是“够不着”某些能力。有的工具偶尔能命中,有的工具从头到尾没被触发过,还有的工具在日志里报了错但你根本看不出是模型不会用,还是权限没给到位。我做过好几个 Agent 项目后越来越觉得,缺的不是单个工具,而是缺一张“能力路线图”——你并不知道 Agent 心里那张地图长什么样。于是我自己折腾了一个叫 Agent-Reach 的小框架,用来量化智能体的工具触达情况:它能到达哪些工具、绕过多少跳、卡死在哪、哪些工具永远在视野之外。这篇文章就把 Agent-Reach 的设计思路、核心实现、常见坑和扩展玩法完整写一遍,适合正在做工具编排、Agent 路由或提示词调优的人参考,也适合想给自己 Agent 做一次“体检”的初学者拿去直接改。
1. Agent-Reach 到底是什么,为什么要做它
1.1 从一次“够不着”的故障说起
先讲一个让我下决心做这套工具的故障。当时我在做一个多工具 Agent,工具数量其实不算多,二十几个,但分了三层:第一层是入口路由,第二层是业务 API,第三层是底层数据查询。Agent 从第一层进入之后,理论上可以通过第二层的某个工具跳到第三层。结果测试的时候发现,Agent 面对某个用户提问,反复绕在第一层的“通用搜索”上,死活不往下走。我一开始以为是大模型没理解工具描述,把描述改了好几版,没用。后来手动模拟了一次完整调用,发现真相特别蠢:入口路由工具的返回结果里,确实带了第二层工具的 ID,但 Agent 使用的后续消息上下文里,那个 ID 已经超出了本轮调用的上下文窗口,模型根本没看到。工具在地理上是连通的,能力是存在的,但因为上下文长度、返回字段截断、路由丢弃了中间结果,Agent 在实际运行时永远“触达”不到那个工具。
这个问题让我意识到,常规的功能测试只验证“工具能不能用”,完全不验证“Agent 能不能到达这个工具”。两者之间隔着上下文、工具描述、状态流转、环境权限。这也是我搞 Agent-Reach 的动机:把“触达能力”单独拎出来测。
1.2 Reach 这个词,在 Agent 场景里的三重含义
Agent-Reach 里的 “Reach” 不是网络路由里那种 IP 可达性,它包含继续往下拆的三层含义。
第一层是物理可达性,指工具在当前 Agent 的启动配置、依赖库、环境变量、API 密钥都正常的情况下,能不能被实例化并调用。比如注册表里写了某个工具,但依赖没装好,或者密钥过期,那么它就是物理上不可达的。这一层最容易排查,但也最容易被忽略,因为很多工具是运行时动态加载的,启动时不报错,一调就崩。
第二层是语义可达性,指大模型是否“知道”有这个工具、知道在什么场景使用它。这里很关键:几十个工具名堆在一起,模型不一定每个都能理解;工具描述用词模糊,模型就可能在相似工具之间摇摆;工具数量太多超出模型注意力上限,排在后半段的工具即使注册了,也基本等于不可达。
第三层是执行可达性,指从某个状态出发,能不能通过一系列中间工具,最终到达目标工具。这很像路径规划问题。A 工具返回的结果能不能作为 B 工具的输入;B 工具的输出格式是不是 C 工具所期待的;中间会不会因为状态被覆盖而断掉。很多 Agent 框架只维护“工具调用链”,但不主动分析这个链路的连通性,Agent-Reach 就把这部分做成显式的、可量化的指标。
这三层含义对应三种不同的失败模式,也对应三种不同的修法。物理不可达去查依赖,语义不可达去改工具描述,执行不可达去检查中间态流转。做 Agent-Reach 时我心里一直很明确:它不替代调试工具,也不替代测试框架,它专门做“可达性画像”,让你一眼看出 Agent 的能力地图哪里是断的。
2. 整体设计思路与指标定义
2.1 核心套路:把工具调用建模成有向图
Agent-Reach 的基本建模方式特别简单:把每个工具看作图中的一个节点,从一个工具调用另一个工具的可能性看作一条有向边。边权表示这次跳转的稳定程度或损耗,比如历史成功次数、平均耗时、返回结果截断率。加上一个特殊的入口节点表示“来自大模型的首次调用”,整个 Agent 的工具空间就变成一张有向图。
为什么选有向图?因为工具调用方向性太强了。A 工具能输出 B 工具需要的参数,不代表 B 工具也能输出 A 工具需要的参数。比如商品查询工具能返回订单 ID,订单详情工具却不一定能返回商品 ID。用无向图会把这种不对称性藏起来,导致你错误地认为“两个工具可以互相触发”。有向图反而能逼着你把每次跳转的输入输出关系理清楚。
图建好之后,很多问题就有了统一答案。Agent 能不能从入口到达某个工具,本质是图上的可达性问题;到达某个工具最少要绕几次,是最短路径问题;哪些工具被大量路径反复经过,是中心度问题。这样设计的好处是,后续扩展只需要增加边权计算规则和分析维度,不用重写核心结构。我实际落地时,直接用 networkx 当基础库,省了不少事,但要记录调用耗时和截断率等动态属性,还是得维护自己的节点数据结构。
2.2 四个核心指标:可达率、可达成本、死节点、调用偏差
指标设计不能贪多,四个足够。第一个是整体可达率,也就是从入口节点出发,沿有向边能够到达的工具数量除以注册表里的工具总数。这个指标衡量的是 Agent 能力覆盖的“理论上限”。如果注册了 30 个工具,但只有 22 个可以从入口出发被访问,那不管提示词写得多好,模型都不可能用到剩下 8 个。我们项目里就曾经发生过,新上线的 3 个工具没有挂到路由入口下,导致上线两周调用量为零,而可达率从 90% 掉到了 87%,图表上一眼就看出异常。
第二个指标是可达成本,用工具调用的平均跳数来表示。假设从入口到工具 X 需要经过 4 个中间工具,那这个成本就是 4。注意这里不是网络跳数,而是 Agent 的决策跳数:每一步都需要模型再输出一次、工具再执行一次、上下文再更新一次。跳数越多,越容易出幺蛾子,比如中间某步返回了空结果,或者某一步模型直接对工具描述产生了误读。我通常把跳数超过 3 的工具标记为“高风险路径”。第三个指标是死节点,指的是一段时间内被注册、但在真实轨迹中从未被作为跳转目标命中的工具。死节点不一定完全不可达,也可能是语义上没被模型选中,但它至少说明这个工具在真实场景里是没有存在感的。
第四个指标是调用偏差,工具链中实际被调用的工具集合距离理想工具集合的偏斜程度。理想集合怎么定义?可以用测试用例里标注的期望工具路径,也可以用人工评估的标准答案。调用偏差大,往往意味着 Agent 对某些工具的偏爱过度,系统性忽略另一类工具。这四个指标合在一起,基本能覆盖从“能不能到达”到“到达后爱不爱用”的完整评价维度。做 Agent-Reach 时我没刻意搞复杂的算法,指标都是围绕日常排查痛点设计的。
3. Agent-Reach 核心实现与实操
3.1 工具注册表和动态装载
先写最底层的工具注册模块。Agent-Reach 里不直接让 Agent 拿着几十个工具满天飞,而是先构建一份标准的 ToolSpec,包含工具名、输入参数 schema、返回字段、依赖服务和可跳转的目标工具列表。这里有个经验之谈:可跳转目标工具列表不能靠手写维护,最好通过解析每个工具的参数注解和返回注解自动生成,否则改一个工具的参数名,边就会断。
我习惯把注册表拆成两层。第一层是静态注册表,启动时从 Python 代码里收集所有被@tool装饰器标记的函数,生成基础节点;第二层是动态解析层,读取每个函数的类型注解和 docstring,尝试推断这个工具可能调用的下游工具。比如def query_orders(user_id: str) -> List[Order],如果系统里存在一个get_order_detail(order_id: str)工具,而 Order 类里有 order_id 字段,Agent-Reach 就会自动建立query_orders -> get_order_detail这条候选边。
手工维护的候选边当然可能存在误报,但没关系,Agent-Reach 的设计原则是“宁可多报,不可漏报”。候选边建出来以后,会在真实调用轨迹中被验证:如果走通了,边的权重上调;如果走不通,边的权重下调并记录失败原因。这种基于反馈调节的边权机制,能让图模型自动逼近真实能力拓扑。
# tool_registry.py class ToolSpec: def __init__(self, name, handler, input_schema, output_schema): self.name = name self.handler = handler self.input_schema = input_schema self.output_schema = output_schema self.targets = [] # 候选可达工具列表 class ToolRegistry: def __init__(self): self.specs = {} self.graph = None def register(self, spec): self.specs[spec.name] = spec def build_graph(self): # 基于 schema 字段匹配建立候选边 for name, spec in self.specs.items(): for other_name, other_spec in self.specs.items(): if name == other_name: continue overlap = set(spec.output_schema.keys()) & set(other_spec.input_schema.keys()) if overlap: spec.targets.append(other_name)这段代码的效果是:任何一个工具的输出字段只要和另一个工具的输入字段有交集,就会生成候选可达边。真实场景里,Order对象里有user_id,而query_user工具的输入正好需要user_id,那么从订单查询跳到用户查询就会被自动标注为候选路径。这个自动建边的方式,比手工维护要省心太多。
3.2 构建 reachability graph
注册表建好之后,下一个要解决的问题是:如何把候选边变成真正的可达图,并评估每条边的可信度。Agent-Reach 的做法是引入一个“证据”概念:每条候选边必须经过三种证据的验证,才会被标记为可信边。
第一种证据是结构证据,也就是字段类型和必要参数是否匹配。比如query_orders返回一个order_id,但get_order_detail要求的是order_number,即使字段值业务上是同一个,结构证据也会判定这条边可疑。第二种证据是规划证据,指在测试集里用大模型生成规划路径时,模型是否真的选择这一跳。比如给 100 个测试问题,模型有 60 次在query_orders后面接着调了get_order_detail,那规划证据就很强。第三种证据是执行证据,指实际调用时这一跳能不能成功完成。如果每次从query_orders跳到query_user都因缺参数失败,说明边虽然存在,但没有执行可用性。
三种证据加权的逻辑是这样:结构证据只决定边的存在性,规划证据决定边是否被模型看见,执行证据决定边是否能真正走通。一条边可信,需要三者都过线。这里有个现实例子:我们有个工具search_product返回了category_id,而get_category_tree的输入也是category_id,结构证据满分,模型也老喜欢这么跳,规划证据不错。但执行证据一直失败,原因是get_category_tree那个工具内部依赖一个已经废弃的缓存服务,一调就超时。如果不做执行验证,只会以为工具链很顺。
# reach_graph.py class ReachGraph: def __init__(self): self.nodes = set() self.edges = {} # (source, target) -> edge_score def add_edge(self, source, target, evidence): combined = ( evidence["structural"] * 0.3 + evidence["planning"] * 0.4 + evidence["execution"] * 0.3 ) self.edges[(source, target)] = combined def neighbors(self, node): return [t for (s, t) in self.edges.keys() if s == node and self.edges[(s, t)] > 0.4]阈值 0.4 是经验值,定得太低会把很多不可用的边放进来,导致可达率虚高;定得太高则可能漏掉一些低频但关键路径。建议第一次跑时先把阈值定在 0.5,然后结合真实日志调整。我用 0.4 是因为我们的测试集覆盖有限,需要更敏感一点。
3.3 计算可达性和可达成本
图建完之后,最核心的计算就是可达性分析。Agent-Reach 使用带权 DFS/BFS 来计算两个指标:某个目标工具的总体可达性,以及从入口到它的最低可达成本。入口是一个虚拟节点,称为__agent__,代表大模型的首次决策点。所有在提示词里暴露给模型的工具,都会建立从__agent__到它们自己的直达边。
注意这里的直达边不代表一定能调用成功,只代表模型在决策空间里“看得到”这个工具。如果某个工具没有暴露在提示词工具列表里,那它就不存在直达边,只能依赖其他工具跳转过去。这就产生了一个很有意思的检查场景:如果某个目标工具没有直达边,但存在一条跳转边A -> B -> target,实际返回结果还是有希望被触达的,只是中间多了一步。
计算时我直接用递归做带环保护的 DFS。因为工具调用图里几乎必然会存在环,比如订单查询可以跳到用户查询,用户查询又可以跳到订单列表。要是不做环保护,死循环会直接让分析任务挂掉。
# reachability.py def compute_reachability(graph, entry="__agent__"): visited = set() reachable = {} cost = {} def dfs(node, depth): visited.add(node) reachable[node] = True cost[node] = min(cost.get(node, float("inf")), depth) for neighbor in graph.neighbors(node): if neighbor not in visited: dfs(neighbor, depth + 1) dfs(entry, 0) return reachable, cost这个实现只做了一层 DFS,没有把边的可信度分数纳入成本。更精确的做法是把成本定义成1 / edge_score,这样分数越低的边,路径成本越高,模型走通这条路径的阻力也越大。我实际上线的时候会同时输出两种结果:一种只算最小跳数,一种算加权成本。最小跳数用来回答“有没有路”,加权成本用来回答“这条路好不好走”。
3.4 跟踪一次真实调用路径
光有静态图还不够,Agent-Reach 还需要和真实调用轨迹打通,才能验证图模型是否准确。我会在 Agent 的调用链中埋一个轻量的 middleware,每次工具被调用,就把当前工具名、输入参数摘要、输出字段摘要和下一步工具名记录下来。这一步说白了就是日志,但格式必须标准化,方便回填到可达图里。
一个典型的调用路径追踪记录长这样:
{ "trace_id": "a3f9...", "steps": [ {"tool": "__agent__", "next": "query_orders", "ts": 1710000001}, {"tool": "query_orders", "next": "get_order_detail", "ts": 1710000003}, {"tool": "get_order_detail", "next": "query_user", "ts": 1710000005} ] }拿到这些痕迹后,Agent-Reach 会把路径和静态图做对照。如果路径上出现了图里不存在的边,说明注册表或者建边逻辑漏了;如果图里存在但路径一直没走,说明这条边可能只是理论上可达,模型并不知道怎么走。这种“轨迹对照”是调试工具描述和提示词的最好依据。
我还习惯把不成功的调用也记录下来。工具抛异常、返回空结果、超时,都要存进回填数据。这样后续分析时能区分两种不可达:一种是模型根本不知道有这么个工具,另一种是它知道但每次调用都失败。前者改描述和曝光顺序,后者改工具内部实现。如果只记录成功的调用,这类问题要花很长时间才能定位。
4. 踩坑与排查:实测 Agent-Reach 时常见问题
4.1 工具名不一致,图里断了链
Agent-Reach 上线第一天,我就发现了一批工具在图里孤立了。排查到最后,竟然是工具命名不一致导致的。注册表里有一个工具叫get_user_info,但 Agent 实际返回的下一个工具名是fetch_user_info,中间偏了一两个词。在静态图层面,这是完全不同的两个节点,自然没有边。这个坑特别常见,尤其是团队里多人并行开发时,工具名没有统一规范,或者大模型在生成工具名时用了同义词。
解决办法是不要在工具名层面做死匹配,我会维护一个工具名别名表,把常见的缩写、同义词、历史版本名都映射到规范工具名上。同时在建边时标注“名称匹配”和“字段匹配”两类不同证据。字段匹配优先,因为字段不会说谎,工具名反而容易被模型改写。
4.2 循环依赖导致遍历死循环
工具调用图有环是常态,但如果环出现在入口附近,就会让整个可达性分析失真。我之前遇到过一组工具,A -> B -> A形成了一个紧密环,从入口出发的路径全部被吸进环里,导致其他工具虽然通过 C 可达,但 DFS 因为访问标记顺序问题,没有把 C 标记成可达。后来我把visited集合改成“当前路径栈”和“全局已访问”两个集合,才解决了这个问题。
简单说,全局已访问避免重复遍历,路径栈避免递归环。只有当一个节点在当前路径栈里再次出现时,才判为循环并剪枝;如果只是全局访问过,但不在当前路径栈里,还是可以继续遍历的。这个微小的细节直接决定了可达率算出来是 90% 还是 40%。
def dfs_with_stack(node, depth): if node in path_stack: return # 发现环,剪枝 path_stack.add(node) visited.add(node) reachable[node] = True cost[node] = min(cost.get(node, float("inf")), depth) for neighbor in graph.neighbors(node): if neighbor not in visited: dfs_with_stack(neighbor, depth + 1) path_stack.remove(node)4.3 权限和服务名空间遮蔽
有一次我们线上的 Agent 一直报某个工具“找不到”,后来发现该工具部署在另一个命名空间里,Agent 运行时的服务账号根本没有调用权限。但这个问题在本地测试环境根本不会出现,因为本地用的管理员密钥能访问所有服务。这种“环境特定不可达”特别吓人,它会污染你的可达率数据,让你以为图没问题,其实是权限把路堵死了。
Agent-Reach 在计算可达率时,会额外维护一个“权限遮蔽层”。每个工具节点标记它依赖的服务名和权限要求,在评估环境里执行一次注入检查,把所有权限不达标的节点单独标红。这样你在图上看到的“不可达”就能区分出到底是路由断、描述烂,还是权限不够。我建议在 CI 环境里定期跑一次完整检查,专门抓这种环境差异。
4.4 大模型幻觉调用污染路径统计
最棘手的坑是大模型生成工具名时出现幻觉。“看起来在调工具,实际名字是编造的”。如果记录轨迹时把这类幻觉调用也喂给图模型,会导致出现大量荒谬的边,严重破坏可达成本的准确性。比如模型把get_order_detail写成了get_ordes_detail,如果系统没有做严格校验,直接按这个名字去调用,就会得到一次失败记录。失败记录如果也用来建图,就会在query_orders下面挂一个不存在的子节点。
解决方式是建立“严格调用校验 + 宽松统计”两层机制。执行层必须校验工具名,不在注册表里的名字一律拒绝;统计层则只记录“成功调用”和“明确参数错误”的轨迹,幻觉调用单独存放在一个忽略列表里,后续可以用来做提示词优化参考。人话就是:不要让 Agent 的嘴瓢影响真实能力评估,但嘴瓢数据也别删,它可以告诉你哪些工具名容易被模型记错。
5. 扩展思路和进阶玩法
5.1 把 RAG 数据源纳入可达图
工具不是唯一需要被“触达”的对象。现在很多 Agent 还会挂 RAG 知识库,通常表现为一个search_knowledge_base工具。Agent-Reach 可以把它当成普通工具节点处理,但更有价值的做法是:把知识库内部的数据分区也摊平成可达节点。比如知识库里有“产品手册”“故障排查”“价格说明”三个分区,你可以为每个分区建立一个子节点,再利用真实召回轨迹判断模型是否能够触达各个子节点。
这样做的好处非常明显:如果模型连续一周都只召回“产品手册”分区,而“故障排查”分区覆盖了很多高价值答案,那问题就很可能是 Agent 在某个中间步骤丢失了关键上下文,导致后续检索总是停留在同一个分区。这种“数据可达性”的评估,比单纯看工具可达性更贴近最终业务效果。
5.2 接入监控与缓存
Agent-Reach 跑顺手之后,下一步就是把它接进日常监控体系。每周自动跑一遍可达率,统计新工具上线后有没有成为“死节点”;每次提示词模板修改后,对比一次可达成本的变化;每次图结构变更后,检查有没有引入新的环和孤点。我们团队现在把 Agent-Reach 的输出渲染成一张 Graphviz 图,发给 Agent 负责人看,哪条边粗、哪个节点红,基本一眼就能定位问题。
缓存这块要额外说一句:可达成本不只是理论跳数,还包括工具的平均响应时间。我在计算权重时会加入“时间惩罚因子”,平均响应时间超过 2 秒的工具,边权会显著降低。这样可达图里那些“能到但贼慢”的路径会被单独标出来,优化时可以优先考虑给中间结果加缓存。比如query_orders的结果如果缓存了,就能直接缩短query_orders -> get_order_detail -> query_user这条链路一半的耗时,而这在原来的调用链分析里是看不出来的。
回到最开始说的那个故障。如果当时有 Agent-Reach 在手,我根本不需要反复猜是哪里断了,只需要打开可达图看一眼,就会发现从入口到目标工具的路径虽然存在,但所有路径都经过一个高延迟工具,导致上下文在等待期间被重排,最终目标工具从模型视野里消失。这个项目的本质,其实就是把“工具都装了”和“工具都够得着”这两件事彻底分开。装了不等于够得着,够得着也不等于会被用。我常用的一个收尾动作是:每次给 Agent 加新工具,先跑一遍 Agent-Reach,确认可达率没掉、没有新增死节点、目标工具的跳数不超过 3,再大范围发布。这个小习惯帮我们躲过了不少线上翻车,也推荐你试试。