Agent-Reach:让 Agent 真正触达目标资源的可达性工程
2026/9/18 4:39:03 网站建设 项目流程

Agent-Reach 这个词第一次出现在我视野里的时候,我脑子里冒出来的不是某个具体框架,而是过去大半年里被问烂的一个问题:我的 Agent 明明在演示里表现挺好,怎么一到真实任务里就"够不着"?它知道该去查订单,但拿不到订单接口;它能写 SQL,但连不上那张表;它推理链条走得挺顺,走到第五步忽然发现自己没有那个权限。演示环境和生产环境之间那条缝,十有八九就裂在"可达性"上。

我把这一整套围绕"Agent 能不能真正触达目标资源"的诊断与补强方案,统一叫 Agent-Reach。它不是又一个 Agent 编排框架,而是一层专门盯住"够不着"的薄层:管工具怎么注册、意图怎么路由、上下文怎么补、执行怎么兜底、失败怎么归因。框架负责让 Agent 会思考,Agent-Reach 负责让它的手真的伸得出去。

这篇文章适合三类人:正在把 Agent 从 demo 往线上推的工程师、被"模型没问题但任务失败"折磨过的产品同学、以及想给自己团队搭一套可达性评估基线的人。不管你是刚接触 Agent 开发,还是已经踩过几轮坑,下面这些内容都能直接拿去用——包括我实际跑过的代码、算过的阈值、以及那些文档里绝对不会写的教训。

1. 先把"够不着"这件事说清楚:Agent-Reach 到底在解决什么

在动手写代码之前,我花了两周时间做了一件看起来"不产出"的事:把过去半年所有失败的 Agent 任务捞出来,逐条标注失败原因。这个动作的价值远超预期,因为它把我原来模糊的"Agent 不稳定"直觉,变成了一张可分类的清单。而这张清单里超过六成的问题,跟模型推理能力关系不大,全是可达性问题。Agent-Reach 这层东西,就是从那张清单里长出来的。

1.1 三个典型现场:工具够不着、上下文够不着、执行够不着

第一种叫工具够不着。用户问"帮我看看上周那笔退款到账没",Agent 需要调用退款查询接口。但注册表里只有"订单查询"和"支付查询"两个工具,描述里还都写着"查询交易相关信息"。模型在两者之间反复横跳,最后挑了一个,参数填错,报错回传,它又换另一个,循环三次超时。这类问题的根因不在模型,在于注册表根本没覆盖到那个能力,或者覆盖了但描述边界含糊,导致模型没法判断该用谁。

第二种叫上下文够不着。Agent 需要知道"上周"具体是哪几天、"那笔退款"是哪一笔。这些信息散在会话历史、用户画像、业务数据库三个地方。如果只把会话历史塞进上下文,模型就只能靠猜。我见过最典型的一次,Agent 把"上周"理解成了自然周,而业务口径里"上周"指的是过去七个自然日,结果查出来的数据完全是错的。模型没错,是它拿到的上下文不足以支撑这个判断。

第三种叫执行够不着。这一种最隐蔽,因为前面几步都成功了。Agent 顺利识别意图、选对工具、参数也对,执行到第六步需要写入一个下游系统时,发现服务账号没有那张表的写权限;或者需要调用一个耗时的批处理接口,而调用链在第三十秒被上游超时掐断。任务链条越长,这种"中途断电"的概率就越接近必然。我统计过一条七步的任务链,每步成功率 0.95 的话,整体成功率只有 0.70 左右,这个衰减速度比大多数人直觉里快得多。

1.2 为什么我把可达性单独抽一层来做

很多团队的做法是把这些问题揉进编排逻辑里:工具不够就多写几个工具,上下文不够就把 prompt 写长一点,执行失败就加重试。短期看能顶住,长期看就是一锅粥。因为你没法回答一个基本问题:这次失败到底是模型的能力问题,还是资源没接上?答不上来,优化方向就是瞎猜,今天调 prompt,明天换模型,后天加工具,成本全花在试错上。

