Agent-Reach 触达层:智能体工具调用语义路由与执行回退

发布时间:2026/9/18 3:21:42
Agent-Reach 触达层:智能体工具调用语义路由与执行回退 1. 先搞清楚 Agent-Reach 在解决什么真问题做智能体落地的团队大概都经历过这样一个阶段模型换了三代评测集上的分数一路往上工具也接了七八十个可一到真实业务里用户问一句我上周那单到底发没发货智能体还是答非所问。这时候大家第一反应是模型不行第二反应是提示词没写好很少有人往下再想一层——真正卡住的是触达。智能体压根没找到那个本该解决问题的能力后面的推理、规划、表达再漂亮都是空转。Agent-Reach 就是冲着这一层来的它不是又一个编排框架也不是模型微调方案而是夹在任务意图和能力清单之间的一层触达治理中间件负责让智能体在面对一个具体任务时能稳定地找得到、选得对、调得动对应的工具与接口。我把这套东西叫触达层是因为它和常规的工具调用关注点完全不同。工具调用关心的是怎么把函数塞进模型能读的格式里而触达关心的是在八十个能力里模型凭什么能在一次推理内定位到那两三个相关的。前者是协议问题后者是检索、描述、路由、归因的复合工程问题。你在做客服智能体、研发助手、数据分析智能体甚至做本地知识库问答只要能力数量超过二十个触达问题就会开始显形。1.1 模型能力过剩触达能力不足这两年被反复验证的一件事是主流模型在单步推理、代码生成、指令遵循上的能力已经足够应付绝大多数企业场景真正拖后腿的是信息入口。我手头有个很直观的数据对比同一个模型在能力清单只有 8 个的时候任务端到端完成率能到 82%把清单扩到 60 个之后完成率掉到 51%。中间没换模型、没改提示词风格、没动业务逻辑唯一变的就是候选空间的规模。这不是模型变笨了而是它在一次前向推理里被要求同时完成理解意图和从 60 个选项里挑这两件事注意力被稀释了。所以触达层的第一个职责就是收窄候选空间。把 60 个选项先压到 5 个以内再交给模型做最终选择模型的表现会回到接近小清单时的水平。这个思路说白了就是把大海捞针拆成先圈定水域再精准下钩。1.2 触达率一个比准确率更早暴露问题的指标大部分团队监控的是任务成功率、工具调用成功率这两个指标的问题在于滞后。任务失败了你分不清是没找到工具、选错了工具还是选对了但参数传错了。Agent-Reach 提倡把触达拆成三级漏斗每一级单独埋点层级名称定义典型健康值R1召回触达正确能力是否进入候选集 top-K95% 以上R2选择触达正确能力是否被模型最终选中90% 以上R3执行触达选中后参数填充正确且调用成功92% 以上三级相乘才是端到端触达率。这个拆法的价值在于定位速度R1 低说明索引和描述有问题R2 低说明候选排序或提示词有问题R3 低说明参数模式、校验、权限、幂等这些工程细节有问题。三个方向要改的东西完全不同混在一个指标里排查能把人熬死。1.3 它适合谁不适合谁Agent-Reach 这类触达层适合能力清单在 20 到 500 之间、任务类型多样、且短期内不会把所有能力合并成几个大工具的团队。典型的如企业内部 Copilot、多业务线客服、运维问答、数据取数助手。不适合的是两类一是能力只有三五个的场景直接全量塞进上下文更省事加一层反而增加延迟和故障点二是能力数量超过千级、且调用关系高度确定的场景那种情况更适合走意图分类 硬路由不需要语义检索。判断标准很简单如果你能穷举出用户说 A 就一定调 B的映射表就不需要触达层。2. 整体设计思路为什么是注册—路由—回退三段式Agent-Reach 的核心链路只有三段能力注册 → 语义路由 → 执行回退。看起来很朴素但每一段里都有几个必须做对的决定做错了后面再优化也补不回来。我见过不少团队把三段揉成一段直接让模型读全量能力清单前期跑 demo 很爽接第三个业务线的时候就开始失控。2.1 为什么不把所有工具一股脑塞进上下文最直接的原因是上下文成本和注意力稀释但更深层的原因是描述质量不可控。六十个工具的描述如果都写在同一个提示词里你没法对每一条单独做质量评估、版本管理和灰度。而拆成注册表之后每个能力的描述变成一个可以独立打分、独立迭代、独立进 A/B 的资产。哪条描述的召回率低改哪条改完立刻能看出 R1 的变化。另一个原因是更新粒度。业务侧接口一天改三次如果描述和代码是写死在一个大提示词里的每次改都得全量回归拆成注册表 热更新索引之后改一个能力只影响它自己那条索引。2.2 分层设计的三条硬约束我在设计时给自己定了三条不能破的约束实践下来觉得挺值路由层不许调用大模型。语义检索必须走轻量向量模型或关键词检索延迟控制在 50ms 以内。因为路由是要在一次对话里可能被调用多次的如果这里挂了模型延迟和成本会指数级上去。回退必须有上限。回退链最多两跳超过就转人工或明确告知做不到。见过太多死循环参数错 → 换个工具 → 还错 → 再换回来用户在那边看着进度条转了四十秒。所有能力必须有反向描述。也就是when_not_to_use明确写出什么情况下不该用我。这一条看起来多余实测能把 R2 提升 6 到 10 个百分点因为最容易出错的往往是几个语义相近的能力之间的混淆。2.3 与常见编排框架的关系经常有人问这跟现有编排框架是不是重复。不是。编排框架管的是多个步骤怎么串、状态怎么传、循环怎么控Agent-Reach 管的是每一步该调哪个能力。它是编排框架的一个插件或者说是一个前置的决策模块。你可以把它接在任何编排框架前面也可以单独用在一个 while 循环里。我的做法是编排层只负责循环控制和状态管理每次进入新一轮时把当前任务描述交给 Agent-Reach拿回一个候选能力列表和推荐参数再由编排层决定执行哪个。这样编排逻辑和触达逻辑解耦两边可以独立演进。3. 核心细节拆解能力清单怎么定义才不返工这一节是整个项目里最容易被低估的部分。很多人以为触达效果不好是检索算法的问题实际上八成问题出在能力描述本身。我做过一次归因统计R1 不达标的原因里描述质量问题占了 71%索引参数问题占 18%检索策略问题只占 11%。所以先把描述规范定死再谈算法。3.1 能力描述的四要素每个能力条目必须包含四块内容缺一块都会在后续出问题正向场景用户会在什么情况下需要它用用户的说话方式写不要用接口文档的口吻。写用户想知道某笔订单有没有发货不要写查询订单物流状态字段。反向场景什么情况下不该用它尤其是那几个容易混淆的邻居能力。这一块是提升 R2 的关键。参数语义每个参数不只要写类型还要写从用户话语里的哪个部分能提取到它。比如订单号通常出现在我的 XX 单订单号是 xxx这类表述里。副作用等级read_only、idempotent_write、non_idempotent_write三档这直接决定回退策略能不能自动重试。下面是我们在用的条目结构字段名做了业务脱敏{ name: query_order_status, domain: order, description: 根据订单号查询订单当前状态与最近一次流转记录, when_to_use: [ 用户询问某笔订单是否已发货、是否已签收、当前处于哪个环节, 用户提供了订单号并希望了解订单进展 ], when_not_to_use: [ 用户只提供了手机号未提供订单号应先用 search_order_by_phone, 用户询问退款进度而非发货进度应使用 query_refund_progress ], params: { order_id: { type: string, required: true, pattern: ^[A-Z0-9]{10,20}$, extract_hint: 形如 ORD 开头的连续字母数字串或用户明确说出的订单号 } }, side_effect: read_only, latency_p95_ms: 320, cost_tier: low, version: 2024.11.1 }3.2 参数模式与副作用标注参数模式我强烈建议用 JSON Schema 严格定义而不是自由文本。原因很实际自由文本描述的参数模型填错的概率明显更高而且你没法在调用前做统一校验。用 Schema 之后校验失败可以在本地拦下来直接触发补参数追问不用把一次错误请求打到后端。副作用标注则决定了回退的边界。read_only的能力可以随便重试、随便换候选idempotent_write可以带幂等键重试non_idempotent_write比如创建工单、发起支付只能试一次失败必须上报而不是自动重试。我踩过最贵的坑就是给一个创建类接口配了自动重试结果用户收到三张同样的工单被投诉到业务线负责人那里。3.3 语义索引的构建与更新索引这块有个容易忽略的点索引必须带版本号且和注册表内容强绑定。我们早期的做法是启动时全量建索引跑起来就不管了。结果某次热更新了三个能力的描述索引还是旧的线上表现是明明描述改了召回率一点没变排查了半天才发现索引没重建。现在的做法是注册表每次变更生成一个内容哈希索引服务定期比对哈希不一致就触发增量重建。重建过程中旧索引继续服务新索引建好后原子切换避免重建期间召回率掉底。索引本身用向量 关键词双路向量负责语义泛化关键词负责精确命中比如订单号这种专有格式纯向量检索经常召回一堆不相关的物流能力。4. 动手实现一个最小可用的 Agent-Reach理论说完了接下来把最小可用版本搭出来。我用 Python 写依赖控制得很克制主要是为了能在一台普通机器上跑起来方便你复用。4.1 环境准备与目录结构pip install numpy jsonschema # 向量检索部分按你自己的基础设施选型这里用抽象接口便于替换目录我习惯这样分边界清楚后面加东西不容易乱agent_reach/ registry/ capabilities.json # 能力注册表 index_cache/ # 索引缓存带版本哈希 router/ retriever.py # 召回 reranker.py # 重排 executor/ validator.py # 参数校验 fallback.py # 回退链 metrics/ tracker.py # 三级触达埋点4.2 能力注册表落地代码注册表加载的核心是启动即校验把描述写得不对的条目在启动阶段就报出来别等线上跑出问题再查import json from jsonschema import validate, ValidationError REQUIRED_FIELDS [name, domain, description, when_to_use, when_not_to_use, params, side_effect] def load_registry(path): with open(path, r, encodingutf-8) as f: raw json.load(f) seen set() registry {} for item in raw: for field in REQUIRED_FIELDS: if field not in item: raise ValueError(f{item.get(name)} 缺少字段 {field}) if item[name] in seen: raise ValueError(f能力名重复: {item[name]}) if not item[when_not_to_use]: raise ValueError(f{item[name]} 缺少反向描述禁止上线) seen.add(item[name]) registry[item[name]] item return registry这里when_not_to_use为空直接抛错是我有意识加的一道门禁。团队里一开始有人嫌麻烦想跳过被卡了两次之后都老老实实写了后来 R2 的改善证明这道门禁是值的。4.3 检索路由从任务到候选能力召回部分我采用三路合并语义向量、关键词、域过滤。域过滤是个便宜且有效的先验——很多系统里任务的业务域是可以从上下文推断出来的比如用户正在订单详情页那订单域的能力应该加权。def retrieve(task_text, registry, vec_index, k8): # 第一路向量召回 vec_hits vec_index.search(task_text, top_kk * 2) # 第二路关键词召回抓专有名词和格式串 kw_hits keyword_search(task_text, registry, top_kk * 2) scores {} for name, score in vec_hits: scores[name] scores.get(name, 0) 0.7 * score for name, score in kw_hits: scores[name] scores.get(name, 0) 0.3 * score # 取前 k 个进入重排 candidates sorted(scores.items(), keylambda x: -x[1])[:k] return rerank(task_text, candidates, registry)权重的 0.7 / 0.3 不是拍脑袋来的。我用一批标注过的历史任务做过网格搜索从 0.5 到 0.9 试了一圈中间有一段平台区取平台中央的 0.7 比取边界更稳。如果你的业务里专有名词特别多比如大量设备编号、物料编码把关键词权重提到 0.45 左右通常更好。重排环节我把when_not_to_use也纳入打分命中了反向描述就扣分。这个小技巧对处理语义邻居特别有效def rerank(task_text, candidates, registry): result [] for name, base in candidates: item registry[name] penalty 0.0 for neg in item[when_not_to_use]: if is_similar(task_text, neg, threshold0.75): penalty 0.4 result.append((name, base - penalty)) return sorted(result, keylambda x: -x[1])4.4 执行、校验与回退链路执行前必做参数校验校验不过直接进追问流程不要浪费一次后端调用def execute_with_fallback(task, plan, registry, client, max_hop2): hop 0 trace [] while hop max_hop: item registry[plan.name] ok, errs validate_params(plan.params, item[params]) if not ok: trace.append((param_invalid, errs)) ask_user_for_missing(errs) return None, trace try: resp client.call(plan.name, plan.params, idempotency_keyplan.idem_key) trace.append((ok, plan.name)) return resp, trace except TransientError: trace.append((transient, plan.name)) if item[side_effect] non_idempotent_write: raise hop 1 continue except PermanentError as e: trace.append((permanent, str(e))) plan next_candidate(plan, excludeplan.name) hop 1 return None, trace回退分三档我按优先级排参数补全 换候选 明确告知做不到。判断依据是副作用的等级和剩余跳数。第三档其实很重要很多团队不愿意承认做不到硬要模型编一个答案出来最后用户信任度归零。4.5 指标埋点把触达率算出来埋点千万别只在最终结果上打一个点那样什么都分析不出来。三级漏斗的每一级都要单独记录并且带上任务 ID 串起来class ReachTracker: def __init__(self): self.records {} def mark(self, task_id, stage, payload): self.records.setdefault(task_id, {})[stage] payload def report(self): total len(self.records) r1 sum(1 for r in self.records.values() if r.get(recall, {}).get(hit)) / total r2 sum(1 for r in self.records.values() if r.get(select, {}).get(hit)) / total r3 sum(1 for r in self.records.values() if r.get(execute, {}).get(success)) / total return {R1: r1, R2: r2, R3: r3, end_to_end: r1 * r2 * r3}跑一段时间之后你会发现end_to_end这个数字通常比任务成功率低不少这个差值本身就很有信息量它告诉你还有多少优化空间藏在触达环节里。5. 参数调优与指标解读数字不好看时先看哪里指标接上之后真正的工作才开始。我总结了一套固定的排查顺序能省掉大量瞎试的时间。5.1 召回阈值与候选数量的权衡候选数量 K 是个玄学参数。K 太小R1 上不去K 太大R2 往下掉、延迟往上走。我的经验区间是K 取 5 到 8超过 10 之后 R2 的下降往往抵不过 R1 的提升。测试方法也简单固定其他变量只扫 K画出 R1 和 R2 随 K 变化的曲线找两条线的交点附近。相似度阈值同理但有个更实用的技巧按域分别设阈值。不同业务域的能力描述质量参差用一个全局阈值经常出现某个域召回过宽、另一个域召回不足。给每个域单独标定阈值维护成本不高收益很明显。5.2 三类核心指标的口径同样是叫触达率口径不同结论能完全相反所以必须先把定义说清楚指标口径定义计算时机R1标注正确答案是否落在 top-K 候选集内K 固定路由返回后R2模型最终选择的能力是否等于标注答案方案生成后R3选中的能力是否被成功调用且返回有效结果执行完成后R1 用离线标注集算即可成本低、迭代快。R2 依赖模型表现换模型时要重跑。R3 依赖后端接口稳定性波动往往来自业务侧而不是触达层本身看的时候要把接口故障时段剔除否则指标会被污染。5.3 一份可复用的调参顺序每次要优化触达效果我都按这个顺序走不跳步先看 R1 的错例分布。把没召回的任务捞出来人工看它们的正确能力是什么看是描述没覆盖还是语义检索没泛化到。前者改描述后者调索引。R1 达标再看 R2。如果 R1 到 96% 而 R2 只有 80%那基本可以确定是候选排序问题或者反向描述缺失重点补when_not_to_use。R2 达标才动 R3。R3 的问题九成在参数校验和重试策略上跟检索没关系别在错误的地方使劲。每次只改一个变量。同时改描述和阈值你永远不知道是哪个起了作用。6. 常见问题与排查实录最后一节放实际踩过的坑这部分是我觉得最值钱的内容文档里通常不会写。6.1 高频故障速查表现象根因排查动作处理方式R1 长期卡在 85% 上不去某几个能力的描述过于笼统按域统计召回率找出洼地重写描述补正向场景的用户原话R1 正常但 R2 很低候选集里有语义近邻互相干扰看错选的两个能力是否同域补反向描述加负样本重排惩罚R2 正常但 R3 偏低参数提取失败或类型不匹配统计校验失败的具体字段强化参数 prompt 提示加格式约束同一次对话重复调用同一能力状态没传递每轮重新路由检查编排层状态管理加轮次内的已调用记录做去重延迟突然从 200ms 涨到 1.2s索引重建时全量加载看索引服务日志改成增量重建 原子切换热更新描述后效果没变索引未同步重建比对注册表哈希与索引哈希加哈希比对触发机制6.2 几个踩过的坑坑一把能力当工具箱列越全越好。一开始我们恨不得把所有接口都注册进去结果几个核心能力的召回率被稀释得很厉害。后来做了一个筛选两周内零调用的能力直接下线需要时再注册。清单从 90 个砍到 54 个R1 直接涨了 7 个百分点。坑二能力名和描述语言不一致。有段时间能力名用英文下划线风格描述用中文模型在选择时经常把名字当成主要依据导致两个名字相似但功能完全不同的能力反复混用。后来统一要求描述必须用用户原话名字只做标识不参与推理依据并在提示词里显式说明这点。坑三重试没配幂等重复写入。前面提过一次这里再说一遍因为它真的太常见了。凡是写操作一定要在调用链路上带幂等键且回退逻辑里对非幂等操作直接禁止重试。这个规则我后来写成了代码里的硬性断言不符合就启动失败。坑四拿线上真实流量当评测集。听起来合理其实有问题——线上流量里失败的任务会被反复重试采样严重偏移。我们现在的做法是线上采样 人工复核构建一个 400 条左右的金标集每次改完只在这个集合上跑回归保证前后可比。坑五忽略冷启动期的描述质量。新接入的业务线描述往往是业务方自己写的偏接口文档风格召回效果普遍很差。后来我们定了个流程新业务线接入必须先跑一轮 50 条的冒烟评测R1 低于 90% 不允许上线。这一步拦下了不少问题。我个人在实际操作中的体会是Agent-Reach 这类触达层的收益不是线性的它在能力数量超过二十个之后才开始明显而真正的瓶颈从来不在检索算法上而在于你愿不愿意花时间把每一条能力描述写成用户会说的话。算法可以换、模型可以升级但描述质量这件事没有捷径。我见过太多团队在向量模型选型上反复横跳却没人愿意去读一遍那几十条描述最后效果一直在原地打转。这个项目里最朴素的功夫反而决定了上限。