把可达性抽成独立一层,最大的收益是归因清晰。Agent-Reach 在每个环节都埋了结构化事件:意图解析出来是什么、路由给哪个工具、路由时打了多少分、参数校验过没过、执行耗时多少、失败在哪一步、失败类型是什么。有了这些事件,你就能把"任务失败"这一个笼统结果,拆成"工具未覆盖 32%、参数校验失败 18%、执行超时 14%、权限拒绝 9%、其余为模型推理问题"。归因一清楚,优先级自然就排出来了。我自己的经验是,这套埋点做完之后,团队每周花在 Agent 调试上的时间大概砍掉了四成。

另一个收益是可回归。可达性是一组可以量化的指标,能量化就能建回归集,能建回归集就敢改。你可以放心大胆地重写工具描述、调整路由阈值、加一层缓存,跑一遍回归集就知道有没有退化。这比"改完上线看看有没有人投诉"要靠谱太多。

1.3 这套东西适合什么规模的团队拿去用

我不想把它说成人人必备。如果你只是一个人写个玩具 Agent 跑几个固定任务,直接用现成的编排框架就够了,加一层抽象纯属给自己找事。以下三种情况,我建议认真考虑把 Agent-Reach 这层搭起来:

  • 你的 Agent 需要对接超过十个外部系统,而且这些系统的接口风格、鉴权方式、错误码约定各不相同;
  • 你的任务链普遍超过四步,中途失败的成本开始变得明显(比如涉及资金、工单、对外通知);
  • 你需要向别人解释某次失败为什么发生,而不只是说"模型抽风了"。

反过来,如果你的 Agent 任务集中在单轮问答、只读查询、单工具调用,那这层的边际收益确实有限。工具选型的判断标准从来不是"先进不先进",而是"当前痛点值不值得为它付维护成本"。

2. 整体设计:Agent-Reach 的分层结构与选型取舍

我见过太多项目死在"一开始就设计得太完整"上。第一版就想要插件市场、想要多租户、想要可视化编排器,结果三个月没跑通一条端到端链路。Agent-Reach 的设计原则只有一条:每一层都必须能在没有任何其他层的情况下独立跑起来。这条原则在后面救了我很多次,因为当路由层出问题时,我可以把它降级成"关键词直匹配",其余层照常工作,至少服务还在。

2.1 四层骨架:注册层、路由层、执行层、反馈层

注册层负责描述"我有什么"。每个工具在注册表里是一条结构化记录,包含名称、功能描述、参数 schema、鉴权方式、限流配置、超时预算、副作用标记。这一层的核心产物是一份机器可读的能力清单,而不是一份给人看的 API 文档。这两者的差别很大:给人看的文档可以写"支持多种查询场景",给模型看的描述必须写"只能查询单笔订单,输入订单号,不支持按时间范围批量查询"。

路由层负责决定"这次该用什么"。输入是当前意图和上下文,输出是一个带分数的候选工具列表,加一个是否够用的判断。我特意让它输出候选列表而不是单一结果,因为当第一名失败了,你手里得有第二、第三名可以退。很多系统一次只选一个工具,失败后重新走一遍路由,白白多花一轮 token 和延迟。

执行层负责"真的去调"。它管参数校验、鉴权注入、超时控制、重试策略、熔断降级、响应裁剪。这一层是最脏最累的,也是最容易被低估的。我见过团队在路由上花了两周,在执行层花了半天,结果线上事故九成出在执行层:没设超时、重试没有幂等保护、响应体没裁剪导致上下文被撑爆。

反馈层负责"失败了怎么办、成功了留下什么"。它把执行结果翻译成模型能理解的信号,同时把结构化事件写到日志管道里。这里有个关键设计:给模型的错误信息,和给人看的错误信息,必须是两套。给人看的要简洁友好,给模型看的要包含足够多的可决策细节,比如"参数 order_id 格式错误,期望 16 位纯数字,收到的是 12 位含字母"。

2.2 选型取舍:为什么不直接上大而全的编排框架

市面上主流编排框架做得都不错,图结构、状态机、断点续跑这些能力很全。但在可达性这件事上,它们有两个不太适配的地方。

第一,它们的抽象层级偏高。框架关心的是"节点怎么连",不关心"这个工具的鉴权 token 怎么在三分钟后自动刷新"。可达性问题大量发生在框架视野之外的脏活里。你当然可以在框架里塞自定义节点,但塞着塞着就发现,真正的逻辑全在自定义节点里,框架只剩个壳。

第二,它们的错误信息不适合模型消费。框架抛出的异常往往是一句人类可读的报错,比如Node execution failed。这句话丢给模型,模型除了重试没有别的选择。而复用 Agent-Reach 的反馈层,你可以把它转成{"step": "refund_query", "error_type": "invalid_param", "field": "order_id", "expected": "16位数字", "retryable": false, "suggestion": "先调用订单搜索获取正确订单号"}。转完这一层,模型的下一步动作准确率会明显不一样。

我的选择是:编排仍然交给框架,可达性交给 Agent-Reach。两者通过一个很窄的接口对接,框架在需要调用工具时问 Agent-Reach 要一个执行句柄,Agent-Reach 返回结果和结构化事件。接口窄的好处是替换成本低,今天用这个框架,明天想换一个,只改对接层就行。

2.3 注册表字段设计:多一个字段少一次事故

下面这张表是我实际用的注册表字段定义,跑了大概半年,中间只加过一个字段(idempotent,因为一次重复扣款事故)。字段设计的原则是:每个字段都要能回答一个具体的决策问题,回答不了问题的字段不要加,加了没人维护就是负债。

字段类型回答什么决策问题备注
namestring模型怎么引用它全局唯一,下划线命名
descriptionstring什么时候该用它、什么时候不该用必须写清边界和反例
params_schemaobject参数怎么填才算合法遵循 JSON Schema,必填项显式标注
auth_typeenum鉴权怎么注入service_token / user_delegated / none
timeout_msint这一步最多等多久按 P99 数据再上浮 30%
retry_policyobject失败了能不能重试含最大次数与退避曲线
idempotentbool重试安不安全false 时禁止自动重试
side_effectenum会不会改变外部状态read / write / notify
rate_limitobject会不会被限流QPS 与突发额度
sample_callsarray少样本示例怎么写两三条真实调用样例

这里我特别想强调description的写法。大部分团队写描述是照着 API 文档抄的,写出来像这样:"查询退款信息。"这行字对模型来说信息量几乎为零。我后来改成了一个固定句式:做什么 + 输入什么 + 不做什么 + 什么时候该用别的工具。改完之后,同一批测试用例上的路由准确率从 71% 提到了 89%,一行代码没改,纯改文案。

3. 核心实现细节:工具注册、路由与上下文补全

这一部分是我踩坑最密集的地方。很多看起来是"模型不行"的现象,拆到最后都是实现细节没做对。下面按我在项目里实际的处理顺序讲:先把工具描述写明白,再把路由打分调准,最后补上下文。顺序不能反,因为上下文补全的效果,很大程度上取决于路由是否已经收敛。

3.1 工具描述:写得含糊就是给自己埋雷

先给一个我实际改过的例子。改之前是这样:

name: query_refund description: 用于查询退款相关信息 params: order_id: 订单号

改之后:

name: query_refund description: | 查询单笔订单的退款状态与退款金额。 输入必须是订单号(16 位纯数字),不支持按用户、按时间范围批量查询。 如果用户只提供了手机号或昵称,先调用 search_order 拿到订单号,再调用本工具。 如果只是想看订单当前的支付状态(不涉及退款),用 query_order_status。 本工具只读,不会改变任何状态。 params: order_id: type: string pattern: "^[0-9]{16}$" required: true desc: 16 位订单号,可从 search_order 的返回字段 orderId 获取

差别在哪?改之前模型只知道"有这么个工具",改之后模型知道了四件事:输入的精确格式、不支持什么、遇到缺参数时该走哪条路、以及和相邻工具的边界在哪。这四件事恰好对应了前面说的四种失败模式。

这里有个反直觉的经验:描述不是越短越好。很多人担心描述太长占 token,但一次路由错误带来的重试开销,远大于多写两百字描述的成本。我算过一笔账,一个中等规模系统,单次路由平均消耗 600 token,一次错误重试平均多烧 1800 token 外加一次工具调用延迟。把描述从 50 字扩到 200 字,每次多花约 200 token,只要能让错误率下降 10% 就回本了。实测下降的幅度远超 10%。

还有一点:给描述配样本sample_calls字段里放两三条真实调用样例,包含一次正确调用和一次"应该走别的工具"的反例。反例这条特别有用,它比任何描述文字都能更快地把边界钉死。

3.2 路由匹配的参数取舍与阈值计算

路由这块,我试过三种方案。第一种是让模型直接从全量工具里选,工具数少于八个时可用,超过十五个之后准确率掉得很明显。第二种是先用向量检索粗筛出前五,再让模型在这五个里选,这是我现在的主力方案。第三种是纯规则匹配,只用在少数高频且格式固定的场景上,比如"查余额"。

粗筛阶段用的是工具描述和样本调用拼成的文本向量,检索 top-k。k 取多少?我做了组对比实验,在 240 条覆盖全部工具的真实任务集上跑:

k 值路由 Top1 准确率平均候选 token 开销平均延迟
382.1%最低
593.3%
894.6%
全部(22 个)88.7%很高最高

k 从 5 提到 8,准确率只涨了 1.3 个点,但候选 token 开销涨了六成,延迟也明显上升。更值得注意的是最后一行:全量投喂时准确率反而下降到 88.7%,这是典型的"选项过载",模型在太多相似描述之间被干扰了。所以我把 k 定在 5,并且在粗筛阶段加了一层硬过滤:把用户没有权限的工具、当前场景明显不适用的工具(比如写操作在只读会话里)直接剔除,不进入候选。这层过滤之后,有效候选往往只有三到四个,准确率还能再提一档。

模型精排阶段的输出不是单一结果,而是带分数的列表。分数怎么用?我设了一条双阈值规则

  • 第一名分数 ≥ 0.75 且与第二名差距 ≥ 0.15,直接执行;
  • 第一名分数在 0.5 到 0.75 之间,或者与第二名差距小于 0.15,走澄清分支,向用户或上游系统确认一次;
  • 第一名分数 < 0.5,判定为工具未覆盖,返回明确的"我没有这个能力"信号,而不是硬选一个去试。

0.75 和 0.15 这两个数是这么来的:我在标注集上把阈值当参数扫了一遍,以"错误执行成本 : 澄清成本 = 5 : 1"为权重算期望损失,损失最小的点落在 0.74 到 0.77 之间,我取了 0.75。澄清成本之所以只有 1,是因为一次澄清对话大约花两秒和几百 token,而一次错误执行可能触发写操作、产生脏数据、需要人工回滚,成本差着一个数量级。这个比例因业务而异,涉及资金和对外通知的场景,应该把错误执行成本权重调得更高,阈值相应往上提。

3.3 上下文补全:检索在可达性里的真实位置

上下文补全最容易做成"什么都往里面塞"。我早期版本就是这样,把用户画像、历史会话、知识库检索结果一股脑塞进去,上下文常常撑到七八千 token,效果反而变差,因为模型开始抓不住重点。

我的做法是按需触发,而不是默认全量注入。具体分三步:

第一步,识别缺口。在意图解析完成后,检查这次任务需要哪些槽位,哪些槽位在现有上下文里没填上。比如任务是"查一下上周那笔退款",槽位是order_idtime_range,两个都缺。

第二步,给每个缺口指定补全来源order_id的来源是会话历史里的实体检索,time_range的来源是业务口径词典(把"上周"映射成具体日期区间)。这里的关键是来源必须显式声明,不能靠模型自己去上下文里翻。我早期让模型自己找,结果它经常从一段无关的历史对话里抓出一个订单号,张冠李戴。

第三步,补全结果带置信度。如果order_id是从会话历史里唯一匹配到的一个,置信度高,直接用;如果匹配到三个,置信度低,就不要猜,直接走澄清分支。这一步帮我挡掉了很多隐蔽的错误——比错误更可怕的是"看起来对的错误",因为它不会报错,只会静静地把结果算错。

关于检索本身的实现,我不想展开太多,只提一个和可达性强相关的点:检索的粒度要匹配槽位。整段历史做向量检索,召回的往往是一大段混杂的对话,里面同时出现三个订单号。更好的做法是把历史拆成结构化的实体记录(谁在什么时候提到了哪个订单号),检索直接命中实体,而不是命中段落。我改完这个之后,槽位填充的准确率从 76% 提到了 92%,改的全是预处理逻辑。

4. 实操:从零搭一个最小可用的 Agent-Reach

前面讲的是设计,这一节讲怎么落地。我给的是一个最小可用版本,大概三百行代码,一两天能跑通。别一上来就想做完整版,我第一版做得太全,反而拖了一个月才上线,而且上线后发现一半的抽象根本用不上。

4.1 环境和最小依赖

环境上我尽量克制,只依赖几个基础组件:

# 运行环境 python >= 3.10 # 核心依赖 pip install fastapi uvicorn pydantic httpx # 向量检索(粗筛阶段用,也可以用任何你顺手的方式) pip install numpy # 可选:缓存与限流计数 docker run -d -p 6379:6379 redis:7-alpine

这里解释一下为什么选这些,以及我踩过的坑。用 Pydantic 做参数 schema 校验是刚需,因为注册表里的params_schema本质就是 JSON Schema,用 Pydantic 可以直接生成和校验,不用自己写一套。httpx 用来做异步调用,超时控制比 requests 干净。Redis 用来存限流计数和短时缓存,但不建议存会话状态,因为一旦你把状态放进去,重启之后会话就断了,调试会很痛苦——这个坑我踩过,后来把状态全部放在无状态的服务里,通过请求携带。

Python 版本要求 3.10 以上,不是为了语法糖,是因为结构化错误信息的类型标注用X | Y写起来清爽很多,日志里也不会出现一堆 Optional 噪音。

4.2 注册表加载与路由打分

注册表用 YAML 存,一个文件一个工具,启动时加载进内存。这样做的好处是改描述不需要重启代码逻辑,只要重新加载配置。下面是一个简化版的路由打分实现:

import json import numpy as np from dataclasses import dataclass, field from typing import Any @dataclass class Candidate: name: str score: float reason: str = "" class ToolRegistry: def __init__(self, tools: list[dict]): self.tools = {t["name"]: t for t in tools} # 每条工具把描述和样本拼成一个文本,作为检索语料 self.corpus = [ (t["name"], t["description"] + " " + json.dumps(t.get("sample_calls", []), ensure_ascii=False)) for t in tools ] def hard_filter(self, tool: dict, ctx: dict) -> bool: """硬过滤:权限、场景、只读约束。返回 True 表示可用""" if ctx.get("readonly") and tool.get("side_effect") != "read": return False if tool.get("auth_type") == "user_delegated" and not ctx.get("user_token"): return False return True def coarse_recall(self, query_vec: np.ndarray, ctx: dict, k: int = 5) -> list[str]: pool = [(n, txt) for n, txt in self.corpus if self.hard_filter(self.tools[n], ctx)] # 这里用点积近似余弦相似度,向量都已归一化 sims = [] for name, _ in pool: sims.append((name, float(np.dot(query_vec, self.vec_of(name))))) sims.sort(key=lambda x: x[1], reverse=True) return [n for n, _ in sims[:k]]

hard_filter这个函数看着简单,但它是我认为整套方案里性价比最高的一段代码。加它之前,粗筛经常把"需要用户授权"的工具排进候选,模型选了之后在执行层才被拒,白烧一轮。加它之后,不可用的工具根本不出现,路由准确率提升的同时延迟也降了。

精排阶段把候选的描述完整拼进 prompt,让模型输出一个 JSON 数组,包含工具名和 0 到 1 的分数。这里有个实践细节:要求模型给出分数,比只要求它给出选择要好得多。因为分数可以被阈值规则消费,选择不能。模型给分不一定校准得很准,但同一模型在同一套 prompt 下,分数的相对大小是有信息量的,这就够用了。

阈值判断的逻辑:

def decide(cands: list[Candidate], t1: float = 0.75, gap: float = 0.15): if not cands: return {"action": "no_capability", "reason": "no_candidate"} top = cands[0] second = cands[1].score if len(cands) > 1 else 0.0 if top.score >= t1 and (top.score - second) >= gap: return {"action": "execute", "tool": top.name} if top.score < 0.5: return {"action": "no_capability", "reason": "low_confidence"} return {"action": "clarify", "candidates": [c.name for c in cands[:3]]}

no_capability这个分支特别重要,很多系统不敢让 Agent 说"我不会"。但实测下来,明确说不会,比硬猜一个然后报错,用户体验好得多,而且不会产生脏数据。我甚至建议把"我不会"做成一个正式的、能触发人工接管的信号,而不是一句普通的回复文本。

4.3 执行层的超时、重试与熔断参数怎么定

执行层的参数都是有据可算的,别拍脑袋。以超时为例,我的做法是:

  1. 先抓取该工具过去一个月的调用耗时分布,取 P99,记为 T99;
  2. 超时预算设为T99 × 1.3,给它 30% 的余量来吸收抖动;
  3. 对整个任务链,设定总预算,各步骤的预算之和不得超过总预算的 80%,剩下 20% 留给路由、澄清和最后的结果生成。

为什么是 1.3 而不是 2?因为超时设太长的代价是隐藏问题。一个接口偶尔慢到十秒,你把超时设成二十秒,它就永远不报警了,但用户的等待时间实实在在变长了。宁可让它在十三秒处失败并留下一条清晰的超时事件,也不要让它悄悄拖二十秒。

重试策略绑定idempotent字段:标记为幂等的,可以重试两次,退避曲线用 200ms、800ms;标记为非幂等的,绝不自动重试,只能由模型显式决策后再调用。我在这里踩过一次坑:一个扣款接口没标幂等,网络抖动触发了一次自动重试,扣了两次。那次之后我给自己定了条铁律——任何写操作必须显式声明幂等性,声明为假的一律不允许自动重试。这条规则看着保守,但省下的回滚成本远超它带来的不便。

熔断用的是最朴素的滑动窗口:某个工具在过去六十秒内连续失败五次,或者失败率超过 40% 且样本数大于十,就打开熔断器,三十秒后放一个探测请求。熔断期间路由层会把这个工具从候选里剔除,避免整条链路被一个坏掉的依赖拖死。这个参数我调过一次,最初是十次失败才熔断,结果发现一个下游服务挂掉之后,前十个请求全都白等超时,用户感知很差。改成五次之后体感明显好转。

4.4 埋点:把"够不着"变成可观测事件

这一节是整篇文章里我最想强调的。没有埋点,前面所有设计都只是感觉良好。埋点的目标是把每一次任务拆成一串可查询的结构化事件,字段大致如下:

字段含义典型取值
trace_id任务链路标识全局唯一
step当前环节route / execute / clarify / fallback
intent解析出的意图query_refund_status
candidates候选工具及分数[{"name":"query_refund","score":0.81}]
decision决策结果execute / clarify / no_capability
param_valid参数校验是否通过true / false
error_type失败类型invalid_param / timeout / auth_denied / upstream_5xx
retryable是否可重试true / false
duration_ms耗时342

有了这套字段,你可以随时回答一些以前回答不了的问题:这周"工具未覆盖"占比多少、哪个工具的参数校验失败最多、平均每个任务路由阶段消耗多少时间。我每周会花二十分钟看一遍这几张表,基本上新出现的问题都能在用户投诉之前被发现。

error_type的枚举值不要随性起,要提前定好。我一开始是随手写字符串,两个月后发现有十七种写法,光"超时"就有 timeout、time_out、timeout_error 三种,统计根本没法做。后来强制走枚举,历史数据重新清洗了一遍,费了不小力气。

5. 指标与评估:怎么判断"够得着"了

设计做完,代码跑通,接下来要回答一个更硬的问题:到底好没好?我见过不少团队,加了这层那层,感觉上更稳了,但拿不出数字。没有数字,就没法判断优化方向对不对,也没法在预算被砍的时候说服人。下面是我一直在用的四个指标,以及一个我觉得比指标本身更重要的东西。

5.1 四个核心指标怎么算才算准

任务端到端成功率。这是最直观的,但定义必须精确:只有最终结果正确且用户没有二次纠正,才算成功。中途重试成功的不算失败,但也不算首次成功,要单独统计。只看端到端成功率容易掩盖问题,因为它是个复合指标,涨了跌了都说不清原因。

首次通过率。指任务在没有任何重试和澄清的情况下一次成功。这个指标最能反映路由质量。我自己的数据是:端到端成功率 91% 的时候,首次通过率只有 68%。两者差了二十多个点,全花在重试和澄清上。所以提升首次通过率是性价比最高的方向,因为它直接对应延迟和成本。

可达覆盖率。定义为:在一批真实任务样本中,能够被当前工具集覆盖(即存在至少一个候选工具得分超过阈值)的比例。这个指标告诉你工具的缺口有多大。我建议每月跑一次,当它低于 85% 的时候,说明该补工具了。这个指标不需要跑完整链路,只跑到路由阶段就行,成本很低。

失败归因分布。这不是一个数,而是一张分布表。我每周看一次,重点看趋势变化。如果"工具未覆盖"从 30% 降到 15%,说明补工具的动作有效;如果"参数校验失败"从 12% 涨到 25%,多半是某次描述改动引入了歧义。这张表是指导优化的罗盘。

5.2 用回归集给可达性做体检

指标是结果,回归集是手段。我维护着一套 240 条任务的回归集,覆盖所有已注册工具,每条包含用户原始表达、期望意图、期望工具、期望参数。每次改动工具描述、路由 prompt、阈值参数之前,先跑一遍。

建这个集子有几点讲究:第一,任务表达要保留真实用户的粗糙感,包括错别字、省略、口语化。我一开始写的是"查询订单号为 X 的退款状态"这种标准句式,结果回归集上准确率 96%,线上只有 72%。后来我把表达全部改成真实用户语料,回归集准确率掉到 78%,这才和线上对上了。第二,每条任务要标注"可接受的其他答案",因为有些任务确实有两种合理解法,严格匹配会误判。第三,定期补充线上失败样本。我每个月从线上捞二十条失败案例加进去,让回归集跟着真实分布走。

跑回归集的成本不高,240 条任务,只跑路由阶段的话大概三分钟。我把它挂在了提交前的检查里,超过阈值退化就拦住。这个机制救过我至少三次——有一次我为了优化一个工具的召回,改了它的描述,结果让另一个相邻工具的 Top1 准确率掉了八个点,回归集当场拦下来了。

6. 常见问题与排查速查

写到这儿,前面基本都是"应该怎么做"。但实战里更有价值的往往是"出问题了怎么查"。我把过去半年处理过的问题整理成了一张表,按现象、可能原因、排查动作三列组织,遇到问题先查表,大多数情况五分钟内能定位。

6.1 排查速查表

现象可能原因排查动作
同一句话两次结果不同路由分数接近阈值,处于边界抖动查 trace 里的候选分数,看第一名与第二名差距是否小于 0.15
模型反复调用同一个工具并失败错误信息对模型不可决策,缺少 suggestion 字段检查返回给模型的错误结构,补上错误字段名和期望格式
任务走到一半突然结束某步超时后未产生明确失败信号查 duration_ms 分布,确认是否触发了上游超时
工具明明存在却总不被选中描述与其他工具重叠,或粗筛阶段被硬过滤单独对该工具跑召回测试,看它在粗筛 top5 里的排名
参数总是差一位或格式错描述里的格式约束没写清,或样本缺失补充 pattern 与 sample_calls,加一条反例样本
高峰期失败率陡增限流触发或连接池耗尽查限流计数与并发数,确认是否触发了熔断
上下文越长效果越差无关内容稀释了注意力关掉默认全量注入,改为按槽位按需补全
澄清分支触发过于频繁阈值定得过高在标注集上重扫一遍阈值,看是否可下调
写操作出现重复执行未标注幂等性却走了自动重试检查 idempotent 字段,非幂等工具关闭自动重试
某工具在某类用户上总失败权限模型不一致查该工具 auth_type 与用户授权范围是否匹配

这张表我打印出来贴在工位上,其实用久了会发现,十种现象里至少有六种可以归到两类根因:描述不清错误信息不可决策。所以如果你刚接手一个 Agent 项目,不知道从哪里开始排查,我建议就先检查这两件事,投入产出比最高。

6.2 几条踩过坑才明白的心得

第一条,别让模型看见它用不了的工具。这是我在权限问题上最大的教训。早期我为了省事,把所有工具都注册进去,靠执行层报错来拦权限。结果是模型经常挑到没权限的工具,报错,重试,再挑到另一个没权限的,一轮下来用户等了两分钟得到一句"抱歉我做不到"。后来把权限过滤前置到粗筛阶段,不可用的工具根本不进候选池,问题直接消失。这个改动的代码量不到二十行,效果却是所有改动里最明显的。

第二条,错误信息要给"下一步",而不只是"哪里错了"。这一点我改了很多轮。最早我返回的是参数错误,模型只能瞎试。后来改成参数 order_id 格式错误,好一些。最后改成参数 order_id 格式错误,期望 16 位数字,收到 12 位含字母;建议先调用 search_order 用手机号获取正确订单号,模型的一次性修复率从 40% 提到了 84%。多写那半句话的成本几乎为零,收益却很大。

第三条,微调描述之后一定要跑回归。我吃过一次亏,为了让一个低频工具的召回好一点,把它的描述改得更宽泛,结果它开始抢另一个高频工具的活,线上首次通过率掉了六个点。六个点在 240 条回归集上就是十五条任务,跑一遍三分钟就能发现。现在我给自己定了个规矩:只要动了工具描述,哪怕只改一个字,也先跑回归再提交。

第四条,留一个可以随时降级的开关。Agent-Reach 的路由层我从第一天就留了降级路径:一切换到关键词直匹配。这个开关平时不用,但有一次向量服务抖动,路由整体不可用,切过去之后虽然准确率掉了一截,但服务没停。这种设计在项目里看起来像是多余的防御,真出事的时候才知道值不值。

第五条,不要为了让数字好看而放宽成功定义。我见过团队把"用户没有继续追问"算作成功,指标一下涨到 95%,但实际问题还在。指标是用来发现问题的,不是用来交差的。宁可数字难看但真实,也别要一个漂漂亮亮的假指标。

最后分享一个我最近在试的方向:把可达性指标反过来喂给工具注册表,让系统自己发现"哪些描述容易被混淆"。具体做法是把回归集里所有路由错误的两两工具配对统计出来,出现频次高的配对,说明这两个工具的描述边界模糊,优先去重写它们的描述。用这个方法,我在一次迭代里定位出了三对高混淆工具,改完之后首次通过率提升了四个点。这个思路还比较粗糙,但至少目前看比人工逐个检查描述要高效得多。

这套东西没有什么高深的技术,难的是把每一层都想清楚、把每个失败都归到位、把每次改动的效果都量出来。Agent 这类系统最怕的就是"感觉好像好一点了",而 Agent-Reach 存在的意义,就是让你不必再依赖感觉。

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

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

立即咨询